ADR-0455 — The Wait Standard's arm-time clauses bind pipewait, not just waitfor
The Wait & Async-Verification Standard requires every wait to be evaluated before it is armed and refused with a reason if it cannot be answered, and requires every refusal to leave a durable, queryable record. It states both clauses for “a wait” and names pipewait.sh in its conformance checklist as the pipeline wait — yet every Enforcement row binds only waitfor. pipewait therefore carried neither control, and a wait that could never match anything armed happily and burned its full budget. This ADR binds the clauses to pipewait.sh and records the mechanism.
| Status | Accepted — implemented 2026-08-20 |
| Date | 2026-08-20 |
| Amends | ADR-0435 and the Wait & Async-Verification Standard (Enforcement + conformance checklist) |
| Extends | ADR-0205 (the durable pipeline-wait pattern), ADR-0141 (proven-red gate tests) |
| ADO | Epic #2581 (pipewait SHA discovery is blind to batched runs); Issues #2597, #2598 |
Context
Section titled “Context”A control declared fleet-wide and enforced in one place decays invisibly — FC-13, orphaned-declaration. That is exactly the shape here, and it was created by the Epic that wrote the standard: Epic #1802 built waitfor, generalised its lessons into a standard binding every wait, and enforced them against the one helper it had just built. Its own Production Readiness Review recorded pipewait as “untouched and still concluding” — true, and not the same as conforming.
The cost landed on 2026-08-20. pipewait’s ancestry accept path — the one that matches a coalesced batch: true run labelled with a later SHA — needs a git checkout for its merge-base test, and the launcher resolved one from the caller’s CWD. Every cc-pool session runs from ~/sessions/cc-N, which is not a git repository, so the matcher silently switched off. Two waits then ran to 2700 s and 900 s and reported DISCOVERY_TIMEOUT, a state ADR-0205 defines as “the trigger never fired”. Both were wrong; the runs existed and were green.
Measured across 324 real waits, 16–20 Aug 2026: 36 ran with ancestry disarmed (11%), and 6 of the 14 DISCOVERY_TIMEOUTs (43%) were disarmed waits.
The tool announced the degradation and nobody read it. Line 2 of both logs said ancestry (UNAVAILABLE: no git repo — coalesced batch runs will not match), and the closing hint repeated it. That is the finding worth generalising: announcing a degradation is not a control (FC-03). A message in a log the caller does not read, followed by proceeding anyway, is a disarmed control wearing an honest label — and it is strictly worse than refusing, because it produces a confident wrong answer instead of an error.
Decision
Section titled “Decision”Where a standard states a clause for a class of thing, every member of that class owes an Enforcement row. Concretely, pipewait.sh now satisfies the two clauses it was already named under:
- Arm-time evaluation, and refusal on a permanent contract error. Before anything is detached, preflight resolves the ancestry repo from the commit (explicit
--repo, the CWD, agit cat-file -eprobe of every checkout on the host, then a fetch of the canonical checkouts and a re-probe) and validates--pipeline-idagainst the repository that definition actually builds. Exhausting the ladder, a--repothat does not contain the commit, a non-existent definition, or a definition belonging to another repository are all permanent — they refuse, exit 2, and arm nothing. - A transient error still arms. An unreachable ADO definitions API is a blip, not a wrong id. Refusing on it would make legitimate waits unarmable during an outage, which is its own failure class (FC-17) — so it arms and records that validation was skipped. Both directions are proven by the self-test, because a guard is only trustworthy when its reachability has been tested as well as its strictness.
- Every refusal leaves a durable record. A refusal is the one terminal state with no marker log to write to, so a false refusal would otherwise survive only as stderr in one session.
pipewait.shemits apipewait-refusaljournald record (sha,pipeline,caller,reason) which control01’s Alloy already ships to Loki withsyslog_identifierpromoted — no new infrastructure, and the estate-wide question “has anything been wrongly refused lately?” is one query. Identical mechanism towaitfor-refusal(ADR-0435), deliberately. REFUSEDis a distinct terminal state (exit 2), and the marker token contract is unchanged.PIPEWAIT_RESULT=,PIPEWAIT_LOG=andPIPEWAIT_RUN=keep their exact spellings — Monitor arming across every skill greps them verbatim — so this adds a new value only. A refusal prints noPIPEWAIT_LOG=line, so no watch can be armed on a path that will never exist.- A hermetic
--self-test, proven red, as a blocking CI step. Ten cases on throwaway git repos under a tempHOMEwith the ADO lookup stubbed: four assert a permanent error refuses, three assert a legitimate wait arms, one is a regression control proving the ancestry matcher itself is unchanged. It runs from$(Build.SourcesDirectory)inansible-cibesidewaitfor’s, so it tests the commit under test rather than the previously-synced copy (ADR-0329).
Alternatives considered
Section titled “Alternatives considered”- Fix
pipewaitand leave the standard alone. Rejected: the gap is not that one helper missed a control, it is that the standard’s Enforcement table did not cover the class it legislates. Patching the instance leaves the next wait helper to rediscover the same reasoning (RULE 9). - Make the launcher fail loudly on a disarmed matcher but keep waiting. Rejected — that is what it already did. The log line was accurate and useless. A control that the caller can proceed past is a comment.
- Auto-detect batched pipelines and only then require a repo. Rejected:
batch: trueis estate-wide (ADR-0311), so the condition is always true, and a conditional control is one whose armed path nobody exercises (FC-17). - Keep a registry mapping ADO repository names to local checkout paths. Rejected as a second source of truth that would drift. A SHA is unique to its repository, so probing for the commit object answers the question directly and cannot go stale.
- Refuse when the ADO definitions API is unreachable. Rejected outright: it converts a transient outage into an unarmable wait, and a guard that refuses a legitimate caller is the failure class this design most risks.
Consequences
Section titled “Consequences”- Both 2026-08-20 failures are now impossible by construction: the mid-batch commit discovers a covering run via ancestry in 0 s (was 2700 s), and the wrong-definition wait refuses in 2 s (was 900 s), each proven live from a non-repo CWD against the real historical runs.
DISCOVERY_TIMEOUTmeans what ADR-0205 says it means again. It is now reachable only with ancestry proven armed, so the three states it used to conflate — wrong definition, mid-batch, genuinely no run — are three signals.- A false refusal is now the residual risk, and it is instrumented rather than assumed away. The
pipewait-refusalstream is reviewed during an early-life soak for a valid wait wrongly rejected; the FC-17 direction is the one this design can get wrong, so it is the one the soak watches. --sha-onlybecomes the documented default form (Pit’s ruling, 2026-08-20), retiring the second recorded instance of a wrong-definition-id wait as a class rather than as an incident.- The Wait & Async-Verification Standard’s Enforcement table now names both helpers for both clauses, so its conformance checklist and its enforcement agree for the first time.
- The generalisable lesson, recorded because it will recur: when a standard is written out of one implementation, its Enforcement rows inherit that implementation’s scope while its prose claims the class. Check every named member of the class at the moment the standard lands, not when one of them fails.