Skip to content

Deliver: Per-Wave Back-Pressure Contract (#89, #90) - #105

Merged
seanrreid merged 31 commits into
mainfrom
rad/wave-back-pressure
Aug 10, 2026
Merged

Deliver: Per-Wave Back-Pressure Contract (#89, #90)#105
seanrreid merged 31 commits into
mainfrom
rad/wave-back-pressure

Conversation

@seanrreid

Copy link
Copy Markdown
Owner

Delivers the approved plan .agents/plans/wave-back-pressure.md.

Closes #89
Closes #90

The defect

RAD already ran a deterministic gate after every wave whose outcome would advance,
demoting successfail-tests through the matrix. But the signal flowing through
that gate was scripts/check-tests-present.sh — a file-presence check.

A wave could create every promised test file, have all of them fail, and the gate
passed.

The only thing that actually executed verification was the wave agent, following
prose in the wave prompt and self-classifying the result. The harness never saw the
command, never ran it, never saw its output. That is the trusted-prose pattern the
deliver gate exists to eliminate everywhere else in RAD.

Compounding it (#90): every retry rebuilt an identical prompt, so a retry differed
from its predecessor only by model nondeterminism. RAD had a doom-loop detector for a
loop its own retry path structurally guaranteed.

What changed

A ### Wave N block may now carry an optional Verify: line. When present, the harness
executes it via scripts/check-verify.sh through the spine's existing sh port and
reads its real exit code. Non-zero demotes to the existing fail-tests; a timeout
maps to the existing fail-timeout. On failure, the next attempt's prompt carries a
## Prior Attempt Failure section with a bounded excerpt and the blocking task's status
— so retries now differ.

### Wave 1
Verify: npm test --prefix harness

Opt-in with a byte-for-byte absent-declaration guarantee: a plan declaring no
Verify: anywhere produces an event sequence byte-identical to a pre-verification run.
Demonstrated, not asserted — by driving origin/main's spine and this branch's spine
through identical scripted results and cmp-ing the serialized event streams.

Waves 1-2 landed the contract substrate folded from #63 (tasks[] and usage as
documented optional contract fields, both new wave-attempt keys designed as one shape
change). #63 keeps its read side.

Security — the plan's named highest risk

The plan flagged Task 3.2 as its highest risk: "check-verify.sh must apply the
allow-listed-env treatment or it becomes a credential-leak path." Verified independently
with canary values, not taken on report:

Check Result
ANTHROPIC_API_KEY, GITHUB_TOKEN, MY_SECRET exported in parent None reached the command. Visible: HOME LANG PATH TERM TMPDIR
sleep 600 under a 3s ceiling exit 124 at 3s, no orphan process
exit 7 exit 7 passed through
passing command exit 0, 0 bytes output (discarded)
500-line failure truncated to 42 lines

timeout(1) is absent on darwin, so check-verify.sh uses a polling watchdog with a
marker file to distinguish a real timeout from a command that chose to exit 143 — a
SIGTERMed process and a self-terminating one are indistinguishable by wait status alone.
Verified under both bash 5.3 and /bin/bash 3.2.

The command is arbitrary shell from a human-approved plan — approval is the trust
boundary, unchanged.

Invariants held

  • harness/gates.js zero diff — verification attaches at the spine/script boundary,
    never inside the fold.
  • harness/matrix.yaml zero diff — the 7-outcome vocabulary gained nothing. Grep
    confirms exactly the frozen seven tokens.
  • scripts/check-tests-present.sh zero diff — presence and behavior catch different
    failure modes; both gates kept (check-tests.sh does not run tests — it checks that promised test files exist #91).
  • Doom-loop breaker and MAX_ATTEMPTS untouched — the only matching diff lines are
    added comments recording that the capture sits after the fingerprint decision and
    never feeds it. This plan makes retries differ; it does not change how many there are.
  • All six event folds unmovedreduce, resumeFrom, totalUsage, outcomeCounts,
    failReasonCounts, retryCounts, proven against a fixture lacking both new keys and
    mutation-checked to confirm the gate is not vacuous.

Verification

  • Harness: 239/239 pass (216 baseline, +23).
  • All 15 shell suites pass, including the new scripts/test-check-verify.sh
    (14 assertions, ~2s, committed 100755) which carries a permanent regression test for
    the env-containment boundary.
  • Scope gate: 14 files, all within declared scope.
  • Tests-present gate: 10 test files.
  • Approval integrity: fingerprint, gate, and authenticity all verified — re-checked
    after two rebases, since rebasing changes the approval commit's hash.

For architect review

Two Program Design signatures proved inaccurate, and in both cases the delivering wave
resolved in favor of the actual constraint rather than the document:

  1. check-verify.sh <feature> <command> — the sh port passes exactly one argument
    (sh('scripts/check-tests-present.sh', feature)), and AC#2 requires the port shape stay
    unchanged. Shipped as check-verify.sh <command>; <feature> was unused.
  2. buildWavePrompt(wave, planCtx, priorFailure?) — the provider-neutral contract defines
    adapter(wave, planCtx) as two-argument. Shipped reading planCtx.priorFailure, folded
    in by cli.js. A third positional argument would have widened the documented contract.

Judgment calls to ratify:

  • Truncation cap PRIOR_FAILURE_FIELD_MAX_CHARS = 4000. The plan deliberately left
    this number to the delivering wave for architect review. Chosen tighter than the
    producer's own bound (40 lines / 8000 bytes) so the prompt stays bounded even for text
    that never passed through check-verify.sh.
  • Exit code 124 is reserved for timeout. A command genuinely exiting 124 is reported as
    a timeout — GNU timeout(1) convention, and it errs conservative: the false positive
    surfaces (terminal) rather than retrying, so it cannot burn attempts or hide.
  • New event type capture-failed, appended only on the fail-open degrade path. Follows
    the hook-failed precedent. Deliberately absent from PHASE_BY_TYPE so it
    establishes no phase and the fold is unaffected — the same treatment as
    architecture-approved/owner-claimed/owner-released — and now documented in both the
    typedef union and the deliberate-absence comment.
  • AC#6's literal wording ("malformed tasks … never fail-protocol") conflicts with
    pre-existing behavior: resultToOutcome({}) === 'fail-protocol'. Ruled to govern the
    tasks pass-through, not the outcome mapping. An agent emitting an unparseable result
    block has committed a real protocol violation; softening that would weaken the exact check
    this feature strengthens and break AC#1's parity guarantee.

Deliberate fail-open exception (AC#8): priorFailure capture is prompt enrichment, not
a gate, so a capture failure logs its reason and degrades to today's prompt rather than
blocking. This is the stated exception to CLAUDE.md's fail-closed default, which governs
gate and check boundaries. Demonstrated by forcing the capture to throw — attempt 2 still
ran, with a prompt byte-identical to attempt 1's, and a capture-failed event recorded.

Delivery notes

Gate 2 initially failed on .agents/research/wave-back-pressure.md — this branch's own
first commit (2026-08-04), untouched by any wave. .agents/research/ was missing from
check-scope.sh's exempt prefixes while logs/plans/state were all exempt. It could not be
fixed from inside this delivery: amending the plan's Files in Scope would invalidate the
approval fingerprint, and RAD has no re-approval path (#39). Fixed separately in #104,
merged, then this branch rebased.

One loop run reported test-check-tests-present.sh failing; not reproducible standalone or
in two subsequent full runs, and that run executed concurrently with the harness suite.
Recorded rather than dismissed.

Full execution log, including every wave's concerns and the architect rulings on each:
.agents/logs/wave-back-pressure-2026-08-10.md

seanrreid and others added 30 commits August 10, 2026 13:00
Feature-scoped research artifact for making "did this wave verify its own
work" a deterministic, event-recorded fact rather than an agent self-report.

Source: HumanLayer, "Skill Issue: Harness Engineering for Coding Agents".

Two load-bearing findings, both verified against the tree:

- scripts/check-tests.sh does not run tests. It checks that test files
  listed in the plan's "## Tests to Write" section exist on disk. The
  per-wave gate at spine.js:292-307 is real and correctly wired, but the
  signal flowing through it is file presence, not behavior. The only thing
  executing verification is the agent, following prose in the wave prompt
  and self-classifying the result.

- Bounded retries re-send an identical prompt. spine.js:249 calls
  runWave(wave); cli.js:433 binds planCtx once per deliver. No failure
  context reaches the next attempt, so the doom-loop breaker detects a loop
  the retry path structurally guarantees.

Carries design recommendations for four decision points (declaration site,
executor, failure-feedback seam, event shape), the constraints they must
respect (pure gate fold, frozen matrix vocabulary, absolute backward
compatibility), and five open questions.

Status: pending-plan — feeds /rad-plan, not /rad-design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: architect
Waves: 5
Tasks: 15
Out-of-scope deps: no

Closes the gap where the per-wave gate checks file presence rather than
behavior, and where every retry rebuilds an identical prompt.

Folds #63's contract substrate (documented usage, tasks[] pass-through, and
the wave-attempt optional-key design) into Waves 1-2, per the architect
decision recorded on #89. #63 stays open for its read side --
blockedReasonCounts, the /rad-insights subsection, and failed-wave transcript
snapshots.

Consumes the pre-existing research artifact .agents/research/wave-back-pressure.md
in place of a fresh Explore sweep; anchors re-verified against post-#100 main.
Plan:  .agents/plans/wave-back-pressure.md
(Status + approved event written by: node harness/cli.js approve)

Two guardrail flags resolved before approval rather than after, so the
fingerprint is frozen over the tightened plan:
  - Task 3.2 now enforces a timeout, mapped to the existing fail-timeout
    outcome (surface) rather than fail-tests -- a retry cannot fix a hang.
  - Task 4.3 now states priorFailure capture is fail-OPEN, the deliberate
    exception to the fail-closed default, which governs gate boundaries.
AC#7 and AC#8 added with test coverage for both.
…ve contract

Wave 1, Task 1.1
Validated: AC#6 — return-shape table lists `tasks` and `usage` with optionality stated; result→outcome section notes per-task pass-through
Wave 1, Task 1.2
Validated: AC#6 — toWaveResult delegates outcome to resultToOutcome unchanged (null/empty/malformed/unparsed all map identically); a malformed or absent tasks block omits the key rather than throwing or forcing fail-protocol; 216/216 harness tests pass
…d `usage`

Wave 1, Task 1.3
Covers BOTH files named by the task: harness/adapters/agent/command.js and
harness/adapters/agent/sdk.js. Each replaces its duplicated local toResult with
the shared contract.toWaveResult builder, so both emit the optional `tasks`
pass-through and normalized `usage` from one definition.
Validated: AC#6 — both adapters exercised end-to-end (stub CLI + injected SDK query): with no usage reported the key is omitted and the budget contribution is 0 (spine's `result.usage?.total ?? 0`); outcomes identical across tasks-present / no-tasks / malformed-tasks; usage still attached and counted when reported (sdk 150, command 10); 216/216 harness tests pass
Wave 1 execution-log entries for tasks 1.1-1.3.
Wave 2, Task 2.1
Validated: AC#5 — typedef declares tasks/verify optional on wave-attempt data; zero fold code changed (doc-only diff), npm test --prefix harness 216/216
…event

Wave 2, Task 2.2
Validated: AC#5 — spread so the key is absent (never undefined) when the result
carries no tasks; drove deliverSpine on the pre-Wave-2 spine.js and on HEAD with
identical tasks-free results (no-usage, with-usage, and a fail-tests retry) and
cmp'd the serialized event stream byte-for-byte: identical. npm test --prefix
harness 216/216.
Wave 2, Task 2.3
Validated: AC#5 — all six folds (reduce, resumeFrom, totalUsage, outcomeCounts,
failReasonCounts, retryCounts) asserted against hand-computed pre-change values
on a deep-frozen historical log with no tasks/verify keys, plus an additive check
that the same log augmented with both keys folds identically. Mutation-checked:
perturbing one expected value fails both tests. npm test --prefix harness
218/218 (216 -> 218, +2 new).
Wave 3, Task 3.1
Validated: AC#1 — parsePlanCtx over a plan with no Verify: yields {}; a plan with one yields exactly that wave's command (deeper #### task headings stay inside the wave; blank value is absent)
Wave 3, Task 3.2
Validated: AC#2, AC#7 — passing command exits 0 with output discarded; failing
command passes its own exit code through with a bounded excerpt on stdout
(500 lines -> 40; 50KB single line -> 8000 bytes); 'sleep 600' under a 3s
timeout is killed in 3s and exits the reserved 124 (distinct from failure), with
no leaked child process. Command runs under env -i + the adapters' allow-list
(PATH HOME LANG LC_ALL TMPDIR TERM): ANTHROPIC_API_KEY/GITHUB_TOKEN/MY_SECRET
exported in the parent are NOT visible to it. cwd asserted (not assumed) through
the real sh port binding: resolves to the worktree checkout when RAD_WORKTREE
isolation is active. Portable kill-after-N (background child + polling watchdog +
marker file) — no timeout(1), verified on bash 3.2 and 5.3.
…he result

Wave 3, Task 3.3
Validated: AC#2, AC#3 — at the existing per-wave gate site, a wave that declares
Verify: now runs scripts/check-verify.sh through the injected sh port (shape
unchanged) and reads its real exit code. A non-zero status demotes the outcome
through resolveOutcome to the EXISTING fail-tests; the reserved timeout status
124 takes the EXISTING fail-timeout instead (matrix action surface, not
revision) because a retry cannot fix a hang. Every attempt that ran a command
records verify: {command, status, passed} — spread, so the key is ABSENT (not
present-and-undefined) when no command ran.

Demonstrated end-to-end against the real check-verify.sh + real matrix: pass ->
success/ok, fail -> fail-tests/doom-loop, sleep 600 -> fail-timeout/surface with
a single attempt. AC#1 parity shown by comparison, not assertion: an empty
waveVerify map appends a byte-for-byte identical event sequence and identical sh
call list to the pre-feature call shape. No eighth outcome token exists; gates.js
and matrix.yaml have zero diff vs origin/main.
Wave 3
Validated: process artifact — no testable surface.
Wave 4, Task 4.1
Validated: AC#4 — npm test --prefix harness 218/218 green; every existing
runWave stub ignores the new second argument and behaves exactly as before.
…pt section

Wave 4, Task 4.2
Validated: AC#4 — absent priorFailure renders byte-for-byte origin/main's prompt
(cmp against a temp module built from origin/main:contract.js, two fixtures plus
explicit null/undefined); present priorFailure renders the section with an 88,889
char excerpt truncated to the 4000-char cap. 218/218 suite green.
…ilure

Wave 4, Task 4.3
Validated: AC#4, AC#8 — end-to-end spine drive shows attempt 2's prompt carrying
attempt 1's outcome, blocking-task status and bounded verify excerpt (attempt 1
and 2 prompts now differ); a forced capture failure still runs attempt 2 with a
byte-identical priorFailure-absent prompt and records a capture-failed event
carrying wave/attempt/outcome/reason. Capture is fail-OPEN by design — the
deliberate exception to CLAUDE.md's fail-closed default, which governs gate and
check boundaries, not prompt enrichment. Doom-loop fingerprint and MAX_ATTEMPTS
byte-identical to origin/main. 218/218 suite green.
Wave 4 execution-log rows for Tasks 4.1-4.3.
Wave 5, Task 5.1
Validated: AC#1 (absent-Verify parity: two runs deep-equal, no verify key,
check-verify.sh never invoked; parsePlanCtx yields an empty map), AC#2 (the
declared command reaches check-verify.sh as its single argument, after the
presence gate), AC#3 (a failing command demotes success to fail-tests inside
the frozen vocabulary), AC#4 (attempt 1 gets priorFailure null; attempt 2
carries outcome, blocking task, and excerpt), AC#7 (exit 124 maps to
fail-timeout/surface and is not retried), AC#8 (a throwing capture logs
capture-failed and attempt 2 still runs) — node --test on both suites, 56/56 pass

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wave 5, Task 5.2
Validated: AC#2, AC#7 — 14 assertions, each with an explicit exit code: passing
(0, output discarded), failing (own code 7), line-cap and byte-cap truncation
each asserted to BITE, usage errors (no arg / two args / blank / malformed
RAD_VERIFY_TIMEOUT_SECONDS) all exit 2, stderr-only captured, and a wedged
command killed at exit 124. Plus the plan's named highest risk as a permanent
regression: a non-allow-listed variable never reaches the executed command,
with a negative control proving the check is not vacuous.
bash scripts/test-check-verify.sh → ALL PASS in 1.4s; committed mode 100755.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wave 5, Task 5.3
Validated: AC#1, AC#6 — CLAUDE.md gains a Per-Wave Verification block beside the
per-wave Model: docs, stating the opt-in, the byte-for-byte absent-declaration
guarantee, the containment check-verify.sh adds, and RAD_VERIFY_TIMEOUT_SECONDS
(default 600, malformed = hard exit 2 never a silent default, timeout = exit 124
mapping to fail-timeout/surface because a retry cannot fix a hang).
agent-contract.test.js covers tasks pass-through, malformed degradation to
OMISSION across eight shapes, outcome-drift parity against resultToOutcome, and
the prompt section: absence is byte-identical and the truncation cap is asserted
to BITE on both the excerpt and the task error. events.js documents the
capture-failed event type in the Event typedef and records why it stays absent
from PHASE_BY_TYPE (audit-only, establishes no phase, fold unaffected) — added
as a comment, never as a key.
node --test harness/test/agent-contract.test.js → 23/23 pass

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wave 5 execution log: step rows for tasks 5.1-5.3, the coverage table, the
absent-declaration parity note, and the two discrepancies raised for Gate 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@seanrreid seanrreid added the rad:deliver RAD delivery PR label Aug 10, 2026
@seanrreid
seanrreid merged commit 58ddc0a into main Aug 10, 2026
7 checks passed
@seanrreid

Copy link
Copy Markdown
Owner Author

Correction. This PR body states that "RAD has no re-approval path (#39)". That is wrong.

PR #45 closed that gap on 2026-06-23. harness/transitions.js:94-116 blocks a duplicate approved event only when the plan fingerprint is unchanged, and explicitly admits a re-attestation whose fingerprint differs. The wave-back-pressure approval carried a fingerprint (0b874d3b…), so editing the plan and re-running /rad-approve was in fact available.

The routing decision still stands, on better grounds than the ones given: .agents/research/ should be exempt for every delivery, so amending a single plan would have papered over a general defect with a per-plan workaround. The fix belonged here in check-scope.sh.

Log corrected in #106.

@seanrreid
seanrreid deleted the rad/wave-back-pressure branch August 10, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rad:deliver RAD delivery PR

Projects

None yet

1 participant