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:
200
docs/adr/3646-per-task-content-resolution-seam.md
Normal file
200
docs/adr/3646-per-task-content-resolution-seam.md
Normal 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.
|
||||||
@@ -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-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-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-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-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 | — |
|
| [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 | — |
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user