From 2a21d3c9135a1ab771568f15fbb3953fe90aa29a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:52:43 -0400 Subject: [PATCH 01/15] docs(autonomy): runner design-pack hub and seam specs (WP7 phase 1) Charter import plus spine and seam specifications, design contract only. runner.md imports the T4 charter verbatim in substance: the queue-contract split (interactive upstream with the plugins, autonomous drain with the runner), both build triggers restated with executors rendered in the shipped surface-class vocabulary, the self-run-primary substrate stance with the vendor-hosted human-merge-gate cap, inherited constraints cited from their owning contracts, anti-goals, and an explicit no-build clause - the runner-execution home stays unborn until a T4 build trigger fires. The two-layer composition spine records the normative shape (minimal composable orchestration over the sandbox-provider and agent-adapter seams with the autonomous/interactive split) and the adopt-first binding stance with reimplement-the-pattern as the named fallback, no library pinned. seams.md specifies the eight seams as the spine's complete interface set, each citing its shipped owning contract (queue+lease, isolation policy, observability+cost, return accounting) or marked runner-new, with the envelope fields stop_reason, outcome, evidence, and resume_handle resolved and all deeper token sets routed to the phase-2 leaves. PLAN phase 1 tagged DONE. Gates: contract validator clean (vendor deny-list on reference/), typos clean, markdownlint clean, 8 seam sections exact, both build triggers and the no-build clause grep-verified, every cited anchor and leaf file verified present. Co-Authored-By: Claude Fable 5 --- docs/topics/ai-ladder-wp7-runner/PLAN.md | 2 +- plugins/autonomy/reference/runner.md | 123 +++++++++++++++++++++ plugins/autonomy/reference/runner/seams.md | 116 +++++++++++++++++++ 3 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 plugins/autonomy/reference/runner.md create mode 100644 plugins/autonomy/reference/runner/seams.md diff --git a/docs/topics/ai-ladder-wp7-runner/PLAN.md b/docs/topics/ai-ladder-wp7-runner/PLAN.md index 9e51cbf02..c0e6d4b0e 100644 --- a/docs/topics/ai-ladder-wp7-runner/PLAN.md +++ b/docs/topics/ai-ladder-wp7-runner/PLAN.md @@ -119,7 +119,7 @@ Design-pack docs live under `reference/runner/` (hub `reference/runner.md`). Prerequisites: WP4 + WP5 + WP6 implementation PRs merged (this pack imports their contracts by citation). Design only — zero build artifacts; the runner-execution home stays unborn. -### Phase 1: Charter import + spine and seam specs [TODO] +### Phase 1: Charter import + spine and seam specs [DONE] | File | Action | What changes | |---|---|---| diff --git a/plugins/autonomy/reference/runner.md b/plugins/autonomy/reference/runner.md new file mode 100644 index 000000000..62ae49ad9 --- /dev/null +++ b/plugins/autonomy/reference/runner.md @@ -0,0 +1,123 @@ +# Runner + +Design-pack hub for the autonomous-drain runner: the charter it graduates from, the +composition spine that shapes it, and the eight seams that are its interface set. This +document is the glance layer of a progressive-disclosure design pack — the charter, the spine +shape, and the seam list here answer "what is the runner, what shape does it take, and what +must it honor"; every deeper question routes to a named leaf under `runner/`, loaded on +demand. The pack is a design contract only: it fixes vocabulary and obligations for a runner +that is not built, so that when a trigger earns the build, the work starts from a resolved +contract rather than a cold charter. + +This document makes no build commitment: no code, no repository, no schedule — no build begins +until a T4 build trigger fires. Every inherited obligation is cited from its already-shipped +owning contract, never restated here. + +## Charter scope — the autonomous-drain side only + +The boundary between plugins and runner IS the governed work-item queue, split +interactive-upstream from autonomous-downstream: + +- **Interactive upstream (plugins own it).** Everything interactive — interview, design, + architect, decompose, triage — produces autonomous-eligible items, and the trigger adapters + live where their signals natively land ([trigger-dispatch](../trigger-dispatch.md)). The + runner never decides what to build. +- **Autonomous downstream (the runner owns it).** The runner is the autonomous drain side + only: lease-claim from the work-item queue, execute in isolation, run the verification + gates, apply the per-class merge policy, and escalate back to humans. It is one executor + behind the [invocation-adapter seam](../trigger-dispatch.md#executor-surface-classes) — + swapping executors leaves the trigger adapters untouched. + +## Build triggers + +The runner's graduation to build is gated: either T4 build trigger fires it, and neither is +assumed to have fired. Restated verbatim in substance (the executor references rendered in the +shipped surface-class vocabulary, never as vendor names): + +- The `C2` promotion trigger fires (per the [guardrail matrix](../guardrails.md#the-matrix)) + AND the existing executors behind the invocation-adapter seam — the self-operated and + vendor-hosted surfaces reachable today — demonstrate a clean autonomous drain; **OR** +- those existing executors hit an isolation, concurrency, or platform wall the runner uniquely + solves. + +Until one build trigger is satisfied, the runner-execution home stays unborn and this pack +adds no runtime artifact. The trust loop earns the build; the build never front-runs the trust +loop (Boris step-3 trap: no agent-count scaling before the loop earns trust). + +## Substrate stance — self-run primary, hosted via adapter + +- **Self-operated is primary.** The self-operated CLI/SDK surface class is the only class + where merge policy is ownable — every vendor-hosted issue-to-change agent keeps a deliberate + human-merge-gate — so the `C2` auto-merge promotion is reachable only self-run. +- **Vendor-hosted is reachable, capped.** Vendor-hosted surfaces stay reachable through the + [invocation-adapter seam](../trigger-dispatch.md#executor-surface-classes) (it covers + local-CLI and cloud-API shapes) but inherit their human-merge-gate: the matrix merge-policy + column caps at human-gated on any vendor-hosted executor. Vendor-managed isolation remains a + legitimate `L3` instance — untrusted-provenance (`C5`) work is its natural fit. +- **Classes, never vendors.** The contract names surface classes — self-operated versus + vendor-hosted — and never a product. + +## Inherited constraints + +All imported unchanged; each is enforced by its owning contract, cited never restated: + +- Execution substrate is at least `L2`, fail-closed where unavailable + ([isolation ladder](../guardrails/isolation-ladder.md)). +- Per-class gates, merge policy, cost tier, and escalation come from the + [guardrail matrix](../guardrails.md#the-matrix). +- Queue and lease are reused from the work-item capability's race-safe lease and its + autonomous/human-gated classes — no second claim mechanism + ([dispatch](../trigger-dispatch.md#dispatch)). +- No queue bypass: every dispatch funnels through the one entrypoint, and the audit trail is + the trust loop ([trigger-dispatch](../trigger-dispatch.md)). + +## Anti-goals + +- Set-and-forget framing — the runner is monitored by exception, never unattended-and-trusted. +- Ungated autonomy — every class keeps its matrix gates; no class drains without them. +- Privileged-context trigger footguns — the trigger class that runs fork-authored code with + write-scoped credentials is never a runner intake path. + +## Composition spine + +The spine is specified in two layers: a normative layer that fixes its shape in seam +vocabulary, and a binding layer that records how the shape is realized at build time. + +### Normative spine shape + +The runner's shape is minimal composable orchestration over two pluggable seams crossed with +one execution split: + +- a **pluggable sandbox-provider seam** — the isolation policy seam, selecting the substrate + that satisfies a class's isolation floor; +- a **pluggable agent-adapter seam** — the invocation adapter seam, normalizing an executor's + native surface behind one contract; +- an **autonomous/interactive split** — the runner drives the autonomous path; the interactive + path stays with the plugins. + +The [eight seams](runner/seams.md) are this spine's complete interface set — nothing in the +spine is expressible outside them. + +### Binding stance — adopt first + +The binding layer records an adopt-first posture: adopt the qualifying composition-spine +library as a build-stage dependency rather than build orchestration from scratch, absorbing +its orchestration, sandbox, and lifecycle patterns re-expressed in this pack's vocabulary. The +choice is re-verified at trigger time on maintenance, license, and seam fit; reimplementing the +pattern is the named fallback when re-verification fails. No library is named here — the +qualifying candidate is re-checked when the build trigger fires, not pinned in the design. + +## Glance-layer rule + +The charter, the spine shape, and the seam list above are the whole glance layer. Every deeper +question routes to a named leaf under `runner/`; depth is never answered from this document: + +| Deeper question | Leaf | +|---|---| +| What each seam obligates, what owns it, and its runner-side interface tokens | [seams](runner/seams.md) | +| The full lifecycle state model and each transition's telemetry | [lifecycle](runner/lifecycle.md) | +| The stop-criteria taxonomy, terminal-handoff escalation, and severity routing | [escalation](runner/escalation.md) | +| Ownership seams, the launch backend set, and birth-time decisions | [topology](runner/topology.md) | + +The `lifecycle`, `escalation`, and `topology` leaves land with the pack's later phase; their +forward links are expected within an in-progress design pack. diff --git a/plugins/autonomy/reference/runner/seams.md b/plugins/autonomy/reference/runner/seams.md new file mode 100644 index 000000000..1a36c2200 --- /dev/null +++ b/plugins/autonomy/reference/runner/seams.md @@ -0,0 +1,116 @@ +# Runner seams + +The eight seams are the runner's complete interface set — the spine shape in +[the hub](../runner.md) is expressible only through them. Each seam below states its +obligations in contract vocabulary, cites the already-shipped contract that owns any inherited +portion (linked, never restated — the pack defines only runner-new content), and names the +runner-side interface tokens that are resolved at this phase. The structured-output envelope's +field names resolve here; every other exact seam token, and all lifecycle, terminal-outcome, +and severity tokens, resolve at `/architect` and in the pack's escalation and lifecycle leaves. + +Three seams carry an already-shipped owning contract — queue+lease, isolation policy, and +observability+cost; the other five are runner-new, though several plug into a shipped boundary +they cite rather than redefine. + +## Invocation adapter + +The runner is one executor behind one adapter contract that normalizes every executor surface +— self-operated CLI/SDK and vendor-hosted cloud-API shapes alike — into a uniform invoke +operation. Swapping the executor leaves the upstream trigger adapters untouched; the +executor-class determination that gates merge policy is security-surface data, never a +repo-local value. + +- **Plugs into (shipped):** the [executor surface classes](../trigger-dispatch.md#executor-surface-classes) + and the [one-entrypoint invariant](../trigger-dispatch.md#dispatch) — the runner is the + executor those cite, not a second dispatch path. +- **Interface tokens:** binds the shipped `executor_class` (`self-operated` | `vendor-hosted`); + the runner-new adapter tokens resolve at `/architect`. + +## Structured-output envelope + +Every executor run hands back exactly one machine-readable envelope at its task boundary. The +envelope is the runner's single structured return — the carrier the verification, escalation, +and lifecycle seams consume; there is no second hand-back path. It records why the run stopped, +what terminal outcome that resolves to, a reference to the run's evidence bundle, and the +handle a human takeover resumes from. + +- **Owning contract:** runner-new (no shipped contract owns it). +- **Interface tokens (resolved here):** `stop_reason`, `outcome`, `evidence` (an + evidence-bundle reference, not the bundle inline), `resume_handle`. The value set for + `outcome` and the stop-reason families are the [escalation leaf](escalation.md)'s subject; + the lifecycle state tokens are the [lifecycle leaf](lifecycle.md)'s — referenced here, never + defined. + +## Queue and lease + +The runner claims work through the work-item capability's race-safe lease and its single +dispatch entrypoint. There is no second claim, dispatch, or escalation mechanism anywhere; the +lease is the guarantor that one leased item maps to one emitting session, and the lease record +is protected dispatch data the runner reads, never rewrites. + +- **Owning contract:** [dispatch](../trigger-dispatch.md#dispatch) and the + [adapter obligations](../trigger-dispatch.md#adapter-obligations) — cited, never restated. +- **Interface tokens:** binds the shipped per-run knobs `autonomous_concurrency` and + `items_per_run`, owned by the admission policy on the security surface. + +## Isolation policy + +The runner runs each work class at or above its isolation floor and fail-closes where the +floor is unavailable — the sandbox-provider seam selects a substrate that satisfies the floor, +and an unattestable or unbound substrate blocks dispatch rather than degrading it. + +- **Owning contract:** the [guardrail matrix](../guardrails.md#the-matrix) min-isolation column + and the [isolation ladder](../guardrails/isolation-ladder.md) leaf. +- **Interface tokens:** binds the shipped ladder levels `L0`–`L3` and the security binding's + isolation entries; the runner-new provider-selection tokens resolve at `/architect`. + +## Outcome-verification gate + +Before disposition, the runner runs the class's verification layers and resolves a pass/fail +result. A failed blocking gate is a terminal stop surfaced through the envelope, never a silent +pass or a merge the policy would gate. + +- **Plugs into (shipped):** the [verification column](../guardrails.md#the-matrix) and the + [security-review leaf](../guardrails/security-review.md) own which layers exist and which + block per class; the gate-running mechanism is runner-new. +- **Interface tokens:** the gate writes its result into the envelope's `stop_reason` and + `outcome`; the exact gate tokens resolve at `/architect`. + +## Merge-policy toggle + +The runner applies the per-class merge policy and never lands a change the policy gates. The +toggle honors the matrix merge-policy column and the vendor-hosted human-merge-gate cap: on any +vendor-hosted executor, every class caps at human-gated regardless of its self-run row. + +- **Plugs into (shipped):** the [merge-policy column](../guardrails.md#the-matrix) and the + vendor-hosted cap on the [executor surface classes](../trigger-dispatch.md#executor-surface-classes). +- **Interface tokens:** reads the shipped `executor_class` cap; the thin launch disposition and + any growth-stage merge serialization are the [lifecycle leaf](lifecycle.md)'s subject. + +## Observability and cost + +Every lifecycle transition emits standard telemetry carrying the work-item join attribute and +the propagated trace context, so runner activity joins the one causal tree. At the task +boundary the runner also drives the return-accounting capture, which joins to machine cost by +the same attribute at query time. The runner adds no parallel telemetry schema and duplicates +no cost value. + +- **Owning contracts:** [telemetry](../telemetry.md) owns emission, the join attribute, and the + causal tree; [return-accounting](../return-accounting.md) owns the task-boundary capture and + its query-side join. +- **Interface tokens:** binds the shipped `autonomy.work_item.url` join attribute and the + return record's task-boundary fields; escalation telemetry rides the telemetry contract's + custom-namespace mechanism, its exact namespace token read from that contract at build. + +## Session, resume, and caps + +The runner persists each executor session so a human takeover resumes it rather than restarting +from cold, and it enforces the caps that bound a single drain. The persisted session behind the +`resume_handle` is what turns a terminal escalation into a resumable takeover. + +- **Plugs into (shipped):** the run caps bind the admission-policy knobs + ([admission policy](../guardrails/admission-policy.md), surfaced as `autonomous_concurrency` + and `items_per_run` on the dispatch contract); session persistence is runner-new. +- **Interface tokens:** the envelope's `resume_handle` is the takeover key; the turn, budget, + and wall-clock caps whose exhaustion is a terminal stop are the [escalation leaf](escalation.md)'s + stop-criteria subject. From ff147e19abcc77d32f7b9bb615a05e338df5acf6 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:03:20 -0400 Subject: [PATCH 02/15] docs(autonomy): runner lifecycle, escalation, and topology leaves (WP7 phase 2) Three leaves complete the design pack's normative content. lifecycle.md fixes the linear state model (leased, executing, verifying, disposing, then escalated or complete), each state's owning seam cited, transition telemetry riding the telemetry contract, the thin per-item-PR launch disposition with the vendor-hosted human-gate cap restated explicitly on the disposition path, and batched gated-merge serialization named as a growth stage binding a platform-native merge-queue facility on its evidence trigger. escalation.md resolves the terminal-handoff shape: four terminal outcomes with success never filing escalation work, the two-family stop-criteria taxonomy (runner-owned deterministic, agent-signaled through stop_reason) with transient-recoverable retrying instead of escalating, the deterministic two-step mapping from stop reason to outcome to event class, two additively registered runner event classes (runner-needs-human, runner-cap-exceeded) beside the guardrail contract's six, contract-default severities for all eight classes on the notice/attention/urgent axis, the urgent stop-reason override for isolation violations and security/data-integrity events, evidence-bundle contents with resume_handle takeover, 72h/one-bump acknowledgment knobs, the deferred mid-run interrupt with its evidence trigger, escalation telemetry on the custom-namespace mechanism, and four research gaps carried as UNVERIFIED. topology.md maps the four ownership homes (design pack, unborn runner-execution home, settings-as-code governance the runner reads and never writes, deployment-owned operational config), the launch backend set (one free self-run L2 container-class backend; L3 deferred, C5 dispatch blocked until bound, the floor cited from the work-classes cell; cloud backends advisory and vendor-hosted-capped), and the USER-RESERVED birth-time decisions. Hub links corrected to reference-relative paths. PLAN phase 2 tagged DONE. Gates: contract validator, typos, markdownlint all clean; lychee over the five-file pack 66 links, 0 errors. Co-Authored-By: Claude Fable 5 --- docs/topics/ai-ladder-wp7-runner/PLAN.md | 2 +- plugins/autonomy/reference/runner.md | 16 +- .../autonomy/reference/runner/escalation.md | 181 ++++++++++++++++++ .../autonomy/reference/runner/lifecycle.md | 59 ++++++ plugins/autonomy/reference/runner/topology.md | 65 +++++++ 5 files changed, 314 insertions(+), 9 deletions(-) create mode 100644 plugins/autonomy/reference/runner/escalation.md create mode 100644 plugins/autonomy/reference/runner/lifecycle.md create mode 100644 plugins/autonomy/reference/runner/topology.md diff --git a/docs/topics/ai-ladder-wp7-runner/PLAN.md b/docs/topics/ai-ladder-wp7-runner/PLAN.md index c0e6d4b0e..0a5b63eff 100644 --- a/docs/topics/ai-ladder-wp7-runner/PLAN.md +++ b/docs/topics/ai-ladder-wp7-runner/PLAN.md @@ -133,7 +133,7 @@ by citation). Design only — zero build artifacts; the runner-execution home st - Seam count: `grep -cE '^## ' plugins/autonomy/reference/runner/seams.md` = 8 - Vendor+fleet deny-list sweep exit 0; lychee lane passes -### Phase 2: Lifecycle + stop-criteria + escalation leaves [TODO] +### Phase 2: Lifecycle + stop-criteria + escalation leaves [DONE] | File | Action | What changes | |---|---|---| diff --git a/plugins/autonomy/reference/runner.md b/plugins/autonomy/reference/runner.md index 62ae49ad9..4cc36410b 100644 --- a/plugins/autonomy/reference/runner.md +++ b/plugins/autonomy/reference/runner.md @@ -20,12 +20,12 @@ interactive-upstream from autonomous-downstream: - **Interactive upstream (plugins own it).** Everything interactive — interview, design, architect, decompose, triage — produces autonomous-eligible items, and the trigger adapters - live where their signals natively land ([trigger-dispatch](../trigger-dispatch.md)). The + live where their signals natively land ([trigger-dispatch](trigger-dispatch.md)). The runner never decides what to build. - **Autonomous downstream (the runner owns it).** The runner is the autonomous drain side only: lease-claim from the work-item queue, execute in isolation, run the verification gates, apply the per-class merge policy, and escalate back to humans. It is one executor - behind the [invocation-adapter seam](../trigger-dispatch.md#executor-surface-classes) — + behind the [invocation-adapter seam](trigger-dispatch.md#executor-surface-classes) — swapping executors leaves the trigger adapters untouched. ## Build triggers @@ -34,7 +34,7 @@ The runner's graduation to build is gated: either T4 build trigger fires it, and assumed to have fired. Restated verbatim in substance (the executor references rendered in the shipped surface-class vocabulary, never as vendor names): -- The `C2` promotion trigger fires (per the [guardrail matrix](../guardrails.md#the-matrix)) +- The `C2` promotion trigger fires (per the [guardrail matrix](guardrails.md#the-matrix)) AND the existing executors behind the invocation-adapter seam — the self-operated and vendor-hosted surfaces reachable today — demonstrate a clean autonomous drain; **OR** - those existing executors hit an isolation, concurrency, or platform wall the runner uniquely @@ -50,7 +50,7 @@ loop (Boris step-3 trap: no agent-count scaling before the loop earns trust). where merge policy is ownable — every vendor-hosted issue-to-change agent keeps a deliberate human-merge-gate — so the `C2` auto-merge promotion is reachable only self-run. - **Vendor-hosted is reachable, capped.** Vendor-hosted surfaces stay reachable through the - [invocation-adapter seam](../trigger-dispatch.md#executor-surface-classes) (it covers + [invocation-adapter seam](trigger-dispatch.md#executor-surface-classes) (it covers local-CLI and cloud-API shapes) but inherit their human-merge-gate: the matrix merge-policy column caps at human-gated on any vendor-hosted executor. Vendor-managed isolation remains a legitimate `L3` instance — untrusted-provenance (`C5`) work is its natural fit. @@ -62,14 +62,14 @@ loop (Boris step-3 trap: no agent-count scaling before the loop earns trust). All imported unchanged; each is enforced by its owning contract, cited never restated: - Execution substrate is at least `L2`, fail-closed where unavailable - ([isolation ladder](../guardrails/isolation-ladder.md)). + ([isolation ladder](guardrails/isolation-ladder.md)). - Per-class gates, merge policy, cost tier, and escalation come from the - [guardrail matrix](../guardrails.md#the-matrix). + [guardrail matrix](guardrails.md#the-matrix). - Queue and lease are reused from the work-item capability's race-safe lease and its autonomous/human-gated classes — no second claim mechanism - ([dispatch](../trigger-dispatch.md#dispatch)). + ([dispatch](trigger-dispatch.md#dispatch)). - No queue bypass: every dispatch funnels through the one entrypoint, and the audit trail is - the trust loop ([trigger-dispatch](../trigger-dispatch.md)). + the trust loop ([trigger-dispatch](trigger-dispatch.md)). ## Anti-goals diff --git a/plugins/autonomy/reference/runner/escalation.md b/plugins/autonomy/reference/runner/escalation.md new file mode 100644 index 000000000..42b25ed4a --- /dev/null +++ b/plugins/autonomy/reference/runner/escalation.md @@ -0,0 +1,181 @@ +# Runner escalation + +Every runner stop is a terminal state, and every non-success terminal state hands the work +back to a human through the one governed queue. This leaf resolves the escalation UX the +[hub](../runner.md) routes here: the terminal-handoff shape, the two-family stop-criteria +taxonomy, the deterministic mapping from a stop to a severity-routed escalation item, and the +acknowledgment and re-escalation knobs. It defines only runner-new content; the inherited +escalation event classes, routing obligation, and one-channel invariant are cited from the +[guardrail escalation contract](../guardrails.md#escalation), never restated. + +## Terminal handoff at launch + +The launch escalation shape is terminal handoff: the runner runs each leased item to a +terminal state and never pauses mid-run for a human. Every stop resolves to exactly one +terminal outcome, carried in the structured-output envelope's `outcome` field +([envelope seam](seams.md)): + +| `outcome` | Meaning | +|---|---| +| `success` | the item drained clean — gates passed, disposition applied | +| `gate-failed` | a blocking verification gate failed | +| `needs-human` | the run cannot proceed without human judgment or intervention | +| `cap-exceeded` | a turn, budget, or wall-clock cap bounded the drain before completion | + +**Non-success outcomes file the escalation item.** `gate-failed`, `needs-human`, and +`cap-exceeded` each file a human-gated work item on the governed queue carrying the evidence +bundle: a failure summary, the run-transcript link, the run cost, the trace link, and the +`resume_handle`. Human takeover is not a distinct API — it resumes the persisted session behind +the `resume_handle` ([session-and-resume seam](seams.md)), so the escalation item is a +resumable takeover, not a cold restart. + +**`success` never escalates.** A successful outcome completes through the normal path — the +per-item disposition ([lifecycle leaf](lifecycle.md)) and the task-boundary return-accounting +capture ([return-accounting](../return-accounting.md)) — with no escalation item and no +acknowledgment or re-escalation obligation. Escalation is the non-success path only; if a +healthy drain filed escalation work, every clean run would generate a false human task. + +## Stop-criteria taxonomy — two families + +A stop belongs to one of two families. The family determines who detects the stop, not its +severity — severity is resolved by the mapping below. + +- **`runner-owned` (deterministic).** The runner detects these itself, without agent judgment: + turn, budget, or wall-clock cap reached; execution error persisting after retries; model + refusal; verification-gate failure; isolation violation. Each is an observable runner + condition, not a signal the agent has to raise. +- **`agent-signaled` (judgment).** The executing agent raises these through the envelope's + `stop_reason` field, because only the agent holds the context to recognize them: ambiguity + (multiple valid interpretations), a design decision needing human judgment, a + security/data-integrity event, an unresolvable blocker, and no-progress (a stuck loop making + no forward movement). + +**Transient-recoverable never escalates.** A transient, recoverable condition — a rate limit, +a retryable execution error, a rescheduled run — is retried with backoff and is not a stop. +Only exhaustion of the retry budget converts it into a `runner-owned` execution-error stop. + +## Severity resolution — the two-step mapping + +Severity routing keys on the escalation *event class*. A runner outcome alone never names an +event class, and a stop reason never names its outcome, so resolution is a deterministic two +steps: stop reason to terminal outcome, then outcome to event class. Every non-success stop +traces the full path stop reason → outcome → event class → severity → route. + +### Step one — stop reason to terminal outcome + +| Family | Stop reason | `outcome` | +|---|---|---| +| `runner-owned` | verification-gate failure | `gate-failed` | +| `runner-owned` | turn / budget / wall-clock cap | `cap-exceeded` | +| `runner-owned` | execution error after retries | `needs-human` | +| `runner-owned` | refusal | `needs-human` | +| `runner-owned` | isolation violation | `needs-human` | +| `agent-signaled` | ambiguity | `needs-human` | +| `agent-signaled` | design decision needing human judgment | `needs-human` | +| `agent-signaled` | security/data-integrity event | `needs-human` | +| `agent-signaled` | unresolvable blocker | `needs-human` | +| `agent-signaled` | no-progress | `needs-human` | + +### Step two — terminal outcome to event class + +| `outcome` | Event class | Provenance | +|---|---|---| +| `gate-failed` | `gate-failure` | the [guardrail contract's](../guardrails.md#escalation) existing gate-failure class — reused, not re-minted | +| `needs-human` | `runner-needs-human` | runner-new, registered additively | +| `cap-exceeded` | `runner-cap-exceeded` | runner-new, registered additively | + +`gate-failed` routes through the guardrail contract's own gate-failure event class; only +`needs-human` and `cap-exceeded` introduce new classes. The two runner classes +`runner-needs-human` and `runner-cap-exceeded` extend the escalation event-class registry +additively, alongside the guardrail contract's set — the security binding accepts route and +severity bindings for them exactly as it does for any guardrail event class, and existing +bindings validate unchanged. Their contract-default severities are `attention` and `notice`, +both org-bindable. + +## Severity axis and notification fan-out + +Every event class escalates at a severity on a three-level axis — `notice`, `attention`, +`urgent` — that maps to org-bound notification fan-out over the single filed item. The fan-out +is notification depth on the one queue item, not a second escalation channel; the one-channel +invariant ([guardrail escalation contract](../guardrails.md#escalation)) holds. + +| Severity | Fan-out | +|---|---| +| `notice` | the tracker item only | +| `attention` | tracker item + channel notification per the org route | +| `urgent` | tracker item + channel notification + a personal-push tier, org-bindable | + +The tracker item is always filed; channel notification and the personal-push tier are +org-bound routes. Absent a bound channel adapter, fan-out degrades to tracker-item-only — it +never drops the escalation item itself. + +### Contract-default severities + +Fan-out is fully defined with no `escalation_severity` binding present at all: every event +class carries a contract-default severity, each an org-bindable override. + +| Event class | Default severity | +|---|---| +| `gate-failure` | `attention` | +| `verification-divergence` | `attention` | +| `admission-rejection` | `notice` | +| `demotion` | `attention` | +| `structural-plan-approval` | `attention` | +| `untrusted-provenance` | `urgent` | +| `runner-needs-human` | `attention` | +| `runner-cap-exceeded` | `notice` | + +The six inherited classes are owned by the [guardrail escalation contract](../guardrails.md#escalation); +their default severities are assigned here so runner fan-out is defined for every class it can +route, without requiring an org to bind one first. + +### Urgent stop-reason override + +Two stop reasons carry an `urgent` severity override that sits on top of the event-class +default: an **isolation violation** and a **security/data-integrity event**. Both resolve to +`needs-human` → `runner-needs-human`, whose default severity is `attention`; the override +forces the filed item to `urgent` regardless of that default. The override keys on the stop +reason, not the outcome or the event class — a `needs-human` stop from any other reason keeps +the `attention` default. + +## The filed escalation item + +The filed item's envelope records both the resolved event class and the originating stop +reason, so a reader recovers the full trace from the item alone: which class routed it, and +which condition raised it. + +**Acknowledgment and re-escalation.** An escalation item carries an acknowledgment state. An +unacknowledged item that goes stale re-escalates once with a one-level severity bump; an +acknowledged item never re-escalates. Both knobs are org-bindable: the default staleness +window is 72h, and the re-escalation cap is 1 (a single bump, never a loop). + +## Deferred — mid-run interrupt + +A mid-run interrupt shape — pausing the run to await human input before it reaches a terminal +state — is deferred. Its adoption trigger is evidence that kill-and-resume loses material cost +or context on real drains; until then, terminal handoff with a resumable session is the whole +escalation surface. First-party pause-and-resume mechanisms exist and are re-verified at build, +so adopting the interrupt shape later needs no change to this contract. + +## Escalation telemetry + +No standard telemetry signal for "an agent escalated to a human" exists, so escalation events +ride the [telemetry contract's](../telemetry.md) governed custom-namespace mechanism under the +work-item join attribute. The exact namespace token is read from the shipped telemetry contract +at build, not pinned here. A standard escalation signal is noted as a candidate upstream +contribution when the relevant conventions mature. + +## Research gaps carried + +The following are unresolved at design time and carried openly rather than closed by assumption: + +- CI-action-class failure-reporting specifics — whether a failure surfaces as a comment, a + check result, or a job failure — are UNVERIFIED; bind the exact reporting surface at build + from live docs. +- Cross-vendor agent-needs-human signaling — the agent-protocol and agent-instruction-file + guidance for how an agent raises a needs-human stop across surfaces — is UNVERIFIED; bind at + build. +- Managed-agent event names drift between the stream surface and the webhook surface; bind the + exact event names at build from live docs rather than pinning them here. +- No maintained, credible approval-as-a-service precedent exists — the once-cited approval SDK + is deprecated and is never treated as living precedent. diff --git a/plugins/autonomy/reference/runner/lifecycle.md b/plugins/autonomy/reference/runner/lifecycle.md new file mode 100644 index 000000000..c986c6009 --- /dev/null +++ b/plugins/autonomy/reference/runner/lifecycle.md @@ -0,0 +1,59 @@ +# Runner lifecycle + +The runner's handling of a single leased work item is a linear state machine: +`leased → executing → verifying → disposing → (escalated | complete)`. Each state's work is +owned by a seam in [the seam set](seams.md); this leaf fixes the state model and the telemetry +every transition emits, and defers the terminal-outcome and severity vocabulary to +[the escalation leaf](escalation.md). It defines only lifecycle-new content — every inherited +obligation is cited from its owning contract, never restated here. + +## State model + +One item occupies one state at a time; the machine is linear with a single terminal branch. +Two states are terminal: `escalated` and `complete`. + +| State | The runner, on entry | Owning seam / contract (cited) | +|---|---|---| +| `leased` | Claims one item through the race-safe lease — one leased item to one emitting session, no second claim path. | [queue and lease](seams.md#queue-and-lease) → [dispatch](../trigger-dispatch.md#dispatch) | +| `executing` | Runs the item in isolation at or above its class's floor; an unattestable or unbound substrate fail-closes rather than degrading. | [isolation policy](seams.md#isolation-policy) → [matrix](../guardrails.md#the-matrix), [isolation ladder](../guardrails/isolation-ladder.md) | +| `verifying` | Runs the class's verification layers and resolves a pass/fail result into the envelope. | [outcome-verification gate](seams.md#outcome-verification-gate) → [matrix](../guardrails.md#the-matrix) | +| `disposing` | Applies the per-class merge policy to a passing result (see [disposition](#disposition) below). | [merge-policy toggle](seams.md#merge-policy-toggle) → [matrix](../guardrails.md#the-matrix) | +| `escalated` | Terminal. Files the human-gated handoff; the terminal outcome and severity vocabulary are the escalation leaf's. | [escalation](escalation.md) | +| `complete` | Terminal. The disposition landed; the run closes through the return-accounting capture at the task boundary. | [observability and cost](seams.md#observability-and-cost) → [return-accounting](../return-accounting.md) | + +A stop at any non-terminal state resolves to `escalated` rather than advancing; the mapping +from a stop reason to its terminal outcome is [the escalation leaf](escalation.md)'s subject. +Only a run that reaches `disposing` and lands its change becomes `complete` — every other +ending is `escalated`. + +## Transition telemetry + +Every transition emits standard telemetry carrying the work-item join attribute and the +propagated trace context, so the whole run is one branch of the one causal tree and the runner +adds no parallel schema. Emission, the join attribute, and trace propagation are the +[telemetry contract](../telemetry.md)'s, driven through the +[observability seam](seams.md#observability-and-cost) — cited, never restated here. The +`escalated` transition additionally carries escalation telemetry on the telemetry contract's +custom-namespace mechanism; that namespace token, and the escalation payload, are +[the escalation leaf](escalation.md)'s. + +## Disposition + +Launch disposition is thin: a passing result lands as a per-item PR through the platform's +native change-proposal flow — one item, one PR, no runner-owned merge machinery. Who lands it +is the [matrix merge-policy row](../guardrails.md#the-matrix) for the item's class: an +auto-merge only where that class's cell is promoted AND the run is self-run; human-gated +everywhere else. + +The vendor-hosted merge cap is restated here, not inherited silently: whenever the executing +backend is a vendor-hosted executor, every class caps at human-gated regardless of its self-run +merge row, so the auto-merge disposition path is reachable only on a self-run backend. +[The topology leaf](topology.md) carries the same cap on the cloud-backend selection. + +### Growth stage — batched gated-merge serialization + +Deferred, with an evidence trigger: observed concurrent auto-merge collisions on the platform's +native flow. When that evidence arrives, the runner serializes gated merges by binding a +platform-native merge-queue facility where one exists — never a reimplemented queue. That +facility's availability is verified at binding time; absent one, the growth stage stays +deferred rather than reimplementing a built-in. No serialization ships at launch. diff --git a/plugins/autonomy/reference/runner/topology.md b/plugins/autonomy/reference/runner/topology.md new file mode 100644 index 000000000..72e7135e2 --- /dev/null +++ b/plugins/autonomy/reference/runner/topology.md @@ -0,0 +1,65 @@ +# Runner topology + +The ownership seam map for the runner: which home owns which part of it, which backend the +launch set requires, and which decisions stay reserved for the human who fires the build +trigger. The map is fixed now, at design time; the homes it assigns are populated only when a +[build trigger](../runner.md#build-triggers) earns the build. This leaf defines only the +ownership seams — every level, floor, and matrix cell it references is cited from its owning +contract, never restated here. + +## Ownership seams + +Four homes own the runner between them; the split is a pre-committed seam, not a runtime choice. + +- **Capability-distribution home — the design pack.** This pack lives here, alongside the other + autonomy contracts. It owns the runner's vocabulary and obligations, never an implementation. +- **Runner-execution home — the implementation.** When a build trigger fires, this home is born + owning the runner implementation and its build/release toolchain. It consumes the contract + docs by citation and never duplicates them; until the trigger fires it does not exist. +- **Settings-as-code home — the security-sensitive bindings.** The runner's governance — + level→substrate isolation bindings, merge policy, escalation routes, and admission rules — + lives here. The runner READS its governance and never writes it: the agent-writable-binding + bypass channel the rest of the contract fail-closes against generalizes to the runner itself, + so no runner-editable surface may supply a binding the runner is governed by. +- **Deployment-owned — non-security operational config.** Executor hosting configuration that is + not security-sensitive is the adopting deployment's, per the hosting stance. + +## Launch backend set + +The sandbox-provider seam is the normative requirement; the backend set below is what satisfies +it for the work the trigger admits. Backend classes are cited from the +[isolation ladder](../guardrails/isolation-ladder.md), never redefined. + +- **One free self-run `L2` backend at launch.** A container-class substrate with a default-deny + egress firewall satisfies the `L2` unattended floor at no standing cost; it is the only + backend the launch set requires. +- **`L3` deferred, fail-closed until bound.** An `L3` backend is deferred with its trigger — the + first `C5`-class work admitted to the autonomous drain. Until an `L3` binding exists, `C5` + dispatch is BLOCKED, never dropped to a lower floor: the `L3` floor for `C5` work is the one + the [work-classes `C5` cell](../guardrails/work-classes.md) fixes and this gate cites, not a + value asserted here. +- **Paid and cloud backends — advisory, explicit opt-in.** No paid or cloud backend is a + default; each is advisory with its cost surfaced first and reached only by explicit opt-in. + Any cloud backend IS a vendor-hosted executor: selecting one forces the security binding's + `executor_class: vendor-hosted`, which caps every merge row at human-gated — the same cap + [the lifecycle leaf](lifecycle.md#disposition) restates on the disposition path. A cloud + backend buys isolation, never an auto-merge it cannot own. + +## Birth-time decisions — USER-RESERVED + +Some decisions resolve only when the build trigger fires, and their arbiter is USER-RESERVED — +the trigger firing is a user-ratified event, never a choice implementation makes on its own: + +- repo count, name, and implementation language, via the naming pass and the re-verified spine + choice; +- the spine re-verification outcome — adopt the qualifying spine library, or fall back to + reimplement-the-pattern; +- the exact managed-agent event-name bindings, bound at build from live surface docs. + +## Absent settings-as-code home + +An adopting org without a settings-as-code home does not lose the governance guarantee. Binding +resolution layers over whatever governance surfaces are available, and an absent security +binding fail-closes — the runner blocks rather than running ungoverned. Guided setup names the +compliant path to a home for the bindings; it never degrades to a repo-local (agent-writable) +one. From ba4824d233925613962873e66fefda2af1c0a6b2 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:23:48 -0400 Subject: [PATCH 03/15] feat(autonomy): escalation-severity join, setup runner note, 0.7.0 (WP7 phase 3) The security binding gains the escalation-severity refinement as three genuinely additive optional keys beside an untouched escalation_routes: escalation_severity (event class to severity token - the explicit join), escalation_severity_routes (severity token to route, personal-push a legal route value at any tier; resolution is severity-first with the event class's own escalation_routes entry as fallback), and escalation_ack (staleness_window, reescalation_cap; contract defaults 72h and one bump live in the runner escalation contract, not the schema). The event-class registry grows additively with runner-needs-human and runner-cap-exceeded: escalation_routes keeps REQUIRING only the six inherited guardrail classes - existing bindings validate unchanged - while the two runner classes are legal OPTIONAL route keys, accepted exactly like any guardrail class per the escalation contract. check-security-binding.mjs mirrors the shapes and adds the one semantic rule the schema cannot express: an event class bound in escalation_severity with neither a bound severity route nor its own escalation_routes entry is unroutable, fail-closed. Setup surface: SKILL.md gains the short runner note (design pack bindable-when-born; nothing runner-specific recorded until a build trigger fires except the escalation notification routes; 427/500 lines), evals.json gains case 29 covering that slice, the plugin README gains the runner-design-pack capability with the build row still trigger-gated, plugin.json bumps to 0.7.0, and the root catalog is regenerated. PLAN phase 3 tagged DONE. Verified: full fixture sweep 11 pass / 95 reject with ZERO flips among the 102 pre-WP7 fixtures (additivity proven, not asserted); the four new escalation fixtures behave exactly as named (valid binding with runner classes, severities, personal-push urgent route, and ack knobs passes; bad severity token, unroutable class, and non-positive ack values each reject with their own finding); a runner class bound through an optional escalation_routes entry alone is routable; validate-plugins.sh, catalog --check, contract validator, and typos all clean. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- docs/topics/ai-ladder-wp7-runner/PLAN.md | 2 +- plugins/autonomy/.claude-plugin/plugin.json | 5 +- plugins/autonomy/README.md | 12 ++- plugins/autonomy/skills/setup/SKILL.md | 14 +++ .../autonomy/skills/setup/evals/evals.json | 13 +++ .../escalation-ack-invalid.json | 73 ++++++++++++++ .../escalation-severity-bad-token.json | 72 ++++++++++++++ .../escalation-severity-unroutable.json | 72 ++++++++++++++ .../escalation-severity-valid.json | 85 ++++++++++++++++ .../guardrails-security-binding.schema.json | 49 +++++++++- .../setup/scripts/check-security-binding.mjs | 96 ++++++++++++++++++- 12 files changed, 486 insertions(+), 9 deletions(-) create mode 100644 plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-ack-invalid.json create mode 100644 plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-bad-token.json create mode 100644 plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-unroutable.json create mode 100644 plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-valid.json diff --git a/README.md b/README.md index 570d258c8..e04096dcc 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ user opts in with `/plugin enable`; an existing install is never flipped by cata ### Autonomy -- [`autonomy`](plugins/autonomy) — Governed autonomous agent operation: role-topology, binding-seam, wiring-vs-advisor, telemetry, return-accounting, trigger-dispatch, per-work-class guardrail-matrix, and standing-routine-catalog contracts for climbing the AI-adoption ladder, plus a guided-setup skill that discovers an adopting org's state, writes its schema-versioned binding, wires standards-pinned OTLP emission with a zero-cost file-artifact default, wires human-attested return capture at the task boundary, wires signal adapters with one governed dispatch entrypoint, binds the five-class guardrail matrix to an org's isolation substrates with an in-boundary live-validation probe before recording each fail-closed binding, and stands up standing-routine-catalog classes as scheduled temporal signal adapters behind the one governed queue with free scheduling defaults wired as reviewable changes and each routine's work-class mapping homed on the security surface. +- [`autonomy`](plugins/autonomy) — Governed autonomous agent operation: role-topology, binding-seam, wiring-vs-advisor, telemetry, return-accounting, trigger-dispatch, per-work-class guardrail-matrix, standing-routine-catalog, and design-only runner-charter contracts for climbing the AI-adoption ladder, plus a guided-setup skill that discovers an adopting org's state, writes its schema-versioned binding, wires standards-pinned OTLP emission with a zero-cost file-artifact default, wires human-attested return capture at the task boundary, wires signal adapters with one governed dispatch entrypoint, binds the five-class guardrail matrix to an org's isolation substrates with an in-boundary live-validation probe before recording each fail-closed binding, and stands up standing-routine-catalog classes as scheduled temporal signal adapters behind the one governed queue with free scheduling defaults wired as reviewable changes and each routine's work-class mapping homed on the security surface. ### Security diff --git a/docs/topics/ai-ladder-wp7-runner/PLAN.md b/docs/topics/ai-ladder-wp7-runner/PLAN.md index 0a5b63eff..7264a7d46 100644 --- a/docs/topics/ai-ladder-wp7-runner/PLAN.md +++ b/docs/topics/ai-ladder-wp7-runner/PLAN.md @@ -149,7 +149,7 @@ by citation). Design only — zero build artifacts; the runner-execution home st - `grep -c 'USER-RESERVED' plugins/autonomy/reference/runner/topology.md` ≥ 1 - Vendor+fleet deny-list sweep exit 0 -### Phase 3: Setup note + WP5 escalation-route join [TODO] +### Phase 3: Setup note + WP5 escalation-route join [DONE] | File | Action | What changes | |---|---|---| diff --git a/plugins/autonomy/.claude-plugin/plugin.json b/plugins/autonomy/.claude-plugin/plugin.json index 9996b780d..d49fd56b8 100644 --- a/plugins/autonomy/.claude-plugin/plugin.json +++ b/plugins/autonomy/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "autonomy", - "version": "0.6.0", - "description": "Governed autonomous agent operation: role-topology, binding-seam, wiring-vs-advisor, telemetry, return-accounting, trigger-dispatch, per-work-class guardrail-matrix, and standing-routine-catalog contracts for climbing the AI-adoption ladder, plus a guided-setup skill that discovers an adopting org's state, writes its schema-versioned binding, wires standards-pinned OTLP emission with a zero-cost file-artifact default, wires human-attested return capture at the task boundary, wires signal adapters with one governed dispatch entrypoint, binds the five-class guardrail matrix to an org's isolation substrates with an in-boundary live-validation probe before recording each fail-closed binding, and stands up standing-routine-catalog classes as scheduled temporal signal adapters behind the one governed queue with free scheduling defaults wired as reviewable changes and each routine's work-class mapping homed on the security surface.", + "version": "0.7.0", + "description": "Governed autonomous agent operation: role-topology, binding-seam, wiring-vs-advisor, telemetry, return-accounting, trigger-dispatch, per-work-class guardrail-matrix, standing-routine-catalog, and design-only runner-charter contracts for climbing the AI-adoption ladder, plus a guided-setup skill that discovers an adopting org's state, writes its schema-versioned binding, wires standards-pinned OTLP emission with a zero-cost file-artifact default, wires human-attested return capture at the task boundary, wires signal adapters with one governed dispatch entrypoint, binds the five-class guardrail matrix to an org's isolation substrates with an in-boundary live-validation probe before recording each fail-closed binding, and stands up standing-routine-catalog classes as scheduled temporal signal adapters behind the one governed queue with free scheduling defaults wired as reviewable changes and each routine's work-class mapping homed on the security surface.", "author": { "name": "Melodic Software", "email": "info@melodicsoftware.com" @@ -17,6 +17,7 @@ "otlp", "return-accounting", "routines", + "runner", "telemetry", "topology" ] diff --git a/plugins/autonomy/README.md b/plugins/autonomy/README.md index ee9dc4c78..f125974c8 100644 --- a/plugins/autonomy/README.md +++ b/plugins/autonomy/README.md @@ -5,7 +5,7 @@ AI-adoption-ladder contract set: it ships the tool-agnostic contracts an adoptin its own repositories, tools, and policies, plus a guided-setup skill that discovers the org's state and records that binding. -## Shipped capability (0.6.0) +## Shipped capability (0.7.0) - **Topology contracts** (`reference/`): role topology for the repositories an adoption spans, the binding-seam shape that maps contract roles to an org's real instances, and the @@ -44,6 +44,14 @@ state and records that binding. that discovers scheduling surfaces, wires the free CI-cron/local-scheduler defaults as reviewable changes, homes each routine's work-class mapping on the security surface, and detect-diff-reconciles existing org schedulers and bots instead of duplicating them. +- **Runner design pack** (`reference/runner.md`): the architect-ready design contract for the + autonomous-drain runner — the composition spine and its eight seams, the lifecycle state + model, the two-family stop-criteria taxonomy with terminal-handoff escalation and + severity-routed notification fan-out, the matrix-derived launch backend set, and the topology + ownership seam map, as a progressive-disclosure hub with `reference/runner/` leaves. Design + only — the build stays gated on the charter's own triggers and the runner-execution home + stays unborn; setup records nothing runner-specific beyond the escalation notification routes + (severity axis + personal-push tier) bound through the security binding. - **Guided setup** (`/autonomy:setup`): discovery-first interview of the adopting org's state — role homes, substrate availability, budget posture — writing a schema-versioned binding under `.claude/autonomy/` as reviewable changes. Never assumes any particular org or repo shape. @@ -56,7 +64,7 @@ locked (no step-skipping — trust before scale). | Capability | Trigger | |---|---| | Fleet adapter materializations (reusable workflows, labels, drain routine) | Work-item backlog, post trigger-package graduation. | -| Runner charter execution pack | The runner build trigger fires (charter's own conditions). | +| Runner charter execution pack (design pack shipped) | The runner build trigger fires (charter's own conditions). | ## Trigger register (plugin-scoped) diff --git a/plugins/autonomy/skills/setup/SKILL.md b/plugins/autonomy/skills/setup/SKILL.md index 84744692a..c0180133f 100644 --- a/plugins/autonomy/skills/setup/SKILL.md +++ b/plugins/autonomy/skills/setup/SKILL.md @@ -394,6 +394,20 @@ resolution order. `signal.routine`, an unresolvable surface, an identity↔surface mismatch, a raw link outside the ratified prefix, a `producer_identity` mismatch, or an unclassified class is a finding. +## Runner note + +The [runner design pack](${CLAUDE_PLUGIN_ROOT}/reference/runner.md) is bindable-when-born: +until a build trigger fires and the runner-execution home is born, setup records NOTHING +runner-specific — no probe, no wiring, no binding section for the unborn home. The single +exception is escalation notification routes, which already home on the security surface: the +severity axis (`notice`/`attention`/`urgent`) and the personal-push tier are prepared as route +options through the security binding's `escalation_severity`, `escalation_severity_routes`, and +`escalation_ack` keys — a reviewable change on the settings-as-code home like every other +security axis, never repo-local. The route set, its two-step severity resolution, and the +per-class default severities are specified by the +[runner escalation leaf](${CLAUDE_PLUGIN_ROOT}/reference/runner/escalation.md); this note points +there rather than restating them. + ## Gotchas Editing- and run-time failure modes — the two-binding split (repo-local autonomy binding vs the diff --git a/plugins/autonomy/skills/setup/evals/evals.json b/plugins/autonomy/skills/setup/evals/evals.json index bd6faa74c..a825dc20d 100644 --- a/plugins/autonomy/skills/setup/evals/evals.json +++ b/plugins/autonomy/skills/setup/evals/evals.json @@ -365,6 +365,19 @@ "Admission validates the (signal.routine, attested source surface) pair AND that raw_link falls under the ratified run_link_prefix AND that producer_identity matches the ratified value before stamping; any mismatch fail-closes human-gated", "A multi-posture class binds per-posture identities on distinct surfaces, never its bare token" ] + }, + { + "id": 29, + "name": "runner-escalation-routes-bindable-home-unborn", + "prompt": "/autonomy:setup apply\n\nWire the runner's escalation notifications — severity levels and a push-to-my-phone tier for urgent stops.", + "expected_output": "Records ONLY the escalation notification routes: the severity axis (notice/attention/urgent) and the personal-push tier as org-bindable route options through the security binding's escalation_severity, escalation_severity_routes, and escalation_ack keys, prepared as a reviewable change on the settings-as-code (security) surface like every other security axis — never repo-local. Restates that the runner-execution home is unborn: no probe, no wiring, no binding section for it, and NOTHING else runner-specific is recorded until a build trigger fires. Cites the runner design pack for the route set and its severity resolution rather than restating them.", + "files": [], + "expectations": [ + "Severity axis (notice/attention/urgent) and personal-push tier recorded as org-bindable routes via the security binding's escalation_severity/escalation_severity_routes/escalation_ack keys", + "Escalation routes prepared as a reviewable change on the settings-as-code (security) surface, never written repo-local", + "Restates that the runner-execution home is unborn and nothing else runner-specific is recorded until a build trigger fires", + "Cites the runner design pack rather than restating its stop-criteria taxonomy or severity-resolution content" + ] } ] } diff --git a/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-ack-invalid.json b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-ack-invalid.json new file mode 100644 index 000000000..beec7e83d --- /dev/null +++ b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-ack-invalid.json @@ -0,0 +1,73 @@ +{ + "schema_version": "1.0", + "executor_class": "self-operated", + "dispatch_posture": "autonomous-enabled", + "isolation_bindings": { + "ci-pool-a": { + "L2": { + "substrate": "egress-denied-container-pool", + "substrate_class": "container", + "probe_evidence": "probe-transcripts/ci-pool-a-l2.json", + "runtime_markers": { + "runner-pool": "ci-pool-a", + "region": "us-east" + } + } + } + }, + "merge_policy": { + "C1": "human", + "C2": "human", + "C3": "human", + "C4": "human", + "C5": "human" + }, + "verification_blocking": { + "deterministic": { + "C1": "not-required", + "C2": "blocking", + "C3": "blocking", + "C4": "blocking", + "C5": "blocking" + }, + "ai-review": { + "C1": "not-required", + "C2": "not-required", + "C3": "advisory", + "C4": "blocking", + "C5": "blocking" + } + }, + "escalation_routes": { + "gate-failure": "queue:escalations/gate-failure", + "verification-divergence": "queue:escalations/verification-divergence", + "admission-rejection": "queue:escalations/admission-rejection", + "demotion": "queue:escalations/demotion", + "structural-plan-approval": "queue:escalations/structural-plan-approval", + "untrusted-provenance": "queue:escalations/untrusted-provenance" + }, + "escalation_ack": { + "staleness_window": 0, + "reescalation_cap": 1 + }, + "admission": { + "classification": { + "tracker-vcs-event": { + "autonomy:audit": "C1", + "autonomy:mechanical": "C2", + "autonomy:scoped": "C3", + "autonomy:structural": "C4", + "autonomy:untrusted": "C5" + } + }, + "rules": [ + { "signal_class": "*", "provenance": "*", "work_class": "C1", "disposition": "autonomous-eligible" }, + { "signal_class": "*", "provenance": "*", "work_class": "C2", "disposition": "autonomous-eligible" }, + { "signal_class": "*", "provenance": "*", "work_class": "C3", "disposition": "human-gated" }, + { "signal_class": "*", "provenance": "*", "work_class": "C4", "disposition": "human-gated" }, + { "signal_class": "*", "provenance": "*", "work_class": "C5", "disposition": "human-gated" } + ], + "autonomous_concurrency": 1, + "items_per_run": 3 + } +} diff --git a/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-bad-token.json b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-bad-token.json new file mode 100644 index 000000000..514a01c64 --- /dev/null +++ b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-bad-token.json @@ -0,0 +1,72 @@ +{ + "schema_version": "1.0", + "executor_class": "self-operated", + "dispatch_posture": "autonomous-enabled", + "isolation_bindings": { + "ci-pool-a": { + "L2": { + "substrate": "egress-denied-container-pool", + "substrate_class": "container", + "probe_evidence": "probe-transcripts/ci-pool-a-l2.json", + "runtime_markers": { + "runner-pool": "ci-pool-a", + "region": "us-east" + } + } + } + }, + "merge_policy": { + "C1": "human", + "C2": "human", + "C3": "human", + "C4": "human", + "C5": "human" + }, + "verification_blocking": { + "deterministic": { + "C1": "not-required", + "C2": "blocking", + "C3": "blocking", + "C4": "blocking", + "C5": "blocking" + }, + "ai-review": { + "C1": "not-required", + "C2": "not-required", + "C3": "advisory", + "C4": "blocking", + "C5": "blocking" + } + }, + "escalation_routes": { + "gate-failure": "queue:escalations/gate-failure", + "verification-divergence": "queue:escalations/verification-divergence", + "admission-rejection": "queue:escalations/admission-rejection", + "demotion": "queue:escalations/demotion", + "structural-plan-approval": "queue:escalations/structural-plan-approval", + "untrusted-provenance": "queue:escalations/untrusted-provenance" + }, + "escalation_severity": { + "gate-failure": "critical" + }, + "admission": { + "classification": { + "tracker-vcs-event": { + "autonomy:audit": "C1", + "autonomy:mechanical": "C2", + "autonomy:scoped": "C3", + "autonomy:structural": "C4", + "autonomy:untrusted": "C5" + } + }, + "rules": [ + { "signal_class": "*", "provenance": "*", "work_class": "C1", "disposition": "autonomous-eligible" }, + { "signal_class": "*", "provenance": "*", "work_class": "C2", "disposition": "autonomous-eligible" }, + { "signal_class": "*", "provenance": "*", "work_class": "C3", "disposition": "human-gated" }, + { "signal_class": "*", "provenance": "*", "work_class": "C4", "disposition": "human-gated" }, + { "signal_class": "*", "provenance": "*", "work_class": "C5", "disposition": "human-gated" } + ], + "autonomous_concurrency": 1, + "items_per_run": 3 + } +} diff --git a/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-unroutable.json b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-unroutable.json new file mode 100644 index 000000000..aad516766 --- /dev/null +++ b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-unroutable.json @@ -0,0 +1,72 @@ +{ + "schema_version": "1.0", + "executor_class": "self-operated", + "dispatch_posture": "autonomous-enabled", + "isolation_bindings": { + "ci-pool-a": { + "L2": { + "substrate": "egress-denied-container-pool", + "substrate_class": "container", + "probe_evidence": "probe-transcripts/ci-pool-a-l2.json", + "runtime_markers": { + "runner-pool": "ci-pool-a", + "region": "us-east" + } + } + } + }, + "merge_policy": { + "C1": "human", + "C2": "human", + "C3": "human", + "C4": "human", + "C5": "human" + }, + "verification_blocking": { + "deterministic": { + "C1": "not-required", + "C2": "blocking", + "C3": "blocking", + "C4": "blocking", + "C5": "blocking" + }, + "ai-review": { + "C1": "not-required", + "C2": "not-required", + "C3": "advisory", + "C4": "blocking", + "C5": "blocking" + } + }, + "escalation_routes": { + "gate-failure": "queue:escalations/gate-failure", + "verification-divergence": "queue:escalations/verification-divergence", + "admission-rejection": "queue:escalations/admission-rejection", + "demotion": "queue:escalations/demotion", + "structural-plan-approval": "queue:escalations/structural-plan-approval", + "untrusted-provenance": "queue:escalations/untrusted-provenance" + }, + "escalation_severity": { + "runner-needs-human": "attention" + }, + "admission": { + "classification": { + "tracker-vcs-event": { + "autonomy:audit": "C1", + "autonomy:mechanical": "C2", + "autonomy:scoped": "C3", + "autonomy:structural": "C4", + "autonomy:untrusted": "C5" + } + }, + "rules": [ + { "signal_class": "*", "provenance": "*", "work_class": "C1", "disposition": "autonomous-eligible" }, + { "signal_class": "*", "provenance": "*", "work_class": "C2", "disposition": "autonomous-eligible" }, + { "signal_class": "*", "provenance": "*", "work_class": "C3", "disposition": "human-gated" }, + { "signal_class": "*", "provenance": "*", "work_class": "C4", "disposition": "human-gated" }, + { "signal_class": "*", "provenance": "*", "work_class": "C5", "disposition": "human-gated" } + ], + "autonomous_concurrency": 1, + "items_per_run": 3 + } +} diff --git a/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-valid.json b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-valid.json new file mode 100644 index 000000000..85a770325 --- /dev/null +++ b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-valid.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.0", + "executor_class": "self-operated", + "dispatch_posture": "autonomous-enabled", + "isolation_bindings": { + "ci-pool-a": { + "L2": { + "substrate": "egress-denied-container-pool", + "substrate_class": "container", + "probe_evidence": "probe-transcripts/ci-pool-a-l2.json", + "runtime_markers": { + "runner-pool": "ci-pool-a", + "region": "us-east" + } + } + } + }, + "merge_policy": { + "C1": "human", + "C2": "human", + "C3": "human", + "C4": "human", + "C5": "human" + }, + "verification_blocking": { + "deterministic": { + "C1": "not-required", + "C2": "blocking", + "C3": "blocking", + "C4": "blocking", + "C5": "blocking" + }, + "ai-review": { + "C1": "not-required", + "C2": "not-required", + "C3": "advisory", + "C4": "blocking", + "C5": "blocking" + } + }, + "escalation_routes": { + "gate-failure": "queue:escalations/gate-failure", + "verification-divergence": "queue:escalations/verification-divergence", + "admission-rejection": "queue:escalations/admission-rejection", + "demotion": "queue:escalations/demotion", + "structural-plan-approval": "queue:escalations/structural-plan-approval", + "untrusted-provenance": "queue:escalations/untrusted-provenance" + }, + "escalation_severity": { + "gate-failure": "attention", + "untrusted-provenance": "urgent", + "admission-rejection": "notice", + "runner-needs-human": "attention", + "runner-cap-exceeded": "notice" + }, + "escalation_severity_routes": { + "notice": "queue:escalations/notice", + "attention": "channel:autonomy-escalations", + "urgent": "personal-push:oncall-primary" + }, + "escalation_ack": { + "staleness_window": 72, + "reescalation_cap": 1 + }, + "admission": { + "classification": { + "tracker-vcs-event": { + "autonomy:audit": "C1", + "autonomy:mechanical": "C2", + "autonomy:scoped": "C3", + "autonomy:structural": "C4", + "autonomy:untrusted": "C5" + } + }, + "rules": [ + { "signal_class": "*", "provenance": "*", "work_class": "C1", "disposition": "autonomous-eligible" }, + { "signal_class": "*", "provenance": "*", "work_class": "C2", "disposition": "autonomous-eligible" }, + { "signal_class": "*", "provenance": "*", "work_class": "C3", "disposition": "human-gated" }, + { "signal_class": "*", "provenance": "*", "work_class": "C4", "disposition": "human-gated" }, + { "signal_class": "*", "provenance": "*", "work_class": "C5", "disposition": "human-gated" } + ], + "autonomous_concurrency": 1, + "items_per_run": 3 + } +} diff --git a/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json b/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json index 1f42821c3..be7ba1644 100644 --- a/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json +++ b/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json @@ -59,7 +59,7 @@ "additionalProperties": { "$ref": "#/$defs/PromotionEntry" } }, "escalation_routes": { - "description": "Org-bound route per escalation event class — a security-sensitive axis; every event class must route.", + "description": "Org-bound route per escalation event class — a security-sensitive axis; every inherited guardrail event class must route. The two additive runner classes are legal OPTIONAL keys (never required, so existing bindings validate unchanged); a runner class may also route solely through the escalation_severity join.", "type": "object", "additionalProperties": false, "required": [ @@ -76,7 +76,38 @@ "admission-rejection": { "$ref": "#/$defs/Route" }, "demotion": { "$ref": "#/$defs/Route" }, "structural-plan-approval": { "$ref": "#/$defs/Route" }, - "untrusted-provenance": { "$ref": "#/$defs/Route" } + "untrusted-provenance": { "$ref": "#/$defs/Route" }, + "runner-needs-human": { "$ref": "#/$defs/Route" }, + "runner-cap-exceeded": { "$ref": "#/$defs/Route" } + } + }, + "escalation_severity": { + "description": "Optional event class -> severity join: which severity each event class escalates at. Keys are the escalation event-class registry — the six inherited guardrail classes plus the two additive runner classes (runner-needs-human, runner-cap-exceeded). Contract-default severities are defined in the runner escalation contract, so an absent binding leaves every class at its default; entries here are org-bound overrides. Route resolution is severity-first: an event class resolves to its severity, then to that severity's escalation_severity_routes entry, falling back to the event class's own escalation_routes entry only when no severity route is bound. An event class keyed here with neither a bound severity route nor an escalation_routes entry is unroutable.", + "type": "object", + "propertyNames": { "$ref": "#/$defs/EscalationEventClass" }, + "additionalProperties": { "$ref": "#/$defs/Severity" } + }, + "escalation_severity_routes": { + "description": "Optional severity token -> route map, the severity-first leg of route resolution. Keyed only by the three severity tokens; the value is an ordinary route, so personal-push is a legal route value at any tier. A runner class with no escalation_routes entry routes through its severity here.", + "type": "object", + "propertyNames": { "$ref": "#/$defs/Severity" }, + "additionalProperties": { "$ref": "#/$defs/Route" } + }, + "escalation_ack": { + "description": "Optional acknowledgment and re-escalation knobs. Contract defaults (72h staleness window, one re-escalation) live in the runner escalation contract, not here, so an absent binding leaves both at their default; entries are org-bound overrides.", + "type": "object", + "additionalProperties": false, + "properties": { + "staleness_window": { + "description": "Whole hours an unacknowledged item may go stale before it re-escalates once with a one-level severity bump.", + "type": "integer", + "minimum": 1 + }, + "reescalation_cap": { + "description": "Maximum number of stale-unacked re-escalations (a single bump, never a loop).", + "type": "integer", + "minimum": 1 + } } }, "admission": { @@ -187,6 +218,20 @@ } }, "Route": { "type": "string", "minLength": 1 }, + "EscalationEventClass": { + "description": "The escalation event-class registry: the six inherited guardrail classes plus the two additive runner classes. escalation_routes keeps requiring only the six inherited classes; the two runner classes bind route and severity entries additively — an optional escalation_routes key, the escalation_severity join, or both.", + "enum": [ + "gate-failure", + "verification-divergence", + "admission-rejection", + "demotion", + "structural-plan-approval", + "untrusted-provenance", + "runner-needs-human", + "runner-cap-exceeded" + ] + }, + "Severity": { "enum": ["notice", "attention", "urgent"] }, "ClassificationRuleHome": { "type": "object", "additionalProperties": { diff --git a/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs b/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs index 7573c0eb5..21ea3b96d 100755 --- a/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs +++ b/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs @@ -68,6 +68,16 @@ const EVENT_CLASSES = [ "structural-plan-approval", "untrusted-provenance", ]; +// The escalation event-class REGISTRY: the six inherited guardrail classes +// above plus the two additive runner classes. escalation_routes keeps +// REQUIRING only the six inherited classes (existing bindings validate +// unchanged), while the two runner classes are legal OPTIONAL +// escalation_routes keys — the binding accepts route and severity bindings +// for them exactly as for any guardrail class; escalation_severity keys +// validate against this full registry. +const RUNNER_EVENT_CLASSES = ["runner-needs-human", "runner-cap-exceeded"]; +const ESCALATION_EVENT_CLASSES = [...EVENT_CLASSES, ...RUNNER_EVENT_CLASSES]; +const SEVERITY_TOKENS = ["notice", "attention", "urgent"]; const LAYERS = ["deterministic", "ai-review"]; const VERIFICATION_TOKENS = ["not-required", "advisory", "blocking"]; const MERGE_TOKENS = ["auto", "human"]; @@ -402,6 +412,9 @@ function validateStructure(binding) { "verification_blocking", "promotion_state", "escalation_routes", + "escalation_severity", + "escalation_severity_routes", + "escalation_ack", "admission", ], "binding", @@ -564,12 +577,66 @@ function validateStructure(binding) { if (!isPlainObject(binding.escalation_routes)) { findings.push("escalation_routes: must be an object keyed by escalation event class"); } else { - checkAllowedKeys(binding.escalation_routes, EVENT_CLASSES, "escalation_routes"); + checkAllowedKeys(binding.escalation_routes, ESCALATION_EVENT_CLASSES, "escalation_routes"); for (const eventClass of EVENT_CLASSES) { if (!isNonEmptyString(binding.escalation_routes[eventClass])) { findings.push(`escalation_routes.${eventClass}: missing or empty — every event class has an org-bound route`); } } + // The two runner classes are OPTIONAL escalation_routes keys — never + // required (existing bindings validate unchanged) — but a bound one + // must still carry a route. + for (const eventClass of RUNNER_EVENT_CLASSES) { + if (Object.hasOwn(binding.escalation_routes, eventClass) && !isNonEmptyString(binding.escalation_routes[eventClass])) { + findings.push(`escalation_routes.${eventClass}: present but empty — a bound runner event-class route carries an org-bound route like any other`); + } + } + } + } + + if (Object.hasOwn(binding, "escalation_severity")) { + if (!isPlainObject(binding.escalation_severity)) { + findings.push("escalation_severity: must be an object mapping escalation event class to severity token"); + } else { + for (const [eventClass, severity] of Object.entries(binding.escalation_severity)) { + const where = `escalation_severity.${eventClass}`; + if (!checkEnum(eventClass, ESCALATION_EVENT_CLASSES, `escalation_severity key ${JSON.stringify(eventClass)}`)) { + continue; + } + checkEnum(severity, SEVERITY_TOKENS, where); + } + } + } + + if (Object.hasOwn(binding, "escalation_severity_routes")) { + if (!isPlainObject(binding.escalation_severity_routes)) { + findings.push("escalation_severity_routes: must be an object mapping severity token to route"); + } else { + for (const [severity, route] of Object.entries(binding.escalation_severity_routes)) { + if (!checkEnum(severity, SEVERITY_TOKENS, `escalation_severity_routes key ${JSON.stringify(severity)}`)) { + continue; + } + if (!isNonEmptyString(route)) { + findings.push( + `escalation_severity_routes.${severity}: missing or empty — a bound severity must carry an org-bound route (personal-push is a legal route value at any tier)`, + ); + } + } + } + } + + if (Object.hasOwn(binding, "escalation_ack")) { + if (!isPlainObject(binding.escalation_ack)) { + findings.push("escalation_ack: must be an object"); + } else { + checkAllowedKeys(binding.escalation_ack, ["staleness_window", "reescalation_cap"], "escalation_ack"); + for (const knob of ["staleness_window", "reescalation_cap"]) { + if (Object.hasOwn(binding.escalation_ack, knob) && (!Number.isInteger(binding.escalation_ack[knob]) || binding.escalation_ack[knob] < 1)) { + findings.push( + `escalation_ack.${knob}: must be a positive integer (>= 1) — a non-positive acknowledgment window or re-escalation cap is meaningless; contract defaults (72h staleness window, one re-escalation) live in the runner escalation contract, not here`, + ); + } + } } } @@ -1420,6 +1487,33 @@ function checkSemantics(binding, probeRoot, egressAllowList) { } } + // Routability of every event class bound in escalation_severity, matching + // the severity-first resolution rule: an event class resolves to its + // severity, then to that severity's escalation_severity_routes entry, + // falling back to the event class's own escalation_routes entry only when + // no severity route is bound. Severity-only fan-out is legal — a legacy + // per-event route is the fallback, never a requirement — so an event class + // is routable when EITHER path yields a route. Only an event class bound to + // a severity with no severity route AND no escalation_routes entry is + // unroutable, a fail-closed finding: a filed escalation would have nowhere + // to go. (A runner class binds an escalation_routes entry optionally, + // exactly like any guardrail class — either path routes it.) + if (isPlainObject(binding.escalation_severity)) { + const severityRoutes = isPlainObject(binding.escalation_severity_routes) ? binding.escalation_severity_routes : {}; + const eventRoutes = isPlainObject(binding.escalation_routes) ? binding.escalation_routes : {}; + for (const [eventClass, severity] of Object.entries(binding.escalation_severity)) { + if (!ESCALATION_EVENT_CLASSES.includes(eventClass) || !SEVERITY_TOKENS.includes(severity)) continue; + const severityRouted = isNonEmptyString(severityRoutes[severity]); + const eventRouted = isNonEmptyString(eventRoutes[eventClass]); + if (!severityRouted && !eventRouted) { + const remedy = `bind escalation_severity_routes.${severity} or an escalation_routes.${eventClass} entry`; + findings.push( + `escalation_severity.${eventClass}: bound to severity ${JSON.stringify(severity)} with no escalation_severity_routes entry for that severity and no escalation_routes entry for the event class — the event class is unroutable, so a filed escalation would have nowhere to go (fail-closed); ${remedy}`, + ); + } + } + } + if (isPlainObject(binding.admission) && Array.isArray(binding.admission.rules)) { checkAdmissionSemantics(binding.admission.rules); } From 56507c517a0ea2b3e45079399312ce35b4bba559 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:35:24 -0400 Subject: [PATCH 04/15] docs(autonomy): zero-build audit passed, all gates green (WP7 phase 4) Mechanical acceptance probe: the branch diff against the base contains exactly seventeen paths, all inside the four allowed surfaces (reference/ pack files, skills/setup/ surfaces, the two READMEs, the plugin manifest) plus the PLAN's own phase tags; no new script files of any kind; zero repo-creation instructions under reference/runner*. Full gate roster run locally: validate-plugins.sh, generate-catalog --check, validate-plugin-contracts.mjs, run-plugin-tests.sh (all pass or skip), claude plugin validate --strict, markdownlint, typos, and lychee over the five-file pack (0 errors). PLAN phase 4 tagged DONE. Co-Authored-By: Claude Fable 5 --- docs/topics/ai-ladder-wp7-runner/PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/ai-ladder-wp7-runner/PLAN.md b/docs/topics/ai-ladder-wp7-runner/PLAN.md index 7264a7d46..cbdfbbcd5 100644 --- a/docs/topics/ai-ladder-wp7-runner/PLAN.md +++ b/docs/topics/ai-ladder-wp7-runner/PLAN.md @@ -166,7 +166,7 @@ by citation). Design only — zero build artifacts; the runner-execution home st - Schema additivity: `grep -c 'escalation_severity_routes' …schema.json` ≥ 1 and a pre-WP7 fixture binding still passes `check-security-binding.mjs` unchanged (no major bump proven, not asserted) - `/skill-quality:check` + `validate-evals` pass; `claude plugin validate --strict` exit 0 -### Phase 4: Zero-build audit + gates [TODO] +### Phase 4: Zero-build audit + gates [DONE] Acceptance probe (mechanical): the package introduces no executable/runtime artifact — the diff contains no new files outside `reference/`, `skills/setup/`, README, and plugin From e6fa78347a66d2ea2c9153864c70919bf09210d3 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:42:48 -0400 Subject: [PATCH 05/15] fix(autonomy): pin re-escalation cap at one, cap follows executor class (wave 1) Two review findings. First, the schema accepted any reescalation_cap >= 1 while the escalation contract allows a single stale-unacked re-escalation - one severity bump, never a loop. The schema now pins the value at exactly 1 and check-security-binding.mjs rejects a higher cap with its own finding (new fixture escalation-ack-loop rejects on exactly that). Second, topology.md forced executor_class vendor-hosted for ANY cloud backend, contradicting the executor-surface contract: self-operated executors are CLI/SDK processes wherever they run, and hosting configuration is deployment-owned. The merge-row cap now follows the executor surface class, never the hosting alone - an org-operated cloud substrate running the self-operated runner stays self-operated, while a vendor-managed backend (the vendor operates the executor) still forces vendor-hosted and its human-gate cap. Verified: loop fixture rejects with the single new finding; full sweep 11 pass / 96 reject, zero flips among prior fixtures; schema parses; contract validator, typos, and lychee clean. Co-Authored-By: Claude Fable 5 --- plugins/autonomy/reference/runner/topology.md | 13 +++- .../security-binding/escalation-ack-loop.json | 73 +++++++++++++++++++ .../guardrails-security-binding.schema.json | 5 +- .../setup/scripts/check-security-binding.mjs | 9 +++ 4 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-ack-loop.json diff --git a/plugins/autonomy/reference/runner/topology.md b/plugins/autonomy/reference/runner/topology.md index 72e7135e2..3908a2fc6 100644 --- a/plugins/autonomy/reference/runner/topology.md +++ b/plugins/autonomy/reference/runner/topology.md @@ -40,10 +40,15 @@ it for the work the trigger admits. Backend classes are cited from the value asserted here. - **Paid and cloud backends — advisory, explicit opt-in.** No paid or cloud backend is a default; each is advisory with its cost surfaced first and reached only by explicit opt-in. - Any cloud backend IS a vendor-hosted executor: selecting one forces the security binding's - `executor_class: vendor-hosted`, which caps every merge row at human-gated — the same cap - [the lifecycle leaf](lifecycle.md#disposition) restates on the disposition path. A cloud - backend buys isolation, never an auto-merge it cannot own. + Whether a cloud backend caps the merge rows follows the executor surface class, never the + hosting alone: an org-operated cloud substrate running the self-operated runner stays + `executor_class: self-operated` — hosting configuration is deployment-owned per the + [executor surface classes](../trigger-dispatch.md#executor-surface-classes) — while a + vendor-MANAGED backend, where the vendor operates the executor itself, is a vendor-hosted + executor and forces the security binding's `executor_class: vendor-hosted`, capping every + merge row at human-gated — the same cap [the lifecycle leaf](lifecycle.md#disposition) + restates on the disposition path. A vendor-managed backend buys isolation, never an + auto-merge it cannot own. ## Birth-time decisions — USER-RESERVED diff --git a/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-ack-loop.json b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-ack-loop.json new file mode 100644 index 000000000..76a7d9e18 --- /dev/null +++ b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-ack-loop.json @@ -0,0 +1,73 @@ +{ + "schema_version": "1.0", + "executor_class": "self-operated", + "dispatch_posture": "autonomous-enabled", + "isolation_bindings": { + "ci-pool-a": { + "L2": { + "substrate": "egress-denied-container-pool", + "substrate_class": "container", + "probe_evidence": "probe-transcripts/ci-pool-a-l2.json", + "runtime_markers": { + "runner-pool": "ci-pool-a", + "region": "us-east" + } + } + } + }, + "merge_policy": { + "C1": "human", + "C2": "human", + "C3": "human", + "C4": "human", + "C5": "human" + }, + "verification_blocking": { + "deterministic": { + "C1": "not-required", + "C2": "blocking", + "C3": "blocking", + "C4": "blocking", + "C5": "blocking" + }, + "ai-review": { + "C1": "not-required", + "C2": "not-required", + "C3": "advisory", + "C4": "blocking", + "C5": "blocking" + } + }, + "escalation_routes": { + "gate-failure": "queue:escalations/gate-failure", + "verification-divergence": "queue:escalations/verification-divergence", + "admission-rejection": "queue:escalations/admission-rejection", + "demotion": "queue:escalations/demotion", + "structural-plan-approval": "queue:escalations/structural-plan-approval", + "untrusted-provenance": "queue:escalations/untrusted-provenance" + }, + "escalation_ack": { + "staleness_window": 72, + "reescalation_cap": 2 + }, + "admission": { + "classification": { + "tracker-vcs-event": { + "autonomy:audit": "C1", + "autonomy:mechanical": "C2", + "autonomy:scoped": "C3", + "autonomy:structural": "C4", + "autonomy:untrusted": "C5" + } + }, + "rules": [ + { "signal_class": "*", "provenance": "*", "work_class": "C1", "disposition": "autonomous-eligible" }, + { "signal_class": "*", "provenance": "*", "work_class": "C2", "disposition": "autonomous-eligible" }, + { "signal_class": "*", "provenance": "*", "work_class": "C3", "disposition": "human-gated" }, + { "signal_class": "*", "provenance": "*", "work_class": "C4", "disposition": "human-gated" }, + { "signal_class": "*", "provenance": "*", "work_class": "C5", "disposition": "human-gated" } + ], + "autonomous_concurrency": 1, + "items_per_run": 3 + } +} diff --git a/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json b/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json index be7ba1644..36a4e9a6a 100644 --- a/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json +++ b/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json @@ -104,9 +104,10 @@ "minimum": 1 }, "reescalation_cap": { - "description": "Maximum number of stale-unacked re-escalations (a single bump, never a loop).", + "description": "Maximum number of stale-unacked re-escalations. The escalation contract caps this at a single bump, never a loop, so 1 is the only accepted value; the key exists so an org binds the knob explicitly rather than inheriting it silently.", "type": "integer", - "minimum": 1 + "minimum": 1, + "maximum": 1 } } }, diff --git a/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs b/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs index 21ea3b96d..e4beb07a7 100755 --- a/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs +++ b/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs @@ -637,6 +637,15 @@ function validateStructure(binding) { ); } } + // The escalation contract caps re-escalation at a single bump, never a + // loop — a cap above one would let the runner repeatedly re-escalate + // the same unacknowledged item, the notification loop the contract + // rules out. + if (Number.isInteger(binding.escalation_ack.reescalation_cap) && binding.escalation_ack.reescalation_cap > 1) { + findings.push( + `escalation_ack.reescalation_cap: ${binding.escalation_ack.reescalation_cap} exceeds the contract cap — the runner escalation contract allows a single stale-unacked re-escalation (one severity bump, never a loop), so 1 is the only bindable value`, + ); + } } } From 7bbfd14c02176364e6a8d737eafcfac7297940fb Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:48:16 -0400 Subject: [PATCH 06/15] fix(autonomy): severity selects notification fan-out, never the item route (wave 2) One review finding. The schema descriptions and the checker's routability rule modeled severity-first ROUTE resolution with the per-event route as fallback - under that reading a bound severity route would replace the event class's queue destination, so a gate failure could notify a channel instead of filing its required queue item, breaking the always-filed tracker item and its acknowledgment lifecycle. Corrected to the escalation contract's actual model: the escalation item always files at the event class's own escalation_routes destination; escalation_severity_routes is the NOTIFICATION fan-out layered on that filed item (channel, personal-push), never its queue destination and never a replacement. A severity with no bound notification route degrades to tracker-item-only fan-out - a conforming binding, not a defect - so the unroutable semantic rule is removed and its fixture flips to escalation-severity-tracker-only-valid (now passing, the degrade proof). Verified: renamed fixture passes; full sweep 12 pass / 95 reject with no other flips; schema parses; contract validator and typos clean. Co-Authored-By: Claude Fable 5 --- ...calation-severity-tracker-only-valid.json} | 0 .../guardrails-security-binding.schema.json | 4 +-- .../setup/scripts/check-security-binding.mjs | 33 ++++--------------- 3 files changed, 9 insertions(+), 28 deletions(-) rename plugins/autonomy/skills/setup/evals/fixtures/security-binding/{escalation-severity-unroutable.json => escalation-severity-tracker-only-valid.json} (100%) diff --git a/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-unroutable.json b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-tracker-only-valid.json similarity index 100% rename from plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-unroutable.json rename to plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-tracker-only-valid.json diff --git a/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json b/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json index 36a4e9a6a..f37a1f52b 100644 --- a/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json +++ b/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json @@ -82,13 +82,13 @@ } }, "escalation_severity": { - "description": "Optional event class -> severity join: which severity each event class escalates at. Keys are the escalation event-class registry — the six inherited guardrail classes plus the two additive runner classes (runner-needs-human, runner-cap-exceeded). Contract-default severities are defined in the runner escalation contract, so an absent binding leaves every class at its default; entries here are org-bound overrides. Route resolution is severity-first: an event class resolves to its severity, then to that severity's escalation_severity_routes entry, falling back to the event class's own escalation_routes entry only when no severity route is bound. An event class keyed here with neither a bound severity route nor an escalation_routes entry is unroutable.", + "description": "Optional event class -> severity join: which severity each event class escalates at. Keys are the escalation event-class registry — the six inherited guardrail classes plus the two additive runner classes (runner-needs-human, runner-cap-exceeded). Contract-default severities are defined in the runner escalation contract, so an absent binding leaves every class at its default; entries here are org-bound overrides. Severity never redirects the escalation item itself: the item files at the event class's own escalation_routes destination regardless, and the severity selects only the NOTIFICATION fan-out layered on that filed item (escalation_severity_routes). A severity with no bound notification route degrades to tracker-item-only fan-out — never a missing item.", "type": "object", "propertyNames": { "$ref": "#/$defs/EscalationEventClass" }, "additionalProperties": { "$ref": "#/$defs/Severity" } }, "escalation_severity_routes": { - "description": "Optional severity token -> route map, the severity-first leg of route resolution. Keyed only by the three severity tokens; the value is an ordinary route, so personal-push is a legal route value at any tier. A runner class with no escalation_routes entry routes through its severity here.", + "description": "Optional severity token -> NOTIFICATION route map: the fan-out depth applied on top of the filed escalation item (a channel notification, a personal-push tier — personal-push is a legal route value at any tier). Keyed only by the three severity tokens. This is never the item's queue destination and never replaces an event class's escalation_routes entry; absent an entry for a severity, fan-out degrades to the tracker item only.", "type": "object", "propertyNames": { "$ref": "#/$defs/Severity" }, "additionalProperties": { "$ref": "#/$defs/Route" } diff --git a/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs b/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs index e4beb07a7..26cb156e6 100755 --- a/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs +++ b/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs @@ -1496,32 +1496,13 @@ function checkSemantics(binding, probeRoot, egressAllowList) { } } - // Routability of every event class bound in escalation_severity, matching - // the severity-first resolution rule: an event class resolves to its - // severity, then to that severity's escalation_severity_routes entry, - // falling back to the event class's own escalation_routes entry only when - // no severity route is bound. Severity-only fan-out is legal — a legacy - // per-event route is the fallback, never a requirement — so an event class - // is routable when EITHER path yields a route. Only an event class bound to - // a severity with no severity route AND no escalation_routes entry is - // unroutable, a fail-closed finding: a filed escalation would have nowhere - // to go. (A runner class binds an escalation_routes entry optionally, - // exactly like any guardrail class — either path routes it.) - if (isPlainObject(binding.escalation_severity)) { - const severityRoutes = isPlainObject(binding.escalation_severity_routes) ? binding.escalation_severity_routes : {}; - const eventRoutes = isPlainObject(binding.escalation_routes) ? binding.escalation_routes : {}; - for (const [eventClass, severity] of Object.entries(binding.escalation_severity)) { - if (!ESCALATION_EVENT_CLASSES.includes(eventClass) || !SEVERITY_TOKENS.includes(severity)) continue; - const severityRouted = isNonEmptyString(severityRoutes[severity]); - const eventRouted = isNonEmptyString(eventRoutes[eventClass]); - if (!severityRouted && !eventRouted) { - const remedy = `bind escalation_severity_routes.${severity} or an escalation_routes.${eventClass} entry`; - findings.push( - `escalation_severity.${eventClass}: bound to severity ${JSON.stringify(severity)} with no escalation_severity_routes entry for that severity and no escalation_routes entry for the event class — the event class is unroutable, so a filed escalation would have nowhere to go (fail-closed); ${remedy}`, - ); - } - } - } + // No routability rule exists for escalation_severity on purpose: severity + // selects only the NOTIFICATION fan-out layered on the filed item — it + // never redirects the item, whose queue destination stays the event + // class's own escalation_routes entry — and the escalation contract makes + // tracker-item-only fan-out the legal degraded form when a severity has no + // bound notification route. A severity binding with no route anywhere is + // therefore a conforming binding, not a defect. if (isPlainObject(binding.admission) && Array.isArray(binding.admission.rules)) { checkAdmissionSemantics(binding.admission.rules); From 46689b41cca5b369c8528c77c1ad8f2945675681 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:56:21 -0400 Subject: [PATCH 07/15] fix(autonomy): C5 always-fires, C1 queue artifact, leg model, runner routes (wave 3) Four review findings. First, the success exception suppressed every escalation, but the guardrail matrix fires untrusted-provenance on every C5 item unconditionally: escalation.md now scopes the exception to the runner's OWN outcome escalation and states that inherited always-firing classes fire regardless of outcome, emitted before completion under their own route and severity. Second, the per-item-PR disposition wrongly covered C1, whose matrix row is not applicable and whose class definition forbids repository mutation: lifecycle.md's disposition now splits - a read-only class completes by attaching its artifact to the governed queue item (the audit trail), only mutating classes take the per-item PR path. Third, a severity route was a single opaque string, so an urgent binding could carry only a push route while the contract's cumulative fan-out ladder requires the channel notification beneath it: escalation_severity_routes entries are now explicit leg objects ({channel, push}, at least one), and a push leg without a channel leg rejects (new fixture escalation-push-without-channel). Fourth, a severity-bound runner event class with no escalation_routes entry had no queue destination for its filed handoff: the checker now requires the runner class's own route whenever that class is severity bound (new fixture escalation-runner-route-missing rejects; legacy bindings that never key a runner class stay untouched), and the tracker-only degrade fixture now proves the legal form on an inherited class whose queue route is always required. Verified: all seven escalation fixtures behave exactly as named; full sweep 12 pass / 97 reject with no other flips; schema parses; contract validator, typos, markdownlint, and lychee clean. Co-Authored-By: Claude Fable 5 --- .../autonomy/reference/runner/escalation.md | 19 +++-- .../autonomy/reference/runner/lifecycle.md | 16 ++-- .../escalation-push-without-channel.json | 75 +++++++++++++++++++ .../escalation-runner-route-missing.json | 72 ++++++++++++++++++ ...scalation-severity-tracker-only-valid.json | 2 +- .../escalation-severity-valid.json | 9 ++- .../guardrails-security-binding.schema.json | 14 +++- .../setup/scripts/check-security-binding.mjs | 55 +++++++++++--- 8 files changed, 233 insertions(+), 29 deletions(-) create mode 100644 plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-push-without-channel.json create mode 100644 plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-runner-route-missing.json diff --git a/plugins/autonomy/reference/runner/escalation.md b/plugins/autonomy/reference/runner/escalation.md index 42b25ed4a..f1b8c95af 100644 --- a/plugins/autonomy/reference/runner/escalation.md +++ b/plugins/autonomy/reference/runner/escalation.md @@ -29,11 +29,20 @@ bundle: a failure summary, the run-transcript link, the run cost, the trace link the `resume_handle` ([session-and-resume seam](seams.md)), so the escalation item is a resumable takeover, not a cold restart. -**`success` never escalates.** A successful outcome completes through the normal path — the -per-item disposition ([lifecycle leaf](lifecycle.md)) and the task-boundary return-accounting -capture ([return-accounting](../return-accounting.md)) — with no escalation item and no -acknowledgment or re-escalation obligation. Escalation is the non-success path only; if a -healthy drain filed escalation work, every clean run would generate a false human task. +**`success` never escalates — the runner's own outcome.** A successful outcome completes +through the normal path — the per-item disposition ([lifecycle leaf](lifecycle.md)) and the +task-boundary return-accounting capture ([return-accounting](../return-accounting.md)) — with +no runner escalation item and no acknowledgment or re-escalation obligation. Escalation is the +non-success path only; if a healthy drain filed escalation work, every clean run would generate +a false human task. + +**Inherited always-firing classes are untouched by the success exception.** An event class the +[guardrail escalation contract](../guardrails.md#escalation) fires unconditionally — +`untrusted-provenance` on every `C5` item — fires regardless of outcome, success included: the +runner emits it before the run completes, and the resulting item and fan-out follow that +class's own route and severity, not the runner outcome mapping. The success exception +suppresses only the runner's own outcome escalation, never an inherited class's standing +obligation. ## Stop-criteria taxonomy — two families diff --git a/plugins/autonomy/reference/runner/lifecycle.md b/plugins/autonomy/reference/runner/lifecycle.md index c986c6009..4bed42f6f 100644 --- a/plugins/autonomy/reference/runner/lifecycle.md +++ b/plugins/autonomy/reference/runner/lifecycle.md @@ -39,11 +39,17 @@ custom-namespace mechanism; that namespace token, and the escalation payload, ar ## Disposition -Launch disposition is thin: a passing result lands as a per-item PR through the platform's -native change-proposal flow — one item, one PR, no runner-owned merge machinery. Who lands it -is the [matrix merge-policy row](../guardrails.md#the-matrix) for the item's class: an -auto-merge only where that class's cell is promoted AND the run is self-run; human-gated -everywhere else. +Launch disposition is thin and follows the class's +[matrix merge-policy row](../guardrails.md#the-matrix): + +- A read-only class — `C1`, whose merge row is not applicable and whose definition forbids + repository mutation — never opens a PR: its passing result completes by attaching the + produced artifact or report to the governed queue item, the audit trail the matrix names as + `C1`'s disposition surface. +- A mutating class lands as a per-item PR through the platform's native change-proposal flow — + one item, one PR, no runner-owned merge machinery. Who lands it is the class's merge-policy + cell: an auto-merge only where that cell is promoted AND the run is self-run; human-gated + everywhere else. The vendor-hosted merge cap is restated here, not inherited silently: whenever the executing backend is a vendor-hosted executor, every class caps at human-gated regardless of its self-run diff --git a/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-push-without-channel.json b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-push-without-channel.json new file mode 100644 index 000000000..c19c5b547 --- /dev/null +++ b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-push-without-channel.json @@ -0,0 +1,75 @@ +{ + "schema_version": "1.0", + "executor_class": "self-operated", + "dispatch_posture": "autonomous-enabled", + "isolation_bindings": { + "ci-pool-a": { + "L2": { + "substrate": "egress-denied-container-pool", + "substrate_class": "container", + "probe_evidence": "probe-transcripts/ci-pool-a-l2.json", + "runtime_markers": { + "runner-pool": "ci-pool-a", + "region": "us-east" + } + } + } + }, + "merge_policy": { + "C1": "human", + "C2": "human", + "C3": "human", + "C4": "human", + "C5": "human" + }, + "verification_blocking": { + "deterministic": { + "C1": "not-required", + "C2": "blocking", + "C3": "blocking", + "C4": "blocking", + "C5": "blocking" + }, + "ai-review": { + "C1": "not-required", + "C2": "not-required", + "C3": "advisory", + "C4": "blocking", + "C5": "blocking" + } + }, + "escalation_routes": { + "gate-failure": "queue:escalations/gate-failure", + "verification-divergence": "queue:escalations/verification-divergence", + "admission-rejection": "queue:escalations/admission-rejection", + "demotion": "queue:escalations/demotion", + "structural-plan-approval": "queue:escalations/structural-plan-approval", + "untrusted-provenance": "queue:escalations/untrusted-provenance" + }, + "escalation_severity": { + "untrusted-provenance": "urgent" + }, + "escalation_severity_routes": { + "urgent": { "push": "personal-push:oncall-primary" } + }, + "admission": { + "classification": { + "tracker-vcs-event": { + "autonomy:audit": "C1", + "autonomy:mechanical": "C2", + "autonomy:scoped": "C3", + "autonomy:structural": "C4", + "autonomy:untrusted": "C5" + } + }, + "rules": [ + { "signal_class": "*", "provenance": "*", "work_class": "C1", "disposition": "autonomous-eligible" }, + { "signal_class": "*", "provenance": "*", "work_class": "C2", "disposition": "autonomous-eligible" }, + { "signal_class": "*", "provenance": "*", "work_class": "C3", "disposition": "human-gated" }, + { "signal_class": "*", "provenance": "*", "work_class": "C4", "disposition": "human-gated" }, + { "signal_class": "*", "provenance": "*", "work_class": "C5", "disposition": "human-gated" } + ], + "autonomous_concurrency": 1, + "items_per_run": 3 + } +} diff --git a/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-runner-route-missing.json b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-runner-route-missing.json new file mode 100644 index 000000000..aad516766 --- /dev/null +++ b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-runner-route-missing.json @@ -0,0 +1,72 @@ +{ + "schema_version": "1.0", + "executor_class": "self-operated", + "dispatch_posture": "autonomous-enabled", + "isolation_bindings": { + "ci-pool-a": { + "L2": { + "substrate": "egress-denied-container-pool", + "substrate_class": "container", + "probe_evidence": "probe-transcripts/ci-pool-a-l2.json", + "runtime_markers": { + "runner-pool": "ci-pool-a", + "region": "us-east" + } + } + } + }, + "merge_policy": { + "C1": "human", + "C2": "human", + "C3": "human", + "C4": "human", + "C5": "human" + }, + "verification_blocking": { + "deterministic": { + "C1": "not-required", + "C2": "blocking", + "C3": "blocking", + "C4": "blocking", + "C5": "blocking" + }, + "ai-review": { + "C1": "not-required", + "C2": "not-required", + "C3": "advisory", + "C4": "blocking", + "C5": "blocking" + } + }, + "escalation_routes": { + "gate-failure": "queue:escalations/gate-failure", + "verification-divergence": "queue:escalations/verification-divergence", + "admission-rejection": "queue:escalations/admission-rejection", + "demotion": "queue:escalations/demotion", + "structural-plan-approval": "queue:escalations/structural-plan-approval", + "untrusted-provenance": "queue:escalations/untrusted-provenance" + }, + "escalation_severity": { + "runner-needs-human": "attention" + }, + "admission": { + "classification": { + "tracker-vcs-event": { + "autonomy:audit": "C1", + "autonomy:mechanical": "C2", + "autonomy:scoped": "C3", + "autonomy:structural": "C4", + "autonomy:untrusted": "C5" + } + }, + "rules": [ + { "signal_class": "*", "provenance": "*", "work_class": "C1", "disposition": "autonomous-eligible" }, + { "signal_class": "*", "provenance": "*", "work_class": "C2", "disposition": "autonomous-eligible" }, + { "signal_class": "*", "provenance": "*", "work_class": "C3", "disposition": "human-gated" }, + { "signal_class": "*", "provenance": "*", "work_class": "C4", "disposition": "human-gated" }, + { "signal_class": "*", "provenance": "*", "work_class": "C5", "disposition": "human-gated" } + ], + "autonomous_concurrency": 1, + "items_per_run": 3 + } +} diff --git a/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-tracker-only-valid.json b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-tracker-only-valid.json index aad516766..6a8bc1ef4 100644 --- a/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-tracker-only-valid.json +++ b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-tracker-only-valid.json @@ -47,7 +47,7 @@ "untrusted-provenance": "queue:escalations/untrusted-provenance" }, "escalation_severity": { - "runner-needs-human": "attention" + "gate-failure": "attention" }, "admission": { "classification": { diff --git a/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-valid.json b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-valid.json index 85a770325..f05a19a1e 100644 --- a/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-valid.json +++ b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-valid.json @@ -44,7 +44,9 @@ "admission-rejection": "queue:escalations/admission-rejection", "demotion": "queue:escalations/demotion", "structural-plan-approval": "queue:escalations/structural-plan-approval", - "untrusted-provenance": "queue:escalations/untrusted-provenance" + "untrusted-provenance": "queue:escalations/untrusted-provenance", + "runner-needs-human": "queue:escalations/runner-needs-human", + "runner-cap-exceeded": "queue:escalations/runner-cap-exceeded" }, "escalation_severity": { "gate-failure": "attention", @@ -54,9 +56,8 @@ "runner-cap-exceeded": "notice" }, "escalation_severity_routes": { - "notice": "queue:escalations/notice", - "attention": "channel:autonomy-escalations", - "urgent": "personal-push:oncall-primary" + "attention": { "channel": "channel:autonomy-escalations" }, + "urgent": { "channel": "channel:autonomy-escalations", "push": "personal-push:oncall-primary" } }, "escalation_ack": { "staleness_window": 72, diff --git a/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json b/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json index f37a1f52b..db1ae20ed 100644 --- a/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json +++ b/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json @@ -82,16 +82,24 @@ } }, "escalation_severity": { - "description": "Optional event class -> severity join: which severity each event class escalates at. Keys are the escalation event-class registry — the six inherited guardrail classes plus the two additive runner classes (runner-needs-human, runner-cap-exceeded). Contract-default severities are defined in the runner escalation contract, so an absent binding leaves every class at its default; entries here are org-bound overrides. Severity never redirects the escalation item itself: the item files at the event class's own escalation_routes destination regardless, and the severity selects only the NOTIFICATION fan-out layered on that filed item (escalation_severity_routes). A severity with no bound notification route degrades to tracker-item-only fan-out — never a missing item.", + "description": "Optional event class -> severity join: which severity each event class escalates at. Keys are the escalation event-class registry — the six inherited guardrail classes plus the two additive runner classes (runner-needs-human, runner-cap-exceeded). Contract-default severities are defined in the runner escalation contract, so an absent binding leaves every class at its default; entries here are org-bound overrides. Severity never redirects the escalation item itself: the item files at the event class's own escalation_routes destination regardless, and the severity selects only the NOTIFICATION fan-out layered on that filed item (escalation_severity_routes). A severity with no bound notification legs degrades to tracker-item-only fan-out — never a missing item. A severity-bound RUNNER class additionally requires its own escalation_routes entry: binding notification depth for a class with no queue destination for its filed item is an incomplete runner binding (the six inherited classes always carry theirs).", "type": "object", "propertyNames": { "$ref": "#/$defs/EscalationEventClass" }, "additionalProperties": { "$ref": "#/$defs/Severity" } }, "escalation_severity_routes": { - "description": "Optional severity token -> NOTIFICATION route map: the fan-out depth applied on top of the filed escalation item (a channel notification, a personal-push tier — personal-push is a legal route value at any tier). Keyed only by the three severity tokens. This is never the item's queue destination and never replaces an event class's escalation_routes entry; absent an entry for a severity, fan-out degrades to the tracker item only.", + "description": "Optional severity token -> NOTIFICATION legs map: the fan-out depth applied on top of the filed escalation item, with the channel and personal-push legs modeled separately. Keyed only by the three severity tokens; each entry binds at least one leg. This is never the item's queue destination and never replaces an event class's escalation_routes entry; absent an entry (or a leg), fan-out degrades toward the tracker item only. The fan-out ladder is cumulative — the push tier rides on top of the channel notification — so a push leg without a channel leg in the same entry is rejected by the checker.", "type": "object", "propertyNames": { "$ref": "#/$defs/Severity" }, - "additionalProperties": { "$ref": "#/$defs/Route" } + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "channel": { "$ref": "#/$defs/Route" }, + "push": { "$ref": "#/$defs/Route" } + } + } }, "escalation_ack": { "description": "Optional acknowledgment and re-escalation knobs. Contract defaults (72h staleness window, one re-escalation) live in the runner escalation contract, not here, so an absent binding leaves both at their default; entries are org-bound overrides.", diff --git a/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs b/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs index 26cb156e6..064173b0b 100755 --- a/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs +++ b/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs @@ -598,27 +598,59 @@ function validateStructure(binding) { if (!isPlainObject(binding.escalation_severity)) { findings.push("escalation_severity: must be an object mapping escalation event class to severity token"); } else { + const eventRoutes = isPlainObject(binding.escalation_routes) ? binding.escalation_routes : {}; for (const [eventClass, severity] of Object.entries(binding.escalation_severity)) { const where = `escalation_severity.${eventClass}`; if (!checkEnum(eventClass, ESCALATION_EVENT_CLASSES, `escalation_severity key ${JSON.stringify(eventClass)}`)) { continue; } checkEnum(severity, SEVERITY_TOKENS, where); + // A severity-bound RUNNER class must carry its own queue destination: + // severity is notification depth on the FILED item, and unlike the six + // inherited classes (whose escalation_routes entries are required), a + // runner class with no escalation_routes entry has nowhere to file its + // human-gated handoff. Legacy bindings that never key a runner class + // stay untouched. + if (RUNNER_EVENT_CLASSES.includes(eventClass) && !isNonEmptyString(eventRoutes[eventClass])) { + findings.push( + `${where}: severity is bound for a runner event class with no escalation_routes.${eventClass} entry — severity only selects notification fan-out on the filed item, so without a queue destination the class's human-gated handoff has nowhere to file (fail-closed); bind escalation_routes.${eventClass}`, + ); + } } } } if (Object.hasOwn(binding, "escalation_severity_routes")) { if (!isPlainObject(binding.escalation_severity_routes)) { - findings.push("escalation_severity_routes: must be an object mapping severity token to route"); + findings.push("escalation_severity_routes: must be an object mapping severity token to notification legs"); } else { - for (const [severity, route] of Object.entries(binding.escalation_severity_routes)) { + for (const [severity, legs] of Object.entries(binding.escalation_severity_routes)) { if (!checkEnum(severity, SEVERITY_TOKENS, `escalation_severity_routes key ${JSON.stringify(severity)}`)) { continue; } - if (!isNonEmptyString(route)) { + if (!isPlainObject(legs)) { + findings.push( + `escalation_severity_routes.${severity}: must be an object binding the notification legs separately ({channel, push}, at least one) — a bare route cannot say which leg it is, and an urgent fan-out carries both`, + ); + continue; + } + checkAllowedKeys(legs, ["channel", "push"], `escalation_severity_routes.${severity}`); + if (Object.keys(legs).length === 0) { + findings.push( + `escalation_severity_routes.${severity}: empty — a bound severity entry carries at least one notification leg (channel or push)`, + ); + } + for (const leg of ["channel", "push"]) { + if (Object.hasOwn(legs, leg) && !isNonEmptyString(legs[leg])) { + findings.push(`escalation_severity_routes.${severity}.${leg}: missing or empty — a bound leg carries an org-bound route`); + } + } + // The fan-out ladder is cumulative — the personal-push tier rides on + // top of the channel notification — so a push leg with no channel leg + // skips a rung and leaves the required channel notification unbound. + if (isNonEmptyString(legs.push) && !isNonEmptyString(legs.channel)) { findings.push( - `escalation_severity_routes.${severity}: missing or empty — a bound severity must carry an org-bound route (personal-push is a legal route value at any tier)`, + `escalation_severity_routes.${severity}: binds a push leg with no channel leg — the fan-out ladder is cumulative (tracker item, then channel notification, then the personal-push tier), so a push-only entry leaves the channel notification the contract requires at this tier unbound; bind the channel leg too`, ); } } @@ -1496,13 +1528,14 @@ function checkSemantics(binding, probeRoot, egressAllowList) { } } - // No routability rule exists for escalation_severity on purpose: severity - // selects only the NOTIFICATION fan-out layered on the filed item — it - // never redirects the item, whose queue destination stays the event - // class's own escalation_routes entry — and the escalation contract makes - // tracker-item-only fan-out the legal degraded form when a severity has no - // bound notification route. A severity binding with no route anywhere is - // therefore a conforming binding, not a defect. + // No notification-routability rule exists for escalation_severity on + // purpose: severity selects only the NOTIFICATION fan-out layered on the + // filed item — it never redirects the item, whose queue destination stays + // the event class's own escalation_routes entry — and the escalation + // contract makes tracker-item-only fan-out the legal degraded form when a + // severity has no bound notification legs. The one queue-side requirement + // (a severity-bound RUNNER class must carry its escalation_routes entry) + // is enforced where escalation_severity entries are walked above. if (isPlainObject(binding.admission) && Array.isArray(binding.admission.rules)) { checkAdmissionSemantics(binding.admission.rules); From 433fb9732adc14e9335e1a35d55c267df825bf40 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:01:35 -0400 Subject: [PATCH 08/15] fix(autonomy): re-escalation bump saturates at urgent (wave 4) One review finding. The one-level severity bump was undefined for an item already at urgent (the untrusted-provenance default and both urgent stop-reason overrides), leaving an implementation unable to bump within the defined axis. escalation.md now specifies saturation: an already-urgent item still re-escalates once, by re-notifying with a fresh urgent fan-out at the same severity - the re-escalation is never skipped and no level above the axis is minted. Co-Authored-By: Claude Fable 5 --- plugins/autonomy/reference/runner/escalation.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/autonomy/reference/runner/escalation.md b/plugins/autonomy/reference/runner/escalation.md index f1b8c95af..380b52c4e 100644 --- a/plugins/autonomy/reference/runner/escalation.md +++ b/plugins/autonomy/reference/runner/escalation.md @@ -155,8 +155,11 @@ which condition raised it. **Acknowledgment and re-escalation.** An escalation item carries an acknowledgment state. An unacknowledged item that goes stale re-escalates once with a one-level severity bump; an -acknowledged item never re-escalates. Both knobs are org-bindable: the default staleness -window is 72h, and the re-escalation cap is 1 (a single bump, never a loop). +acknowledged item never re-escalates. The bump saturates at `urgent`: an item already at +`urgent` — an untrusted-provenance default, or either urgent stop-reason override — still +re-escalates once, by re-notifying with a fresh `urgent` fan-out at the same severity, never by +skipping the re-escalation or minting a level above the axis. Both knobs are org-bindable: the +default staleness window is 72h, and the re-escalation cap is 1 (a single bump, never a loop). ## Deferred — mid-run interrupt From ea741e04b24a22618aea131894c6231c4449afe7 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:08:09 -0400 Subject: [PATCH 09/15] fix(autonomy): C4 execution gates on the plan-approval item (wave 5) One review finding. The state model let a C4 item pass leased to executing on ordinary admission, but the guardrail matrix fires structural-plan-approval for a C4 item before execution. lifecycle.md now specifies the two-phase C4 drain through the one queue: the first leased run plans only and disposes as the structural-plan-approval item (the inherited event class on its own route and severity, plan attached); the structural execution is a second run admitted only from the human-approved item, whose leased-to-executing transition requires the recorded approval and fail-closes to escalated without it. No second approval channel; the terminal-handoff shape is untouched - neither phase pauses mid-run. Co-Authored-By: Claude Fable 5 --- plugins/autonomy/reference/runner/lifecycle.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/plugins/autonomy/reference/runner/lifecycle.md b/plugins/autonomy/reference/runner/lifecycle.md index 4bed42f6f..68f5a32b7 100644 --- a/plugins/autonomy/reference/runner/lifecycle.md +++ b/plugins/autonomy/reference/runner/lifecycle.md @@ -26,6 +26,20 @@ from a stop reason to its terminal outcome is [the escalation leaf](escalation.m Only a run that reaches `disposing` and lands its change becomes `complete` — every other ending is `escalated`. +### C4 pre-execution plan approval + +A `C4` (structural) item never travels `leased → executing` into structural work on ordinary +admission alone: the [guardrail escalation contract](../guardrails.md#escalation) fires its +`structural-plan-approval` event class for a `C4` item BEFORE execution, and the runner honors +that as a two-phase drain through the one queue. The first leased run plans only — its +disposition is the `structural-plan-approval` item (the inherited class, on its own route and +severity, with the produced plan attached), and it completes without touching the structure. +The structural execution is a second run, admitted only from the human-approved item; its +`leased → executing` transition requires that recorded approval, and absent one the run +fail-closes to `escalated` rather than executing. No second approval channel exists — the +approval item is ordinary human-gated queue work, and the terminal-handoff shape +([escalation leaf](escalation.md)) is untouched: neither phase pauses mid-run. + ## Transition telemetry Every transition emits standard telemetry carrying the work-item join attribute and the From 958655c73c264d3ddc68f5a582512f1dd0b7efb2 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:14:06 -0400 Subject: [PATCH 10/15] fix(autonomy): runner launch fail-closes without its escalation routes (wave 6) One review finding. A legacy binding omitting the optional escalation_severity map and both optional runner routes still validates, yet every non-success stop maps to a runner event class - a runner launched under such a binding would have no queue destination for its required human-gated handoff. The static checker cannot close this (no binding key says a runner is enabled), so the escalation contract now carries the launch precondition: at startup the runner verifies both runner-needs-human and runner-cap-exceeded carry bound escalation_routes entries and fail-closes - blocking dispatch - when either is absent, exactly as for an absent security binding. The binding stays valid for every pre-runner surface; the requirement binds the runner. Co-Authored-By: Claude Fable 5 --- plugins/autonomy/reference/runner/escalation.md | 10 ++++++++++ .../skills/setup/scripts/check-security-binding.mjs | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/plugins/autonomy/reference/runner/escalation.md b/plugins/autonomy/reference/runner/escalation.md index 380b52c4e..4463ef8f9 100644 --- a/plugins/autonomy/reference/runner/escalation.md +++ b/plugins/autonomy/reference/runner/escalation.md @@ -101,6 +101,16 @@ severity bindings for them exactly as it does for any guardrail event class, and bindings validate unchanged. Their contract-default severities are `attention` and `notice`, both org-bindable. +**Runner launch precondition.** Both runner classes' queue routes are part of the runner's +required governance: at launch the runner verifies that `runner-needs-human` and +`runner-cap-exceeded` each carry a bound `escalation_routes` entry, and fail-closes — blocking +dispatch — when either is absent, exactly as it does for an absent security binding +([topology leaf](topology.md)). Every non-success stop maps to one of these classes, so a +runner without their routes would have no queue destination for its required human-gated +handoff. The requirement binds the RUNNER, not the binding: a binding without the runner keys +stays valid for every pre-runner surface, which is why the static checker cannot enforce this +(no binding key says a runner is enabled) and the launch gate does. + ## Severity axis and notification fan-out Every event class escalates at a severity on a three-level axis — `notice`, `attention`, diff --git a/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs b/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs index 064173b0b..bd1ffdb9b 100755 --- a/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs +++ b/plugins/autonomy/skills/setup/scripts/check-security-binding.mjs @@ -610,7 +610,10 @@ function validateStructure(binding) { // inherited classes (whose escalation_routes entries are required), a // runner class with no escalation_routes entry has nowhere to file its // human-gated handoff. Legacy bindings that never key a runner class - // stay untouched. + // stay untouched — no binding key says a runner is enabled, so the + // runner-side requirement (both runner routes bound, else no dispatch) + // is the runner escalation contract's LAUNCH precondition, not a + // static rule here. if (RUNNER_EVENT_CLASSES.includes(eventClass) && !isNonEmptyString(eventRoutes[eventClass])) { findings.push( `${where}: severity is bound for a runner event class with no escalation_routes.${eventClass} entry — severity only selects notification fan-out on the filed item, so without a queue destination the class's human-gated handoff has nowhere to file (fail-closed); bind escalation_routes.${eventClass}`, From 0dabf52fc9a22d5bf3fac8281b32ac975aafd0ca Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:19:42 -0400 Subject: [PATCH 11/15] fix(autonomy): completion is a landed disposition, not a landed change (wave 7) One review finding. The terminal invariant said only a run that lands its CHANGE becomes complete, contradicting the same leaf's non-mutating dispositions - a C1 queue artifact and a C4 planning run's plan-approval item both complete without a repository change. The invariant now keys on a successfully landed class disposition (queue artifact, plan-approval item, or per-item PR); every other ending stays escalated. Co-Authored-By: Claude Fable 5 --- plugins/autonomy/reference/runner/lifecycle.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/autonomy/reference/runner/lifecycle.md b/plugins/autonomy/reference/runner/lifecycle.md index 68f5a32b7..643f95285 100644 --- a/plugins/autonomy/reference/runner/lifecycle.md +++ b/plugins/autonomy/reference/runner/lifecycle.md @@ -23,8 +23,9 @@ Two states are terminal: `escalated` and `complete`. A stop at any non-terminal state resolves to `escalated` rather than advancing; the mapping from a stop reason to its terminal outcome is [the escalation leaf](escalation.md)'s subject. -Only a run that reaches `disposing` and lands its change becomes `complete` — every other -ending is `escalated`. +Only a run that reaches `disposing` and successfully lands its class's disposition — a queue +artifact for a read-only class, the plan-approval item for a `C4` planning run, a per-item PR +for a mutating class — becomes `complete`; every other ending is `escalated`. ### C4 pre-execution plan approval From c1aabe21e1aa29f2004c9a58788ddbcc623f7800 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:26:05 -0400 Subject: [PATCH 12/15] docs(autonomy): channel-only urgent is the legal degraded fan-out (wave 8) One review finding, resolved as a contract clarification rather than a new rejection. The fan-out legs are org-bound routes and each leg exists only where its route is bound: an org with no push adapter legitimately binds urgent with the channel leg alone, and an unbound leg degrades the fan-out toward the always-filed tracker item - a reviewed security-surface choice, not an unverifiable claim, so it does not fail closed. The one rejected shape stays the inverse (a push leg without the channel beneath it), because the ladder is cumulative. escalation.md now states this explicitly where the fan-out table is defined. Co-Authored-By: Claude Fable 5 --- plugins/autonomy/reference/runner/escalation.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/autonomy/reference/runner/escalation.md b/plugins/autonomy/reference/runner/escalation.md index 4463ef8f9..6923eb965 100644 --- a/plugins/autonomy/reference/runner/escalation.md +++ b/plugins/autonomy/reference/runner/escalation.md @@ -125,8 +125,12 @@ invariant ([guardrail escalation contract](../guardrails.md#escalation)) holds. | `urgent` | tracker item + channel notification + a personal-push tier, org-bindable | The tracker item is always filed; channel notification and the personal-push tier are -org-bound routes. Absent a bound channel adapter, fan-out degrades to tracker-item-only — it -never drops the escalation item itself. +org-bound routes, and each leg exists only where its route is bound. An unbound leg degrades +the fan-out toward the always-filed tracker item — an org with no push adapter legitimately +binds `urgent` with the channel leg alone, and absent a bound channel adapter fan-out degrades +to tracker-item-only; degradation never drops the escalation item itself. The one rejected +shape is the inverse: a push leg bound without the channel leg beneath it, because the ladder +is cumulative and the push tier rides on top of the channel notification. ### Contract-default severities From fd0b5a7861116d065964445bac4e1316aee57111 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:31:52 -0400 Subject: [PATCH 13/15] fix(autonomy): schema descriptions match the runner-route requirement (wave 9) One review finding. Two schema descriptions still carried the superseded severity-only routing option for runner classes, so a consumer following the schema contract would prepare a binding the checker rejects. Both now state the enforced rule: a runner class keyed in escalation_severity must also carry its own escalation_routes entry, because severity selects only notification fan-out and can never supply the filed item's queue destination. Co-Authored-By: Claude Fable 5 --- .../setup/schemas/guardrails-security-binding.schema.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json b/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json index db1ae20ed..0131176a3 100644 --- a/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json +++ b/plugins/autonomy/skills/setup/schemas/guardrails-security-binding.schema.json @@ -59,7 +59,7 @@ "additionalProperties": { "$ref": "#/$defs/PromotionEntry" } }, "escalation_routes": { - "description": "Org-bound route per escalation event class — a security-sensitive axis; every inherited guardrail event class must route. The two additive runner classes are legal OPTIONAL keys (never required, so existing bindings validate unchanged); a runner class may also route solely through the escalation_severity join.", + "description": "Org-bound queue route per escalation event class — a security-sensitive axis; every inherited guardrail event class must route. The two additive runner classes are legal OPTIONAL keys (never required, so existing bindings validate unchanged) — but a runner class bound in escalation_severity REQUIRES its entry here: severity selects only notification fan-out and can never supply the filed item's queue destination.", "type": "object", "additionalProperties": false, "required": [ @@ -228,7 +228,7 @@ }, "Route": { "type": "string", "minLength": 1 }, "EscalationEventClass": { - "description": "The escalation event-class registry: the six inherited guardrail classes plus the two additive runner classes. escalation_routes keeps requiring only the six inherited classes; the two runner classes bind route and severity entries additively — an optional escalation_routes key, the escalation_severity join, or both.", + "description": "The escalation event-class registry: the six inherited guardrail classes plus the two additive runner classes. escalation_routes keeps requiring only the six inherited classes; the two runner classes bind additively, and a runner class keyed in escalation_severity must also carry its own escalation_routes entry (the queue destination severity can never supply).", "enum": [ "gate-failure", "verification-divergence", From 05e96dcafdcd5d57f209fe1b9e71a25c4f2644a7 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:37:59 -0400 Subject: [PATCH 14/15] fix(autonomy): missing C4 approval is a mapped runner-owned stop (wave 10) One review finding. The C4 fail-close on a missing recorded approval went straight to escalated with no enumerated stop, leaving its event class, severity, and route unspecified. Missing plan approval is now a runner-owned stop reason in the taxonomy and the step-one table, mapping to needs-human (so it files through runner-needs-human at its bound route and severity like every other non-success stop), and the lifecycle leaf's C4 gate names that stop instead of a bare escalated transition. Co-Authored-By: Claude Fable 5 --- plugins/autonomy/reference/runner/escalation.md | 5 ++++- plugins/autonomy/reference/runner/lifecycle.md | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/autonomy/reference/runner/escalation.md b/plugins/autonomy/reference/runner/escalation.md index 6923eb965..cf3d17ac7 100644 --- a/plugins/autonomy/reference/runner/escalation.md +++ b/plugins/autonomy/reference/runner/escalation.md @@ -51,7 +51,9 @@ severity — severity is resolved by the mapping below. - **`runner-owned` (deterministic).** The runner detects these itself, without agent judgment: turn, budget, or wall-clock cap reached; execution error persisting after retries; model - refusal; verification-gate failure; isolation violation. Each is an observable runner + refusal; verification-gate failure; isolation violation; missing plan approval (a `C4` + structural run leased without its recorded approval — + [lifecycle leaf](lifecycle.md#c4-pre-execution-plan-approval)). Each is an observable runner condition, not a signal the agent has to raise. - **`agent-signaled` (judgment).** The executing agent raises these through the envelope's `stop_reason` field, because only the agent holds the context to recognize them: ambiguity @@ -79,6 +81,7 @@ traces the full path stop reason → outcome → event class → severity → ro | `runner-owned` | execution error after retries | `needs-human` | | `runner-owned` | refusal | `needs-human` | | `runner-owned` | isolation violation | `needs-human` | +| `runner-owned` | missing plan approval (`C4`) | `needs-human` | | `agent-signaled` | ambiguity | `needs-human` | | `agent-signaled` | design decision needing human judgment | `needs-human` | | `agent-signaled` | security/data-integrity event | `needs-human` | diff --git a/plugins/autonomy/reference/runner/lifecycle.md b/plugins/autonomy/reference/runner/lifecycle.md index 643f95285..5b2f2b06d 100644 --- a/plugins/autonomy/reference/runner/lifecycle.md +++ b/plugins/autonomy/reference/runner/lifecycle.md @@ -37,7 +37,8 @@ disposition is the `structural-plan-approval` item (the inherited class, on its severity, with the produced plan attached), and it completes without touching the structure. The structural execution is a second run, admitted only from the human-approved item; its `leased → executing` transition requires that recorded approval, and absent one the run -fail-closes to `escalated` rather than executing. No second approval channel exists — the +fail-closes through the missing-plan-approval stop — a `runner-owned` stop reason resolving to +`needs-human` in [the escalation leaf](escalation.md)'s mapping — rather than executing. No second approval channel exists — the approval item is ordinary human-gated queue work, and the terminal-handoff shape ([escalation leaf](escalation.md)) is untouched: neither phase pauses mid-run. From 5081b81699398e35e67b300d919a845298a57f8b Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:44:09 -0400 Subject: [PATCH 15/15] fix(autonomy): per-item caps get an owning binding home (wave 11) One review finding. The turn, budget, and wall-clock caps (and the retry budget behind the execution-error stop) had no owning configuration seam - only the drain-level autonomous_concurrency and items_per_run were cited - so cap-exceeded could not be made deterministic. The session/resume/caps seam now pins the home: the per-item caps are admission-policy knobs on the SECURITY binding, siblings of the drain-level pair on the same agent-unwritable surface (a cap the governed agents could edit is no cap). Their exact keys land as additive schema keys with the build, token names resolving at /architect like every other deferred seam token; the runner reads them and fail-closes at launch when they are unbound, so no item ever runs unbounded on implicit defaults. Co-Authored-By: Claude Fable 5 --- plugins/autonomy/reference/runner/seams.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/plugins/autonomy/reference/runner/seams.md b/plugins/autonomy/reference/runner/seams.md index 1a36c2200..83670b249 100644 --- a/plugins/autonomy/reference/runner/seams.md +++ b/plugins/autonomy/reference/runner/seams.md @@ -108,9 +108,17 @@ The runner persists each executor session so a human takeover resumes it rather from cold, and it enforces the caps that bound a single drain. The persisted session behind the `resume_handle` is what turns a terminal escalation into a resumable takeover. -- **Plugs into (shipped):** the run caps bind the admission-policy knobs +- **Plugs into (shipped):** the drain-level caps bind the admission-policy knobs ([admission policy](../guardrails/admission-policy.md), surfaced as `autonomous_concurrency` and `items_per_run` on the dispatch contract); session persistence is runner-new. -- **Interface tokens:** the envelope's `resume_handle` is the takeover key; the turn, budget, - and wall-clock caps whose exhaustion is a terminal stop are the [escalation leaf](escalation.md)'s +- **Per-item caps — owning home pinned.** The turn, budget, and wall-clock caps that bound a + single run, and the retry budget behind the execution-error stop, are admission-policy knobs + on the SECURITY binding — siblings of the drain-level pair, on the same agent-unwritable + surface, for the same reason: a cap the governed agents could edit is no cap. Their exact + keys land as ADDITIVE schema keys with the build (token names resolve at `/architect` like + every other deferred seam token); the runner READS them and fail-closes at launch when they + are unbound, so the `cap-exceeded` stop is deterministic and no item ever runs unbounded on + implicit defaults. +- **Interface tokens:** the envelope's `resume_handle` is the takeover key; the caps whose + exhaustion is a terminal stop are the [escalation leaf](escalation.md)'s stop-criteria subject.