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 9e51cbf02..cbdfbbcd5 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 | |---|---|---| @@ -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 | |---|---|---| @@ -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 | |---|---|---| @@ -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 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/reference/runner.md b/plugins/autonomy/reference/runner.md new file mode 100644 index 000000000..4cc36410b --- /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/escalation.md b/plugins/autonomy/reference/runner/escalation.md new file mode 100644 index 000000000..cf3d17ac7 --- /dev/null +++ b/plugins/autonomy/reference/runner/escalation.md @@ -0,0 +1,210 @@ +# 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 — 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 + +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; 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 + (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` | +| `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` | +| `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. + +**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`, +`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, 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 + +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. 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 + +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..5b2f2b06d --- /dev/null +++ b/plugins/autonomy/reference/runner/lifecycle.md @@ -0,0 +1,81 @@ +# 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 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 + +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 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. + +## 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 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 +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/seams.md b/plugins/autonomy/reference/runner/seams.md new file mode 100644 index 000000000..83670b249 --- /dev/null +++ b/plugins/autonomy/reference/runner/seams.md @@ -0,0 +1,124 @@ +# 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 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. +- **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. diff --git a/plugins/autonomy/reference/runner/topology.md b/plugins/autonomy/reference/runner/topology.md new file mode 100644 index 000000000..3908a2fc6 --- /dev/null +++ b/plugins/autonomy/reference/runner/topology.md @@ -0,0 +1,70 @@ +# 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. + 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 + +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. 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-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/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-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-tracker-only-valid.json b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-tracker-only-valid.json new file mode 100644 index 000000000..6a8bc1ef4 --- /dev/null +++ b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-tracker-only-valid.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": "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..f05a19a1e --- /dev/null +++ b/plugins/autonomy/skills/setup/evals/fixtures/security-binding/escalation-severity-valid.json @@ -0,0 +1,86 @@ +{ + "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", + "runner-needs-human": "queue:escalations/runner-needs-human", + "runner-cap-exceeded": "queue:escalations/runner-cap-exceeded" + }, + "escalation_severity": { + "gate-failure": "attention", + "untrusted-provenance": "urgent", + "admission-rejection": "notice", + "runner-needs-human": "attention", + "runner-cap-exceeded": "notice" + }, + "escalation_severity_routes": { + "attention": { "channel": "channel:autonomy-escalations" }, + "urgent": { "channel": "channel:autonomy-escalations", "push": "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..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 event class must route.", + "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": [ @@ -76,7 +76,47 @@ "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. 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 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": { + "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.", + "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. 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, + "maximum": 1 + } } }, "admission": { @@ -187,6 +227,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 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", + "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..bd1ffdb9b 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,110 @@ 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 { + 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 — 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}`, + ); + } + } + } + } + + 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 notification legs"); + } else { + 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 (!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}: 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`, + ); + } + } + } + } + + 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`, + ); + } + } + // 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`, + ); + } } } @@ -1420,6 +1531,15 @@ function checkSemantics(binding, probeRoot, egressAllowList) { } } + // 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); }