Files
PaperClipAI/packages/paperclip-runner/docs/protocol-compatibility.md
T
DottaandClaude Opus 5.5 2575b941d0 feat(task-chat): show hook and auto-review decisions as governance rows (#19266)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents run on harnesses such as Codex and Claude, and the task chat
shows what each run did
> - Harnesses make automatic decisions: hooks block or allow tool calls,
and Codex auto-review approves or denies actions
> - Today Paperclip drops these decisions or shows them as generic rows.
Codex `safety.review.*` always says `decision: "unknown"`, the native
Rust runner drops `hook/*` and `item/autoApprovalReview/*`, and
claude-agent-acp drops Claude hook messages
> - An operator cannot see why an agent changed course or which action
ran without a person's approval. This conflicts with Paperclip's
approval-gate model
> - This pull request records each hook and review decision with its
reason and shows it as a small governance row in the task chat
> - The benefit is that automatic decisions become auditable, and a
blocked action explains itself without opening run logs

## Linked Issues or Issue Description

Refs #19252 (precedent: canonical `plan.updated` events rendered as a
task-chat card).
Builds on #19259 (ACP `_meta` passthrough from ACPX), now merged.

**Subsystem affected**
Runner provider events (Rust native Codex normalizer, TS drivers, ACPX
sidecar), the claude-agent-acp patch, the PRP provider-event schema, and
the task chat activity UI.

**Problem or motivation**
Hook and auto-review outcomes reach Paperclip but are lost or flattened.
Codex reviews always record an unknown decision. Native Codex runs drop
hook and review notifications in the Rust normalizer. Claude hook
messages never leave the adapter. Codex reuses one `run.id` for every
call of a hook, and the id contains a host path.

**Proposed solution**
Map the real outcome, decision, action, risk and reason into additive
optional fields on `paperclip.hook.v1` and `paperclip.safety.review.v1`.
Forward Claude SDK hook messages. Render small grey rows: a stopped
action is tinted and shows its reason, an approved review folds into the
command it approved as an "auto-approved" mark, and routine hook passes
stay out of activity summaries.

**Alternatives considered**
Separate rows for every approval. This duplicated each approved command,
so approvals fold into the command row instead.

## What Changed

- Runner (Rust): new `governance_events.rs`.
`normalize_codex_notification` now maps `hook/started|completed` and
`item/autoApprovalReview/started|completed`. Each hook call gets its own
id (a digest of the run id and start time), so no host path is stored.
- Runner (TS): new `governance-events.ts` with the same mapping for the
TS Codex driver, and the Claude hook mapping. The ACPX sidecar publishes
Claude hook updates as schema-validated `hook.*` rich events.
- claude-agent-acp patch: turns on `includeHookEvents` and forwards
`hook_started` / `hook_response` on
`session_info_update._meta.claudeCode.hook`, with bounded output.
- Protocol: additive optional fields. Hook records get `outcome`,
`message`, `source` and `toolName`, and the `blocked` and `stopped`
statuses. Review records get `actionKind`, `action`, `riskLevel`,
`userConsent` (the reviewer's user-authorization level; keys that end in
"authorization" are redacted as secrets), `rationale` and `decidedBy`,
and the `timed_out` and `aborted` decisions. Regenerated the manifest
and validators.
- Fixed: the hook `blocking` flag read `executionMode === "blocking"`,
but Codex sends `sync` / `async`.
- UI: `native-run-events` keeps the full hook and review payloads.
`governance-presentation.ts` writes the row labels.
`fold-auto-reviews.ts` attaches an approved review to its command row.
The rows show the tint, the reason line and the "auto-approved" mark.
Activity summaries lead with a block or denial.
- Storybook: "Task chat / Governance rows" shows every outcome, a Codex
run, a Claude run and the live line.
- Docs: added a governance activity section to
`packages/paperclip-runner/docs/protocol-compatibility.md`.

## Verification

- `pnpm --filter @paperclipai/paperclip-runner exec vitest run
src/governance-events.test.ts src/provider-events.test.ts
src/drivers/codex src/drivers/acpx src/cli`. 2305 tests pass. This
includes a replay of real Codex 0.160 notifications from a session with
hooks and auto-review
(`test-fixtures/governance/codex-0.160-hooks-auto-review.json`,
sanitized).
- `cargo test --release -p paperclip-runner-core`. The full suite
passes, and `governance_events::tests` replays the same fixture.
- `cd ui && npx vitest run`. 11,047 tests pass.
- `node scripts/typecheck-low-memory.mjs`, `pnpm check:token-gates`, and
the runner contract checks (`check:protocol-manifest`,
`check:protocol-types`, `check:capability-contract`,
`check:protocol-coverage`, replay goldens, contract fingerprints) pass.
- Live test on a local isolated instance. A Codex agent and a Claude
agent each used a preconfigured home with a PreToolUse hook that blocks
edits to `0001_init.sql`, plus UserPromptSubmit and PostToolUse hooks.
Both runs showed "PreToolUse hook blocked a tool call" (Codex) or
"PreToolUse hook blocked Bash" (Claude), with the hook's message. They
also showed "UserPromptSubmit hook added context", and the group summary
started with the block.
- Auto-review cannot run in Paperclip today, because the native Codex
runner only allows `approvalPolicy: never`. The review mapping and the
approval fold are proven against real Codex 0.160 app-server output
(fixture above) and in Storybook.

## Risks

- Low risk to execution. These records are display-only and carry no
authority. All new schema fields are optional, so older records stay
valid and older UIs ignore the new fields.
- The `auto-approved` fold depends on `targetItemId` matching the
command's execution id. Real Codex 0.160 data confirms that it matches.
When there is no match, the review stays as its own row.
- In Paperclip's isolated homes, hooks do not run, because the runner
never copies hooks into an isolated home. Rows appear only for agents
with a preconfigured home, or with future managed hooks.
- The claude-agent-acp patch changes. The ACPX profile check still
passes, because the patch is Paperclip's ACPX layer and not the upstream
release.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, browser and file editing) and extended reasoning.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:35:57 -06:00

364 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PRP Compatibility and Versioning Policy
## Authority
The JSON Schema files in [`protocol/schemas/`](../protocol/schemas/) are the
language-neutral source of truth for the Replay and Local runner executable contract. The
generated TypeScript schema module is checked against those files before every
TypeScript typecheck. Rust consumes the same fixtures and must produce the same
golden parity summaries.
The reviewable architecture and trust boundaries are defined in
[`architecture.md`](./architecture.md); durable transport and recovery behavior
is defined in [`durable-recovery.md`](./durable-recovery.md). Local runner reuses
the protocol contract for local live events. It adds package-local stdio and
stream envelopes, but it does not add durable transport, persistence, or
production control-plane behavior.
## Version fields
| Field | Replay support | Compatibility rule |
|---|---:|---|
| `protocolVersion` | `1` | Required. Negotiate the highest overlapping version; no overlap fails closed. |
| `fixtureVersion` | `1` | Required by the conformance corpus. Unknown values fail closed. |
| `event.schemaVersion` | `1` | Required on every event. Unknown values fail closed before reduction. |
| `capabilities.semanticTools.schemaVersion` | `1` | Optional advertisement. When present, an unknown required version fails closed. |
| `payload.semantic_tool.schemaVersion` | `1` | Optional on paired semantic tool input/result events. When present, an unknown required version fails closed. |
| `terminal.stopReason.schemaVersion` | `1` | Optional budget/cost receipt. When present, an unknown required version fails closed. |
| Typed `schema` discriminators | `*.v1` | Required. Unknown required schema identities fail JSON Schema validation. |
Wire protocol versions and fixture-corpus versions are independent. A fixture
format can evolve without changing PRP, and a future PRP version can be
represented only after the consumer advertises support for it.
## Forward compatibility
- Unknown object properties are accepted and preserved by validation. Reducers
ignore fields they do not understand until a later schema version gives those
fields defined behavior.
- Unknown required versions, schema discriminators, enum values, and required
fields fail closed. A consumer must never guess at their semantics.
- Scripted fixtures bind every event to the fixture run/session, require
contiguous controller command order, exactly one unique proposed result, and
exactly one unique terminal event.
- The top-level fixture result must equal the `run.result.proposed` payload after
canonical key ordering. Repeated `sourceEventId` deliveries must be
byte-equivalent after the same normalization.
The forward-compatibility fixture proves that optional fields survive validation
without changing the v1 snapshot. The unsupported-version fixture proves that a
required v2 protocol cannot be replayed by this consumer.
## Within-turn checklist snapshots
`plan.updated` / `paperclip.plan.updated.v1` is a complete, ordered snapshot of
the provider's checklist for one active turn. It is not a Paperclip Plan
document and must never be inferred from assistant prose, Codex proposed-plan
items, or generic TodoWrite output. Every replacement uses the provider turn ID
as `planId`; PRP `sourceSeq`, not an optional provider revision, determines
snapshot order. An empty step array clears the checklist, and `complete` is true
only when a non-empty snapshot contains only `completed` steps. The legacy
document-coupling fields are always `syncStatus: "not_applicable"` and
`documentRevision: null`.
| Qualified adapter profile | Checklist support |
|---|---|
| Direct Codex App Server | `turn/plan/updated` |
| ACPX Codex | Structured ACP `plan` entries |
| ACPX Claude | Structured ACP `plan` entries |
| ACPX Pi | Unavailable; no production-qualified profile is exposed |
| OpenCode | Unsupported until it exposes a structured plan event |
Codex `turn/diff/updated` is normalized separately as the latest same-turn
`workspace.change.updated` snapshot. Together these two independent event
families can drive a turn-status UI without changing the public PRP family or
adding a control-plane endpoint.
## Governance activity: hooks and automatic approval reviews
`hook.started` / `hook.completed` (`paperclip.hook.v1`) and
`safety.review.started` / `safety.review.completed`
(`paperclip.safety.review.v1`) are display records. They make decisions that
no person clicked auditable. They never grant or deny execution authority.
- A hook record has an `outcome`: `running`, `passed`, `context`, `feedback`,
`blocked`, `stopped` or `failed`. `message` holds the hook's own text for
that outcome. `source` names where the hook is configured (`user hooks`,
`project hooks`) or the script file name, never an absolute host path.
`toolName` is set only when the harness reports it.
- A review record has a `decision`: `pending`, `allowed`, `denied`,
`user_required`, `timed_out`, `aborted` or `unknown`. `action` is a short
description of the reviewed action. Terminal input is never echoed.
`targetExecutionId` matches the `executionId` of the reviewed tool
execution when the harness reports one.
- All fields after `summary` are optional. A record without them is a
valid v1 record.
| Qualified adapter profile | Hooks | Approval reviews |
|---|---|---|
| Direct Codex App Server | `hook/started`, `hook/completed` | `item/autoApprovalReview/*` (only with an on-request approval policy) |
| ACPX Claude | Claude Agent SDK hook messages, forwarded by the patched adapter on `session_info_update._meta.claudeCode.hook` | Unavailable |
| Other ACPX profiles | Unavailable | Unavailable |
Hooks run only when the harness home defines and trusts them. An isolated
Paperclip home defines none, so these records appear for agents with a
preconfigured home.
## Provider-neutral semantic receipts
- `capabilities.semanticTools` advertises stable operation IDs, availability,
required claims, and redaction disposition without naming a provider API.
- `mcp_app.tool_input` and `mcp_app.tool_result` may carry paired
`semantic_tool` envelopes. Correlation IDs must match the containing event;
operation ID and idempotency key must match across the pair.
- Content is represented by a canonical SHA-256 digest plus allowlisted typed
references. Raw credentials, provider payloads, and hidden identifiers do not
belong on the wire.
- Result receipts distinguish success, denial, conflict, exact duplicate,
unavailable, and failure. They can name the authorization boundary, safe
revision, artifact/work-product refs, immutable governed targets, and bounded
wake/monitor causality.
- `terminal.stopReason` records budget/cost kind, stable code, retryability,
limit class, safe aggregate, and decision receipt.
These fields are trace evidence only. The v1 reducer ignores `semantic_tool`
payloads, so adding or extending the optional envelope has no projection
effect. Eval `trace_completeness` treats PRP wire receipts as authoritative when
present and retains the pre-existing scalar fallback for live evidence that has
not yet emitted them.
## Provider-neutral structured input
Harness-initiated forms cross PRP as `paperclip.runtime_request.v2` with
`requestKind: "runtime"`, `type: "input"`, and an embedded
`paperclip.question_set.v1`. A submission is always
`{ "action": "submit", "response": paperclip.question_response.v1 }`.
Codex answer objects, OpenCode answer arrays, and ACP typed content exist only
inside their adapters; `origin` may retain the provider method and adapter name
for diagnostics but never provider response data.
Text-mode questions may include `initialText` (at most 100,000 Unicode code points,
within the existing 196 KiB question-set bound). It supplies an editable starting
draft, including empty text and whitespace. ACP plain-string `default` values map
without trimming; numeric defaults become editable numeric text. Select questions
do not accept this field. The UI preserves saved drafts and existing responses
ahead of provider defaults. Initial text never submits or resolves a request:
the operator must explicitly submit, and all existing response validation still
applies. Canonical text answers retain submitted whitespace and literal escape
sequences; legacy and select custom answers retain their existing normalization.
JSON Schema, TypeScript and Rust count draft Unicode code points alike. Existing
submitted-answer limits still count UTF-16 code units; a draft can require editing
before it satisfies those limits or field-specific constraints. Required blank
answers remain invalid. Redaction, company scope and durable
replay rules remain in force.
Question and option order is significant, while answers are keyed by stable
question IDs and selections reference stable option IDs. The canonical modes
are `text`, `single_select`, and `multi_select`. Text validation is repeated at
the untrusted server edge and again against the persisted question set before a
provider receives the translated response.
Every harness adapter must add a
`paperclip.question_adapter_fixture.v1` fixture proving its native request
normalizes to the canonical shape and its canonical response can be translated
back. The shared fixture format deliberately contains both native and canonical
objects so adding a provider does not change PRP or the UI contract.
V1 runtime requests and their legacy resolutions remain accepted during the
migration. A request that contains no structured form stays on the legacy path;
once a provider supplies a form, malformed or unsupported fields fail closed
instead of silently degrading. ACPX sidecars advertise only form elicitation
and use sidecar protocol v2 `runtime.input_requested` / `input.resolve` frames.
The live lifecycle pauses and resumes the same provider turn. If the provider
process is lost first, Paperclip emits one non-replayable
`runtime_request.expired` fact and materializes an idempotent durable
`ask_user_questions` interaction using the identical question set. Explicit
cancellation and already-resolved requests never create that fallback.
## Native execution permission compatibility
`paperclip.native-execution-input.v4` pins the effective harness permission
policy in the closed provider configuration: `approvalPolicy` for Codex and
`permissionMode` for OpenCode and ACPX. The pinned value participates in
provider-session identity, so an incompatible idle or recovered session is
replaced on the next execution. An active turn is never mutated in place.
`paperclip.native-execution-input.v5` remains the ordinary input format.
Claude inputs with explicitly configured effort use v9 with the optional
`provider.claudeEffort` field. V9 retains the compact v3 model envelope; effort
is host configuration, never model-visible task text. V6 remains the separate
OpenAI Dot format. Readers retain persisted v1-v8 contracts, including v7 home modes and v8 Muse. Claude effort is
acknowledged on each ACP connection before prompting, bound into the session
identity and recovery checkpoint, and cross-checked against the public provider
descriptor. Missing effort retains the provider default; values are never clamped.
The v5 input format retains
the v4 permission pinning and adds optional completion-source references;
`paperclip.native-model-envelope.v3` is the corresponding explicit model
projection. Readers continue to accept persisted v1-v4 inputs, and v5 readers
must preserve the historical behavior of inputs that do not carry the new
optional fields. Missing Codex and OpenCode policy fields retain their
historical effective behavior. Legacy ACPX `permissionPolicy: "interactive"`
is interpreted as `approve-reads`, while new v4 and v5 ACPX executions default
to `approve-all` at the server boundary.
When a healthy provider session is resumed, its persisted v4 or v5 input format
is retained even if the newly built input uses the other format. This avoids
rotating an active session for a presentation-only schema change. A safe
rollback from v5 to v4 removes only the optional completion-source references;
it retains the task, contract, provider, workspace, and permission fields.
Format changes still go through the normal provider-session identity checks,
and an active turn is never mutated in place.
See [Adding a harness](adding-a-harness.md) for the permission catalog,
isolation rules, and provider conformance requirements.
Seven conformance fixtures cover artifact success, redacted denial without
fallback, stale conflict plus duplicate retry, governed target and continuation
causality, budget/cost stop, unknown optional fields, and rejection of an
unknown required version. The six accepted fixtures have shared TypeScript and
Rust golden parity summaries.
## Replay semantics
- Events are applied in fixture order and ordered independently by
`(sourceKind, sourceInstanceId, sourceSeq)`.
- A repeated source event ID has no second projection effect.
- A forward source-sequence gap is recorded explicitly; the reducer never
invents a missing event.
- An event at or behind the committed source cursor is ignored and recorded as
out of order.
- Replaying an already-applied batch leaves the snapshot unchanged.
The CLI and browser import the same `replayReplayFixtureText` function, so
validation, compatibility errors, and final snapshots cannot drift between the
two surfaces.
## Local envelope rules
- Mock-core commands use `paperclip.prp.command.v1` over stdin JSONL.
- Runner output uses `paperclip.runner.stream.v1` over stdout JSONL.
- Fake-harness commands use `paperclip.fake_harness.command.v1`.
- Fake-harness output uses `paperclip.fake_harness.message.v1`.
- An equivalent repeated `commandId` returns a duplicate receipt and has no
second driver effect. Reuse with different data is rejected.
- A new command must use the next contiguous `controllerSeq`.
- Harness logs are bounded diagnostic data. They are not canonical PRP events.
- `run.result.proposed`, `harness.exited`, and `run.terminal` are separate facts
and appear in that order when a semantic result exists.
- The live browser rejects an event with an invalid schema, run ID, or session
ID before it reaches the reducer.
These envelopes are local Local runner implementation contracts.
## Durable wire rules
- The runner opens loopback `ws://` or hostname-verified `wss://`, or accepts a
preview-proxy connection on its fixed listener, and completes the PRP v1
authenticated handshake before any command result or event.
- A one-use bootstrap bearer capability returns a short-lived connection lease
in `welcome`. Later connections use that lease. Neither raw capability is
durable state.
- `welcome.payload.connectionLeaseRenewalVersion: 1` opts into authenticated
`lease_renew` / `lease_renewed` control frames. Renewal extends the persisted
expiry on the same live authority without restarting provider work. Identity,
protocol, and revocation epoch remain fixed; expired or revoked leases cannot
renew. See [durable recovery](durable-recovery.md#execution-duration-and-operation-deadlines)
for retry and warm-handoff rules. Peers lacking this capability retain their
original lease expiry.
- `hello.resume` reports the last processed controller sequence, next source
sequence, cumulative ACK cursor, and current unacknowledged range.
- `welcome` selects the one overlapping protocol version, returns the core's
cumulative ACK cursor, and carries at most one durable pending command.
- An event is durable before send. Event IDs and source sequences stay stable
across replay and process restart.
- An ACK is cumulative. The runner rejects a cursor behind its durable ACK or
beyond its produced source cursor.
- An equal repeated command ID and canonical digest returns its stored result.
Reuse with different bytes fails closed and cannot repeat an effect.
- Frames are bounded at 1 MiB and upgrade headers at 16 KiB. Unknown or invalid
required protocol data fails closed; malformed JSON is a bounded diagnostic.
Runnerd build-metadata contract v2 advertises the exact transport inventory:
`dial_ws_loopback`, `dial_wss`, and `listen_ws`. Plaintext dial destinations
must resolve entirely to loopback. Public dial targets require TLS trust and
hostname validation; a private CA bundle augments the platform roots and must
be a bounded, private, regular file. Listener mode binds to `0.0.0.0` and a
single run-bound path. The optional `--listen-port` selects a port in
`1..=65535` and defaults to `43127`. Warm attachments retain the existing
listening port. All modes retain the same message/frame bounds and PRP authentication.
These are package-local Durable recovery and transport rules. Control-plane
admission and deployment policy remain separately reviewed work.
## Change policy
1. Change JSON Schema first.
2. Regenerate the TypeScript schema module.
3. Add or revise a shared fixture and its golden snapshot/summary.
4. Prove TypeScript and Rust parity.
5. Update this policy and the normative spike specification when behavior
changes.
Breaking changes require a new required version. Additive optional fields may
remain in v1 only when old consumers can safely ignore them.
## Package-level compatibility
PRP is one independently versioned component of the runner bundle. Catalog,
runner-client, control-plane-adapter, testkit, and eval-corpus compatibility is
declared by `PAPERCLIP_RUNNER_COMPATIBILITY` and checked before execution by
`assertPaperclipRunnerCompatibility`. A mismatch fails with
`paperclip_runner_incompatible` and stable per-issue codes; a provider-specific
tool error is not a compatibility negotiation mechanism.
See [ADR 0001](adr/0001-runner-testing-eval-package-boundaries.md) for the
component rules and clean-consumer packaging gate.
## Evals integration negotiation
The packed `./evals` entry point adds a stricter execution preflight for the
App/Evals join. `assertPaperclipRunnerEvalCompatibility` requires simultaneous
agreement on package semver, runnerd build metadata, a common PRP version,
semantic catalog version and SHA-256 digest, harness-driver contract and
required capabilities, and the native-execution version. It reports
`paperclip_runner_eval_incompatible` with expected/received values for every
mismatch and must run before launching a provider.
runnerd itself reports `paperclip-runner/runnerd-build-metadata/v1` from
`--build-metadata`. The consumer passes its path and expected content digest to
`resolvePaperclipRunnerdArtifact`; implicit PATH or source-tree discovery is
not part of the contract. Native attempt output is
`paperclip-runner/native-execution/v1`, whose parser accepts unknown additive
fields but rejects unknown required versions and inconsistent terminal,
semantic-denial, usage, or transcript facts. Full fields and the deterministic
gate are exercised by the package-local deterministic conformance suite.
## Claude effort after the Codex default merge
Native input v9 adds configured Claude effort while retaining v1–v8 readers.
It carries the explicit home mode from v7; v8 Muse remains separate. The shared
executor retains master's provider and cleanup behavior. Copilot's shared source
attestation advances from pending profile 21 to 22 without replacing historical
fixtures. Pi advances from master's profile 24 to 27 to bind the combined source,
including master's OpenRouter attribution. The prior branch profile 24 is
preserved separately from master's published profile 24.
Provider executable bytes remain unchanged. These new source identities require
fresh compatibility checks; earlier provider runs retain their original identities.
## Claude essential task tools
Claude's runner-owned MCP bridge marks `set_task_title`, `paperclip_finish` and
`paperclip_block` with `_meta["anthropic/alwaysLoad"]: true`. The pinned SDK can
include these definitions in its first request without a ToolSearch round trip.
Other public tools keep their existing deferral policy. Private operations stay
undiscoverable, and incoming tool definitions cannot set this metadata themselves.
The host enables this only for Claude; Codex and Copilot catalogs are unchanged.
No server-wide tool preloading, model change or permission change is introduced.
The shared bridge's source binding advances pending Copilot profile 22 to 23.
Profile 22 remains decodable and its fixture is immutable. Copilot remains pending;
the revision does not qualify a new provider or change its executable closure.