Skip to content

ADR-0414: A harness-blocked action defers rather than halts, and the end-of-run script is a standing sub-grant

This ADR amends ADR-0403 in two places. It changes the circuit breaker’s input from failed-or-blocked children to failed children only, and it names a second authority tier inside one /go run: delivery stays delegated, while the end-of-run deferred-action script is a standing sub-grant carrying the standing tier’s controls. Nothing else in ADR-0403’s envelope moves — the hard stops, the work-order contract, the independent re-assertion and the conformance fail-safe are unchanged and remain load-bearing.

StatusAccepted
Date2026-08-16
EpicADO #2436 (Blocked-action deferral for /go); Issue #2437
AmendsADR-0403, the Autonomous Remediation Authority Standard

A /go run halted when the harness auto-mode classifier blocked an action. That is the sub-agent behaving correctly — it has no channel to reach a human and must not invent one — but the run then stopped on friction rather than on failure, and the Epic needed the very context handover /go exists to remove.

The problem was measured before it was designed. Across 958 session transcripts on 2026-08-15, 54 handoff scripts sit adjacent to real block language — roughly 1 session in 18. Classifying them by what they actually did:

ClassCountExamples
Classifier — authorised, the classifier could not tell~45ADO pipeline authorisation, secret and credential provisioning, host permission fixes, Dependency-Track and Zabbix API writes, a Terraform apply
Policy — a human genuinely should decide~9Cloudflare, Home Assistant, PVE firewall, a decom

Roughly 4:1 toward friction rather than policy. Doing the measurement first was deliberate: an explanation generated rather than derived is FC-19 (authored-not-observed), and this design exists because of a defect that class produced elsewhere.

Seven of the 54 were an agent hand-running ADO pipeline authorisation that ado-pool-auth-reconcile.sh already performs nightly at 04:10. That block was never a permission problem. It was the agent reaching for the wrong path to do something already automated.

The first candidate fix was rejected on evidence. Pre-authorising the narrow classes in settings.json cannot work, because the auto-mode classifier gates on intent, not command pattern, and is independent of the permission allow-list: Bash(curl *) is already allowed, yet a curl that reads a secret still blocks. The lesson corpus records that for the credential category in-chat approval never clears the block — only a code-path change does — and that in one case even writing a script containing the blocked action to /tmp was itself blocked while the same write to the scratchpad passed. This is the same finding ADR-0413 reached from the other direction: the classifier is a separate gate from the permission set. Routing the action to a sanctioned deterministic path is the code-path change the corpus says is the only thing that works.

A blocked action is DEFERRED, not halted, when — and only when — its class is on an enumerated whitelist. The run records the deferral with a timestamp, moves to the next child, and finishes. One self-verifying script lands at the end.

The decision tree is committed, machine-readable YAMLansible/scripts/go-routed-actions.yml, deployed beside go_run.py — with three sections and a fail-closed default:

  • routed — a sanctioned deterministic path already exists. go_run.py child-defer refuses (exit 5) and prints the path. Deferring here would put a hand-run copy of a nightly reconciler into an unattended script, which is the wrong-path reach that caused the block. Seeded with ADO pipeline authorisation → ado-pool-auth-reconcile.sh.
  • deferrable — the enumerated whitelist. Only a class named here may be batched.
  • policy_excluded — named policy classes, refused by name with the prime directive each rests on (exit 2). An id absent from all three is refused as unknown, also exit 2: an unrecognised class is not benign.

The register is a whitelist, not a blacklist, because the script it feeds runs unattended — see the tier decision below. It is machine-readable rather than a runbook section so /go can classify a block at the moment it happens; prose cannot be evaluated and drifts.

Policy-class actions are held out of the deferrable set entirely. “Nothing halts” plus “the script runs unread” would otherwise let a Cloudflare, UDM, Home Assistant or exposure change auto-run, dissolving RULE 3, 4, 13 and 14 as a side effect of an unrelated design. That costs ~9 of the 54 blocks, and the cost is the point. A child hitting one returns BLOCKED naming exactly what Arron must approve.

Only FAILED counts toward the circuit breaker (amending ADR-0403). K=2 was derived from a measured 3.2% CI-failure floor — it measured failures and never measured blocks, which ADR-0403 recorded as genuinely unmeasured because /go had never run when K was set. A blocked or deferred child is evidence about the harness classifier, not evidence that the run is operating on a broken premise, so counting it halts a healthy run on friction. It also pollutes the change-failure rate with work that never failed. K itself is unchanged at 2, and remains provisional; only its input narrows. The breaker still bites on two real failures, and is still enforced by go_run.py child-done’s exit code rather than by anyone counting.

A deferred child’s Issue stays To Do, with the deferred action recorded on it. State is a claim, not proof: the work is not done until the script runs the action and the Issue’s own acceptance assertion passes. Closing it as Done with a caveat would launder unfinished work into a green board, and a new Deferred state or tag is unavailable — ADR-0399 closed the tag vocabulary to the three lifecycle tags.

Dependents of a deferred child are deferred too, and never spawned. The work order already declares Depends, so the orchestrator knows without burning a worker to rediscover it — and a FAILED there would pollute the breaker with a prerequisite that was simply not ready. They are reported by the script, not attempted by it: their work was never done, and delivering it needs a fresh run, not a shell command.

The end-of-run script runs unread, self-verifies, and closes an Issue only on a real PASS. For each deferred action it runs the action, then re-runs that Issue’s own acceptance assertion, and closes the Issue only if the assertion actually passes. A failed action or a failed assertion leaves the Issue open and fires one Pushover at priority 0 — the estate ceiling (ADR-0326); nothing here can wake anyone, and a failure overnight fails safe because the Issue stays open and the dead-man keeps ageing. There is no manifest and no per-action confirmation: Arron has stated he executes these scripts without reading them, so a review prompt would be theatre by his own account, and designing around a fiction is worse than designing without it. The control moved to the whitelist.

Delivery stays delegated; the end-of-run script is a standing sub-grant. This is the framing this ADR adds to the Autonomous Remediation Authority Standard. No human confirms scope at the script’s execution time — which is the standard’s own definition of standing authority — so the standing tier’s controls are mandatory rather than optional: a narrow enumerated whitelist (not the broad hard-stop-bounded grant delivery runs under), a blast-radius cap (the script contains only this run’s deferrals), and a fail-safe (an unreadable register refuses every deferral rather than permitting all of them). Naming the tier honestly is what makes those controls obligatory instead of nice-to-have; calling the whole run delegated would have rested the script’s safety on a gesture Arron has said is not a review.

Two independent checks of the same envelope. child-defer classifies at record time and script-emit re-classifies at emit time, refusing to write a script containing any deferral that is not deferrable whatever the run state says. The run state is a file, and a file can be edited.

Every deferral becomes a distinctly-marked snag on the delivering Epic’s [SNAG LIST], adjudicated once at close-out like any other finding (ADR-0372). The marking is what keeps the deferrals legible as a toil measurement: recurring ones are the backlog telling us what to route next, and the routed set grows from observed blocks rather than from anyone remembering.

A Zabbix dead-man ages anything unactioned. “Nobody pasted the script” is silent by construction — the run reported success and the board looks busy rather than stuck — so it is caught by instrumentation. go.deferral.age reads the live run state; the trigger fires at 24h, derived from an estate constraint rather than borrowed (FC-16): quiet hours are 21:00–07:00 Melbourne, so a deferral raised in the evening is legitimately unseen for up to ten hours and any shorter threshold would fire on the sleep window rather than on neglect. 24h is the smallest round window surviving one full quiet-hours period plus a working day. It is explicitly provisional — there is no measurement of real paste latency, which the design refused to invent — and Issue #2438 derives it from observed data.

Every clause below was proven against the real generated artifact, both directions, before this ADR was written.

ClaimProof
A deferral is recorded and the run continuesChild deferred on a synthetic run; state running, halt_requested: false, remaining children untouched
The breaker stays at 0 on a deferralbreaker 0/2 after a deferral; two BLOCKED children also returned rc 0 where the pre-change code returned 3
The breaker still bites on real failuresTwo FAILED children → rc 3, CIRCUIT BREAKER TRIPPED
The script closes an Issue only on a real PASSOne run, two deferrals: the one whose assertion held was closed; the one whose action ran but whose assertion failed left its Issue open, and the only az boards … --state Done call recorded was for the passing Issue
A failure notifies at priority 0Pushover fired once, --priority 0, on the failing run; silent on the clean run
The whitelist refuses a policy-class actioncloudflare-mutation → exit 2, refused by name with RULE 14. No Cloudflare change was attempted — proving a refusal must not perform the act
…and an unknown class, and a routed onereboot-the-hypervisor → exit 2; ado-pipeline-authorisation → exit 5 with its sanctioned path
The emitter re-validates independentlyA cloudflare-mutation deferral hand-injected into the state file → exit 2, no script written
The dead-man returns a non-zero agego_run.py deferrals --max-age → 73, then 74 seconds while unactioned; 0 once actioned
Backward compatibilityEvery pre-existing subcommand run unchanged; the live run-2436.json and the real ledger loaded, read and left byte-identical

The PRR ran on 2026-08-16. It settled two of the three things this ADR left provisional, and it failed to settle the third — recorded here as an open residual rather than closed on a convenient proxy.

The dead-man threshold is now measured — 24h stands, its stated reason does not

Section titled “The dead-man threshold is now measured — 24h stands, its stated reason does not”

The original 24h rested on the quiet-hours constraint. The measurement shows quiet hours are not what drives the tail, so the number survives and the reasoning behind it is replaced.

Method: 919 session transcripts swept for the handoff pattern (an assistant message offering bash /tmp/<name>.sh with handoff intent nearby), giving 78 distinct genuine handoffs over 30 days — 18.2/week. For each, the interval from handoff to the first observation proving the script had run. That interval is an upper bound on true paste latency — the agent polls and cannot observe the paste itself — which is the correct side for sizing a dead-man: a threshold above the maximum observed upper bound cannot fire on a legitimately-actioned handoff.

StatisticValue
p504.5 min
p7532.9 min
p903.20 h
p955.80 h
p9912.95 h
max13.02 h
Handoffs never confirmed run in-session20 of 95 raw (21%)

24h clears the measured maximum with 1.8× headroom, so it is retained. But the quiet-hours derivation is withdrawn: only 13% of handoffs were raised during quiet hours, and their maximum was 3.43 hshorter than the daytime maximum of 13.02 h. The tail is driven by daytime absence, not by the sleep window. The original reasoning invoked a constraint that is not the binding one; it reached a defensible number for the wrong reason.

Two limits on this measurement, stated rather than buried. The tightly-bracketed subset — a failed poll immediately followed by a successful one, the only samples where the instrument brackets the paste — is n=2, far too small to carry a percentile, which is why the looser upper-bound population is used and labelled as such. And the 21% never-confirmed figure is an upper bound on neglect: at least three of that group (lxcfs_loadavg_apply, rotate_dtrack_github_pat, authorize_lxcfs_pipeline) are confirmed run in a later resumed session, so “the session never saw it run” is not “it was never run”.

The sub-agent classifier question is answered in part, and is structurally hard in the rest

Section titled “The sub-agent classifier question is answered in part, and is structurally hard in the rest”

ADR-0413 does settle the premise this design needs: its four live probes include a sub-agent, wrapped, ordinary-task framing, blocked by the classifier. That is direct evidence the classifier covers sub-agents. Issue #2438 corroborated it independently and by accident: the delivering sub-agent attempted its own probe script and was blocked by the auto-mode classifier, a second live instance in a different session on a different action.

What ADR-0413 does not settle is the symmetry, exactly as this ADR’s original consequence stated. Its four probes never hold framing constant across the parent/child boundary — the sub-agent block used ordinary-task framing while the parent block used bypass-testing framing, so the comparison is confounded and the missing cell is parent, wrapped, ordinary-task framing.

#2438 designed the controlled experiment and could not run it. The probe held action and framing identical and varied only parent-vs-child, n=3 per arm, using a credential read against a nonexistent secret path so that an unblocked probe could disclose nothing. The classifier blocked the sub-agent from running it. Per the standing rule the block was not routed around.

That failure is itself the finding, and it generalises: the classifier gates on intent, so any stimulus that reliably triggers it must be genuinely sensitive — which means an agent-run symmetry experiment is blocked by the very mechanism it is trying to measure. The question is therefore not merely unanswered but not answerable by an agent acting alone; it needs a human-executed probe. This is a structural property of the control, not a gap in effort.

Sizing impact: none. The design’s premise — the classifier covers children — is proven. Only the finer question of identical weighting is open, and this ADR already recorded that if it resolves the other way, “part of this design’s sizing changes — not its shape.”

The whitelist edges: one class added, one figure withdrawn

Section titled “The whitelist edges: one class added, one figure withdrawn”

Reviewed against the 78 re-derived handoffs. pve01-host-mutation was added to policy_excluded — six observed blocks (pvefw_revert, pvefw_fullrevert, pvefw_b2, pvefw_c, fix-pbs-server-cfg, pbs_gc_fix) that no class named, one of which says verbatim “Blocked (pve01 firewall mutation)”. They were refused as unknown, so the fail-closed default held and nothing unsafe was permitted — the defect was legibility, not safety. Adding to policy_excluded only ever refuses more by name and cannot widen unattended authority, which is what made it safe to do here rather than defer to a decision.

The 45:9 friction:policy split is withdrawn as unvalidated. It came from reading 54 script names. Re-deriving it by keyword over the handoff text over-matched badly — ado_pat_cleanup and vulnscan-followups both classified as Cloudflare on incidental context words — so the re-derivation was discarded rather than published as a correction. Neither number is measured. The mechanism is its own instrument: once real blocks are recorded, go_run.py deferrals holds the class of every one, and the split becomes counted. That is the sanctioned source.

No real deferral was observed end to end, so this ADR’s central operational claim remains proven only synthetically. The three /go runs on 2026-08-16 (Epics #2420, #2441, #2436) produced zero genuine classifier blocks in delivery, and go_run.py deferrals is empty.

The empty reading is a finding, not a broken query: an isolated positive control (GO_STATE_DIR pointed at a scratch directory) drove go_deferrals_total 0 → 1 → 0 and go_deferrals_open with it, so the instrument demonstrably reports non-zero when there is something to report. The live run state was verified byte-identical by checksum throughout.

The one genuine block that did occur — the probe above — was deliberately not deferred. Issue #2438’s acceptance requires a real deferral end to end, so no --assert could be written that exits 0 only when that acceptance holds without being circular; deferring it would have closed the Issue unattended on a proxy assertion, which is FC-02 with the board as the casualty. The correct disposition was to leave the Issue open and say so.

What would satisfy it: the next /go run whose delivery hits a genuine block in a deferrable class, carried through defer → batch → paste → assertion → close. It is a matter of waiting for one to occur naturally, not of building anything further.

  • Pre-authorise the narrow classes in settings.json. Rejected on evidence — the classifier gates on intent, not command pattern, and is independent of the allow-list. This is the finding that forced the routed-set design rather than a permission grant.
  • Escalate to a human mid-run. Rejected — it reintroduces the interruption /go exists to remove, and against quiet hours it can block a run overnight for ten hours.
  • A runbook section instead of committed YAML. Rejected — not machine-readable, so /go cannot classify a block at the moment it happens, and prose drifts silently from the code that would have to honour it.
  • Bake the whitelist into the agent definition. Rejected — it grows without bound and every addition becomes a config change to a file many workers read.
  • Let policy blocks halt the run. Rejected — the two classes are not reliably distinguishable at the moment of blocking, so halting on the ambiguity returns the original problem.
  • Cap total deferrals per run. Rejected as a second unjustified number (FC-16). The whitelist is the bound; a count with no measurement behind it is decoration.
  • Everything deferrable, including policy classes. Rejected — four prime directives would become advisory as a side effect of an unrelated design.
  • Policy actions pause inside the script for confirmation. Rejected — a mostly-unattended script that occasionally waits is easy to abandon half-finished, which is worse than not offering the option.
  • A plain-English manifest with a pause, or per-action confirmation. Rejected on Arron’s stated behaviour: he executes these without reading them, so both are review theatre by the third action.
  • Close a deferred Issue as Done with a caveat. Rejected — it launders unfinished work into a green board. Also rejected: a new Deferred state or tag, which ADR-0399 forecloses.
  • Spawn a dependent child and let it discover the missing prerequisite. Rejected — it burns a worker to learn what the work order already declared, and would record a FAILED that pollutes the breaker.
  • A dedicated /wrapup check or a standalone scheduled review for deferrals. Rejected — the snag close-out is a drain that already works; the marking is enough to keep them legible.
  • Rely on the open Issues alone to catch an unactioned deferral. Rejected — that is only caught when a session happens to look. Also rejected: Pushover alone, since a 22:00 notification may never be seen.
  • Keep the whole run delegated and skip the tier framing. Rejected — it rests the unattended script’s safety on a gesture Arron has stated is not a review, and it leaves the standing-tier controls optional exactly where they are most needed.
  • The change failure rate becomes materially more accurate. BLOCKED currently counts friction as failure; removing it from the breaker’s input removes that pollution. MTTR gains a second dimension — the deferral-to-paste interval, which is precisely what the dead-man measures.
  • A run can now finish with work genuinely undone. The Epic is not delivered until the script runs and each assertion passes, and the only things holding that visible are the open Issues, the marked snags and the dead-man. All three are deliberate redundancy, because the failure is silent by construction.
  • The deferral count is a toil signal, not a reliability one, and must not be folded into the SLI. It should trend toward zero as the routed set grows. A rising deferral rate is not a fault — it is the backlog telling you what to automate next.
  • The whitelist derives from reading 54 script names, not from a classifier taxonomy. It will be wrong at the edges before it is right. Partly corrected at the PRRpve01-host-mutation added to policy_excluded from six observed blocks, and the 45:9 split withdrawn as unvalidated. Adding a class is an amendment to this ADR made from observed blocks, never an edit made to unblock one run.
  • The review-integrity trade was made knowingly. The script runs unread by design. That is an accepted risk recorded here rather than mitigated, and the control that replaces review is the whitelist plus the self-verification.
  • The dead-man’s 24h threshold is provisional RESOLVED by the PRR above. 24h is retained on a measurement — it clears the observed maximum of 13.02 h across 78 handoffs with 1.8× headroom — and its original quiet-hours derivation is withdrawn, because quiet-hours handoffs proved faster, not slower, than daytime ones.
  • Whether the classifier weighs an identical action identically for a child and its parent is still open, and is not answerable by an agent alone. The premise this design needs — that the classifier covers sub-agents — is proven twice over (ADR-0413’s probe, and the PRR’s own accidental block). The symmetry question needs a human-executed probe, because any stimulus that reliably triggers an intent-gated classifier is by construction sensitive enough to be blocked. Sizing impact if it resolves the other way: part of the sizing, not the shape.
  • The mechanism’s central claim is still proven only synthetically. No /go run has yet deferred a real block end to end; see the PRR. The instrument is confirmed working by positive control, so the empty ledger is a real reading rather than a broken one.