Wait & Async-Verification Standard
How anything in this estate waits for something else to become true — a CI gate polling an asynchronously-ingested result, an agent watching for a pipeline marker, a deploy waiting on a health endpoint, a script waiting for a file to appear. It exists because a wait that cannot conclude is indistinguishable from one that is merely being patient: both are silent, so the broken one is discovered only by the thing it was supposed to catch. This standard makes waiting a tool with a contract instead of a hand-written habit, and it is the standard behind ADR-0435. It widens ADR-0200’s single-instance async-verify precedent — one CI gate polling Dependency-Track to a deadline — from that one gate to any wait, agent or pipeline.
The failure class this standard governs — silence that means nothing
Section titled “The failure class this standard governs — silence that means nothing”A watch that fires spuriously is obvious and gets fixed. A watch that can never fire looks exactly like patience. Three instances in this estate, one shape:
| Date | Instance | Why it could never conclude |
|---|---|---|
| 2026-08-04 | Six-hour stall on a handoff script | pgrep -f <script> matched the monitor’s own command line, so the liveness predicate was true forever |
| 2026-08-04 | Monitor never fired on a green pipeline | The marker path was reconstructed from a 7-char SHA; the real file used 8 |
| 2026-08-18 | Fifteen-minute stall on a recovered probe | Shell quoting sent service%3D%5C%22…%5C%22; Prometheus answered HTTP 400 forever |
The estate’s own doctrine already said the right thing — the Token-Optimization Standard publishes “immediately after arming, confirm the file or condition the watch names actually exists” — and it did not prevent the third failure. That is the argument for a mechanism rather than more words, and it is why the agent-wait clause below is class machine with a named helper rather than class review.
A wait MUST reach a terminal state, and that state MUST be recorded where the waiter looks
Section titled “A wait MUST reach a terminal state, and that state MUST be recorded where the waiter looks”Every wait has exactly one contract: it concludes, and it says so. Concretely:
- Every terminal path writes a terminal marker — satisfied, timed out, refused, errored, or crashed. A wait that ends without one has converted a result into silence, and silence is the ambiguity this standard exists to remove.
- The marker is written to the artifact the waiter actually reads — the deterministic log a
Monitorgreps, not a process exit code the waiting party never sees. - A timeout is a terminal state, not a failure to reach one. An unsatisfiable predicate must still conclude; “it is taking a while” must never be the resting state.
- Silence therefore means “still running” and nothing else. Before reporting a wait as still in flight, confirm it positively — the log grew, the process exists by PID, the run is in flight upstream. Absence of an event includes the watch being broken or the job being dead.
- A REFUSAL has no per-wait log to write to, so it MUST be recorded somewhere durable and queryable. This is the one terminal state that precedes the artifact: nothing was armed, so no log exists yet. Left there, a false refusal — a valid predicate wrongly rejected — survives only as stderr in whichever session hit it, which makes the failure mode invisible in exactly the way the rest of this standard forbids (FC-03). The record must be reviewable across sessions and hosts, and must carry enough to judge the refusal: the label, the predicate form, and the reason. A per-host file is acceptable; a shipped log stream is better, because the question “has anything been wrongly refused lately?” is estate-wide, not per-host.
A predicate MUST be proven evaluable BEFORE the wait is armed, and refused if it is not
Section titled “A predicate MUST be proven evaluable BEFORE the wait is armed, and refused if it is not”The arm-time check is the load-bearing clause. Every failure in the table above was armed successfully on a question the system was never going to answer.
- Evaluate the predicate once, synchronously, before arming. A predicate that cannot be evaluated is refused with a reason, not waited on.
- Distinguish a permanent contract error from a transient one, and refuse only the permanent. An unknown system, a verb that is not on the read surface, a malformed query answered
HTTP 4xx, a command that does not exist, a shell syntax error — these will be equally wrong in an hour, so arming on them is the defect. A5xx, a connection failure or a timeout is a service that may genuinely come back, so “wait until it recovers” must remain possible. A guard that refuses a legitimate caller is its own failure class (FC-17), so both directions owe a proof. - A predicate that cannot answer “no” is refused.
true,… || true,… || echo 0— each produces a check that passes with the feature removed (FC-02), and the|| echo 0form specifically records a failed query as a real zero (FC-07). - A predicate MUST be side-effect free and MUST answer 0 (yes) or 1 (no). Any other exit is not an answer; treating it as “not yet” is precisely how a broken predicate becomes an eternal wait.
Prefer a typed predicate; the shell escape hatch carries a mandatory dry-run
Section titled “Prefer a typed predicate; the shell escape hatch carries a mandatory dry-run”Hand-composing a shell string for every wait is what put a quoting error on the common path. So:
- Use a typed predicate where one exists — a system read through
pitlab-access, a file/marker check. In the typed path the quoting failure is impossible, not merely detected. --shellremains available and is gated. It must pass abash -nparse (no execution, no side effect) and then execute once at arm time. That second stage is also what enforces the side-effect-free rule: a mutating predicate cannot be used without mutating where it is visible.- The escape hatch is honestly weaker and that is accepted. A dry-run catches structural breakage — a syntax error, a missing command, a non-boolean exit. It cannot catch a predicate that runs cleanly and asks the wrong question. That residual is the reason the typed forms are the default rather than an option.
Never construct the path or predicate you are about to watch — read it back
Section titled “Never construct the path or predicate you are about to watch — read it back”- Capture the log path the tool prints; never rebuild it. The 2026-08-04 monitor failure was a hand-built path differing by one character (FC-11). Paths may also carry a PID or nonce for uniqueness, because five cc-pool sessions share
/tmpand two waits on the same predicate would otherwise truncate each other’s log (FC-12) — which makes them unguessable by design. - Prove the marker file exists before arming the watch. If it is absent seconds after launch the wait is not armed — a bug to fix immediately, never something to sit and wait on.
- Never use a process-name match as a liveness predicate.
pgrep -f <script>matches the watcher’s own command line and returns a match forever (FC-03). Test liveness by the artifact: the marker, the log’s mtime, or a PID file. - A wait far past the job’s plausible duration is a broken wait, not a slow job. Patience applies to a confirmed-armed wait on a confirmed-running job; past that, stop waiting and go look.
An agent wait MUST go through the declared helper, not a hand-rolled loop
Section titled “An agent wait MUST go through the declared helper, not a hand-rolled loop”waitfor on control01 is the declared mechanism for any non-pipeline wait: it refuses an unevaluable predicate at arm time, runs the poller setsid-detached so a long wait survives harness turn-boundary culling, and writes a WAITFOR_RESULT= marker on every terminal path.
pipewait.sh is its sibling, not its predecessor — it owns the ADO-pipeline domain (SHA discovery, cross-pipeline fan-out, approval detection) under ADR-0205, holds the same terminal-marker contract as PIPEWAIT_RESULT=, and is unchanged by this standard. Waiting on a pipeline run still goes through pipewait.sh; everything else goes through waitfor.
An in-session background poll loop is not a conforming wait: the harness culls it at the next turn boundary, so it fails silently by construction.
A CI gate verifying asynchronous work MUST poll to a deadline with early exit
Section titled “A CI gate verifying asynchronous work MUST poll to a deadline with early exit”The original ADR-0200 case, retained and generalised. A gate that reads an asynchronously-ingested result once, immediately misreads queue lag as systemic loss and fails a healthy pipeline.
- Poll to an explicit deadline, exit early the moment the condition holds, and fail only after the window has genuinely elapsed.
- Derive the deadline from a measurement of the thing being waited on, not from a borrowed default (FC-16).
- Distinguish “not yet ingested” from “ingested and wrong” — they are different findings and only one is a gate failure.
Conformance checklist
Section titled “Conformance checklist”- The wait reaches a terminal state on every path, including timeout, refusal and crash.
- Every terminal path writes a marker to the artifact the waiter reads.
- The predicate is evaluated once before arming, and refused with a reason if unevaluable.
- Refusals are recorded durably and queryably, not only on the caller’s stderr.
- Permanent contract errors refuse; transient errors keep waiting. Both directions are proven.
- A predicate that cannot answer “no” is refused.
- A typed predicate is used where one exists; any
--shellpredicate passes a dry-run before arming. - The log/marker path is captured from the tool’s output, never reconstructed, and proven to exist before the watch is armed.
- Liveness is tested by artifact, never by a process-name match.
- A non-pipeline agent wait goes through
waitfor; a pipeline wait goes throughpipewait.sh— and both evaluate before arming, refuse a permanent contract error, arm on a transient one, and record every refusal durably. A clause stated for a wait binds every wait helper, not only the one it was written from (FC-13, ADR-0455). - A CI gate over async work polls to a measured deadline with early exit.
Enforcement
Section titled “Enforcement”The agent-wait clause is class machine with waitfor as its declared mechanism, per the Standard-Enforcement Standard’s rule that a structural control at the point of use beats an inspection afterwards. Two layers guard the helper itself, because they fail in different directions: the --self-test proves it correct at a commit and is blind to an out-of-band break, while the scheduled synthetic proves it still concludes on the host and is blind to a change that has not deployed yet. The clauses governing how an author writes a predicate cannot be machine-checked across every call site — no gate can enumerate every wait an agent might compose mid-session — so they are review against a named checkpoint rather than a review-shaped dumping ground.
| Obligation | Class | Layer | Mechanism | Dead-man |
|---|---|---|---|---|
| A wait reaches a terminal state on every path — satisfied, timeout, refused, error, crash — and writes a marker to the artifact the waiter reads | machine | pre-merge | ansible/scripts/waitfor_poll.py --self-test runs blocking in ansible/pipelines/ansible_ci.yml; every case asserts the marker, not merely the exit code, because the exit code is what a stranded caller never sees. Proven red by suppressing the timeout marker alone (2 cases fail) | waitfor.synthetic.age |
| A predicate is evaluated once before arming and REFUSED with a reason if it cannot be evaluated — never armed on a question the system will not answer | machine | pre-arm | ansible/scripts/waitfor_poll.py preflight refuses an unknown system, a verb off the read surface, an HTTP 4xx, a bash -n failure, a non-boolean exit and a missing command; the ansible/scripts/waitfor launcher detaches nothing until preflight passes. Proven red by gutting preflight (10 cases fail) | waitfor.synthetic.age |
A predicate that cannot answer “no” is refused — true, || true, || echo 0 (FC-02 / FC-07) | machine | pre-arm | ansible/scripts/waitfor_poll.py vacuity check in preflight, covered by three self-test cases in ansible/pipelines/ansible_ci.yml | waitfor.synthetic.age |
| Every refusal leaves a durable, queryable record naming the label, predicate form and reason, so a false refusal cannot hide | machine | scheduled | ansible/scripts/waitfor emits a waitfor-refusal journald record on every refusal path (including a malformed command line), which control01’s Alloy ships to Loki with syslog_identifier promoted to a label. ansible/scripts/waitfor_synthetic_cron.sh’s durable-refusal-case asserts the record lands, keyed on a label carrying the run’s own pid so a previous run’s line cannot satisfy it (FC-02). Proven red against a launcher without the record (4 cases pass, this one fails) | waitfor.synthetic.age |
| A permanent contract error refuses while a transient one keeps waiting, so “wait until the service recovers” stays possible (FC-17) | machine | pre-arm | ansible/scripts/waitfor_poll.py splits pitlab-access rc 2/3 and HTTP 4xx (permanent, refuse) from 5xx/connection failures (transient, arm); both directions exercised | waitfor.synthetic.age |
A PIPELINE wait is evaluated before arming and REFUSED on a permanent contract error — the ancestry repo is resolved from the COMMIT (explicit --repo, the CWD, a git cat-file -e probe of every checkout on the host, then a fetch of the canonical ones and a re-probe), and --pipeline-id is checked against the repository that definition builds. A transient ADO fault ARMS rather than refusing (FC-17) | machine | pre-arm | ansible/scripts/ado_pipeline_run.py preflight, called synchronously by the ansible/scripts/pipewait.sh launcher before anything is detached; --self-test runs blocking in ansible/pipelines/ansible_ci.yml. Ten cases: four assert a permanent error refuses, three assert a legitimate wait arms (including an unreachable definitions API), one is a regression control on the matcher itself. Proven red by gutting the checkout probe (3 cases fail) (ADR-0455) | n/a |
| Every pipewait refusal leaves a durable, queryable record naming the sha, pipeline, caller and reason, so a false refusal cannot hide | review | — | ansible/scripts/pipewait.sh emits a pipewait-refusal journald record on every refusal path including a malformed command line, which control01’s Alloy ships to Loki with syslog_identifier promoted to a label; a refusal writes NO marker log and prints no PIPEWAIT_LOG= line, so no watch can be armed on a path that will never exist. Review-tier, and the gap is stated rather than implied: unlike waitfor, pipewait has no synthetic asserting the record still lands, so an Alloy or logger regression would be silent. A pipewait synthetic belongs with the pipewait dead-man tracked under Epic #1988’s child #2092, not here (ADR-0455) | n/a |
| The deployed helper still concludes on every path — including the out-of-band breaks a commit-time gate cannot see | machine | scheduled | ansible/scripts/waitfor_synthetic_cron.sh daily 16:10 AEST, declared in ansible/scripts/doc_gen/schedule_config.yml; arms real waits against the deployed /usr/local/bin/waitfor and stamps its heartbeat only on a full pass | waitfor.synthetic.age |
A non-pipeline agent wait goes through waitfor; a pipeline wait goes through pipewait.sh; an in-session background poll loop is not a conforming wait | review | — | /code-review and /wrapup check that a wait added by a session used the helper rather than a hand-rolled loop; a hand-rolled loop in a committed script is caught by /code-review against this checklist | n/a |
| The log/marker path is captured from the tool’s output, never reconstructed, and proven to exist before the watch is armed | review | — | /code-review of any call site that arms a watch; the helper’s own output makes the correct path unmissable, and the path carries a PID so a reconstructed one cannot accidentally match | n/a |
| A CI gate verifying asynchronous work polls to a measured deadline with early exit rather than reading once (ADR-0200) | review | — | /code-review of any new gate that reads an asynchronously-produced result, against ADR-0200’s polling shape; the deadline must cite the measurement it came from (FC-16) | n/a |