Files
PaperClipAI/packages/paperclip-runner/docs/durable-recovery.md
T
Nicky LeachandClaude Opus 5.5 8362dbff4d feat(runner): make every ACPX provider declare its release attestation (#18342)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native ACPX runs admit a provider only when its saved profile
version and `commandDigest` match the current profile exactly.
> - #18325 made every provider digest attest its upstream release, and
bound the shared ACPX layer and Runner source through the pack
`contractDigest`.
> - The model is documented, but it lived in prose. A new provider, such
as one in an open PR, could still choose another digest model without
any check or guidance.
> - This pull request makes the model the default path: each provider
declares its attestation in one registry, and the generator, a test and
a scoped rule explain the pattern where contributors meet it.
> - The benefit is that new providers and upstream bumps follow one
model, without a growing list of blocked legacy patterns.

## Linked Issues or Issue Description

Refs #18325, #18237.

**What happened?**

After #18325, only Copilot, Grok and Pi went through
`acpx-release-attestations.mjs`. Cursor and Antigravity had separate
inline checks in the generator, and Claude and Codex had none. A new
provider could add a profile with any digest. Nothing told the author
how to compute it, and nothing pinned the profile versions that now
change only for deliberate resets.

**Expected behavior**

Every ACPX provider declares how its `commandDigest` attests the
upstream release. Tools and review guidance describe the pattern when a
contributor adds a provider, bumps an upstream release or changes a
version.

**Steps to reproduce**

1. Add a new provider profile to
`packages/paperclip-runner/acpx-profiles.json` with an arbitrary
`commandDigest`.
2. Run `node
packages/paperclip-runner/scripts/generate-acpx-profiles.mjs --check`.
3. Before this change, the check passes.

## What Changed

- Add `ACPX_RELEASE_ATTESTATIONS` to
`scripts/acpx-release-attestations.mjs`. Every profile declares one
kind:
- `installed-command` (Claude, Codex): installation hashes the upstream
launch command.
  - `launcher` (Grok): the SHA-256 of the launcher shipped verbatim.
- `closure` (Copilot, Pi): upstream versions and complete platform
closure pins.
- `declaration` (Cursor, Antigravity): a checked-in release declaration,
as canonical JSON.
- `generate-acpx-profiles.mjs` checks every profile through the
registry. The separate Cursor and Antigravity digest checks are now part
of the same path. No digest changes.
- A profile without a declared attestation fails generation. The message
says to declare one and points to the rule.
- A stale digest message now says that a digest changes only with its
upstream release, and that an upstream change keeps the profile version.
- Add a test that pins the profile versions. It explains that a bump
resets every saved session for that provider, and that a new provider
starts at 1.
- Add `rules/acpx-profile-identity.mdc`. It describes the identity
model, how to add a provider, when to bump a version, and review
criteria. `AGENTS.md` and `.greptile/files.json` route the profile,
attestation, contract-registry, ACPX driver and provider paths to it.
- Update the profile identity section in
`packages/paperclip-runner/docs/durable-recovery.md` with the
attestation kinds and the version policy.

## Verification

- In `packages/paperclip-runner`:
- `node scripts/generate-acpx-profiles.mjs --check` passes. Running it
without `--check` changes no digest.
- `node --test scripts/acpx-release-attestations.test.mjs
scripts/contracts/contract-fingerprints.test.mjs`: 22 tests pass. New
cases: every profile declares an attestation, a new provider without one
fails with guidance, and Cursor and Antigravity declaration changes
affect only their own digests.
- `vitest run src/drivers/acpx/profile-compatibility.test.ts`: 15 tests
pass, including the version table.
  - `tsc -p tsconfig.json --noEmit` passes.

## Risks

- Low risk. No digest, version or runtime admission behavior changes.
- A new provider PR must add one registry entry. The generator error
states what to add.
- The version table needs an edit for each deliberate reset. Upstream
bumps do not touch it.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, file edits, local test runs).

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] 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-10-30 11:25:06 -07:00

606 lines
36 KiB
Markdown

# Durable recovery Durable Transport and Recovery
Durable recovery adds a real outbound WebSocket path to the standalone package. The
Rust `paperclip-runnerd` process is the client. The TypeScript mock core is the
remote peer. Neither side imports Paperclip server, UI, database, or shared
control-plane code.
This is a local reliability and authenticated-transport proof. The mock keeps
the RFC 6455 carrier on `ws://127.0.0.1`, but PRP application frames use a
mutually authenticated, encrypted session. Loopback position and the
WebSocket acceptance header are not authentication. A production bridge should
still use `wss://` for defense in depth and remains a separately reviewed
deployment phase.
## Execution duration and operation deadlines
Native turns have no total elapsed-time limit unless the operator configures
`timeoutSec` on the agent. Zero means unlimited. The controller passes that
setting separately from bootstrap, recovery, checkpoint, and finalization
operation bounds. A live turn must not inherit the internal 15-minute operation
deadline; tool waits count toward an explicitly configured turn duration.
The native-session API exposes `turnTimeoutMs` for that duration. An explicit
`timeoutMs` retains its prior operation/turn behavior for existing embedding
callers, while `turnTimeoutMs: 0` overrides its turn bound. Cancellation, budget
controls, input handoffs, and bounded cleanup remain independent.
Normal runner launches also set `--max-runtime-ms 0`, meaning no total process
lifetime limit. The standalone durable runner defaults to the same value.
Explicit positive limits still stop at their deadline; connection/auth attempts,
reconnect grace, cancellation, and idle cleanup remain bounded independently.
Neither quiet tool execution nor a productive turn is an idle session.
The authenticated welcome advertises `connectionLeaseRenewalVersion: 1`.
Supporting runners send `lease_renew` halfway through the remaining lease,
independently of provider output. The request carries the current expiry and
revocation epoch under the exact connection, lease, and run identity. The
controller persists an extended expiry before returning `lease_renewed`, which
also echoes the request's previous expiry. The runner updates its in-memory
lease without replacing its process, provider, thread, turn, token, or epoch.
Retries of the same observed expiry replay the persisted extension. Renewal
never admits an expired, revoked, or differently bound credential.
If a reply is lost, reconnect authentication may reconcile a later expiry only
when this runner has an outstanding renewal on that same credential. Warm
handoff receipts continue to bind exact expiry and renewal pauses during their
transition. Old controllers that do not advertise renewal retain their bounded
lease behavior; deploying both updated controller and runner is required.
Tests simulate three weeks of renewal on one authenticated connection, exercise
lost-reply reconnect without reexecuting provider startup, and keep a quiet
active Codex fixture in the same runner/provider PIDs beyond its original lease
expiry. These are boundary regressions, not a weeks-long real-provider soak.
Provider startup ownership summaries validate goal commands with their required
PRP v2 command vocabulary. Incoming wire commands still undergo the negotiated
protocol validation before execution; ordinary persisted v1 summaries remain
compatible.
## Adoption and retired launch artifacts
A live runner is adopted with its admitted artifact binding and authenticated
process identity. ACPX and OpenCode launch profiles are constructed only when a
new runner will actually be spawned. Adoption does not read the retired
controller's provider-pack executables, sidecar or proxy files. Fresh launches
still require the existing artifact digests and provider-pack authority.
The component regression starts the production runner and OpenCode proxy against
a scripted harness, removes the controller's launch files, then recovers the
same process and provider session and completes another turn. This exercises the
real transport; it does not qualify a hosted OpenCode or ACPX deployment.
## Profile identity
Every ACPX provider's `commandDigest` attests only its own upstream release.
Each provider declares how in `ACPX_RELEASE_ATTESTATIONS`
(`scripts/acpx-release-attestations.mjs`):
| Kind | Providers | The digest is |
| --- | --- | --- |
| `installed-command` | Claude, Codex | the upstream package's launch command, hashed at installation |
| `launcher` | Grok | the SHA-256 of the launcher shipped verbatim |
| `closure` | Copilot, Pi | upstream versions and the complete platform closure pins |
| `declaration` | Cursor, Antigravity | a checked-in release declaration, as canonical JSON |
`generate:acpx-profiles` writes the computed digests and `check:acpx-profiles`
refuses a stale one, so a digest changes only with its upstream release. Every
ACPX harness binds the shared ACPX layer the same way: saved inputs must match
the current ACPX version, and the provider pack's `contractDigest` binds the
Runner's ACPX patch with the other Runner contracts. Runner source changes
change no provider digest.
A profile version changes only to reset every saved session for that provider
on purpose; an upstream release keeps it, and a new provider starts at 1. Saved
inputs with any version from 1 through the current one decode, so they reach
recovery. Launch and recovery admission require the exact current profile and
digest, and refuse the rest. A profile identity does not establish hosted
harness qualification. [`rules/acpx-profile-identity.mdc`](../../../rules/acpx-profile-identity.mdc)
summarizes these practices for contributors.
## External controller checkpoints
The optional controller persistence callback stores recovery control state, not
an output transcript. Text deltas do not trigger callbacks; usage updates are
coalesced into the next control checkpoint. Authentication, command intents and
outcomes, semantic inputs, lifecycle and result events retain commit barriers.
Priority-zero and unknown event kinds are never treated as disposable output.
Installing the callback makes it the controller's durable authority. The local
state file is replaced once with a fail-closed marker; ordinary control changes
no longer rewrite or fsync a second local journal. The controller can continue
if that directory disappears. A new controller must restore the committed
external checkpoint before opening the directory; the marker cannot recover
an execution or authorize a new one. This does not change the sandbox runner's
journal or controllers without an external persistence callback.
Fresh semantic inputs reserve their original live callback before awaiting the
checkpoint barrier. Recovery cannot steal first dispatch when no saved outcome
exists. The reservation is released if persistence or admission fails; business
execution still starts only after durable persistence succeeds.
Recovery and live semantic-tool dispatch share per-call ownership. Overlapping
reconciliation calls cannot invoke the same callback twice while its outcome is
pending. An already queued authoritative result is replayed without resolving
its business outcome again. Failed or unknown outcomes retain the existing
reconciliation and authority rules.
Pending completion inputs (`paperclip_finish` and `paperclip_block`) use the
driver's existing completion handler even when its caller supplies no recovery
lookup for ordinary tools. Codex transport waits for both authenticated startup
and handler installation; the external driver waits for session opening.
An ordinary tool with no outcome lookup remains held. Reconciliation does not
turn a missing lookup into permission to repeat that tool.
Transport readers use a committed view when external persistence is enabled.
Lifecycle and result events, command outcomes and warm-handoff authority become
visible only after the checkpoint callback succeeds. A failed write leaves the
previous view in place until a successful retry. Internal admission still sees
staged intents so overlapping recovery cannot duplicate them. Command results
flush their checkpoint even when no next command is waiting.
Streamed output continues without checkpoint writes. The view stops before a
pending control event to preserve event order, reuses immutable event records
and unchanged decoded commands, and caches the visible projection between
changes. It neither copies a second transcript nor adds an idle scan.
A rejected persistence callback closes the current connection and keeps the last
successful revision. A later flush or reconnect retries from a complete checkpoint,
including when the failed response hid a successful commit. Queued writes also
rebuild their delta against the last successful callback. Commit barriers remain
in force, and no retry timer runs when the controller is idle.
Supporting runners advertise `recoveryCursorVersion: 1` in `auth_hello`. The core
includes that capability and `recoveryAckedSourceSeq` in both authentication
proofs. The runner verifies the cursor equals its persisted acknowledgement.
After authentication and owner admission, a replacement core can reconcile that
cursor with its sparse checkpoint. It only skips already-acknowledged history;
new delivery remains contiguous. Unacknowledged events use the existing outbox.
A peer without this capability cannot attach to a sparse external checkpoint.
New runners can still authenticate to older controllers that use full journals.
There is no per-output external checkpoint timer or remote-controller cache
fsync. Local-only controllers retain their full file journal. Existing application
callbacks, the bounded runner outbox, and backpressure remain. Losing the
controller before acknowledged display output reaches its application log can
leave a display tail gap; this must not trigger provider or tool reexecution.
Prepared environments retain at most two opaque configuration IDs, each bound to
its exact execution identity across a warm handoff. They are captured with control
state, never output, and do not duplicate instructions or preparation requirements.
An execution driver can provide `authorizeRunnerAdoption(configurationId, identity)`
to recheck current authority before registering a surviving runner, without
preparing its resources again. The driver owns policy; Runner knows no project or
account rules. Missing bindings and rejected checks fail closed. Without this
optional hook, adoption retains the existing `prepareLaunch` recovery callback
and requires its controller-local preparation cache. Older checkpoints without
a configuration binding cannot use the cache-independent adoption path.
Supplying the hook for adoption without `prepareLaunch` is rejected at construction;
the transport must not silently skip a requested authority check. Drivers that do
not use prepared environments keep their existing adoption contract.
## Connection and authentication
The connection starts in this order:
1. The mock core creates a random bootstrap ticket with a five-second lifetime.
2. The ticket is passed to the runner through
`PAPERCLIP_RUNNER_BOOTSTRAP_TICKET`. It is not a command-line argument.
3. The runner opens an unauthenticated WebSocket upgrade with no bearer header,
then sends only a public credential locator, a fresh client nonce, complete
runner/run/session identity, approved runner version and digest, negotiated
protocol range, and durable resume cursors.
4. The core returns a fresh server nonce plus an HMAC-SHA-256 proof over the
complete transcript. The proof binds runner, environment lease, run,
normalized session, turn, item, runner artifact, selected protocol,
credential/connection lease identity, expiry, and revocation epoch.
5. The runner validates that proof before returning its own transcript-bound
proof. The bootstrap ticket or connection lease token itself never crosses
the socket. A failed proof does not consume a one-use bootstrap ticket.
6. Both sides derive directional AES-256-GCM keys from the capability and both
nonces. Strict per-direction counters reject replays and out-of-order frames.
7. Only after mutual authentication does the core send an encrypted `welcome`.
The welcome selects a supported PRP version, returns a short-lived connection lease, reports
the cumulative committed event cursor, and carries at most one pending
command. Every later ACK, command, revoke, event, and command result remains
inside the encrypted session.
8. Later connections authenticate with the connection lease. A real runner
process restart receives a new one-time bootstrap ticket and loads the same
durable state before it connects.
The runner keeps the live connection lease token in memory only. Its state file
does not contain the bootstrap ticket or connection lease token. The mock core
persists domain-separated SHA-256 authentication keys instead of raw
capabilities. Used, expired, revoked, or identity-mismatched credentials fail
closed during the challenge. The runner validates the complete encrypted
welcome and every control envelope against the authenticated connection,
runner, environment lease, run, normalized session, turn, item, protocol,
lease ID, expiry, and revocation metadata before applying an ACK or command.
Semantic input integrity uses SHA-256 of the complete transmitted input's
canonical JSON, with UTF-16 object-key order and JavaScript number formatting.
The controller checks this proof after authentication and exact run/session/
turn/item correlation, before committing, dispatching, or acknowledging the
input. Receipt redaction has a separate digest; a redacted digest cannot stand
in for wire integrity, including when only a protected field changes. A failed
integrity proof keeps the existing operator-required recovery fence. Updating
the verifier does not clear a previously failed run or replay its work.
The daemon captures and removes the bootstrap environment variable before it
parses arguments or starts child work. Secret buffers are overwritten when they
are dropped. It resolves the destination once before sending a bearer value and
accepts only concrete loopback addresses. Userinfo, query strings, fragments,
wildcards, private-network addresses, public addresses, and mixed DNS answers
fail closed.
## WebSocket limits
The package client implements the RFC 6455 upgrade and masked client text
frames with Node-free Rust standard-library code. The mock core validates the
upgrade and parses bounded masked frames.
- Maximum HTTP upgrade headers: 16 KiB.
- Maximum PRP frame: 1 MiB.
- Unknown frame opcodes and unmasked client frames close the connection.
- Malformed JSON is recorded as a bounded diagnostic. Durable state remains
available for reconnect.
- WebSocket ping/pong and PRP diagnostic pong state are supported.
## Durable runner state
The runner writes `runner-state.json` below its private state directory. The
directory uses mode `0700` and the file uses mode `0600` on Unix. Every update
is written to an exclusive unpredictable sibling file, synchronized, atomically
renamed, and followed by a parent-directory sync. Symlinked directories or state
files, wrong ownership, and wrong modes fail closed.
The state contains:
- stable runner, environment lease, run, session, turn, and item IDs;
- next source event sequence and cumulative acknowledged source sequence;
- unacknowledged event envelopes and their byte counts;
- a bounded recent processed-command cache with a SHA-256 command digest and
prior result, plus a fixed-size fail-closed replay filter for compacted IDs;
- lifecycle, reconnect, backpressure, harness generation, and recovery facts;
- bounded, redacted diagnostics.
Raw runner stdout and stderr are never redirected to durable files. The runner
itself redacts and bounds a terminal diagnostic before publishing it through an
atomic private-file replacement, so neither an output burst nor controller
restart can create a transient unbounded or unredacted diagnostic file.
Authentication capabilities and arbitrary command bodies are not stored. A
recent command is represented by a SHA-256 comparison digest from the vetted
RustCrypto implementation, its stable ID and controller sequence, the redacted
result, and the logical-effect count. The exact cache keeps at most 128 entries.
Older IDs are added to a fixed 4 KiB Bloom filter before their exact records are
removed. A possible filter match is rejected with zero effects, so Bloom false
positives can reject new work but can never make an old command effective again.
The durable controller sequence remains monotonic across compaction and restart.
## Event delivery and ACKs
The runner writes an event to the outbox before it sends the event. Event IDs
and source sequence numbers do not change after reconnect or restart.
The mock core commits or deduplicates an event before it sends this cumulative
ACK:
```json
{
"kind": "ack",
"payload": { "ackedSourceSeq": 9 }
}
```
The ACK means that every source event through sequence 9 is committed or
deduplicated. The runner rejects an ACK that moves backward or beyond its
produced source cursor. It removes only events at or below a valid ACK.
The controller stages routine priority 1 and 2 event snapshots with asynchronous
file writes and file fsyncs. Those operations let unrelated HTTP requests run
while the volume is slow. Rename and directory fsync remain synchronous. The
candidate snapshot stays private until its atomic replacement
and directory flush finish. Lifecycle readers and replacement connections see
only the committed cursor. The controller acknowledges the event after that commit.
A command that saves during staging includes the pending event in its own
durable write. Further commands do not add event deliveries or cause another
staged write. Other authority changes leave an uncommitted event available for
replay instead of repeatedly rewriting the snapshot. A replaced
connection cannot publish its old candidate. Its unacknowledged event remains
available for replay. A failed write fences further authority until reload.
Priority 0 events, all `run.*` and `turn.*` lifecycle events, commands,
authentication, and semantic tool input retain synchronous persistence. The
lifecycle rule also covers producers that label a terminal event priority 1.
A terminal suffix remains in the same
control-loop turn. Semantic tool admission and dispatch cannot yield to a
replacement connection between those operations.
Controller replacement must still stop ingress and drain admitted processing
before a successor reuses the journal. External control routes stop their
reconnect loop before the controller joins admitted work. An incomplete drain
cannot certify a reusable checkpoint. Snapshots use compact JSON; existing
indented snapshots remain readable.
For the lost-ACK fault, the mock commits an event, drops the ACK and socket, and
reports the prior cursor once after reconnect. The runner sends the same bytes
again. The mock increments delivery count, keeps one logical event, and sends
the committed cumulative ACK.
## Command delivery and effects
Mock-core commands are durable before delivery. Only the lowest pending
controller sequence is sent. A command result advances the queue.
The runner stores a command result before it sends the result. When a result or
socket is lost, the same command is delivered again. An equal command ID and
digest returns the stored result. It does not add events or repeat a process
effect. Reusing the ID with different bytes is rejected.
The trace records `logicalEffectCount`. Every completed command has exactly one
logical effect. A policy rejection, such as a new turn during drain or storage
pressure, has zero effects.
## Semantic operations that outlive a turn
An execution session includes the provider process, the runner's durable call
receipts and event outbox, and the controller's durable command queue. Ending a
provider turn closes admission of new work. It does not establish whether a
semantic operation already dispatched to the server succeeded or failed.
The tool bridge retains pending call IDs, operation IDs, and exact arguments
across that boundary. It accepts the authority's late result without delivering
it to an obsolete provider turn. An identical result is an idempotent replay;
a different result for that call remains a conflict. Codex records harmless
result replay as `semantic_tool_result_duplicate` in a diagnostic event, with
no task failure. Conflict errors identify the call, operation, and both result
digests without including the result bodies.
The controller checkpoints a reusable session only after every admitted tool
has a matching completed result-delivery command and the provider has drained.
A callback rejected before business dispatch can report
`semantic_tool_not_dispatched`. A callback rejected after dispatch has an
unknown outcome; the controller must not invent a failure or execute it again.
After controller restart, an already committed input without a result remains
unsettled. It requires authoritative reconciliation, not an automatic retry.
A different case is a runner crash after the controller saved the result but
before its delivery command completed. Codex/OpenCode explicitly opt in to
reconciling that exact `semantic_tool.result` command through their durable tool
receipt. The full command fingerprint must match. This delivers the saved
answer without executing the business operation. Unsupported providers and all
other indeterminate commands remain non-reexecutable. This change does not
repair legacy journals already containing contradictory or failed receipts.
`update_agent_instructions` and `restore_agent_instructions` use a durable,
company/run-scoped mutation receipt keyed by call ID. Before attempting a file
write, the authority commits an `instructionToolAttempts` record and an
`agent.instruction_write_attempted` activity row with the call ID, operation ID,
and input digest. This evidence survives a later filesystem or database failure.
Completed calls and retries after authority restart return the exact original receipt.
Changing the arguments under that ID is an idempotency conflict. Receipt replay
still checks current instruction-write authorization. Concurrent callers in the
same process wait for the first handler before checking the receipt again.
The filesystem and PostgreSQL are not one atomic store. If a write is visible
but its receipt transaction rolls back, the attempt record remains durable and
the result is unknown. An identical call with an attempt but no committed result
raises `paperclip_runner_instruction_outcome_unknown`; it does not write again
or pretend to have succeeded. This also applies to another process reaching a
reserved call before its effect transaction starts. The controller retains an
unsettled operation for authoritative reconciliation. If only the commit
acknowledgement was lost, the committed receipt is replayed normally.
The native tool wrapper propagates `SemanticToolOutcomeUnknownError` instead
of turning it into a completed failed tool response. Ordinary validation,
authorization, and stale-base errors still return normal tool errors. Before
returning a definite pre-write failure, the authority saves its status, message,
and details under the attempt's `failure` receipt. Replays return that original
error without executing again, even if the old CAS base becomes current again.
Failure-receipt storage errors retain an unsettled operation instead of exposing
an unrecorded final answer.
### Lossless arguments and bounded transport
Execution arguments do not use the diagnostic preview formatter. Accepted
input remains exact, including Unicode, line endings, and long document tails.
The credential policy rejects input it would have to alter before dispatch.
Diagnostic copies remain redacted and bounded. The semantic input limit is
480 KiB of encoded JSON, leaving room for the encrypted frame's hex expansion
and envelope inside the 1 MiB wire limit. Oversized input fails before a write;
it is never truncated into an apparently successful write.
A result command retains a SHA-256 `inputDigest` instead of duplicating the
full input alongside the full result. The canonical input event retains the
arguments. Settlement validates the digest and all run, event, call, operation,
and correlation identities. Legacy result commands carrying the full input
remain supported. Large results still have to fit the command transport limit;
exceeding it leaves the outcome unsettled rather than retrying an effect.
### Deterministic fault coverage
These tests use explicit barriers, durable reloads, real runner processes with
scripted providers, and a real PostgreSQL database. They do not require a paid
model or rely on an LLM choosing the desired timing.
| Fault | Required assertion | Regression suite |
| --- | --- | --- |
| Stop before/after server result; restart before late delivery | No invented failure; pending identity survives; late result accepted | Rust `provider_bridge`, `codex_provider` |
| Provider terminal before tool completion | New turn blocked until actual result; safe replay and later reuse | Rust `acpx_provider_turns`, `acpx_provider_state` |
| Crash before/after applying a saved delivery receipt, before command completion | Exact result delivery recovers only for opted-in providers; changed commands and ordinary operations never replay | Rust durable runner tests |
| Lost result acknowledgement; conflicting redelivery | Identical result accepted; changed result rejected with IDs and digests | Rust `codex_provider`, `provider_bridge` |
| Concurrent writes held at filesystem commit; authority restart | One write, one audit record, same receipt; revoked authorization rejected | Server `agent-instruction-tools.integration.test.ts` |
| Failure after file rename or rollback while saving the receipt | Durable attempt and audit evidence survive; no success claim and no second write | Server `agent-instruction-tools.integration.test.ts` |
| Receipt commits but its database acknowledgement is lost | Exact original result replays; one file-update audit and one attempt record | Server `agent-instruction-tools.integration.test.ts` |
| Tool handler throws an unknown-outcome error versus a validation error | Unknown propagates without a completed tool event; validation returns an ordinary failed response | `codex-app-server-driver.semantic-results.test.ts` |
| Invalid arguments, oversized input, or stale base; restart and replay | Original failure replays with zero writes, even after the base becomes valid | Server `agent-instruction-tools.integration.test.ts` |
| Failure receipt cannot be stored | No unrecorded final failure; attempt remains unsettled and never reexecutes | Server `agent-instruction-tools.integration.test.ts` |
| Long Unicode input and result; corrupted input digest | Exact bytes across persistence/wire; no copied input in result; bad proof blocks settlement | Rust durable-state tests; `durable-prp-control-plane.test.ts` |
| Handler finishes during close, after close budget, or persistence fails | Checkpoint marked settled only with completed delivery and drained provider | `runnerd-codex-transport.test.ts` |
| Controller restarts with committed input but no result | No second dispatch and no fabricated outcome | `durable-prp-control-plane.test.ts` |
| Execution and cleanup both fail | Original execution error retained; cleanup error attached | `native-session-runtime.test.ts` |
| Retry in same process or after local log loss | Earlier attempts survive locally and in the durable mirror | Server `run-log-store.test.ts` |
| Two restores overlap and one appends before the other publishes | Complete prefix is published without replacement; both attempts survive | Server `run-log-store.test.ts` |
| New run whose row has no log reference, output sequence, or log size | No durable-mirror request; a later attempt with a recorded log reference still restores after local loss | Server `run-log-store.test.ts`, `heartbeat/run-output.test.ts` |
## Residual local trust and revocation window
The authenticated session removes trust in whichever process wins the configured
loopback port: a relay can forward opaque bytes, but it cannot learn a bootstrap
or lease capability, decrypt control data, or forge a welcome, ACK, command, or
revoke frame. The remaining local-host assumption is that the OS protects the
runner process memory, inherited bootstrap environment at launch, private
`0700`/`0600` state paths, and the mock core's derived authentication keys from
other same-user processes with debugging, memory-reading, or filesystem access.
An attacker with those privileges is outside this transport boundary.
Lease expiry is checked locally before control data is applied. Explicit
revocation reaches an already-connected runner through an authenticated revoke
frame; if that frame cannot be delivered, the session remains usable until the
connection closes or the runner reaches the signed lease expiry. The mock uses
a 30-second lease, so that is the maximum demonstrated revocation window. A
production core should close active sessions when it revokes a lease and choose
the lease TTL to match its required revocation bound.
## Restart and reconciliation
Local Linux process receipts use the kernel's `/proc/PID/stat` start ticks,
`/proc/stat` boot time and `CLK_TCK` to record process birth. The server and
retained Runner maintenance use the same reader. `/proc/PID` directory ctime
can differ from birth and must not grant process ownership. Invalid or missing
birth metadata fails closed. PID, process group and owner checks remain required.
Drain active Linux runs before updating or rolling back across the previous
ctime-based receipt format; do not rewrite a live receipt to force adoption.
A socket drop keeps the same Rust process and in-memory lease. The process
reconnects and reloads the mock-core command and event cursor.
A runner restart kills the real Rust process with unacknowledged work. A new
Rust process receives a fresh ticket, reads the same state file, emits a P0
`runner.reconciled` event, and continues the same runner, session, turn, item,
command, and source-event identities.
The harness-restart fault starts the real Local runner `fake-harness` child, waits
for its ready message, terminates its process group, and starts a second child.
The runner then emits `harness.exited`, `harness.ready`, and
`session.reconciled` events with the same normalized session, turn, and item
IDs.
If a lease expires, reconnect fails closed. The runner records
`lease_expired_requires_bootstrap` and exits with its state intact. The mock
then gives the replacement runner a fresh ticket. Recovery continues from the
same cursor.
If recovery cannot be truthful, state names the outcome. For example, failure
to reserve storage for a P0 event records `p0_storage_exhausted` and the
`unrecoverable` lifecycle. It never reports a fresh session as resumed.
## Protocol versions and warm handoff
Ordinary PRP v1 connections remain supported. A warm `run.attach` that changes
run authority requires negotiated PRP v2, as well as the warm-transition
capability. The runner refuses a v1 warm attachment before provider work. A v1
peer acknowledges placeholders for native session-goal and capability events;
those acknowledgements do not prove that the peer observed the native state.
Rotation must not discard that retained evidence or relabel it as a new run.
A connection lease fixes its protocol version. Advertising v2 on reconnect
does not upgrade an existing v1 lease. For a v2-capable runner holding a v1
lease, the owned transport's process-recovery path can replace the process
with fresh authorization when configured with a reconnect grace. It preserves
validated state and original authority and issues a new one-use bootstrap.
The old process must exit first; any old provider owner must also be retired.
The connection lease exists only in process
memory; no credential or journal file needs to be edited. The replacement
negotiates v2 and replays retained native session state on the original
authority. Its native event acknowledgements must settle before warm handoff.
The controller must still authorize replacement and verify current process,
artifact, and run ownership. This is not an automatic in-place lease upgrade
or a general operator UI migration flow. An old binary stays old when its
immutable launcher restarts it. Replacing a legacy binary or an adopted owner
requires separate artifact and ownership admission; this path does not qualify
that migration. Pending warm-transition receipts
require their separate exact recovery admission, not an ordinary bootstrap.
## Backpressure and bounded storage
The runner has a byte limit and a reserved P0 region.
- P2 item deltas coalesce before delivery.
- P1 events cannot consume the P0 reserve.
- New turns are rejected while backpressure is active.
- A P0 `runner.backpressure` event explains the state.
- P0 events are never dropped to make room.
- The state records peak outbox bytes so the final empty outbox does not hide a
limit violation.
The storage-pressure trace emits 250 P2 updates. They become one coalesced
event, all P0 facts reach the mock core, peak bytes remain below the configured
limit, and the next turn is rejected without an effect.
## Drain and revoke
`runner.drain` persists the `draining` lifecycle and a P0
`runner.draining` event. It rejects later `turn.start` commands but continues to
deliver the existing outbox. `runner.shutdown` stops only after the outbox is
acknowledged.
A `revoke` envelope persists the `revoked` lifecycle. The runner flushes any
existing durable events and exits. It does not accept new work or delete
unacknowledged facts.
## Process identity
An external process owner can publish `startedAt` after launch dispatch returns.
`spawnRunner` preserves that live value through its restart wrapper. Once the
owner has a verified process identity, consumers use its birth time rather than
controller construction or attachment time. Replacing the controller must not
change the admitted process identity.
## Verification
The production durable control plane and live-session suites cover reconnect,
ACK replay, restart recovery, backpressure, lease expiry, drain, and revoke.
`src/control-plane/runner-recovery-fixture.test.ts` builds the test-only Cargo
example `recovery-fixture`. It links the unchanged production durable runner with
a scripted `CommandExecutor`, whose worker thread performs bounded hashing steps
and retains each resulting output until the normal executor ACK transfers it to
the runner outbox. There is one process, no HTTP counter service, and no production
provider-admission switch. Private fixture receipts report completed steps, queue
depth and process identity; they do not acknowledge or recover protocol events.
The short experiment starts one turn, removes the controller, then releases real
work. Eight retained events fill the fixture queue and pause work explicitly.
A replacement authenticates and drains through PRP. At step 32 the executor waits
for a controller command, survives another replacement, then completes 64 steps
without repeating the turn. The test runs with ordinary filesystem persistence
and with opaque external checkpoints restored onto empty controller storage.
An independent short-deadline case checks termination with recovery state intact.
These prove the runner mechanism, not Codex, Claude, Daytona or application
authorization. Server adoption and installed-artifact qualification have separate
owners and evidence.
Run the short experiment from this package with:
```sh
pnpm exec vitest run src/control-plane/runner-recovery-fixture.test.ts --maxWorkers=1
```
The fixture builds through Cargo during test setup. Production distribution builds
do not include the example. Existing control-plane tests separately inject failed
checkpoint persistence, stale/unauthenticated cursors and output floods, including
the zero-per-text-checkpoint-write gate.
Use `--json` for the complete trace or `--output <path>` to write it. The CLI
and browser show connection counts, safe lease ID and expiry, stable identities,
source and ACK cursors, outbox current/peak bytes, command delivery/effect
counts, replay counts, restart counts, outcomes, and redaction assertions.
They never show bootstrap tickets or connection lease tokens.
Regenerate the checked fault matrix and every exact per-fault trace with:
```sh
# Recorded evidence generation is deferred from this release.
```
Checkpoint decoding also exposes read-only recovery evidence: the admitted runner/session identity, authenticated connection count, and highest recorded source sequence. This projection grants no authority and adds no checkpoint writes. Qualification compares it across controller replacement; a replayed canonical event by itself does not prove that a replacement controller has authenticated a runner.