fix(#4594): one canonical dispatch-identity owner — the emitted format and the parser that reads it back (#4693)
* fix(#4594): give dispatch identity one owner for the emitted format and its parser The isolation guards decided whether a run-scoped sentinel applied to a dispatch by regex-scraping model-authored prose. The scrape returned values in a different namespace from the ones the sentinel records, so the comparison could never succeed: sentinel { phase: "03", plan: "03-02-hardening" } <- $PHASE_NUMBER / $plan_id prose "Execute plan 02 of phase 03-auth." scraped { phase: "03-auth.", plan: "02" } <- greedy (\S+), both wrong #4594 reports only the phase half. Measured against a real phase-plan-index run, plans[].id is phase-prefixed, plan-numbered AND slugged, while the prose carries a bare in-phase plan number — so the plan field mismatches too, and the Claude path is dead rather than latent. A fresh sentinel was therefore discarded on every executor dispatch and every legitimate ISOLATION=none degrade was denied, leaving the work unrun. hooks/lib/dispatch-identity.js is now the single owner of both halves. The two prompt-body producers emit a canonical marker carrying the same shell values the sentinel records, so producer and consumer agree by construction. The prose frame stays as a fallback, bounded by the phase-token grammar ADR-2121 owns and deliberately reporting no plan — an absent identifier means "cannot compare" and is safe; a wrong one is a false mismatch and is not. The prose sentence itself is byte-identical: the executor agent reads it too, so the marker is purely additive (Hyrum's Law). An inapplicable sentinel is now named in the guards' deny reason instead of being dropped silently — the silence is why this survived three producers and two consumers unnoticed. Interpolated values come from a sentinel file and from prompt text, so both are length-bounded and stripped of control characters. ADR-4630 locks the seam and maps the epic's three phases. Refs #4630 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4594): resolve eight review findings across the dispatch-identity seam Three orthogonal review engines ran on 43418af144 — the code-review skill's Standards and Spec axes, and an isolated adversarial security pass — plus a self-review of the committed diff. Every finding is fixed here; none deferred. F1 (major, reproduced). A keyless or unknown-key-only marker — the literal "[gsd:dispatch]" or "[gsd:dispatch run=..]" — matched the marker grammar and returned source:'marker' with both fields null, suppressing the prose fallback entirely. Any prompt text containing that literal silently disabled identity narrowing, so a fresh sentinel applied to a dispatch it was never scoped to, defeating #3045 SECURITY F2. Prompt text is attacker-influenceable. A marker that yields neither recognized key is no longer a marker: the scan continues to later markers, then later texts, then prose. Forward-compatible tolerance of unknown keys is unchanged. F2/F3 (major). The first cut duplicated sanitizeForReason, describeSentinelDiscard and REASON_INTERPOLATION_MAX_LEN byte-for-byte across both guards — the exact defect class this epic exists to delete, and with no cold-load justification, since both hooks already require hooks/lib/. They now live in hooks/lib/isolation-deny-reason.js, and buildSentinelDiscard lives in isolation-sentinel.js beside the comparison it mirrors, returning the nested {sentinel:{phase,plan}, dispatch:{phase,plan}} shape instead of a bespoke four-field bag that renamed the pairs already flowing through the seam. F4 (hard violation). The visibility test asserted on the deny reason's prose. CONTRIBUTING.md prohibits raw text matching on hook output, which is why every deny carries a stable reason_code. The discard is now a structured sentinel_discarded field on each hook's stdout JSON, and the test asserts that; the sentence stays for the operator but is no longer the contract. F5 (hard violation). The 64-character truncation limit had no boundary coverage. 63/64/65 are now exercised against the single consolidated helper. F6 (minor). sanitizeForReason stripped C0/C1 controls but not U+2028/U+2029 or the bidi overrides, so a crafted value could still reflow or reverse the message. Both classes are stripped, with a test each. F7 (major). The producer/template parity test was vacuous — it rendered a marker and re-parsed its own output, and would have passed with both templates deleted. It now reads the two workflow templates, extracts each marker line, substitutes the measured values and asserts the owner's parser returns them. Proven red by deleting one template's marker line before being proven green. F8 (doc). ADR-4630 and the design notes claimed the marker is guaranteed on the orchestrator-worktree path because that prompt is built in shell. It is not: executor-isolation-dispatch.md:131 says plainly that those are template placeholders, not shell variables, so {plan_id} is model-substituted there too. A false guarantee in a design lock is worse than a stated limit. Both documents now say the marker is model-substituted on both paths and that the prose fallback is the real floor everywhere. The "3 workflow templates" count was also wrong — 3 prose sites across 2 files, 2 of which carry the marker. Refs #4630 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#4594): refresh the compact-content baseline and acknowledge execute-phase.md growth Refs #4630. The dispatch-identity marker and its substitution note grew gsd-core/workflows/execute-phase.md by 525 bytes (91846 -> 92371), which drifts two real-tree guards that lint:ci does not run: - tests/benchmark-compact-content.test.cjs asserts the committed baseline is "up to date"; the split for execute-phase.md moved off 25827 -> 25952 and on 23576 -> 23701, taking its compaction reduction 8.72% -> 8.67%. Baseline regenerated with scripts/benchmark-compact-content.cjs --write. - tests/emitted-attribution.test.cjs requires a growth acknowledgment trailer for any emitted file that grows, keyed on the bare filename. Added below. The growth is two additions and no rewrites: the [gsd:dispatch ...] marker line inside the Agent() prompt's <objective>, and the note telling the orchestrator to substitute {plan_id} with the plan's id verbatim. Both are load-bearing -- the marker is what lets a guard hook match a dispatch to the sentinel the per-plan gate wrote, and without the note the orchestrator has no instruction telling it the value must not be paraphrased. Emitted-Drift-Ack-Growth: execute-phase.md — adds the canonical [gsd:dispatch] identity marker and its {plan_id} substitution note, which the isolation guards compare verbatim against the run-scoped sentinel (#4594) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#4594): set changeset fragment pr to 4693 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
211
docs/adr/4630-dispatch-identity-and-isolation-decision-seam.md
Normal file
211
docs/adr/4630-dispatch-identity-and-isolation-decision-seam.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# ADR-4630: One Canonical Dispatch-Identity Owner and a Recorded Isolation Decision
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-09-13
|
||||
- **Issue:** [#4630](https://github.com/open-gsd/gsd-core/issues/4630) — epic (`type: chore`, `area: agents`)
|
||||
- **Absorbs:** [#4222](https://github.com/open-gsd/gsd-core/issues/4222), [#4561](https://github.com/open-gsd/gsd-core/issues/4561), [#4594](https://github.com/open-gsd/gsd-core/issues/4594) — three `confirmed-bug` issues that are the same missing owner reported at two ends of one wire
|
||||
- **Framed by:** [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) (host-integration interface — agent dispatch is interface point 2)
|
||||
- **Pattern copied from:** [ADR-2121](2121-phase-identifier-parsing-consolidation.md) (phase-identifier consolidation: seam + migration + anti-divergence guard). This ADR reuses its phase-token grammar and its drift-lint shape deliberately.
|
||||
- **Scope note:** unlike ADR-2121, this ADR does **not** land as a code-free Phase 0. It ships alongside Phase 1's implementation because the epic's phase sub-issues could not be created in the session that executed it; the decisions below were nonetheless fixed before that implementation was written, and Phases 2 and 3 execute against this file.
|
||||
|
||||
## Context
|
||||
|
||||
A GSD phase execution decides, per dispatch, whether the executor runs isolated in a git
|
||||
worktree or sequentially in the primary checkout. That decision is:
|
||||
|
||||
1. **produced** in workflow shell (`execute-phase/steps/executor-isolation-dispatch.md`, `per-plan-worktree-gate.md`, `references/dispatch-isolation-gate.md`, `execute-plan.md`),
|
||||
2. **transported** through a run-scoped sentinel at `.gsd/dispatch-isolation-sentinel.json`,
|
||||
3. **consumed** by two guard hooks (`hooks/gsd-agent-isolation-guard.js` for Claude-shaped hosts, `hooks/gsd-cursor-subagent-start.js` for Cursor).
|
||||
|
||||
At every hop, both sides hand-roll their own copy of the format. #4561 states the census
|
||||
directly: the dispatch-isolation membership is **hand-written at eight uncross-checked
|
||||
sites**. The consequence is not a style complaint — it is three confirmed bugs:
|
||||
|
||||
| Issue | Defect |
|
||||
|---|---|
|
||||
| #4222 | A plain `dispatch-isolation` re-query re-resolves the host *capability* and re-persists it, clobbering every shell-computed degrade but the #3737 opt-out. The guard then denies the mandated sequential dispatch with `exit 2`. |
|
||||
| #4561 | #4232's re-derivation fix reaches only producers whose degrade is re-derivable from a file the resolver reads. Three producers compute their degrade from call-site context that is **not on disk** and are structurally unreachable that way. |
|
||||
| #4594 | `extractDispatchIdentifiers` cannot parse the canonical prompt-body dispatch text, so the sentinel match never succeeds. |
|
||||
|
||||
### What #4594 actually is, measured
|
||||
|
||||
The reported symptom is a greedy capture: `/execute\s+plan\s+(\S+)\s+of\s+phase\s+(\S+)/i`
|
||||
swallows the `-{phase_name}` suffix and the sentence-terminating period, so
|
||||
`Execute plan 02 of phase 03-auth.` yields phase `03-auth.` and can never equal the
|
||||
sentinel's `03`.
|
||||
|
||||
That is true and incomplete. Running `phase-plan-index` against a real fixture shows
|
||||
`plans[].id` is `03-02-hardening` — phase-prefixed, plan-numbered **and slugged** — and
|
||||
`per-plan-worktree-gate.md:17` records exactly that value as the sentinel's `plan`. The
|
||||
prose, meanwhile, carries a bare in-phase plan number (`02`). The two fields therefore live
|
||||
in **different namespaces**, and the plan comparison mismatches on *every* dispatch, on
|
||||
*both* hosts. The issue calls the Claude path "latent rather than dead"; it is dead too, for
|
||||
a second reason the issue does not name. Fixing only the greedy capture would have shipped
|
||||
as a green, fully-tested no-op.
|
||||
|
||||
### Why this recurs rather than staying fixed
|
||||
|
||||
Repairing each symptom where it was reported leaves the missing owner missing, and the next
|
||||
occurrence is already being written elsewhere in the tree. The recurrence has a mechanical
|
||||
root, and it is the same one ADR-2121 identified for phase identifiers: **a format with no
|
||||
single owner is re-derived at each site, and two copies that agree today are the same defect
|
||||
as two that disagree.**
|
||||
|
||||
## Decision
|
||||
|
||||
### Decision 1 — one module owns the emitted format *and* the parser that reads it back
|
||||
|
||||
`hooks/lib/dispatch-identity.js` owns both halves together:
|
||||
|
||||
- `renderDispatchIdentityMarker({phase, plan})` — the format producers emit.
|
||||
- `parseDispatchIdentity(...texts)` — the pattern consumers read back.
|
||||
|
||||
Producers emit a structured marker carrying the **same shell values the sentinel records**:
|
||||
|
||||
```text
|
||||
[gsd:dispatch phase="03" plan="03-02-hardening"]
|
||||
```
|
||||
|
||||
`$PHASE_NUMBER` and `$plan_id` are already in scope at every producer site. Producer and
|
||||
consumer then agree *by construction*, independently of how the surrounding prose reads or
|
||||
whether a model paraphrases it.
|
||||
|
||||
The prose frame remains as a fallback, with the greedy `(\S+)` replaced by the phase-token
|
||||
grammar ADR-2121 already owns, and with `plan` deliberately **not** reported — the prose
|
||||
plan token is a different namespace from `plan_id`, and reporting it is the bug. The
|
||||
fallback is therefore **correct-or-absent** in both fields: an absent identifier means
|
||||
"cannot compare" and is safe by the existing contract; a wrong one is a false mismatch and
|
||||
is not.
|
||||
|
||||
**The owner lives in `hooks/lib/`, not `src/`.** The guard hooks must load on a raw
|
||||
plugin-marketplace install where the compiled `gsd-core/bin/lib/` is absent and the
|
||||
self-healing build seam has not run — a hook that dies at module load is worse than one
|
||||
carrying a mirror. A `src/*.cts` owner would force either a top-level require of the
|
||||
compiled lib (breaking that install) or a second mirrored copy (the exact defect this epic
|
||||
removes). Nothing in `src/**` needs this format: the producers are workflow *markdown* and
|
||||
the consumers are *hooks*.
|
||||
|
||||
### Decision 2 — the isolation decision is a recorded fact, not a re-derived one
|
||||
|
||||
A producer writes an `IsolationDecision` (the value, which producer decided it, and its run
|
||||
scope); consumers **read** it. A plain re-query must not overwrite a recorded decision.
|
||||
|
||||
Re-derivation is the wrong primitive for the three producers #4561 names, because each
|
||||
computes its degrade from call-site context that does not exist on disk:
|
||||
|
||||
| Producer | Why re-derivation cannot reach it |
|
||||
|---|---|
|
||||
| `gsd-core/references/dispatch-isolation-gate.md` | the `orchestrator-worktree` fallback fires only at a subagent-tool-only dispatch site; the resolver cannot see which tool the caller has |
|
||||
| `gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md` | the #2474 per-plan submodule intersection is a property of the plan set, not the repo |
|
||||
| `gsd-core/workflows/execute-plan.md` | Pattern B's unisolated segments are by design; which segment is executing is not on disk |
|
||||
|
||||
**A decision that cannot be recomputed must be recorded and honoured, not recomputed and
|
||||
overwritten.** #4232's base-check re-derivation stays — it is correct for the producers it
|
||||
covers, and this decision builds on it rather than replacing it.
|
||||
|
||||
### Decision 3 — a copy that must exist is pinned by a parity test, never merely tolerated
|
||||
|
||||
Decision 1's cold-load constraint means `hooks/lib/` carries deliberate mirrors of two
|
||||
things it cannot import: the phase-token grammar (from `src/phase-id.cts`) and the isolation
|
||||
vocabulary (from the Phase 2 owner). Each mirror is **pinned by a parity assertion that reds
|
||||
when the owner changes**.
|
||||
|
||||
This matters more than it looks. A drifted grammar mirror fails as a **non-match** — nothing
|
||||
throws, no test that only feeds the old shape notices, and the symptom is a guard that
|
||||
quietly stops recognizing valid input. That is the failure mode that produced #4594 and it
|
||||
is invisible without a pin.
|
||||
|
||||
A mirror's justification is part of the contract: **do not "clean up" a pinned mirror
|
||||
without replacing the reason it exists.**
|
||||
|
||||
### Decision 4 — the anti-divergence lint is part of the deliverable, not a follow-up
|
||||
|
||||
A drift lint asserts that (a) every dispatch-identifier template in
|
||||
`gsd-core/workflows/**/*.md` and every parsing regex in `hooks/**` derives from the single
|
||||
exported token source, and (b) every dispatch-isolation producer site appears in the
|
||||
canonical membership list. It is modelled on `scripts/lint-phase-id-drift.cjs`.
|
||||
|
||||
Two lessons from #2121 are binding:
|
||||
|
||||
- Compare the regex derived from the **source string** (`.source` / `.flags` against the
|
||||
canonical token), not the rendered literal.
|
||||
- A sanctioned exemption is a **dedicated comment line** that states which half is
|
||||
deliberate — never a line-level substring escape. Partial ownership is drift, and a
|
||||
line-level escape waves through exactly the case under review.
|
||||
|
||||
Decisions 1 and 2 without Decision 4 un-consolidate quietly as soon as someone who has not
|
||||
read this ADR touches the area. **The lint must be demonstrated red before it is
|
||||
demonstrated green** — a drift guard that has never failed is not evidence.
|
||||
|
||||
### Decision 5 — an inapplicable sentinel is reported, never silently discarded
|
||||
|
||||
When a fresh sentinel exists but does not apply to a dispatch, the guard states so in its
|
||||
deny reason and names both sides' identifiers.
|
||||
|
||||
This is Postel's Law's "be liberal but *visible*" half, and its absence is why the defect
|
||||
survived three producers and two consumers unnoticed: the guard discarded a fresh sentinel
|
||||
and then denied with a message about registry resolution that never mentioned the sentinel
|
||||
existed. Values interpolated into that message come from a sentinel file and from
|
||||
model-authored prompt text — both untrusted — so each is length-bounded and stripped of
|
||||
control characters first, matching the escaping discipline `phase-plan-index` already
|
||||
applies to a `depends_on` token.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
1. **Repair the three absorbed issues at their existing call sites.** This is the pattern
|
||||
that produced them. It leaves the epic open with every symptom gone and CI green — the
|
||||
specific outcome the epic's scoping exists to prevent.
|
||||
2. **Normalize the plan comparison** — strip the phase prefix and slug and compare the
|
||||
middle segment. Invents a cross-namespace equivalence no producer guarantees, and
|
||||
silently restores wrong-value-instead-of-no-value the moment plan-id naming changes.
|
||||
Returning `null` is honest; a heuristic match is not.
|
||||
3. **Carry a structured per-dispatch kwarg through the Agent/Task protocol.** This is the
|
||||
correct end state and `hooks/lib/isolation-sentinel.js` has named it as such since #3045.
|
||||
Rejected *for now* under Gall's Law: it is a 19-runtime descriptor change and an
|
||||
ADR-1239-level negotiation change. The marker is the simple version that works, and
|
||||
growing one into the other is the sanctioned path.
|
||||
4. **Own the format in `src/dispatch-identity.cts`.** Breaks the cold-tree hook load
|
||||
contract, or forces the second mirror this epic exists to delete.
|
||||
5. **Delete `sentinelAppliesToDispatch`'s plan/phase narrowing**, on the grounds that it has
|
||||
never worked so removing it changes nothing observable. It is #3045 SECURITY F2's defence
|
||||
against a stale sentinel from an earlier phase authorizing a later dispatch. The marker
|
||||
makes it work for the first time; deleting it would trade one silent hole for another.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Accepted cost.** `[gsd:dispatch k="v"]` is a homegrown key/value mini-syntax, and
|
||||
Greenspun's Tenth Rule is the standing warning against letting one grow. It is therefore
|
||||
deliberately inert: exactly two recognized keys, flat, no nesting, no conditionals, no
|
||||
variables, no escaping rules beyond quoted values, and unrecognized keys ignored so a later
|
||||
addition cannot break a deployed parser. **Revisit condition, stated so it is actionable:
|
||||
the moment this marker needs a third semantic key or any structure, stop and implement
|
||||
alternative 3 instead.**
|
||||
|
||||
**Accepted limit.** The marker is emitted by a model following a template on BOTH paths —
|
||||
the harness path AND the orchestrator-worktree path. Per
|
||||
`executor-isolation-dispatch.md:131`, the orchestrator-worktree path's `{plan_number}` and
|
||||
`{phase_number}` "are template placeholders, not shell variables," and `{plan_id}` is
|
||||
model-substituted there exactly as on the harness path — there is no shell-built prompt on
|
||||
either path, so the marker is NOT guaranteed on either. A model that drops it degrades to
|
||||
the prose fallback everywhere. That fallback is now correct-or-absent, which is sufficient
|
||||
to resolve #4594 on the phase field alone, but plan-level scoping is exact only when the
|
||||
marker survives. The prose fallback is therefore the real floor on both paths.
|
||||
|
||||
**Hyrum's Law constraint.** The prose sentence `Execute plan X of phase Y` is read by the
|
||||
executor agent itself, not only by the guards. It stays **byte-identical**; the marker is
|
||||
purely additive. Any future change to that sentence is a behavior change to every executor
|
||||
dispatch, not a formatting edit.
|
||||
|
||||
**Out of scope.** The sentinel remains a `cwd`-derived, gitignored file; #3045 SECURITY F3's
|
||||
accepted forgery risk is unchanged. This ADR changes how the decision is transported, not
|
||||
when a degrade should occur.
|
||||
|
||||
## Phase mapping
|
||||
|
||||
| Phase | Delivers | Decisions |
|
||||
|---|---|---|
|
||||
| 1 | `hooks/lib/dispatch-identity.js`; producers emit the marker; both guards parse through the owner; #4594 regression | 1, 3 (grammar mirror), 5 |
|
||||
| 2 | Isolation-vocabulary owner; recorded `IsolationDecision` that a re-query holds; #4222 / #4561 regressions | 2, 3 (vocabulary mirror) |
|
||||
| 3 | `scripts/lint-dispatch-identity-drift.cjs`, demonstrated red then green | 4 |
|
||||
|
||||
Each deliverable is claimed by exactly one phase. The epic stays open until Phase 3 lands.
|
||||
@@ -269,6 +269,7 @@ These govern the system as it stands. Cite these.
|
||||
| [ADR-3806](3806-review-dispositions-ledger.md) | Review Dispositions Ledger canonizes where and how reviews-mode records incorporate/defer decisions in PLAN.md | Accepted | — |
|
||||
| [ADR-4139](4139-compact-content-seam.md) | The compact-content seam — shrink the eager window, never the guarantee | Accepted | — |
|
||||
| [ADR-4593](4593-macos-conformance-tier-architecture.md) | A macOS-specific conformance-tier classifier, separate from the Windows-oriented one | Accepted | — |
|
||||
| [ADR-4630](4630-dispatch-identity-and-isolation-decision-seam.md) | One Canonical Dispatch-Identity Owner and a Recorded Isolation Decision | Accepted | — |
|
||||
| [ADR-4641](4641-windows-selector-consolidation.md) | One Windows test selector, and a proportional ceiling on the conformance tier | Accepted | — |
|
||||
|
||||
### Proposed
|
||||
|
||||
Reference in New Issue
Block a user