docs(adr): ADR-3646 — per-task external-tracker content-resolution seam (#3991)

Resolves the four conditions on the approved-feature verdict for #3646:
new execute:task granularity tier below wave, hard-halt enforced via a
code-side resolver seam (Lens B) rather than prose dispatch (since #3647's
dispatch-reliability defect is still open), registration/validation
requirements for loop-hook-dispatch.md + capability-validator.cjs, and
autonomous-mode behavior via a new non-gate kind.

Closes #3969

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-28 09:32:37 -04:00
committed by GitHub
parent cf3eb84b3f
commit b351c83e03
2 changed files with 201 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
# ADR-3646: Per-task external-tracker content-resolution seam
| | |
|---|---|
| **Status** | Proposed |
| **Date** | 2026-08-27 |
| **Issue** | [#3646](https://github.com/open-gsd/gsd-core/issues/3646) |
| **Phase-0 sub-issue** | [#3969](https://github.com/open-gsd/gsd-core/issues/3969) |
| **Implementation phase** | [#3970](https://github.com/open-gsd/gsd-core/issues/3970) |
| **Related** | [#3554](https://github.com/open-gsd/gsd-core/issues/3554) (generic `ship:pre` gate/step dispatch — same class of gap, independent) · [#3647](https://github.com/open-gsd/gsd-core/issues/3647) (lifecycle-dispatch steps intermittently skipped) · [#3606](https://github.com/open-gsd/gsd-core/issues/3606) (validator checks hook existence, not dispatch — CLOSED) |
| **Reference** | [`loop-hook-dispatch.md`](../../gsd-core/references/loop-hook-dispatch.md), [`autonomous-ui-design-contract.md`](../../gsd-core/references/autonomous-ui-design-contract.md) |
## Context
`gsd-core/workflows/execute-plan.md`'s `<step name="execute">` reads every task's instructions
(`<read_first>`, `<action>`, `<verify>`, `<acceptance_criteria>`, `<done>`, etc.) directly out of
the `PLAN.md` task block. There is no seam for a capability to resolve that content from an
external issue tracker (beads, Linear, Jira, GitHub Issues, …) by an id carried in the task block
instead. A project that wants the tracker to own task *content* — not just task *status* — has to
patch the workflow file locally. #3646 asks for a native, opt-in seam.
The Feature Review Report on #3646 approved this `go-with-conditions`, with four conditions this
ADR exists to resolve before any implementation lands.
### The existing extension-point system
`gsd-core/references/loop-hook-dispatch.md` defines the dispatch contract consumed by
`gsd_run loop render-hooks <point>`. Twelve points exist today, all phase- or wave-scoped:
`discuss:pre`, `discuss:post`, `plan:pre`, `plan:post`, `execute:pre`, `execute:wave:pre`,
`execute:wave:post`, `execute:post`, `verify:pre`, `verify:post`, `ship:pre`, `ship:post`. A
search for `task:pre` / `task:post` / `execute:task` across the tree returns nothing — the finest
granularity in the system is the wave, one level coarser than what #3646 asks for.
Each point's `activeHooks` array carries entries of `kind: "contribution" | "step" | "gate"`,
dispatched by prose instructions in the consuming workflow file: `step` invokes a Skill/Agent/
command; `gate` evaluates a check and blocks or advises depending on `blocking`. Both kinds are
**natural-language instructions inside a markdown workflow document**, executed by whichever LLM
is driving the loop.
### Why that mechanism cannot deliver this feature's safety property
The seam's own stated purpose is a **hard halt** on an unresolvable id — deliberately, because a
silent fall-back to inline `PLAN.md` text would require the tracker and `PLAN.md` to stay in sync
forever, defeating the point of moving content out of `PLAN.md`.
But #3647 (filed the same day as #3646, still open) reports that lifecycle-dispatch steps are
**intermittently skipped in real usage** — 1 of 4 wave-close dispatches observed firing. #3606
(closed) documented a related but distinct failure: the capability validator checked that a hook
call site *existed*, not that it *dispatched*, so a wired-and-enabled hook could be declared and
silently never run; that specific validator gap is now closed, but #3647's broader dispatch-
reliability problem is not.
A per-task point built as a `step`/`gate` entry in `activeHooks`, dispatched the same way the
twelve existing points are, inherits that reliability profile. Per-task dispatch multiplies the
number of dispatch opportunities by the task count — a plan with 20 tasks creates 20 chances for
the dispatch to silently not fire, instead of the 1-per-wave exposure the existing points carry.
When a per-task dispatch does not fire, nothing errors: the executor simply proceeds to read the
task's inline `PLAN.md` body. That is **exactly branch 3 of the proposal** (the legitimate
pre-migration fall-back for tasks authored before a tracker migration) — a missed dispatch and a
genuine pre-migration task are indistinguishable at the point of failure, so the executor edits
stale content while believing it authoritative. This reproduces the exact hazard the proposal's
own hard-halt requirement exists to prevent.
## Decision
### 1. Granularity tier
This ADR adds a **thirteenth extension point, at a new tier below wave**: `execute:task`. It sits
inside the existing `execute:wave:pre` / `execute:wave:post` bracket, evaluated once per task,
immediately before that task's `read_first` gate (`execute-plan.md:221`) — the `read_first` list
is itself a field this point can resolve, so a point evaluated after `read_first` would gate on
content that has not been fetched yet.
`execute:task` is **not registered as a `contribution` / `step` / `gate` entry in
`loop-hook-dispatch.md`'s existing `kind` vocabulary.** Decision 2 explains why a new `kind` is
required rather than reusing one of the three.
Ordering within one task: `execute:task` → `read_first` gate → task body (`type="auto"` /
`"tracer"` / `"checkpoint:*"`) → `acceptance_criteria` hard gate. A `checkpoint:*` task never
enters `execute:task` — its interactive structure (options, pros/cons, resume-signal) is
irreducibly a human-facing prose step and stays sourced from `PLAN.md`. Plan-level sections
(`<objective>`, `<context>`, `<verification>`, `<success_criteria>`) are out of scope for this
point; `load_prompt`'s whole-file read is unchanged.
### 2. Hard-halt enforcement: Lens B — a code-side resolution seam
**Chosen over Lens A** (a prose `step`/`gate` dispatched the same way as the twelve existing
points), for the reason argued in Context: #3647 is open, so any `step`/`gate`-shaped
implementation inherits a live, unresolved reliability defect and cannot deliver the feature's own
safety property. The Feature Review Report's Lens B recommendation is adopted as-is; Lens A is
rejected outright rather than sequenced behind #3647, because sequencing behind an open reliability
issue with no committed fix date blocks #3646 indefinitely on someone else's timeline for no
architectural gain — Lens B is buildable today and is strictly the safer shape regardless of
whether #3647 is ever fixed.
`execute:task` resolution is a **real subprocess invocation with a real exit code**, not an LLM
instruction:
```bash
gsd_run task resolve-content --plan "<PLAN.md path>" --task-id "<task id>" --raw
```
Contract:
- **No capability registers a task-content resolver for this plan** → the command exits `0` with
`{"resolved": false}`. The workflow proceeds to read the task's inline `PLAN.md` body exactly as
it does today. This is the unconditional default for every existing project — the seam is
additive and opt-in.
- **A capability registers a resolver and the resolution fails** (tracker unreachable, id not
found, malformed response) → the command **exits non-zero**. `execute-plan.md`'s `execute` step
treats any non-zero exit from `task resolve-content` as a hard halt for that task: surface the
id, the tracker, and the raw error, and stop. This is enforced by the same mechanism every other
`gsd_run` subcommand's failure is enforced by (`src/cli-exit.cts`'s `runMain` / `ExitError`
seam — see [ADR-3889](3889-process-exit-contract.md)) — a real process exit code the calling
loop cannot fail to observe the way it can fail to execute a prose instruction.
- **A capability registers a resolver and resolution succeeds with non-empty content** → exits `0`
with `{"resolved": true, "content": {...}}`; that payload's fields stand in for the task's
`<read_first>` / `<action>` / `<verify>` / `<acceptance_criteria>` / `<done>` PLAN.md elements
for every downstream gate in the `execute` step (the `read_first` MANDATORY gate, the
`acceptance_criteria` HARD GATE, `<verify>`/`<done>` checks).
- **A capability registers a resolver and resolution succeeds with empty/absent content** → exits
`0` with `{"resolved": false, "reason": "empty"}` — the documented pre-migration boundary case;
the workflow falls back to the inline `PLAN.md` body. This is the *only* legitimate fall-back
path, and it is now distinguishable from a missed dispatch: a missed dispatch cannot happen
because there is no dispatch to miss — the resolver either runs (and reports which of the three
outcomes above occurred) or is absent by construction (`resolved: false`, no resolver
registered).
Resolver registration itself (how a capability declares "I own task-content resolution for this
project") is implementation detail for #3970, constrained by Decision 3.
### 3. Registration and validation
`execute:task` is declared in `gsd-core/references/loop-hook-dispatch.md` in the same change that
implements it (#3970), as a new section documenting the `kind: "resolver"` shape (Decision 4) and
the `gsd_run task resolve-content` contract above — not as a new entry in the existing
`contribution`/`step`/`gate` dispatch-rules list, since it is dispatched differently (a required
subprocess call with a binding exit code, not a best-effort prose step). `gsd-core/bin/lib/
capability-validator.cjs` is extended in the same change to recognize a manifest declaring a
task-content resolver, so a capability that declares one but never wires the corresponding command
fails validation at install time rather than failing silently at first use — closing, for this new
point, the exact class of gap #3606 closed for the existing twelve.
### 4. Autonomous-mode behavior
`gsd-core/references/autonomous-ui-design-contract.md:34` records that `activeHooks` entries with
`kind == "gate"` are silently ignored on the autonomous path, because autonomous mode is always
pipeline mode and has no blocking-gate UI to route through. `execute:task` is **not** a `gate` —
it is the new `kind: "resolver"` — so that silent-ignore rule does not apply to it and does not
need an exception carved out. `execute:task` runs identically in autonomous and interactive modes:
a required subprocess call whose exit code is either observed (halt) or falls through by design
(`resolved: false`). Autonomous mode has no discretion to skip it, because there is no discretion
built into a `kind` that autonomous mode's dispatcher does not special-case at all — the contract
is honored by omission, not by an added branch.
## Rejected alternatives
- **Lens A, unconditionally** — matches the established `step`/`gate` pattern, cheapest to build,
reviewable in one PR. Rejected: cannot deliver the hard-halt guarantee while #3647 is open (see
Context); multiplies dispatch-reliability exposure by task count.
- **Lens A, sequenced behind a fix to #3647** — would deliver the guarantee once #3647 lands.
Rejected: blocks #3646 on an unscoped, undated fix to a different issue, for no benefit over
Lens B, which needs no such dependency and is architecturally the more precise fit (a resolution
failure is a data-fetch failure, which is what process exit codes exist to signal — not a
workflow-routing decision, which is what the `step`/`gate` vocabulary exists for).
- **Reusing `gate` with `kind: "gate"` and `blocking: true`** — would at least be checked by the
existing dispatch contract. Rejected: `blocking: true` gates still route through the autonomous
silent-ignore rule at `autonomous-ui-design-contract.md:34` for *other* points sharing that
`kind`, and reusing the tag would require carving out a per-point exception to that rule rather
than making the safety property structural. It also still depends on the LLM executing the
dispatch instruction at all, which is the reliability problem this ADR exists to route around.
- **A per-task point that falls back to inline `PLAN.md` on ANY resolver error** (matching
branch-3 semantics for both failure and pre-migration) — rejected explicitly by the issue itself:
it requires the tracker and `PLAN.md` to stay in sync forever, defeating the purpose of the
feature.
## Consequences
- `execute-plan.md`'s per-task loop gains one new required call before the `read_first` gate,
for every task, on every plan — including plans with no resolver registered, where it is a
fast, always-`{"resolved":false}` no-op. This is a small, unconditional per-task cost in
exchange for the hard-halt guarantee being real.
- A new `kind: "resolver"` is added to the dispatch vocabulary alongside `contribution` / `step` /
`gate`. Any future per-task or finer-grained point that needs a binding (not best-effort)
outcome has a precedent to follow instead of inventing its own shape.
- `capability-validator.cjs` grows a new manifest shape to validate, and every runtime's emitted
copy of `execute-plan.md` carries the new step through the installer — this puts the change
under the workflow-content gates (`docs/INVENTORY.md`, manifest regen, `size:baseline`) for
#3970, not the doc-only exemption this ADR itself qualifies for.
- The `beads`/`bd` local patch referenced in #3646 becomes portable: its three branches (halt on
fetch failure, non-empty description supersedes PLAN.md, empty description falls back) map
directly onto the three `gsd_run task resolve-content` outcomes above, so #3970 can validate the
new seam against that existing, already-proven-in-production patch.
## Open questions for #3970
- Exact resolver-registration surface in the capability manifest schema (a new `contributions`
entry shape, most likely — concrete design left to the implementation phase).
- Whether `gsd_run task resolve-content` needs a `--dry-run`/preview mode for `gsd-plan-checker`
to validate resolver reachability before execution starts, rather than only at first task.

View File

@@ -276,6 +276,7 @@ Decided in principle, not yet ratified. Do not cite as settled architecture.
| [ADR-1671](1671-dynamic-context-management-platform.md) | Dynamic context management platform | Proposed | — |
| [ADR-1953](1953-complexity-triggered-refactor.md) | Complexity-triggered refactor — the loop measures the entropy it just added | Proposed | — |
| [ADR-3128](3128-adaptive-runtime-evidence.md) | Adaptive runtime evidence for GSD Debug | Proposed | — |
| [ADR-3646](3646-per-task-content-resolution-seam.md) | Per-task external-tracker content-resolution seam | Proposed | — |
| [ADR-3889](3889-process-exit-contract.md) | One exit-code registry — 0 and 1 are free, everything else is allocated | Proposed | — |
| [ADR-3942](3942-emitted-drift-ack-commit-trailer.md) | The emitted-drift acknowledgment is PR-lifetime data — it belongs in a commit trailer, not the working tree | Proposed | — |