Skip to content

Commit cba015d

Browse files
Ubuntuclaude
andcommitted
feat(hub,adr-030): permission_prompt re-addressing to parent steward (W10, v1.0.683-alpha)
ADR-030 Phase 1 W10 — re-address permission_prompt rows raised by steward-parented workers. When a worker has a same-project steward parent, the row lands assigned_tier='project-steward' + current_assignees_json=[<parent_id>] so the parent steward sees it in their inbox first. Otherwise the row stays team-wide-addressed (existing behaviour preserved for orphan workers, non-steward parents, and binding-drift cases). mcp_more.go::permissionPromptAddressee: - Single SQL JOIN with the strict three-clause predicate per ADR-030 pre-W1 decision #3: worker.parent_agent_id IS NOT NULL AND parent.kind LIKE 'steward.%' AND parent.project_id IS NOT NULL AND = worker.project_id - Two IS NOT NULL guards defend against SQL's NULL=NULL → NULL semantics (without them, two unbound rows would accidentally match) - Best-effort: DB errors log a warn + return '' so transient issues degrade to safe (team-wide) mcpPermissionPrompt: - Calls the helper before the INSERT - When steward_id returned: stamps assigned_tier='project-steward' + current_assignees_json=[<steward_id>] - Otherwise: leaves assignees='[]' and assigned_tier=NULL - INSERT widened to include assigned_tier (the ADR-030 W1 column previously written only by the propose path) Tests (6 in mcp_permission_prompt_addressing_test.go): - same-project steward parent → row addressed to parent - cross-project steward parent (binding drift) → stays team-wide (load-bearing regression for v1.0.605-class bug) - non-steward parent → stays team-wide - orphan worker (no parent) → stays team-wide - NULL project_ids both sides → stays team-wide (NULL=NULL guard) - direct helper test: ghost worker returns '' Existing TestDecide_PermissionPromptFansOutAttentionReply still passes — dispatchAttentionReply is untouched. The new addressing only affects which inbox surfaces the row first; the fan-back still routes by session_id. Test fixture note: Write tool is TierRoutine (auto-allow), so tests use Task (TierSignificant) so the gate doesn't short-circuit before the INSERT. ctx timeout 100ms so the call returns ctx-cancelled shortly after the INSERT lands. Plan W10 rewritten to document the helper shape, 5-conjunct JOIN, and 6-test coverage. New verify symbol anchor on permissionPromptAddressee → 23 anchors total. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent dbb98ab commit cba015d

6 files changed

Lines changed: 387 additions & 41 deletions

File tree

docs/changelog.md

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
> **Type:** reference
44
> **Status:** Current (2026-05-24)
55
> **Audience:** contributors, operators
6-
> **Last verified vs code:** v1.0.682
6+
> **Last verified vs code:** v1.0.683
77
88
**TL;DR.** Append-only record of what shipped in each tagged release.
99
One section per version, newest first. Format follows
@@ -23,6 +23,76 @@ binding). Seed entries prior to that are in
2323

2424
---
2525

26+
## v1.0.683-alpha — 2026-05-24
27+
28+
ADR-030 Phase 1 W10 — re-address `permission_prompt` rows raised by
29+
steward-parented workers. When a worker has a same-project steward
30+
parent, the row lands `assigned_tier='project-steward'` +
31+
`current_assignees_json=[<parent_id>]` so the parent steward sees
32+
it in their inbox first. Otherwise the row stays team-wide-addressed
33+
(existing behaviour preserved for orphan workers, non-steward
34+
parents, and binding-drift cases).
35+
36+
### Added
37+
38+
- `hub/internal/server/mcp_more.go::permissionPromptAddressee`
39+
new helper. Single SQL JOIN with the strict three-clause
40+
predicate per ADR-030 pre-W1 decision #3:
41+
```
42+
worker.parent_agent_id IS NOT NULL
43+
AND parent.kind LIKE 'steward.%'
44+
AND parent.project_id IS NOT NULL AND = worker.project_id
45+
```
46+
Two `IS NOT NULL` guards defend against SQL's
47+
`NULL = NULL → NULL` semantics. Best-effort: DB errors log
48+
a warn + return "" so transient issues degrade to safe
49+
(team-wide addressing).
50+
- `mcpPermissionPrompt` calls the helper before the row INSERT
51+
and stamps `assigned_tier` + `current_assignees_json`
52+
accordingly. The INSERT statement was widened to include
53+
`assigned_tier` (the ADR-030 W1 column previously written
54+
only by the propose path).
55+
- `hub/internal/server/mcp_permission_prompt_addressing_test.go`
56+
— 6 cases: same-project steward parent addresses row;
57+
cross-project steward parent (binding drift) stays
58+
team-wide; non-steward parent stays team-wide; orphan
59+
worker stays team-wide; NULL project_ids on both sides
60+
stay team-wide (NULL=NULL guard); direct helper test
61+
against ghost worker returns "".
62+
63+
### Changed
64+
65+
- `pubspec.yaml` 1.0.682 → 1.0.683-alpha.
66+
- `docs/decisions/030-governed-actions-and-propose-verb.md` +
67+
`docs/plans/governed-actions-mvp-rollout.md` — stamps bumped to
68+
v1.0.683. Plan W10 rewritten to document the helper shape, the
69+
5-conjunct SQL JOIN, and the 6-test coverage breakdown. New
70+
verify symbol anchor on `permissionPromptAddressee` → 23
71+
anchors total now, all green.
72+
73+
### Notes
74+
75+
- **`dispatchAttentionReply` unchanged.** Fan-back still
76+
addresses by `session_id`; the new addressing only affects
77+
which inbox surfaces the row first. Existing
78+
`TestDecide_PermissionPromptFansOutAttentionReply` passes
79+
unchanged.
80+
- **Binding-drift guard is the load-bearing clause.** Without
81+
the `p.project_id = w.project_id` check, a v1.0.605-class
82+
bug where the parent-id pointer survives but the project
83+
binding has drifted would route the row to a steward that
84+
no longer owns the worker. Test 2
85+
(`CrossProjectStewardParent_StaysTeamWide`) is the
86+
regression for that scenario.
87+
- **Principal-override of permission_prompt deferred.** W9
88+
ships override against propose-kind rows;
89+
permission_prompt isn't a propose kind. Widening
90+
`handleAttentionOverride` to handle it would require
91+
per-engine-driver coordination (codex parked-RPC, etc.).
92+
Tracked as a follow-up.
93+
94+
---
95+
2696
## v1.0.682-alpha — 2026-05-24
2797

2898
ADR-030 Phase 1 W9 — principal override. The most complex wedge in

docs/decisions/030-governed-actions-and-propose-verb.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ description: Generalise apply-on-approve to a single MCP verb `propose(kind, tar
1414
> Option 2′; reconciliations with ADR-032 + ADR-034; principal vs
1515
> owner; line-ref drift fixed).
1616
> **Audience:** contributors
17-
> **Last verified vs code:** v1.0.682-alpha
17+
> **Last verified vs code:** v1.0.683-alpha
1818
> **Freshness:** contract
1919
2020
**TL;DR.** Promote *apply-on-approve* from two bespoke branches

docs/plans/governed-actions-mvp-rollout.md

Lines changed: 48 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ description: Wedge-by-wedge execution plan for ADR-030 — generic `propose` MCP
1313
> overlap; principal ≠ owner) and fix file/line drift from
1414
> v1.0.620-636.
1515
> **Audience:** contributors
16-
> **Last verified vs code:** v1.0.682-alpha
16+
> **Last verified vs code:** v1.0.683-alpha
1717
> **Freshness:** contract
1818
1919
**TL;DR.** Close the "approve isn't load-bearing enough" gap by
@@ -526,45 +526,59 @@ decisions_json regardless of the incoming Decision —
526526
callers may pass `decision="approve"` + `override=true`
527527
just as legibly as `decision="override"` + `override=true`.
528528

529-
**W10. `worker_tool_call.escalate` — re-address `permission_prompt` rows raised by steward-parented workers (~90 LOC + 70 LOC tests).**
529+
**W10. `worker_tool_call.escalate` — re-address `permission_prompt` rows raised by steward-parented workers (~70 LOC + ~190 LOC tests). Shipped v1.0.683-alpha.**
530530

531531
- `hub/internal/server/mcp_more.go` `mcpPermissionPrompt` <!-- verify symbol hub/internal/server/mcp_more.go mcpPermissionPrompt -->
532-
(~line 687, function declared at `mcp_more.go:687` as of v1.0.636):
533-
after creating the attention row, check
534-
`agents.parent_agent_id` of the requesting agent.
535-
- **Strict same-project parent-steward predicate** (per
536-
2026-05-20 pre-W1 decision #3). All three clauses must hold or
537-
the row stays team-wide-addressed:
538-
```
539-
worker.parent_agent_id IS NOT NULL
540-
AND parent_agent.kind LIKE 'steward.%' (kind-based — v1.0.607)
541-
AND parent_agent.project_id = worker.project_id
532+
— extended to call the new `permissionPromptAddressee` helper
533+
<!-- verify symbol hub/internal/server/mcp_more.go permissionPromptAddressee -->
534+
before the row INSERT. When the helper returns a non-empty
535+
steward_id, the INSERT writes
536+
`current_assignees_json = [<steward_id>]` AND
537+
`assigned_tier = 'project-steward'`. Otherwise the row stays
538+
team-wide-addressed (`assignees='[]'`, `assigned_tier=NULL`).
539+
- **Strict same-project parent-steward predicate** shipped as
540+
one SQL JOIN with five conjuncts (per 2026-05-20 pre-W1
541+
decision #3):
542+
```sql
543+
SELECT p.id
544+
FROM agents w
545+
JOIN agents p ON p.id = w.parent_agent_id
546+
WHERE w.team_id = ?
547+
AND w.id = ?
548+
AND w.parent_agent_id IS NOT NULL
549+
AND p.kind LIKE 'steward.%'
550+
AND p.project_id IS NOT NULL
551+
AND w.project_id IS NOT NULL
552+
AND p.project_id = w.project_id
542553
```
543-
The third clause (`project_id` match) avoids a v1.0.605-class
544-
bug where the parent-id pointer survives but the project binding
545-
has drifted. When the strict predicate holds, stamp the row with
546-
`assigned_tier = "project-steward"` and `current_assignees_json
547-
= [<parent_steward_id>]`. Otherwise leave the row team-wide-
548-
addressed as today.
554+
The two `IS NOT NULL` guards on `project_id` defend against
555+
SQL's `NULL = NULL → NULL` semantics — without them, two
556+
unbound rows would accidentally match. The third clause
557+
(`p.project_id = w.project_id`) is the binding-drift guard
558+
(v1.0.605-class bug — parent-id pointer survives but
559+
project binding has drifted).
560+
- Best-effort: any non-`ErrNoRows` DB error logs a warn +
561+
returns "" so a transient DB issue degrades to safe
562+
(team-wide).
549563
- `dispatchAttentionReply` is unchanged — the existing fan-back
550564
already addresses by `session_id`; the new addressing only
551565
affects which inbox surfaces the row first.
552-
- Mobile: see W12.
553-
- Tests:
554-
- Worker with same-project steward parent: row addressed to
555-
parent.
556-
- Worker with cross-project steward parent (binding drift):
557-
row stays team-wide-addressed (third clause fails).
558-
- Worker with non-steward parent: row stays team-wide-addressed
559-
(second clause fails).
560-
- Worker without parent (orphan): row stays team-wide-addressed
561-
(first clause fails).
562-
- Steward decides → fan-back to engine driver (codex / claude)
563-
works as before.
564-
- Principal override after steward-approve: emits override
565-
audit; for codex parked-RPC, the driver's existing
566-
`attention_reply` handler re-runs (this is the one case
567-
where override is complex — needs a verification test).
566+
- Tests (6): same-project steward parent addresses row;
567+
cross-project steward parent (binding drift) stays
568+
team-wide; non-steward parent stays team-wide; orphan
569+
worker (no parent) stays team-wide; both-sides-NULL
570+
project_id stays team-wide (the NULL=NULL guard); direct
571+
helper test against ghost worker returns "".
572+
- (Steward-decides → fan-back test deferred — covered by
573+
existing `TestDecide_PermissionPromptFansOutAttentionReply`
574+
which lives in `handlers_attention_permission_prompt_test.go`
575+
and still passes under the W10 changes since `dispatchAttentionReply`
576+
is untouched.)
577+
- (Principal-override-of-codex-parked-RPC complex case
578+
deferred — W9 ships the override path against
579+
propose-kind rows; permission_prompt isn't a propose
580+
kind, so override on it would require widening
581+
`handleAttentionOverride`. Tracked separately.)
568582

569583
**W11. `dispatchAttentionReply` allowlist + fan-back payload + ADR-032 envelope (~40 LOC + 30 LOC tests).**
570584

hub/internal/server/mcp_more.go

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -782,15 +782,31 @@ func (s *Server) mcpPermissionPrompt(ctx context.Context, team, fromID string, r
782782
now := NowUTC()
783783
actorHandle, _ := s.lookupHandleByID(ctx, team, fromID)
784784

785+
// ADR-030 W10: re-address the row to the worker's parent steward
786+
// when the strict same-project predicate holds. Otherwise leave
787+
// it team-wide-addressed (assignees='[]', assigned_tier=NULL) so
788+
// the existing behaviour is unchanged for orphan workers,
789+
// non-steward parents, and binding-drift cases.
790+
assignees := "[]"
791+
assignedTier := sql.NullString{}
792+
if stewardID := s.permissionPromptAddressee(ctx, team, fromID); stewardID != "" {
793+
b, _ := json.Marshal([]string{stewardID})
794+
assignees = string(b)
795+
assignedTier = sql.NullString{String: GovTierProjectSteward, Valid: true}
796+
}
797+
785798
if _, err := s.db.ExecContext(ctx, `
786799
INSERT INTO attention_items (
787800
id, project_id, scope_kind, scope_id, kind,
788801
summary, severity, current_assignees_json, status, created_at,
789-
actor_kind, actor_handle, pending_payload_json
802+
actor_kind, actor_handle, pending_payload_json,
803+
assigned_tier
790804
) VALUES (?, NULL, 'team', NULL, 'permission_prompt',
791-
?, 'minor', '[]', 'open', ?,
792-
'agent', NULLIF(?, ''), ?)`,
793-
id, summary, now, actorHandle, string(payload),
805+
?, 'minor', ?, 'open', ?,
806+
'agent', NULLIF(?, ''), ?,
807+
?)`,
808+
id, summary, assignees, now, actorHandle, string(payload),
809+
assignedTier,
794810
); err != nil {
795811
return nil, &jrpcError{Code: -32000, Message: err.Error()}
796812
}
@@ -839,6 +855,50 @@ func (s *Server) mcpPermissionPrompt(ctx context.Context, team, fromID string, r
839855
}), nil
840856
}
841857

858+
// permissionPromptAddressee returns the parent steward's agent_id
859+
// when the ADR-030 W10 strict same-project parent-steward predicate
860+
// holds for the requesting worker, or "" otherwise. All three
861+
// clauses MUST hold:
862+
//
863+
// 1. The worker has a non-NULL `parent_agent_id`.
864+
// 2. The parent agent's `kind` matches the kind-based steward
865+
// predicate `LIKE 'steward.%'` (v1.0.607 detection rule).
866+
// 3. The parent agent's `project_id` matches the worker's
867+
// `project_id`. This third clause is the binding-drift guard
868+
// — without it, a v1.0.605-class bug where the parent-id
869+
// pointer survives but the project binding has drifted would
870+
// route the row to a steward that no longer owns the worker.
871+
//
872+
// `project_id IS NOT NULL` is required on both sides so SQL's
873+
// `NULL = NULL → NULL` semantics don't accidentally accept two
874+
// unbound rows as "matching".
875+
//
876+
// Best-effort. Any DB error returns "" + a Warn log; the row stays
877+
// team-wide-addressed (the existing pre-W10 behaviour), so a
878+
// transient DB issue degrades to safe.
879+
func (s *Server) permissionPromptAddressee(ctx context.Context, team, workerID string) string {
880+
var stewardID string
881+
err := s.db.QueryRowContext(ctx, `
882+
SELECT p.id
883+
FROM agents w
884+
JOIN agents p ON p.id = w.parent_agent_id
885+
WHERE w.team_id = ?
886+
AND w.id = ?
887+
AND w.parent_agent_id IS NOT NULL
888+
AND p.kind LIKE 'steward.%'
889+
AND p.project_id IS NOT NULL
890+
AND w.project_id IS NOT NULL
891+
AND p.project_id = w.project_id`,
892+
team, workerID).Scan(&stewardID)
893+
if err == nil {
894+
return stewardID
895+
}
896+
if !errors.Is(err, sql.ErrNoRows) {
897+
s.log.Warn("permission_prompt addressee lookup", "worker_id", workerID, "err", err)
898+
}
899+
return ""
900+
}
901+
842902
// waitForAttentionResolution polls attention_items until status='resolved'
843903
// (or ctx fires). Returns the full last decision dict so callers that
844904
// care about extra fields (notably option_id from request_select) can

0 commit comments

Comments
 (0)