diff --git a/docs/contracts/handoff-v1.md b/docs/contracts/handoff-v1.md new file mode 100644 index 00000000..5f691e51 --- /dev/null +++ b/docs/contracts/handoff-v1.md @@ -0,0 +1,203 @@ +# Handoff v1 contract — design proposal v0.2 + +## Status and provenance + +This is an unimplemented design for Python #181 and the TypeScript 0.4.0 design review. It is not a frozen protocol, a release promise, or evidence of live ACK support. Merge of this document does not authorize runtime implementation, publication, native submissions, or operational changes. The fixture contains synthetic examples, not recorded deliveries. + +The published Python reference is [v1.0.3](https://github.com/abruption/session-peer/tree/0d252550ab40c26d1ac4a19193df6ca2f84d830b). The ordinary output source is [adapters](https://github.com/abruption/session-peer/blob/0d252550ab40c26d1ac4a19193df6ca2f84d830b/session_peer_core/adapters.py), [Codex](https://github.com/abruption/session-peer/blob/0d252550ab40c26d1ac4a19193df6ca2f84d830b/session_peer_core/codex.py), [SSH](https://github.com/abruption/session-peer/blob/0d252550ab40c26d1ac4a19193df6ca2f84d830b/session_peer_core/ssh.py), and [output](https://github.com/abruption/session-peer/blob/0d252550ab40c26d1ac4a19193df6ca2f84d830b/session_peer_core/output.py). The [shared fixture](https://github.com/abruption/session-peer/blob/main/tests/fixtures/handoff-v1.json) separates normalized legacy examples from proposed opt-in examples. Runtime reference and design fixture pins are separate: TypeScript's published historical reference is not silently changed to an unshipped Python version. + +## Evidence and compatibility + +Submission is acceptance by the native transport; delivery is evidence that the exact original user-message ID was injected into the exact original target generation. Acknowledgement is an explicit correlated receipt, not completion, responsibility acceptance, or proof that the model read every byte. Queue deletion, process exit, turn completion, reply silence and a successful status query are not ACKs. Unknown native turn states never become completed. + +Ordinary send output stays runtime-specific. Python Claude success omits status, submitted and consumptionConfirmed; Codex queued success includes submitted true and consumptionConfirmed false. Legacy unknown with submitted false is not universal no-effect proof. Do not add missing false/null fields, reinterpret optional absence, overwrite consumptionConfirmed, or change ordinary exits. Normalized fixture values hide identities only; each example records provenance and is not an exhaustive output schema. + +Opt-in adds one handoff object to each destination result under the existing outer schemaVersion 1. There is no fanout-level handoff. Explicit waiting can change outer ok and exit only: it preserves the native status, target, queueId, submitted, consumptionConfirmed and all independently valid submission evidence. The receiver of a result must use its ORIGINAL request context, not an untrusted returned field, to select this validation mode AND the runtime/agent native profile. Only an original explicit-wait request plus a fully validated handoff, positive native profile and matching original target/home/context permits a failed-wait exception. Timeout, failure and post-submission unsupported use ok false/exit 1; interruption of a pending wait uses ok false/exit 130. Receiving handoff alone never enables either exception in an opt-out request. Best-effort observation failure cannot change native submission success. Invalid/oversized handoff cannot erase a separately validated, correctly targeted native snapshot or promote an ACK; explicit waiting fails validation without resubmitting. A wrong native target/home or altered consumption fact is not independently valid native evidence. A valid ordinary opt-out success remains valid with unrequested handoff ignored. + +Positive native profiles are separate from the common handoff submission enum. Do not infer positive Python Claude submission from outer status/submitted fields that its native success does not emit, and never add those fields merely to satisfy a guard. The opt-in adapter must retain its validated pre-wait native success snapshot; a consumer validates that profile and exact original context rather than trusting handoff.submission alone. + +| Original runtime / agent | Positive native snapshot | Target/context matching | +| --- | --- | --- | +| Python / Claude | status, submitted and consumptionConfirmed absent; existing positive socket-write target fields retained | original resolved target pid/name under Python's existing rules | +| TypeScript / Claude | status posted, submitted true, consumptionConfirmed false; queueId absent | resolved pid; agent may be absent, name may be null under the existing TS schema | +| Python / Codex | status queued, submitted true, consumptionConfirmed false; native queueId and codexHome retained | original thread and canonical home/context under Python's existing rules | +| TypeScript / Codex | status queued, submitted true, consumptionConfirmed false; native queueId and codexHome retained | UUID comparison remains case-insensitive; agent may be absent; canonical home/context must match | + +This table defines synthetic positive-profile fixtures, not an exhaustive runtime envelope or permission to relax each runtime's existing validator. A Codex snapshot with posted status or consumptionConfirmed true is invalid. Target comparison is profile-aware, not raw dictionary equality: permitted optional absence and null values stay permitted, while wrong agents, ids/pids or canonical homes fail. Canonical home matching is an additional proposed handoff gate where a runtime's current SSH validator does not yet enforce it, not a claim of shipped protection. Native snapshot validation is independent of whether a handoff is present or valid. + +## Public schema + +The following grammar is normative for this proposal. All objects are closed except the runtime-specific outer envelope. A question mark means optional and absent when unavailable; nullable fields are explicit below. Conditional rules follow the grammar. + +```text +handoff.schemaVersion = 1 +handoff.correlationId = UUID-v4 lowercase canonical +handoff.ledgerEpoch = UUID-v4 lowercase canonical +handoff.state = validated | refused | submitted | delivered | acknowledged | unknown | timed_out_unknown +handoff.submission.status = not_attempted | submitted | refused | unknown +handoff.observation.status = not_requested | pending | observed | unsupported | failed +handoff.observation.injectionObserved = boolean +handoff.observation.clientUserMessageId? = safe-id +handoff.observation.turn? = {id: safe-id, status: running | completed | failed | interrupted | unknown} +handoff.ack.status = not_requested | pending | acknowledged | unsupported +handoff.ack.assurance? = token_possession | operator_confirmed +handoff.ack.receivedAtUtcMs? = safe-integer +handoff.ack.late? = boolean +handoff.wait.for = none | delivered | acknowledged +handoff.wait.status = not_requested | pending | satisfied | timed_out_unknown | stopped | unsupported | failed +handoff.wait.operationId? = UUID-v4 lowercase canonical +handoff.wait.deadlineAtUtcMs? = safe-integer +handoff.wait.reason? = insufficient_budget | deadline_before_effect | evidence_unsupported | evidence_failed | history_unavailable | stopped_by_operator | invalid_handoff +handoff.targetGeneration = safe-generation | null +handoff.decisionOwner = sender_operator +handoff.retry = {allowed: false, reason: receiver_dedup_unavailable} +handoff.nextActions = unique subset of [keep_waiting, reconcile, stop_waiting] +``` + +Delivery requires observed injection plus an exact clientUserMessageId and non-null original targetGeneration. Turn evidence requires observed injection; a turn is separate from receipt state. Receipt acceptance does not manufacture injection evidence. An acknowledged state requires acknowledged ack with assurance, receipt time and late flag; other ACK statuses omit those fields. Submitted, delivered and acknowledged states require submitted submission. Validated requires not_attempted; refused requires refused; unknown requires unknown. Timed-out state permits submitted or unknown, never refused/not_attempted. + +Each explicit wait has an operationId and deadline, even pre-effect refusal. No-wait records have neither. Satisfied delivered waits require injection evidence; satisfied acknowledged waits require valid ACK. Stopped/failed/unsupported waits preserve known effect evidence. nextActions depend on a live validated channel and retained history: unsupported or expired channels never offer keep_waiting. stop_waiting ends observation only; it does not cancel native work. All native local/SSH results forbid retry. No resend_same_id action exists in v1; correlation and user approval alone do not establish safe resend. Receiver dedup (#182) is not a prerequisite for implementing this no-resend contract. + +## State and exit tuples + +Absent means a legacy field remains absent. Native denotes an unchanged per-runtime snapshot. The same table applies to local and SSH, not to an unimplemented paired-device transport. + +| Mode / evidence | state | submission | wait status | outer status / submitted | ok / exit | +| --- | --- | --- | --- | --- | --- | +| Ordinary send | absent | absent | absent | native / native or absent | native | +| Opt-in dry-run | validated | not_attempted | not_requested | native / native or absent | true / 0 | +| Required channel unavailable before effect | refused | refused | unsupported | native if present / no synthetic field | false / 1 | +| Budget exhausted definitely before effect | refused | refused | failed | native if present / no synthetic field | false / 1 | +| Known queued, best-effort observer fails | submitted | submitted | not_requested | queued / true | true / 0 | +| Known queued, explicit ACK deadline expires | timed_out_unknown | submitted | timed_out_unknown | queued / true | false / 1 | +| Positive native profile, channel becomes unsupported after submission | submitted or delivered | submitted | unsupported | native / native or absent | false / 1 | +| Pending explicit wait interrupted by operator | submitted, delivered or unknown | submitted or unknown | stopped | native / native or absent | false / 130 | +| Injection known, ACK deadline expires | timed_out_unknown | submitted | timed_out_unknown | native / native or absent | false / 1 | +| Effect may have occurred, no receipt | unknown | unknown | not_requested | native / native or absent | false / 1 | +| Valid receipt satisfies explicit wait | acknowledged | submitted | satisfied | native / native or absent | true / 0 | +| Successful status query of prior timeout | timed_out_unknown | submitted | timed_out_unknown | absent / absent | true / 0 | +| Successful status query of stopped wait | retained state | retained submission | stopped | absent / absent | true / 0 | + +Invalid flags, invalid timeout lexemes, missing targets and malformed IDs retain usage/no-target exit 2 and may have no JSON/handoff at all. No synthetic correlation ID or legacy false/null is invented for such failures. Interrupting a pending explicit send/wait operation, including SIGINT, terminally records wait stopped with reason stopped_by_operator without cancel/resend. If complete structured output is available, that operation returns ok false/exit 130 while preserving known native/submission/injection facts; SSH accepts this exception only for the original explicit-wait request and a validated stopped wait with matching target/home/bindings. A deliberate operator stop of a pending wait follows the same rule, without adding a new API spelling. Already-terminal waits are immutable: an interrupt cannot rewrite a committed timeout, satisfaction or other terminal result to stopped. A successful status query of that stopped record uses ok true/exit 0. Partial/no interruption output preserves independently known evidence and otherwise uncertainty, never synthetic false fields, fallback or resend. A successful status query reports query success, not acknowledgement. + +## Capabilities and bootstrap + +Every capability is a future acceptance gate, not current product support. A route must validate the receipt consumer, producer and immutable ORIGINAL target generation independently before advertising ACK support. Null generation cannot mint receipt authority or support required delivered/acknowledged wait; that requirement refuses before effect with wait unsupported. A failed bootstrap is unsupported, not permission to try generic message delivery. Newly discovered current generation never substitutes for the original binding. Target restart prevents new submission/rebinding; it does not by itself invalidate a delayed receipt bound to the original generation. A validated original tuple and original capability prove delegated token possession, not original agent/model identity. A successor's new generation is rejected. A successor or same-user reader with a copied original tuple AND original capability is indistinguishable under token_possession assurance; do not claim that this scheme excludes that replay by authenticating model identity. + +| Route / observer | injection observation | receipt ACK | +| --- | --- | --- | +| Same-machine private collector plus compatible producer | Codex only after version/home/writer/ID validation | supported after private bootstrap | +| Explicitly trusted reverse SSH to installed compatible receipt handler | separately validated Codex route | supported after handler/configuration validation | +| Source-streamed SSH without compatible handler installation | separately validated Codex route | unsupported | +| Claude native observer | unsupported by default | compatible explicit receipt producer only | +| Paired device, Relay, Side Session, wake | outside initial route scope | unsupported | + +Required delivered or acknowledged wait refuses before effect when its channel is unsupported. Best-effort correlated send may submit with observation/ack unsupported, without minting an unused secret capability. No collector calls delivery, wake, model prompts or approvals. Native app-server fallback to the queue is allowed only before the original native submission may have begun; post-attempt ambiguity and observer failures never trigger another submission. Version, platform, home and original writer identity checks are mandatory. No transcripts or arbitrary reply bodies are scanned. + +## Receipt wire and lifecycle + +Receipt v1 is separate from Reply-To v1, whose parser and permissions are unchanged. A receipt handle is a non-secret local selector, not a URI or authority. The actual bearer capability travels only through authorized private effect input and private handler stdin. The no-secret guarantee covers sender/collector argv, URI, environment, ordinary JSON, diagnostics, error/ACK results and their own raw journal/persistence. It does NOT cover the intended recipient's native queue, history or transcript: native effect input may be retained there outside CLI control, and same-user readers may acquire delegated receipt-only authority. token_possession is not independent agent authentication. Our observer still never reads transcripts. Revocation, the 24-hour TTL and atomic first-receipt acceptance bound new receipt authority, not the recipient's storage lifetime. + +```json +{ + "schemaVersion": 1, + "kind": "receipt", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "correlationId": "22222222-2222-4222-8222-222222222222", + "targetGeneration": "fixture-generation", + "receiptId": "33333333-3333-4333-8333-333333333333", + "capability": "" +} +``` + +The capability is 32 cryptographically random bytes encoded as 43 unpadded base64url characters. The example placeholder is not valid wire input. The protected collector stores only a SHA-256 capability hash bound to epoch, correlation and ORIGINAL generation; constant-time verification is required for every handler request, including read-only duplicates. Atomic durable first-receipt commit includes receipt ID/binding, proven classification, assurance, late flag, collector receipt time and the wait-order/deadline facts that justify it. State and relevant wait transitions commit consistently with this record, not through separate later writes. Wrong IDs, wrong token, successor generations, conflicting receipts, expired uncommitted authority and invalid frames cannot advance state. ACK arrival time is collector-assigned; caller timestamps are not authority. + +The collector is a user-owned process independent of the sender conversation. Files/directories are private 0600/0700; endpoints are local owner-checked private IPC or an explicitly configured trusted reverse SSH handler. No public listener, auto SSH key installation, profile changes, transcript reading, secret export or generic CLI exec is permitted. Restart recovers committed receipt hashes and classified receipts; lost raw capabilities are never regenerated for an attempted intent. Capability lifetime is 24 hours from intent commit and ends with detail expiry, revocation, quarantine or first valid receipt. The sole expiry exception is read-only retrieval of the SAME already committed receipt: require original epoch, correlation, generation, receiptId AND constant-time proof of the original capability hash. TTL expiry/first-receipt spending does not permit accepting a new receipt or creating/promoting state. Revocation, quarantine, missing/expired detail or a wrong token deny even this exception. Identifiers alone confer no generic status/query permission. Authorized sender-operator status queries use a separate owner-checked local interface. + +Manual producer is the proposed no-submission handoff confirm operation. It requires the exact epoch/ID/ORIGINAL generation and a local sender-operator assertion of an observed explicit correlated reply; stdin receives metadata only, not transcript/body. It records operator_confirmed, never token_possession. Confirmation is a NEW current attestation/event, not retroactive proof of an old receipt's arrival time. It may become acknowledged only if originating wait ordering/late classification is provable, for example from durable terminal wait facts. Otherwise refuse confirmation with ok false, exit 1 and fixed reason receipt_order_unprovable; preserve the original pending/unknown ACK and original wait, do not commit an accepted ACK or invent a late boolean. An already committed valid receipt is never downgraded or replaced by this failed confirmation. The token-possession producer must be an installed compatible receipt-only handler reading private input; it cannot execute arbitrary commands. Both producers remain implementation acceptance gates. No assurance is advertised until its producer is validated. + +## Sender ledger, freshness and retention + +A single private, serialized durable ledger owns a random epoch. Its target-bound prepared intent is committed and synced before effect; once an effect-intent fence is committed, only one native attempt is authorized in that intact epoch. Crash after that commit, including a crash before effect actually starts, recovers as unknown and never submits again. Binding includes agent, destination, home/writer identity, generation and a protected internal payload digest; the digest is not public output. Conflict or target restart never silently rebinds the intent. + +Fresh IDs are generated and reserved by automatic preparation or by the new no-effect handoff prepare operation. Caller-supplied unknown UUIDs are refused before effect, because freshness cannot be proved. A supplied known prepared ID must match its exact binding and be unattempted. A supplied attempted ID is query/reconcile only, never another send. Ordered fanout generates and durably associates one intent per destination once; callers cannot reuse one ID for multiple destinations. Reconciliation never regenerates IDs or capabilities. + +Limits per epoch are 10,000 retained intent/fence records and a 32 MiB journal, whichever fills first. Admission reserves bounded storage for terminal records and one receipt before effect, rather than filling capacity after submission. No fence eviction/LRU is allowed. Detail retention is 30 days from preparation, capability lifetime 24 hours, maximum 64 waits per intent; expired detail becomes a compact target-bound fence tombstone retained for the entire epoch. Tombstones count toward quota. wait quota exhaustion refuses a new wait without changing native effect facts. Pruning details never restores submission authority; quota exhaustion refuses preparation/effect until explicit operator archival, not automatic reset. + +Missing, corrupt, expired-detail or known-restored history yields unknown for status/reconcile, with retry forbidden. A known tombstone retains its original epoch and target binding: it can return a normal handoff with state/submission unknown, no ACK/injection claim and no keep_waiting. Missing/corrupt ledger or an unknown ID cannot recover that context and MUST return the separate query-error envelope below, without handoff or a fabricated epoch. An existing ledger is never recreated automatically; first use requires explicit local initialization and a fresh epoch. Known restoration quarantines all old intents, disabling effects and receipt acceptance until reconciliation of retained evidence. New epochs require explicit operator initialization and generated fresh IDs; they make no claim about old requests. No local-only design can reliably detect an unmarked rollback or cross-machine ledger clone: fencing guarantees are limited to proven intact, retained, non-rolled-back history. External monotonic anchors/clone-proof storage are outside v1; this limitation must remain visible, not hidden behind a universal exactly-once claim. + +The proposed query error uses exit 1. Its handoffQuery is a closed schema with the fields shown; context is ledger_missing, ledger_corrupt or id_unknown. The caller's syntactically valid correlationId is echoed, not treated as proof of history. No ledgerEpoch, handoff, top-level status/submitted/consumptionConfirmed or synthetic native snapshot is added. Outer host/command are the queried destination and handoff, with reason handoff_history_unavailable. This is an opt-in query result, not a change to ordinary legacy errors. + +```json +{ + "schemaVersion": 1, + "ok": false, + "host": "fixture", + "command": "handoff", + "reason": "handoff_history_unavailable", + "handoffQuery": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "status": "unknown", + "context": "ledger_missing", + "retry": {"allowed": false, "reason": "history_unavailable"} + } +} +``` + +## Wait history and late receipts + +Each wait has a distinct operation ID, goal, monotonic deadline and immutable terminal result. Status returns the most recently started wait for that intent (serialized sequence order), or not_requested if none; a future separate wait-history API is not implied. Current receipt/state can advance independently. Thus a late ACK can set current state to acknowledged while the returned originating wait remains timed_out_unknown. It never rewrites that wait to satisfied. + +A receipt is late if accepted at or after the originating send wait's observation deadline; if there was no send wait, compare with the earliest explicit wait. With no wait, late is false. A later wait can be satisfied by an already accepted late receipt; the ack late flag remains true. Receipt acceptance and deadline transitions are serialized against the same monotonic clock while active, and their proven ordering/classification is durably committed atomically. A fully committed valid ACK restores its original classification, assurance, late and immutable wait facts: a changed restarted clock alone NEVER downgrades it. Pre-commit crash or otherwise uncommitted/unclassified timing evidence cannot become acknowledged or supply a synthetic late flag. Preserve it as unclassified evidence, with required wait unsatisfied and existing pending/unknown ACK unchanged. Manual confirmation is a new event under the proof-or-refusal rule above, never retroactive rewriting. UTC timestamps are diagnostics only. Repeated waiting makes no new native submission. + +## Budgets and bounds + +Default budget is 30 seconds per destination; CLI accepts only ASCII decimal integers 1 through 60 without leading zeros, signs, whitespace, decimal points or exponents. New flags do not change legacy parsing. The total monotonic budget starts before setup, preflight, preparation, effect and observation. Reserve exactly 5 seconds within it for cleanup; budgets at or below 5 seconds refuse before effect with insufficient_budget. Observation/effect cutoff is start plus budget minus 5 seconds; cleanup cannot extend start plus budget. A wait deadline diagnostic describes this cutoff, not a renewed remote budget. + +SSH passes only remaining duration and keeps the original caller deadline; remote handlers cannot reset the budget or use UTC to authorize effect. If the remaining total budget is at or below cleanup reserve, refuse before effect. At the cutoff boundary use now >= cutoff as expired; a receipt committed strictly before cutoff may satisfy that wait, one at/after cutoff is late. Once effect may occur, timeout is unknown; no retry/fallback or new submission is allowed. Ordered fanout gives each destination its own bounded budget, not one host's repeated budget. Implementations that cannot guarantee bounded cleanup/owned resources cannot advertise that route. + +```text +safe-integer = JSON integer token, 0..9007199254740991; booleans/fractions/exponents rejected +safe-id = 1..128 UTF-8 bytes, no U+0000..001F or U+007F..009F, valid Unicode scalars +safe-generation = 1..256 UTF-8 bytes, same control/scalar rule +receipt/confirmation frame = raw strict UTF-8 JSON input, at most 4096 bytes including LF/whitespace +public handoff size = compact UTF-8 encoding of the handoff subtree, at most 8192 bytes +opt-in destination stdout frame = at most 1048576 bytes including LF/whitespace +opt-in fanout = at most 32 destinations and 34603008 total stdout bytes including LF/whitespace +duplicate JSON keys, unknown nested keys, NaN/Infinity and trailing data rejected +turn/native IDs are opaque; only correlation/epoch/wait/receipt IDs use UUID-v4 syntax +``` + +JSON number tokens must be validated before lossy parsing; lexical 1.0 and 1e0 are rejected even if a JavaScript Number equals 1. The lexical restriction applies ONLY to the handoff/handoffQuery subtree and private receipt/confirmation schemas, not unrelated legacy outer fields. Keep existing legacy parsing and duplicate-key/nonfinite rules; a legacy field does not become invalid just because a handoff request exists. Hand-off subtree sizing uses canonical compact encoding (no optional whitespace, UTF-8 scalar characters not ASCII escapes, separators comma/colon); subtree whitespace in the enclosing wire counts toward the outer frame limit instead. Private receipt/confirmation raw input includes every byte, including optional trailing JSON whitespace and LF; trailing non-whitespace data is rejected. No extra newline allowance exists. The destination/aggregate limits apply only to new opt-in mode; refuse excessive destination count before any effect and do not change ordinary opt-out wire/output limits. Cap stdout incrementally before allocation; bounded aggregate formatting is compact JSON plus optional LF. When an optional subtree is malformed/oversized inside a bounded complete outer frame, retain independently validated native evidence, reject ACK/explicit wait and never resubmit. If the outer frame limit is exceeded, do not partially parse it into success; retain only already independently validated evidence and otherwise return unknown. Bounds are checked before persistence, not after truncation. Sender/collector ordinary stdout, diagnostics, errors/ACK results and own journal never contain capability or payload digest; they do not read recipient transcript/body. Bounded metadata observation must not hide a body-returning API behind output filtering. + +## Proposed CLI and zero-submit operations + +These are proposed spellings, not commands available in published Python. Preparation binds the same target/message input as send without submission; confirmation accepts metadata-only private stdin. Explicit local ledger initialization is a separate opt-in setup prerequisite, never an automatic recovery action. + +```text +handoff init +handoff prepare --to TARGET --message-file PRIVATE_FILE +send --to TARGET --message-file PRIVATE_FILE --correlation-id PREPARED_UUID +send --to TARGET --request-ack +send --to TARGET --observe-delivery +send --to TARGET --wait-for delivered|acknowledged --wait-timeout SECONDS +handoff status --correlation-id UUID +handoff wait --correlation-id UUID --wait-for delivered|acknowledged --wait-timeout SECONDS +handoff confirm --receipt - +ack --receipt - +``` + +Without correlation-id, request-ack/observe-delivery/wait-for automatically reserve a fresh intent; otherwise ordinary send stays unchanged. A correlation-only send uses best-effort evidence and no explicit wait. Status, wait, confirm, ack, init and prepare make zero native submissions. Receipt selectors do not contain secrets. Confirmation stdin contains schemaVersion, ledgerEpoch, correlationId, targetGeneration and a caller assertion confirmed=true; it rejects extra fields/body/capability and records operator_confirmed only. Flag incompatibilities and unsupported route requirements are checked before durable effect intent. Remote setup and host options must follow existing SSH trust restrictions, not arbitrary receipt routing. + +ACK evidence also requires a non-null original targetGeneration and submitted submission; once a valid receipt is accepted, current state is acknowledged. Observed injection always implies submitted submission. A supplied prepared ID must not belong to a prior real send invocation, including a terminal pre-effect refusal; dry-run never consumes that first-submission authority. Dry-run is incompatible with request-ack, observe-delivery and wait-for (usage exit 2); correlation-only dry-run uses an already prepared ID. + +If a restarted collector cannot prove monotonic capability lifetime continuity, it expires unused new-receipt authority conservatively rather than extending its TTL or regenerating it. Valid committed receipt classification/assurance/late remains recoverable; authenticated same-receipt read-only duplicates follow the explicit expiry exception. For a new wait on an already attempted intent, insufficient budget fails only that wait and preserves submission/injection facts; it does not relabel the original intent refused. The reconcile action means status/evidence comparison, not a new submission or an implicit repair command. + +## Freeze and implementation gates + +This proposal requires TypeScript review of its exact commit and fixture before freezing. The new prepare/confirm spellings, ledgerEpoch and wait operationId fields, 5-second cleanup reserve, numeric token validation and restoration limitations are explicit v0.2 amendments, not already accepted or shipped capabilities. + +The fixture must cover per-runtime optional absence, request-context-gated explicit wait timeout with queued true, forged opt-out handoff, wrong native target, authenticated/wrong-token/expired duplicates, committed/pre-commit/unknown-order receipt recovery, manual-confirm refusal, original/new/null generation, no secret reflection, missing-context query errors versus known tombstones, wait history, missing/restored/expired/full ledger, custom unknown IDs, deadline/framing boundaries, malformed schemas and SSH preservation. Strict SSH opt-in validation must preserve a valid native snapshot even if handoff is rejected; no legacy Claude reconstruction or generic failure path may discard it. Complete responses survive transport timeout/nonzero exits only through the same strict, ORIGINAL-request-aware parser; partial frames remain unknown. + +Before implementation acceptance: validate producer bootstrap and collector ownership/restart, durable intent and capacity reservation, cleanup under each supported platform/version/home, metadata-only Codex observation, strict local/SSH tuples and zero-submit status/wait/ack/confirm operations. No native/live evidence is supplied by this design PR. Side Session and wake experiments are separate future work, not prerequisites. Paired-device/Relay dedup and operator monitoring are not silently added to v1. diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index c974d5ad..5ef65517 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -17,6 +17,7 @@ "docs/architecture/agent-transports.md": "01a4e3942e87acb476595859f2e37f9653ea1e3bc19ebc68dced1adf20a6966f", "docs/cli-reference.md": "a4abf8ca375d058a23b3ae1d91698f95c77e59f0ebd98f623da26461e6b3e08b", "docs/compatibility-v1.md": "7e551aad37a369faff7e370728376763efb54c2c9e9cfd8556a0c19aabc3d103", + "docs/contracts/handoff-v1.md": "46a953010c49214bd8f969412f26713384fbe09a0ba78ebf0fef75da40d0d889", "docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481", "docs/download-metrics.md": "ded60e7b84d14ce779f8d639516b4e390358a06ef76b677dc9c1f997fb626a9f", "docs/mcp.md": "d8db1c66a5a2f500c8916d7a739616a5fe36af3a89ef8de631af4e427c8334de", diff --git a/docs/ja/contracts/handoff-v1.md b/docs/ja/contracts/handoff-v1.md new file mode 100644 index 00000000..45a82757 --- /dev/null +++ b/docs/ja/contracts/handoff-v1.md @@ -0,0 +1,203 @@ +# Handoff v1 契約 — 設計提案 v0.2 + +## 状態と出典 + +これは Python #181 と TypeScript 0.4.0 設計レビューに向けた未実装の設計です。確定したプロトコルでも、リリースの約束でも、実際の ACK 対応の証拠でもありません。この文書のマージは、ランタイム実装、公開、ネイティブ送信、運用変更を許可しません。フィクスチャに含まれるのは合成した例であり、記録された配信ではありません。 + +公開済みの Python 参照版は [v1.0.3](https://github.com/abruption/session-peer/tree/0d252550ab40c26d1ac4a19193df6ca2f84d830b) です。通常出力のソースは [adapters](https://github.com/abruption/session-peer/blob/0d252550ab40c26d1ac4a19193df6ca2f84d830b/session_peer_core/adapters.py)、[Codex](https://github.com/abruption/session-peer/blob/0d252550ab40c26d1ac4a19193df6ca2f84d830b/session_peer_core/codex.py)、[SSH](https://github.com/abruption/session-peer/blob/0d252550ab40c26d1ac4a19193df6ca2f84d830b/session_peer_core/ssh.py)、[output](https://github.com/abruption/session-peer/blob/0d252550ab40c26d1ac4a19193df6ca2f84d830b/session_peer_core/output.py) です。[共有フィクスチャ](https://github.com/abruption/session-peer/blob/main/tests/fixtures/handoff-v1.json) は、正規化されたレガシーの例と、提案されたオプトインの例を分けています。ランタイム参照と設計フィクスチャの固定版は別です。TypeScript の公開済みの過去の参照を、未リリースの Python 版へ暗黙に変更しません。 + +## 証拠と互換性 + +送信とはネイティブのトランスポートによる受け付けを指し、配信とは正確な元のユーザーメッセージ ID が正確な元の対象世代に注入された証拠を指します。確認応答は、明示的に相関付けされた受領確認であり、完了、責任の引き受け、モデルが全バイトを読んだ証拠ではありません。キュー削除、プロセス終了、ターン完了、返信がないこと、状態照会の成功は ACK ではありません。不明なネイティブのターン状態が completed になることはありません。 + +通常の send 出力はランタイム固有のままです。Python Claude の成功時は status、submitted、consumptionConfirmed を省略します。Codex の queued 成功時は submitted true と consumptionConfirmed false を含みます。submitted false のレガシー unknown は、作用がなかったことの普遍的な証拠ではありません。欠けている false/null フィールドの追加、任意フィールドの不在の再解釈、consumptionConfirmed の上書き、通常の終了コードの変更を行わないでください。正規化されたフィクスチャ値は識別情報だけを隠します。各例は出典を記録しており、網羅的な出力スキーマではありません。 + +オプトインは、既存の外側の schemaVersion 1 の下で、各宛先の結果に handoff オブジェクトを 1 つ追加します。fanout 全体の handoff はありません。明示的な待機が変更できるのは外側の ok と終了コードだけです。ネイティブの status、target、queueId、submitted、consumptionConfirmed と、独立して有効なすべての送信証拠を保持します。結果の受信側は、検証モードとランタイム/エージェントのネイティブプロファイルの選択に、信頼できない返却フィールドではなく自身の元の要求コンテキストを使う必要があります。元の要求が明示的な待機で、handoff が完全に検証され、肯定的なネイティブプロファイルと元の target/home/context の一致が確認された場合にだけ、待機失敗の例外を許可します。タイムアウト、失敗、送信後の非対応には ok false/終了コード 1 を使い、保留中の待機の割り込みには ok false/終了コード 130 を使います。handoff の受信だけでは、オプトアウト要求でいずれの例外も許可しません。ベストエフォートの観測失敗はネイティブ送信の成功を変更できません。不正またはサイズ超過の handoff は、別途検証された正しい対象のネイティブスナップショットを消去したり ACK に昇格させたりできません。明示的な待機は再送信せずに検証に失敗します。不正なネイティブ target/home または変更された消費の事実は、独立して有効なネイティブ証拠ではありません。有効な通常のオプトアウト成功は、要求していない handoff を無視しても有効なままです。 + +肯定的なネイティブプロファイルは、共通の handoff submission 列挙型とは別です。ネイティブの成功時に出力しない外側の status/submitted フィールドから Python Claude の肯定的な送信を推論せず、ガードを満たすためにそれらを追加しないでください。オプトインアダプターは、検証済みの待機前のネイティブ成功スナップショットを保持する必要があります。消費側は handoff.submission だけを信頼せず、そのプロファイルと正確な元のコンテキストを検証します。 + +| 元のランタイム / エージェント | 肯定的なネイティブスナップショット | 対象/コンテキストの一致 | +| --- | --- | --- | +| Python / Claude | status、submitted、consumptionConfirmed は不在; 既存の肯定的なソケット書き込み target フィールドを保持 | Python の既存規則に従う元の解決済み対象 pid/name | +| TypeScript / Claude | status posted、submitted true、consumptionConfirmed false; queueId は不在 | 解決済み pid; 既存 TS スキーマでは agent は不在、name は null になり得る | +| Python / Codex | status queued、submitted true、consumptionConfirmed false; ネイティブ queueId と codexHome を保持 | Python の既存規則に従う元の thread と正規化した home/context | +| TypeScript / Codex | status queued、submitted true、consumptionConfirmed false; ネイティブ queueId と codexHome を保持 | UUID 比較は引き続き大文字小文字を区別しない; agent は不在になり得る; 正規化した home/context は一致が必要 | + +この表は合成の肯定プロファイルフィクスチャを定義し、網羅的なランタイムのエンベロープや各ランタイムの既存バリデーターを緩和する許可ではありません。posted status または consumptionConfirmed true の Codex スナップショットは無効です。対象比較は生の辞書全体の一致ではなく、プロファイルを考慮します。許可された任意フィールドの不在と null は引き続き許可し、不正な agent、id/pid または正規化した home は失敗します。正規化した home の一致は、現行の SSH バリデーターがまだ強制しないランタイムでは追加の提案 handoff 条件であり、出荷済みの保護を主張するものではありません。ネイティブスナップショットの検証は handoff の有無や有効性と独立しています。 + +## 公開スキーマ + +次の文法はこの提案の規範です。ランタイム固有の外側のエンベロープを除き、すべてのオブジェクトは閉じたオブジェクトです。疑問符は任意であり、利用できない場合は存在しないことを意味します。null を許容するフィールドは下記に明記します。条件付き規則は文法の後に続きます。 + +```text +handoff.schemaVersion = 1 +handoff.correlationId = UUID-v4 lowercase canonical +handoff.ledgerEpoch = UUID-v4 lowercase canonical +handoff.state = validated | refused | submitted | delivered | acknowledged | unknown | timed_out_unknown +handoff.submission.status = not_attempted | submitted | refused | unknown +handoff.observation.status = not_requested | pending | observed | unsupported | failed +handoff.observation.injectionObserved = boolean +handoff.observation.clientUserMessageId? = safe-id +handoff.observation.turn? = {id: safe-id, status: running | completed | failed | interrupted | unknown} +handoff.ack.status = not_requested | pending | acknowledged | unsupported +handoff.ack.assurance? = token_possession | operator_confirmed +handoff.ack.receivedAtUtcMs? = safe-integer +handoff.ack.late? = boolean +handoff.wait.for = none | delivered | acknowledged +handoff.wait.status = not_requested | pending | satisfied | timed_out_unknown | stopped | unsupported | failed +handoff.wait.operationId? = UUID-v4 lowercase canonical +handoff.wait.deadlineAtUtcMs? = safe-integer +handoff.wait.reason? = insufficient_budget | deadline_before_effect | evidence_unsupported | evidence_failed | history_unavailable | stopped_by_operator | invalid_handoff +handoff.targetGeneration = safe-generation | null +handoff.decisionOwner = sender_operator +handoff.retry = {allowed: false, reason: receiver_dedup_unavailable} +handoff.nextActions = unique subset of [keep_waiting, reconcile, stop_waiting] +``` + +配信には、観測された注入、正確な clientUserMessageId、null ではない元の targetGeneration が必要です。ターンの証拠には観測された注入が必要です。ターンは受領状態とは別です。受領確認の受け付けは注入の証拠を作り出しません。acknowledged 状態には、assurance、受領時刻、late フラグを備えた acknowledged ack が必要です。他の ACK 状態ではこれらのフィールドを省略します。submitted、delivered、acknowledged 状態には submitted submission が必要です。validated には not_attempted、refused には refused、unknown には unknown が必要です。タイムアウト状態は submitted または unknown を許可し、refused/not_attempted は許可しません。 + +各明示的な待機には、作用前の拒否であっても operationId と期限があります。待機のない記録にはどちらもありません。satisfied の delivered 待機には注入の証拠が、satisfied の acknowledged 待機には有効な ACK が必要です。stopped/failed/unsupported の待機は既知の作用の証拠を保持します。nextActions は稼働中の検証済みチャネルと保持された履歴に依存します。未対応または期限切れのチャネルは keep_waiting を提示しません。stop_waiting は観測だけを終了し、ネイティブの作業を取り消しません。すべてのネイティブのローカル/SSH 結果は再試行を禁じます。v1 に resend_same_id アクションはありません。相関付けとユーザー承認だけでは安全な再送を確立できません。受信側の重複排除(#182)は、この再送禁止契約を実装する前提条件ではありません。 + +## 状態と終了コードのタプル + +不在とは、レガシーフィールドが引き続き存在しないことを意味します。ネイティブとは、変更されていないランタイムごとのスナップショットを指します。同じ表がローカルと SSH に適用され、未実装のペアリングデバイスのトランスポートには適用されません。 + +| モード / 証拠 | state | submission | wait status | 外側の status / submitted | ok / 終了コード | +| --- | --- | --- | --- | --- | --- | +| 通常の send | 不在 | 不在 | 不在 | ネイティブ / ネイティブまたは不在 | ネイティブ | +| オプトインの dry-run | validated | not_attempted | not_requested | ネイティブ / ネイティブまたは不在 | true / 0 | +| 作用前に必須チャネルが利用不可 | refused | refused | unsupported | 存在すればネイティブ / 合成フィールドなし | false / 1 | +| 確実に作用前に予算が尽きた | refused | refused | failed | 存在すればネイティブ / 合成フィールドなし | false / 1 | +| キュー登録が既知、ベストエフォート観測者が失敗 | submitted | submitted | not_requested | queued / true | true / 0 | +| キュー登録が既知、明示的 ACK 期限が切れた | timed_out_unknown | submitted | timed_out_unknown | queued / true | false / 1 | +| 肯定的なネイティブプロファイル、送信後にチャネルが非対応になる | submitted または delivered | submitted | unsupported | ネイティブ / ネイティブまたは不在 | false / 1 | +| オペレーターが保留中の明示的待機を中断 | submitted、delivered または unknown | submitted または unknown | stopped | ネイティブ / ネイティブまたは不在 | false / 130 | +| 注入が既知、ACK 期限が切れた | timed_out_unknown | submitted | timed_out_unknown | ネイティブ / ネイティブまたは不在 | false / 1 | +| 作用が起きた可能性があり、受領確認なし | unknown | unknown | not_requested | ネイティブ / ネイティブまたは不在 | false / 1 | +| 有効な受領確認が明示的待機を満たす | acknowledged | submitted | satisfied | ネイティブ / ネイティブまたは不在 | true / 0 | +| 以前のタイムアウトの状態照会が成功 | timed_out_unknown | submitted | timed_out_unknown | 不在 / 不在 | true / 0 | +| 停止した待機の状態照会が成功 | 保持された状態 | 保持された送信 | stopped | 不在 / 不在 | true / 0 | + +不正なフラグ、不正なタイムアウトの字句、対象の欠落、不正な ID は、使用法/対象なしの終了コード 2 を維持し、JSON/handoff がまったくない場合もあります。こうした失敗に合成の相関 ID やレガシーの false/null を作りません。SIGINT を含む保留中の明示的な send/wait 操作への割り込みは、取り消し/再送なしで wait stopped と reason stopped_by_operator を終端記録します。完全な構造化出力が利用できれば、その操作は既知のネイティブ/送信/注入の事実を保持し、ok false/終了コード 130 を返します。SSH は元の明示的待機要求で、target/home/結合が一致する検証済み stopped 待機の場合だけ、この例外を受け付けます。オペレーターが意図的に保留中の待機を停止する場合も、新しい API 表記を追加せず同じ規則に従います。すでに終端となった待機は不変です。割り込みはコミット済みのタイムアウト、充足、その他の終端結果を stopped に書き換えられません。その停止記録の成功した状態照会は ok true/終了コード 0 を使います。部分出力/出力なしの割り込みでは、独立して既知の証拠を保持し、それ以外は不確実なままとし、合成 false フィールド、フォールバック、再送を作りません。成功した状態照会が報告するのは照会の成功であり、確認応答ではありません。 + +## 機能とブートストラップ + +すべての機能は将来の受け入れ条件であり、現在の製品対応ではありません。経路は ACK 対応を表明する前に、受領確認の消費側、生成側、不変の元の対象世代を独立して検証する必要があります。null 世代は受領確認の権限を発行できず、必須の delivered/acknowledged 待機にも対応できません。その要件は作用前に wait unsupported として拒否します。ブートストラップの失敗は unsupported であり、汎用メッセージ配信を試みる許可ではありません。新たに発見した現在の世代で元の結合を置き換えることはありません。対象の再起動は新たな送信/再結合を防ぎますが、それだけで元の世代に結合された遅延受領確認を無効にしません。検証済みの元のタプルと元の権限が証明するのは委任されたトークン所持であり、元のエージェント/モデルの身元ではありません。後継の新しい世代は拒否します。コピーされた元のタプルと元の権限の両方を持つ後継や同一ユーザーの閲覧者は、token_possession の保証では区別できません。この仕組みがモデルの身元を認証してその再生を排除すると主張してはなりません。 + +| 経路 / 観測者 | 注入の観測 | 受領確認 ACK | +| --- | --- | --- | +| 同一マシンの非公開コレクターと互換生成側 | version/home/writer/ID の検証後の Codex のみ | 非公開ブートストラップ後に対応 | +| インストール済みの互換受領確認ハンドラーへの明示的に信頼されたリバース SSH | 別途検証された Codex 経路 | ハンドラー/設定の検証後に対応 | +| 互換ハンドラーのインストールなしでソースをストリーミングする SSH | 別途検証された Codex 経路 | 未対応 | +| Claude ネイティブ観測者 | デフォルトでは未対応 | 互換性のある明示的な受領確認生成側のみ | +| ペアリングデバイス、Relay、Side Session、wake | 初期経路の範囲外 | 未対応 | + +必須の delivered または acknowledged 待機は、チャネルが未対応なら作用前に拒否します。ベストエフォートの相関付き send は、未使用の秘密の権限を発行せず、observation/ack unsupported で送信できます。コレクターは配信、wake、モデルのプロンプト、承認を呼び出しません。ネイティブ app-server からキューへのフォールバックは、元のネイティブ送信が開始した可能性が生じる前にだけ許可されます。試行後の不確実性や観測者の失敗は、別の送信を決して引き起こしません。version、platform、home、元の writer の識別情報の確認は必須です。トランスクリプトや任意の返信本文は走査しません。 + +## 受領確認のワイヤー形式とライフサイクル + +Receipt v1 は Reply-To v1 とは別であり、そのパーサーと権限は変わりません。受領確認ハンドルは秘密ではないローカル選択子であり、URI や権限ではありません。実際の bearer 権限は、許可された非公開の作用入力と非公開ハンドラーの stdin を通じてだけ伝わります。秘密を含めない保証は、送信者/コレクターの argv、URI、環境、通常の JSON、診断、エラー/ACK 結果、および自身の生のジャーナル/永続化を対象とします。意図された受信者のネイティブキュー、履歴、トランスクリプトは対象ではありません。ネイティブの作用入力は CLI の管理外でそこに保持される可能性があり、同一ユーザーの閲覧者は委任された受領確認専用権限を取得できる場合があります。token_possession は独立したエージェント認証ではありません。私たちの観測者は引き続きトランスクリプトを読みません。失効、24 時間の TTL、最初の受領確認の原子的な受け付けは、新しい受領確認権限を制限するもので、受信者の保存期間を制限しません。 + +```json +{ + "schemaVersion": 1, + "kind": "receipt", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "correlationId": "22222222-2222-4222-8222-222222222222", + "targetGeneration": "fixture-generation", + "receiptId": "33333333-3333-4333-8333-333333333333", + "capability": "" +} +``` + +権限は、暗号学的にランダムな 32 バイトをパディングなしの base64url の 43 文字で符号化したものです。例のプレースホルダーは有効なワイヤー入力ではありません。保護されたコレクターは、epoch、相関、元の世代に結合された権限の SHA-256 ハッシュだけを保存します。読み取り専用の重複を含むすべてのハンドラー要求で定時間検証が必要です。最初の受領確認の原子的な永続コミットには、受領確認 ID/結合、証明された分類、assurance、late フラグ、コレクターの受領時刻と、それを正当化する待機順序/期限の事実を含みます。状態と関連する待機の遷移は、この記録と一貫してコミットし、後の別の書き込みでは行いません。不正な ID、不正なトークン、後継世代、競合する受領確認、期限切れの未コミット権限、不正なフレームは状態を進められません。ACK 到着時刻はコレクターが割り当てます。呼び出し側のタイムスタンプは権威ある根拠ではありません。 + +コレクターは送信者の会話から独立した、ユーザー所有のプロセスです。ファイル/ディレクトリは非公開の 0600/0700 です。エンドポイントは、ローカルの所有者確認付き非公開 IPC、または明示的に設定された信頼できるリバース SSH ハンドラーです。公開リスナー、自動 SSH 鍵インストール、プロファイル変更、トランスクリプト読み取り、秘密のエクスポート、汎用 CLI exec は許可しません。再起動ではコミット済みの受領確認ハッシュと分類済みの受領確認を復元します。試行済みの意図について、失われた生の権限を再生成しません。権限の寿命は意図のコミットから 24 時間で、詳細の期限切れ、失効、隔離、または最初の有効な受領確認で終わります。期限切れの唯一の例外は、すでにコミットされた同一の受領確認の読み取り専用取得です。元の epoch、相関、世代、receiptId と、元の権限ハッシュの定時間証明のすべてを要求します。TTL の期限切れ/最初の受領確認による権限消費は、新しい受領確認の受け付けや状態の作成/昇格を許可しません。失効、隔離、詳細の欠落/期限切れ、不正なトークンはこの例外も拒否します。識別子だけでは汎用の状態/照会権限は付与されません。許可された送信者オペレーターの状態照会は、別の所有者確認付きローカルインターフェースを使います。 + +手動の生成側は、提案された送信なしの handoff confirm 操作です。正確な epoch/ID/元の世代と、観測された明示的な相関付き返信に関するローカルの送信者オペレーターの申告を必要とします。stdin が受け取るのはメタデータだけで、トランスクリプト/本文ではありません。operator_confirmed を記録し、token_possession は決して記録しません。確認は新しい現在の証言/イベントであり、古い受領確認の到着時刻を遡って証明するものではありません。永続的な終端待機の事実などから元の待機の順序/遅延分類を証明できる場合だけ acknowledged になれます。それ以外では、ok false、終了コード 1、固定の reason receipt_order_unprovable で確認を拒否します。元の pending/unknown ACK と元の待機を保持し、受け付けた ACK をコミットしたり late の真偽値を作り出したりしません。すでにコミットされた有効な受領確認は、この確認失敗で降格または置換しません。トークン所持の生成側は、非公開入力を読むインストール済みの互換受領確認専用ハンドラーである必要があり、任意のコマンドを実行できません。両方の生成側は引き続き実装の受け入れ条件です。生成側を検証するまで assurance は表明しません。 + +## 送信者台帳、新規性、保持 + +単一の非公開で直列化された永続台帳がランダムな epoch を所有します。対象に結合された準備済みの意図は、作用前にコミットして同期します。作用意図のフェンスをコミットした後は、その無傷の epoch 内でネイティブの試行は 1 回だけ許可されます。そのコミット後のクラッシュは、実際の作用が始まる前のクラッシュも含めて unknown として復旧し、再送信しません。結合には agent、宛先、home/writer の識別情報、世代、保護された内部ペイロードダイジェストを含みます。ダイジェストは公開出力ではありません。競合や対象の再起動で意図を暗黙に再結合しません。 + +新しい ID は、自動準備または新しい作用なしの handoff prepare 操作によって生成・予約します。呼び出し側が渡した未知の UUID は、新規性を証明できないため作用前に拒否します。渡された既知の準備済み ID は、正確な結合に一致し、未試行でなければなりません。渡された試行済み ID は照会/照合専用であり、別の send には使えません。順序付き fanout は、宛先ごとに 1 つの意図を一度だけ生成して永続的に関連付けます。呼び出し側は 1 つの ID を複数の宛先に再利用できません。照合は ID や権限を再生成しません。 + +epoch ごとの上限は、保持する意図/フェンス記録 10,000 件と 32 MiB のジャーナルのうち先に満たされる方です。受け入れ時には送信後に容量を埋めるのではなく、作用前に終端記録と受領確認 1 件に必要な上限付き保存領域を予約します。フェンスの退去/LRU は許可しません。詳細の保持期間は準備から 30 日、権限の寿命は 24 時間、意図ごとの最大待機数は 64 です。期限切れの詳細は対象に結合されたコンパクトなフェンス tombstone となり、epoch 全期間保持します。tombstone は割り当てに数えます。待機枠を使い切った場合、ネイティブの作用の事実を変えずに新しい待機を拒否します。詳細の削除によって送信権限は戻りません。容量を使い切ると、自動リセットではなく明示的なオペレーターのアーカイブまで準備/作用を拒否します。 + +欠落、破損、詳細の期限切れ、または既知の復元済み履歴は、status/reconcile で unknown を返し、再試行を禁じます。既知の tombstone は元の epoch と対象の結合を保持します。state/submission unknown、ACK/注入の主張なし、keep_waiting なしの通常の handoff を返せます。台帳の欠落/破損や未知の ID ではそのコンテキストを復元できないため、handoff や架空の epoch を含めず、以下の別の照会エラーエンベロープを必ず返します。既存の台帳を自動再作成しません。初回利用には明示的なローカル初期化と新しい epoch が必要です。既知の復元は古いすべての意図を隔離し、保持された証拠の照合まで作用と受領確認の受け付けを無効にします。新しい epoch には明示的なオペレーター初期化と生成された新しい ID が必要であり、古い要求について何も保証しません。ローカルだけの設計では、印のないロールバックやマシン間の台帳複製を確実に検出できません。フェンシングの保証は、無傷で保持され、ロールバックされていないと証明された履歴に限られます。外部の単調アンカー/複製防止ストレージは v1 の範囲外です。この制限は、普遍的な exactly-once の主張の裏に隠さず、見える状態に保つ必要があります。 + +提案された照会エラーは終了コード 1 を使います。handoffQuery は表示されたフィールドだけを持つ閉じたスキーマで、context は ledger_missing、ledger_corrupt または id_unknown です。呼び出し側の構文上有効な correlationId はそのまま返しますが、履歴の証拠とは扱いません。ledgerEpoch、handoff、最上位の status/submitted/consumptionConfirmed、合成のネイティブスナップショットは追加しません。外側の host/command は照会先と handoff で、reason は handoff_history_unavailable です。これはオプトインの照会結果であり、通常のレガシーエラーの変更ではありません。 + +```json +{ + "schemaVersion": 1, + "ok": false, + "host": "fixture", + "command": "handoff", + "reason": "handoff_history_unavailable", + "handoffQuery": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "status": "unknown", + "context": "ledger_missing", + "retry": {"allowed": false, "reason": "history_unavailable"} + } +} +``` + +## 待機履歴と遅延した受領確認 + +各待機には異なる操作 ID、目標、単調時計の期限、不変の終端結果があります。status は、その意図について最後に開始した待機(直列化された順序)を返し、待機がなければ not_requested を返します。将来の別個の待機履歴 API を示唆しません。現在の受領確認/状態は独立して進められます。そのため遅延した ACK は現在の状態を acknowledged にできますが、返される元の待機は timed_out_unknown のままです。その待機を satisfied に書き換えません。 + +受領確認は、元の send 待機の観測期限と同時またはそれ以降に受け付けられた場合に遅延とします。send 待機がなければ最初の明示的な待機と比較します。待機がなければ late は false です。後の待機は受け付け済みの遅延した受領確認で満たせますが、ack の late フラグは true のままです。稼働中は受領確認の受け付けと期限の遷移を同じ単調時計に対して直列化し、証明された順序/分類を原子的に永続コミットします。完全にコミットされた有効な ACK は、元の分類、assurance、late、不変の待機の事実を復元します。再起動後の時計が変わったという理由だけで決して降格しません。コミット前のクラッシュやその他の未コミット/未分類の時刻証拠は、acknowledged になったり合成の late フラグを提供したりできません。必須の待機を未充足、既存の pending/unknown ACK をそのままにして、未分類の証拠として保持します。手動確認は上記の証明または拒否の規則による新しいイベントであり、遡って書き換えません。UTC タイムスタンプは診断専用です。繰り返し待機しても新たなネイティブ送信は行いません。 + +## 予算と制限 + +デフォルトの予算は宛先ごとに 30 秒です。CLI は、先頭のゼロ、符号、空白、小数点、指数を含まない ASCII の 10 進整数 1 から 60 だけを受け付けます。新しいフラグはレガシーの解析を変更しません。単調時計による合計予算は、セットアップ、事前検証、準備、作用、観測より前に開始します。その中でクリーンアップに正確に 5 秒を予約します。予算が 5 秒以下なら insufficient_budget で作用前に拒否します。観測/作用の打ち切りは開始時刻に予算を加え、5 秒を引いた時刻です。クリーンアップは開始時刻に予算を加えた限度を延ばせません。待機期限の診断はこの打ち切りを説明するもので、更新されたリモート予算ではありません。 + +SSH は残り時間だけを渡し、元の呼び出し側の期限を保持します。リモートハンドラーは予算をリセットしたり、UTC を使って作用を許可したりできません。残りの合計予算がクリーンアップの予約以下なら作用前に拒否します。打ち切りの境界では now >= cutoff を期限切れとします。打ち切りより厳密に前にコミットされた受領確認はその待機を満たせますが、打ち切り時刻以降のものは遅延です。作用の可能性が生じたら、タイムアウトは unknown です。再試行/フォールバックや新しい送信は許可しません。順序付き fanout は宛先ごとに固有の上限付き予算を与え、1 つのホストの予算を繰り返しません。上限内のクリーンアップ/所有リソースを保証できない実装は、その経路の対応を表明できません。 + +```text +safe-integer = JSON integer token, 0..9007199254740991; booleans/fractions/exponents rejected +safe-id = 1..128 UTF-8 bytes, no U+0000..001F or U+007F..009F, valid Unicode scalars +safe-generation = 1..256 UTF-8 bytes, same control/scalar rule +receipt/confirmation frame = raw strict UTF-8 JSON input, at most 4096 bytes including LF/whitespace +public handoff size = compact UTF-8 encoding of the handoff subtree, at most 8192 bytes +opt-in destination stdout frame = at most 1048576 bytes including LF/whitespace +opt-in fanout = at most 32 destinations and 34603008 total stdout bytes including LF/whitespace +duplicate JSON keys, unknown nested keys, NaN/Infinity and trailing data rejected +turn/native IDs are opaque; only correlation/epoch/wait/receipt IDs use UUID-v4 syntax +``` + +JSON の数値トークンは、損失を伴う解析の前に検証する必要があります。JavaScript Number が 1 と等しくても、字句の 1.0 と 1e0 は拒否します。字句の制約は handoff/handoffQuery サブツリーと非公開の受領確認/確認スキーマだけに適用し、無関係なレガシーの外側フィールドには適用しません。既存のレガシー解析と重複キー/非有限数の規則を維持します。handoff 要求があるというだけでレガシーフィールドが無効になることはありません。handoff サブツリーのサイズは正規のコンパクトな符号化で測ります(任意の空白なし、ASCII エスケープではなく UTF-8 スカラー文字、区切りはカンマ/コロン)。囲むワイヤーのサブツリー内の空白は、代わりに外側のフレーム制限に数えます。非公開の受領確認/確認の生の入力は、任意の末尾 JSON 空白と LF を含む全バイトを含めます。末尾の空白以外のデータは拒否します。改行のための追加許容量はありません。宛先/合計の制限は新しいオプトインモードだけに適用します。作用前に過剰な宛先数を拒否し、通常のオプトアウトのワイヤー/出力制限を変更しません。割り当て前に stdout を逐次制限します。上限付き集計形式はコンパクトな JSON と任意の LF です。上限内の完全な外側フレームに含まれる任意のサブツリーが不正またはサイズ超過の場合、独立して検証されたネイティブ証拠を保持し、ACK/明示的待機を拒否し、再送信しません。外側のフレーム制限を超えた場合、部分解析で成功にしてはなりません。すでに独立して検証された証拠だけを保持し、それ以外は unknown を返します。制限は切り詰め後ではなく永続化前に検査します。送信者/コレクターの通常の stdout、診断、エラー/ACK 結果、自身のジャーナルは権限やペイロードダイジェストを決して含みません。受信者のトランスクリプト/本文は読みません。上限付きメタデータ観測は、出力フィルタリングの裏に本文を返す API を隠してはなりません。 + +## 提案された CLI と送信ゼロの操作 + +これらは提案された表記であり、公開済みの Python で使えるコマンドではありません。準備は送信せずに send と同じ対象/メッセージ入力を結合します。確認はメタデータだけの非公開 stdin を受け付けます。明示的なローカル台帳初期化は、別個のオプトイン設定の前提条件であり、自動復旧動作ではありません。 + +```text +handoff init +handoff prepare --to TARGET --message-file PRIVATE_FILE +send --to TARGET --message-file PRIVATE_FILE --correlation-id PREPARED_UUID +send --to TARGET --request-ack +send --to TARGET --observe-delivery +send --to TARGET --wait-for delivered|acknowledged --wait-timeout SECONDS +handoff status --correlation-id UUID +handoff wait --correlation-id UUID --wait-for delivered|acknowledged --wait-timeout SECONDS +handoff confirm --receipt - +ack --receipt - +``` + +correlation-id がなければ、request-ack/observe-delivery/wait-for は新しい意図を自動予約します。それ以外の通常の send は変わりません。相関だけの send はベストエフォートの証拠を使い、明示的な待機を行いません。status、wait、confirm、ack、init、prepare はネイティブ送信を一切行いません。受領確認の選択子は秘密を含みません。確認の stdin は schemaVersion、ledgerEpoch、correlationId、targetGeneration と、呼び出し側の confirmed=true という申告を含みます。追加のフィールド/本文/権限を拒否し、operator_confirmed だけを記録します。フラグの非互換性と未対応経路の要件は、永続的な作用意図の前に検査します。リモート設定とホストオプションは、任意の受領確認ルーティングではなく、既存の SSH 信頼制約に従う必要があります。 + +ACKの証拠にはnullではない元のtargetGenerationとsubmitted submissionも必要であり、有効な受領確認を受け入れると現在の状態はacknowledgedになります。観察された注入は常にsubmitted submissionを意味します。指定する準備済みIDは、効果前の終端拒否を含む過去の実際のsend呼び出しに属していてはなりません。dry-runは最初の送信権限を消費しません。dry-runはrequest-ack、observe-delivery、wait-forと併用できず、使用法終了コード2になります。correlation-only dry-runは既に準備されたIDを使用します。 + +再起動したコレクターが権限の寿命に関する単調時計の連続性を証明できない場合、TTL を延長したり権限を再生成したりせず、未使用の新しい受領確認権限を保守的に失効させます。有効にコミットされた受領確認の分類/assurance/late は引き続き復元できます。認証された同一受領確認の読み取り専用重複は、明示された期限切れの例外に従います。既に試行した意図への新しい待機で予算が不足した場合、その待機だけが失敗し、送信・注入の事実は維持されます。元の意図を refused に再分類しません。reconcile アクションは状態・証拠の照合であり、新たな送信や暗黙の修復コマンドではありません。 + +## 確定と実装の受け入れ条件 + +この提案は確定前に、正確なコミットとフィクスチャの TypeScript レビューを必要とします。新しい prepare/confirm の表記、ledgerEpoch と待機の operationId フィールド、5 秒のクリーンアップ予約、数値トークン検証、復元の制限は、明示的な v0.2 の改訂であり、すでに受け入れられたりリリースされたりした機能ではありません。 + +フィクスチャは、ランタイムごとの任意フィールドの不在、要求コンテキストで許可される queued true の明示的待機タイムアウト、偽造されたオプトアウト handoff、不正なネイティブ対象、認証済み/不正トークン/期限切れの重複、コミット済み/コミット前/順序不明の受領確認の復旧、手動確認の拒否、元の/新しい/null 世代、秘密の反映がないこと、コンテキスト欠落の照会エラーと既知の tombstone の区別、待機履歴、欠落/復元済み/期限切れ/満杯の台帳、ユーザー指定の未知の ID、期限/フレームの境界、不正なスキーマ、SSH の保持をカバーする必要があります。厳密な SSH オプトイン検証は、handoff が拒否されても有効なネイティブスナップショットを保持する必要があります。レガシー Claude の再構築や汎用の失敗経路でそれを破棄してはなりません。完全な応答は、元の要求を認識する同じ厳密なパーサーを通じてだけトランスポートのタイムアウト/非ゼロ終了後にも保持されます。部分フレームは unknown のままです。 + +実装の受け入れ前に、生成側のブートストラップとコレクターの所有権/再起動、永続的な意図と容量予約、対応する各 platform/version/home でのクリーンアップ、メタデータ専用 Codex 観測、厳密なローカル/SSH タプル、送信ゼロの status/wait/ack/confirm 操作を検証します。この設計 PR はネイティブ/実動作の証拠を提供しません。Side Session と wake の実験は別の将来の作業であり、前提条件ではありません。ペアリングデバイス/Relay の重複排除とオペレーター監視を暗黙に追加しません。 diff --git a/docs/ko/contracts/handoff-v1.md b/docs/ko/contracts/handoff-v1.md new file mode 100644 index 00000000..b9dc12c6 --- /dev/null +++ b/docs/ko/contracts/handoff-v1.md @@ -0,0 +1,203 @@ +# Handoff v1 계약 — 설계 제안 v0.2 + +## 상태와 출처 + +이 문서는 Python #181과 TypeScript 0.4.0 설계 검토를 위한 미구현 설계입니다. 확정된 프로토콜이나 출시 약속이 아니며, 실제 ACK 지원의 증거도 아닙니다. 이 문서의 병합은 런타임 구현, 게시, 네이티브 제출 또는 운영 변경을 승인하지 않습니다. 픽스처에는 기록된 전달이 아닌 합성 예제가 들어 있습니다. + +게시된 Python 참조 버전은 [v1.0.3](https://github.com/abruption/session-peer/tree/0d252550ab40c26d1ac4a19193df6ca2f84d830b)입니다. 일반 출력의 소스는 [adapters](https://github.com/abruption/session-peer/blob/0d252550ab40c26d1ac4a19193df6ca2f84d830b/session_peer_core/adapters.py), [Codex](https://github.com/abruption/session-peer/blob/0d252550ab40c26d1ac4a19193df6ca2f84d830b/session_peer_core/codex.py), [SSH](https://github.com/abruption/session-peer/blob/0d252550ab40c26d1ac4a19193df6ca2f84d830b/session_peer_core/ssh.py), [output](https://github.com/abruption/session-peer/blob/0d252550ab40c26d1ac4a19193df6ca2f84d830b/session_peer_core/output.py)입니다. [공유 픽스처](https://github.com/abruption/session-peer/blob/main/tests/fixtures/handoff-v1.json)는 정규화된 레거시 예제와 제안된 옵트인 예제를 구분합니다. 런타임 참조와 설계 픽스처의 고정 버전은 별개입니다. TypeScript의 게시된 과거 참조를 출시되지 않은 Python 버전으로 암묵적으로 바꾸지 않습니다. + +## 증거와 호환성 + +제출은 네이티브 전송 계층의 수락을 뜻하며, 전달은 정확한 원래 사용자 메시지 ID가 정확한 원래 대상 세대에 주입되었다는 증거를 뜻합니다. 확인 응답은 명시적으로 상관관계가 연결된 수신 확인이며, 완료나 책임 수락 또는 모델이 모든 바이트를 읽었다는 증거가 아닙니다. 큐 삭제, 프로세스 종료, 턴 완료, 답변 없음, 성공한 상태 조회는 ACK가 아닙니다. 알 수 없는 네이티브 턴 상태는 절대로 completed가 되지 않습니다. + +일반 send 출력은 런타임별 형식을 유지합니다. Python Claude 성공은 status, submitted, consumptionConfirmed를 생략합니다. Codex의 queued 성공에는 submitted true와 consumptionConfirmed false가 포함됩니다. submitted false인 레거시 unknown은 아무 효과도 없었다는 보편적인 증거가 아닙니다. 빠진 false/null 필드를 추가하거나, 선택적 필드의 부재를 재해석하거나, consumptionConfirmed를 덮어쓰거나, 일반 종료 코드를 바꾸지 마세요. 정규화된 픽스처 값은 식별 정보만 숨깁니다. 각 예제는 출처를 기록하며 완전한 출력 스키마는 아닙니다. + +옵트인은 기존 외부 schemaVersion 1 아래에서 각 대상 결과에 handoff 객체 하나를 추가합니다. fanout 수준의 handoff는 없습니다. 명시적인 대기는 외부 ok와 종료 코드만 바꿀 수 있습니다. 네이티브 status, target, queueId, submitted, consumptionConfirmed와 독립적으로 유효한 모든 제출 증거를 보존합니다. 결과를 받는 쪽은 검증 모드와 런타임/에이전트 네이티브 프로필을 선택할 때 신뢰할 수 없는 반환 필드가 아니라 자신의 원래 요청 맥락을 사용해야 합니다. 원래 요청이 명시적 대기이고 handoff가 완전히 검증되었으며, 긍정적인 네이티브 프로필과 원래 target/home/context의 일치가 확인된 경우에만 대기 실패 예외를 허용합니다. 시간 초과, 실패, 제출 후 지원 불가는 ok false/종료 코드 1을 사용합니다. 진행 중인 대기의 인터럽트는 ok false/종료 코드 130을 사용합니다. handoff를 받았다는 사실만으로 옵트아웃 요청에서 어느 예외도 허용하지 않습니다. 최선 노력 관찰 실패는 네이티브 제출 성공을 바꿀 수 없습니다. 잘못되거나 크기 한도를 초과한 handoff는 별도로 검증되고 올바른 대상을 가리키는 네이티브 스냅샷을 지우거나 ACK로 승격할 수 없습니다. 명시적 대기는 재제출 없이 검증에 실패합니다. 잘못된 네이티브 target/home 또는 변경된 소비 사실은 독립적으로 유효한 네이티브 증거가 아닙니다. 유효한 일반 옵트아웃 성공은 요청하지 않은 handoff를 무시해도 계속 유효합니다. + +긍정적인 네이티브 프로필은 공통 handoff submission 열거형과 별개입니다. 네이티브 성공이 출력하지 않는 외부 status/submitted 필드로 Python Claude의 긍정적인 제출을 추론하지 말고, 가드를 충족하려고 그 필드를 추가하지 마세요. 옵트인 어댑터는 검증된 대기 전 네이티브 성공 스냅샷을 보존해야 합니다. 소비자는 handoff.submission만 신뢰하지 않고 해당 프로필과 정확한 원래 맥락을 검증합니다. + +| 원래 런타임 / 에이전트 | 긍정적인 네이티브 스냅샷 | 대상/맥락 일치 | +| --- | --- | --- | +| Python / Claude | status, submitted, consumptionConfirmed 부재; 기존의 긍정적인 소켓 쓰기 target 필드 보존 | 기존 Python 규칙에 따른 원래 해석된 대상 pid/name | +| TypeScript / Claude | status posted, submitted true, consumptionConfirmed false; queueId 부재 | 해석된 pid; 기존 TS 스키마에 따라 agent는 생략되고 name은 null일 수 있음 | +| Python / Codex | status queued, submitted true, consumptionConfirmed false; 네이티브 queueId와 codexHome 보존 | 기존 Python 규칙에 따른 원래 thread와 정규화된 home/context | +| TypeScript / Codex | status queued, submitted true, consumptionConfirmed false; 네이티브 queueId와 codexHome 보존 | UUID 비교는 대소문자를 구분하지 않음; agent는 생략될 수 있으며 정규화된 home/context가 일치해야 함 | + +이 표는 합성 긍정 프로필 픽스처를 정의하며 완전한 런타임 봉투나 각 런타임의 기존 검증기를 완화할 권한이 아닙니다. posted status 또는 consumptionConfirmed true인 Codex 스냅샷은 무효입니다. 대상 비교는 원시 사전 전체의 동등 비교가 아니라 프로필을 고려합니다. 허용된 선택적 부재와 null은 계속 허용하되, 잘못된 agent, id/pid 또는 정규화된 home은 실패합니다. 정규화된 home의 일치는 현재 SSH 검증기가 아직 강제하지 않는 런타임에서 추가로 제안된 handoff 관문이며, 출시된 보호를 주장하는 것이 아닙니다. 네이티브 스냅샷 검증은 handoff의 존재나 유효성과 독립적입니다. + +## 공개 스키마 + +다음 문법은 이 제안의 규범입니다. 런타임별 외부 봉투를 제외한 모든 객체는 닫힌 객체입니다. 물음표는 선택 사항이며 이용할 수 없으면 생략됨을 뜻합니다. null 허용 필드는 아래에 명시합니다. 조건부 규칙은 문법 뒤에 나옵니다. + +```text +handoff.schemaVersion = 1 +handoff.correlationId = UUID-v4 lowercase canonical +handoff.ledgerEpoch = UUID-v4 lowercase canonical +handoff.state = validated | refused | submitted | delivered | acknowledged | unknown | timed_out_unknown +handoff.submission.status = not_attempted | submitted | refused | unknown +handoff.observation.status = not_requested | pending | observed | unsupported | failed +handoff.observation.injectionObserved = boolean +handoff.observation.clientUserMessageId? = safe-id +handoff.observation.turn? = {id: safe-id, status: running | completed | failed | interrupted | unknown} +handoff.ack.status = not_requested | pending | acknowledged | unsupported +handoff.ack.assurance? = token_possession | operator_confirmed +handoff.ack.receivedAtUtcMs? = safe-integer +handoff.ack.late? = boolean +handoff.wait.for = none | delivered | acknowledged +handoff.wait.status = not_requested | pending | satisfied | timed_out_unknown | stopped | unsupported | failed +handoff.wait.operationId? = UUID-v4 lowercase canonical +handoff.wait.deadlineAtUtcMs? = safe-integer +handoff.wait.reason? = insufficient_budget | deadline_before_effect | evidence_unsupported | evidence_failed | history_unavailable | stopped_by_operator | invalid_handoff +handoff.targetGeneration = safe-generation | null +handoff.decisionOwner = sender_operator +handoff.retry = {allowed: false, reason: receiver_dedup_unavailable} +handoff.nextActions = unique subset of [keep_waiting, reconcile, stop_waiting] +``` + +전달에는 관찰된 주입과 정확한 clientUserMessageId, null이 아닌 원래 targetGeneration이 필요합니다. 턴 증거에는 관찰된 주입이 필요합니다. 턴은 수신 상태와 별개입니다. 수신 확인의 수락은 주입 증거를 만들어 내지 않습니다. acknowledged 상태에는 assurance, 수신 시각, late 플래그를 갖춘 acknowledged ack가 필요합니다. 다른 ACK 상태는 이 필드들을 생략합니다. submitted, delivered, acknowledged 상태에는 submitted submission이 필요합니다. validated에는 not_attempted가, refused에는 refused가, unknown에는 unknown이 필요합니다. 시간 초과 상태에는 submitted 또는 unknown만 허용되며 refused/not_attempted는 허용되지 않습니다. + +각 명시적 대기에는 효과 발생 전 거절인 경우에도 operationId와 기한이 있습니다. 대기 없는 기록에는 둘 다 없습니다. satisfied인 delivered 대기에는 주입 증거가, satisfied인 acknowledged 대기에는 유효한 ACK가 필요합니다. stopped/failed/unsupported 대기는 알려진 효과 증거를 보존합니다. nextActions는 검증된 활성 채널과 보존된 이력에 따라 달라집니다. 지원되지 않거나 만료된 채널은 keep_waiting을 제공하지 않습니다. stop_waiting은 관찰만 끝내며 네이티브 작업을 취소하지 않습니다. 모든 네이티브 로컬/SSH 결과는 재시도를 금지합니다. v1에는 resend_same_id 동작이 없습니다. 상관관계와 사용자 승인만으로 안전한 재전송이 성립하지 않습니다. 수신자 중복 제거(#182)는 이 재전송 금지 계약 구현의 선행 조건이 아닙니다. + +## 상태와 종료 코드 튜플 + +부재는 레거시 필드가 계속 생략됨을 뜻합니다. 네이티브는 변경되지 않은 런타임별 스냅샷을 뜻합니다. 같은 표가 로컬과 SSH에 적용되며 미구현 페어링 기기 전송에는 적용되지 않습니다. + +| 모드 / 증거 | state | submission | wait status | 외부 status / submitted | ok / 종료 코드 | +| --- | --- | --- | --- | --- | --- | +| 일반 send | 부재 | 부재 | 부재 | 네이티브 / 네이티브 또는 부재 | 네이티브 | +| 옵트인 dry-run | validated | not_attempted | not_requested | 네이티브 / 네이티브 또는 부재 | true / 0 | +| 효과 발생 전 필수 채널 이용 불가 | refused | refused | unsupported | 있으면 네이티브 / 합성 필드 없음 | false / 1 | +| 확실히 효과 발생 전에 예산 소진 | refused | refused | failed | 있으면 네이티브 / 합성 필드 없음 | false / 1 | +| 큐 등록 확인, 최선 노력 관찰자 실패 | submitted | submitted | not_requested | queued / true | true / 0 | +| 큐 등록 확인, 명시적 ACK 기한 만료 | timed_out_unknown | submitted | timed_out_unknown | queued / true | false / 1 | +| 긍정적인 네이티브 프로필, 제출 후 채널 지원 불가 | submitted 또는 delivered | submitted | unsupported | 네이티브 / 네이티브 또는 부재 | false / 1 | +| 운영자가 진행 중인 명시적 대기를 중단 | submitted, delivered 또는 unknown | submitted 또는 unknown | stopped | 네이티브 / 네이티브 또는 부재 | false / 130 | +| 주입 확인, ACK 기한 만료 | timed_out_unknown | submitted | timed_out_unknown | 네이티브 / 네이티브 또는 부재 | false / 1 | +| 효과 발생 가능, 수신 확인 없음 | unknown | unknown | not_requested | 네이티브 / 네이티브 또는 부재 | false / 1 | +| 유효한 수신 확인으로 명시적 대기 충족 | acknowledged | submitted | satisfied | 네이티브 / 네이티브 또는 부재 | true / 0 | +| 이전 시간 초과의 상태 조회 성공 | timed_out_unknown | submitted | timed_out_unknown | 부재 / 부재 | true / 0 | +| 중단된 대기의 상태 조회 성공 | 보존된 상태 | 보존된 제출 | stopped | 부재 / 부재 | true / 0 | + +잘못된 플래그, 잘못된 시간 초과 표기, 누락된 대상, 잘못된 ID는 사용법/대상 없음 종료 코드 2를 유지하며 JSON/handoff가 전혀 없을 수도 있습니다. 이러한 실패에 합성 상관관계 ID나 레거시 false/null을 만들어 넣지 않습니다. SIGINT를 포함하여 진행 중인 명시적 send/wait 작업을 중단하면 취소/재전송 없이 wait stopped와 reason stopped_by_operator를 종결 기록합니다. 완전한 구조화 출력이 있으면 해당 작업은 알려진 네이티브/제출/주입 사실을 보존하면서 ok false/종료 코드 130을 반환합니다. SSH는 원래의 명시적 대기 요청이고 target/home/결합이 일치하는 검증된 stopped 대기인 경우에만 이 예외를 수락합니다. 운영자가 의도적으로 진행 중인 대기를 멈추는 경우도 새 API 표기 없이 같은 규칙을 따릅니다. 이미 종결된 대기는 불변입니다. 인터럽트는 커밋된 시간 초과, 충족 또는 다른 종결 결과를 stopped로 다시 쓸 수 없습니다. 그 중단 기록의 성공한 상태 조회는 ok true/종료 코드 0을 사용합니다. 부분 출력이나 출력 없는 인터럽트는 독립적으로 알려진 증거를 보존하고 나머지는 불확실성으로 유지하며, 합성 false 필드, 폴백 또는 재전송을 만들지 않습니다. 성공한 상태 조회는 조회 성공을 보고하며 확인 응답을 뜻하지 않습니다. + +## 기능과 부트스트랩 + +모든 기능은 현재 제품 지원이 아닌 향후 수락 관문입니다. 경로는 ACK 지원을 표시하기 전에 수신 확인 소비자, 생산자, 불변의 원래 대상 세대를 독립적으로 검증해야 합니다. null 세대는 수신 확인 권한을 발급하거나 필수 delivered/acknowledged 대기를 지원할 수 없습니다. 해당 요구는 효과 발생 전에 wait unsupported로 거절합니다. 부트스트랩 실패는 unsupported이며 일반 메시지 전달을 시도할 권한이 아닙니다. 새로 발견한 현재 세대는 원래 결합을 대신하지 않습니다. 대상 재시작은 새 제출/재결합을 막지만, 원래 세대에 결합된 지연 수신 확인을 그 자체로 무효화하지 않습니다. 검증된 원래 튜플과 원래 권한은 위임된 토큰 소유를 증명할 뿐 원래 에이전트/모델 신원을 증명하지 않습니다. 후속 에이전트의 새 세대 값은 거절합니다. 복사된 원래 튜플과 원래 권한을 모두 가진 후속 에이전트나 동일 사용자 독자는 token_possession 보증 아래에서 구별할 수 없습니다. 이 방식이 모델 신원을 인증하여 그러한 재생을 배제한다고 주장하지 마세요. + +| 경로 / 관찰자 | 주입 관찰 | 수신 확인 ACK | +| --- | --- | --- | +| 동일 기기의 비공개 수집기와 호환 생산자 | 버전/home/writer/ID 검증 후 Codex만 | 비공개 부트스트랩 후 지원 | +| 설치된 호환 수신 확인 핸들러로 향하는 명시적으로 신뢰된 역방향 SSH | 별도로 검증된 Codex 경로 | 핸들러/설정 검증 후 지원 | +| 호환 핸들러 설치 없이 소스를 스트리밍하는 SSH | 별도로 검증된 Codex 경로 | 지원되지 않음 | +| Claude 네이티브 관찰자 | 기본적으로 지원되지 않음 | 호환되는 명시적 수신 확인 생산자만 | +| 페어링 기기, Relay, Side Session, wake | 초기 경로 범위 밖 | 지원되지 않음 | + +필수 delivered 또는 acknowledged 대기는 해당 채널이 지원되지 않으면 효과 발생 전에 거절합니다. 최선 노력의 상관관계 연결 send는 사용되지 않을 비밀 권한을 발급하지 않고 observation/ack unsupported로 제출할 수 있습니다. 수집기는 전달, wake, 모델 프롬프트 또는 승인을 호출하지 않습니다. 네이티브 app-server에서 큐로의 폴백은 원래 네이티브 제출이 시작되었을 가능성이 생기기 전에만 허용됩니다. 시도 후의 불확실성과 관찰자 실패는 절대로 또 다른 제출을 유발하지 않습니다. 버전, 플랫폼, home, 원래 writer 식별 정보 검사는 필수입니다. 트랜스크립트나 임의 답변 본문을 스캔하지 않습니다. + +## 수신 확인의 전송 형식과 수명 주기 + +Receipt v1은 Reply-To v1과 별개이며, Reply-To v1의 파서와 권한은 변경되지 않습니다. 수신 확인 핸들은 비밀이 아닌 로컬 선택자이며 URI나 권한이 아닙니다. 실제 bearer 권한은 승인된 비공개 효과 입력과 비공개 핸들러 stdin을 통해서만 전달됩니다. 비밀 비노출 보장은 송신자/수집기의 argv, URI, 환경, 일반 JSON, 진단, 오류/ACK 결과와 자체 원시 저널/영속 저장에 적용됩니다. 의도된 수신자의 네이티브 큐, 이력 또는 트랜스크립트에는 적용되지 않습니다. 네이티브 효과 입력은 CLI 통제 밖에서 그곳에 보존될 수 있으며 동일 사용자 독자는 위임된 수신 확인 전용 권한을 얻을 수 있습니다. token_possession은 독립적인 에이전트 인증이 아닙니다. 우리 관찰자는 여전히 트랜스크립트를 읽지 않습니다. 철회, 24시간 TTL, 원자적인 첫 수신 확인 수락은 새 수신 확인 권한을 제한하며 수신자의 저장 수명을 제한하지 않습니다. + +```json +{ + "schemaVersion": 1, + "kind": "receipt", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "correlationId": "22222222-2222-4222-8222-222222222222", + "targetGeneration": "fixture-generation", + "receiptId": "33333333-3333-4333-8333-333333333333", + "capability": "" +} +``` + +권한은 암호학적으로 무작위인 32바이트를 패딩 없는 base64url 문자 43개로 인코딩한 값입니다. 예제의 자리표시자는 유효한 전송 입력이 아닙니다. 보호된 수집기는 epoch, 상관관계, 원래 세대에 결합된 권한의 SHA-256 해시만 저장합니다. 읽기 전용 중복을 포함한 모든 핸들러 요청에서 상수 시간 검증이 필요합니다. 원자적이고 영속적인 첫 수신 확인 커밋에는 수신 확인 ID/결합, 입증된 분류, assurance, late 플래그, 수집기 수신 시각 및 이를 뒷받침하는 대기 순서/기한 사실이 포함됩니다. 상태와 관련 대기 전이는 이후의 별도 쓰기가 아니라 이 기록과 일관되게 커밋합니다. 잘못된 ID, 잘못된 토큰, 후속 세대, 충돌하는 수신 확인, 만료된 미커밋 권한, 잘못된 프레임은 상태를 전진시킬 수 없습니다. ACK 도착 시각은 수집기가 지정하며 호출자 타임스탬프는 권한 있는 근거가 아닙니다. + +수집기는 송신자 대화와 독립적인 사용자 소유 프로세스입니다. 파일/디렉터리는 비공개 0600/0700입니다. 엔드포인트는 로컬 소유자 검사를 수행하는 비공개 IPC 또는 명시적으로 설정된 신뢰할 수 있는 역방향 SSH 핸들러입니다. 공개 리스너, 자동 SSH 키 설치, 프로필 변경, 트랜스크립트 읽기, 비밀 내보내기, 범용 CLI exec는 허용되지 않습니다. 재시작은 커밋된 수신 확인 해시와 분류된 수신 확인을 복구합니다. 시도된 의도에 대해 잃어버린 원시 권한을 다시 생성하지 않습니다. 권한 수명은 의도 커밋부터 24시간이며 상세 정보 만료, 철회, 격리 또는 첫 유효 수신 확인과 함께 끝납니다. 유일한 만료 예외는 이미 커밋된 동일한 수신 확인의 읽기 전용 조회입니다. 원래 epoch, 상관관계, 세대, receiptId와 원래 권한 해시에 대한 상수 시간 증명을 모두 요구합니다. TTL 만료/첫 수신 확인으로 인한 권한 소모는 새 수신 확인의 수락이나 상태 생성/승격을 허용하지 않습니다. 철회, 격리, 상세 정보 누락/만료 또는 잘못된 토큰은 이 예외도 거절합니다. 식별자만으로 일반 상태/조회 권한이 생기지 않습니다. 승인된 송신자 운영자의 상태 조회는 별도의 소유자 검사를 수행하는 로컬 인터페이스를 사용합니다. + +수동 생산자는 제안된 무제출 handoff confirm 작업입니다. 정확한 epoch/ID/원래 세대와, 관찰된 명시적 상관관계 연결 답변에 대한 로컬 송신자 운영자의 진술이 필요합니다. stdin은 메타데이터만 받으며 트랜스크립트/본문은 받지 않습니다. operator_confirmed를 기록하고 token_possession은 절대 기록하지 않습니다. 확인은 새 현재 증언/이벤트이며 과거 수신 확인의 도착 시각에 대한 소급 증거가 아닙니다. 영속적인 종결 대기 사실 등으로 원래 대기의 순서/늦음 분류를 입증할 수 있을 때만 acknowledged가 될 수 있습니다. 그렇지 않으면 ok false, 종료 코드 1, 고정 reason receipt_order_unprovable로 확인을 거절합니다. 원래 pending/unknown ACK와 원래 대기를 보존하고, 수락된 ACK를 커밋하거나 late 불리언을 만들어 넣지 않습니다. 이미 커밋된 유효한 수신 확인은 이 확인 실패로 하향 조정하거나 교체하지 않습니다. 토큰 소유 생산자는 비공개 입력을 읽는 설치된 호환 수신 확인 전용 핸들러여야 하며 임의 명령을 실행할 수 없습니다. 두 생산자는 모두 구현 수락 관문으로 남아 있습니다. 생산자가 검증되기 전에는 어떠한 assurance도 표시하지 않습니다. + +## 송신자 원장, 신규성 및 보존 + +단일 비공개 직렬화 영속 원장이 무작위 epoch를 소유합니다. 대상에 결합된 준비된 의도는 효과 발생 전에 커밋하고 동기화합니다. 효과 의도 펜스가 커밋되면 온전한 해당 epoch 안에서 네이티브 시도는 단 한 번만 승인됩니다. 해당 커밋 이후 충돌은 실제 효과 시작 전의 충돌도 포함하여 unknown으로 복구되며 절대로 다시 제출하지 않습니다. 결합에는 에이전트, 대상, home/writer 식별 정보, 세대, 보호된 내부 페이로드 다이제스트가 포함됩니다. 다이제스트는 공개 출력이 아닙니다. 충돌이나 대상 재시작은 의도의 결합을 암묵적으로 바꾸지 않습니다. + +새 ID는 자동 준비 또는 새로운 무효과 handoff prepare 작업이 생성하고 예약합니다. 호출자가 제공한 알 수 없는 UUID는 신규성을 증명할 수 없으므로 효과 발생 전에 거절합니다. 제공된 알려진 준비 ID는 정확한 결합과 일치하고 시도되지 않은 상태여야 합니다. 제공된 시도 완료 ID는 조회/조정 전용이며 또 다른 send에는 사용할 수 없습니다. 순서가 있는 fanout은 대상마다 의도 하나를 한 번 생성하고 영속적으로 연결합니다. 호출자는 ID 하나를 여러 대상에 재사용할 수 없습니다. 조정은 ID나 권한을 다시 생성하지 않습니다. + +epoch당 한도는 보존된 의도/펜스 기록 10,000개와 32 MiB 저널 중 먼저 차는 쪽입니다. 수용 시 제출 후 용량을 채우는 대신 효과 발생 전에 종결 기록과 수신 확인 하나를 위한 제한된 저장 공간을 예약합니다. 펜스 축출/LRU는 허용되지 않습니다. 상세 정보 보존 기간은 준비부터 30일, 권한 수명은 24시간, 의도당 최대 대기는 64회입니다. 만료된 상세 정보는 대상에 결합된 간결한 펜스 tombstone으로 바뀌어 epoch 전체 기간 동안 보존됩니다. tombstone도 할당량에 포함됩니다. 대기 할당량 소진은 네이티브 효과 사실을 바꾸지 않고 새 대기를 거절합니다. 상세 정보 정리는 제출 권한을 복구하지 않습니다. 할당량 소진 시 자동 초기화가 아닌 명시적 운영자 보관 처리 전까지 준비/효과를 거절합니다. + +누락, 손상, 상세 정보 만료 또는 알려진 복원 이력은 status/reconcile에서 unknown을 반환하며 재시도는 금지됩니다. 알려진 tombstone은 원래 epoch와 대상 결합을 보존합니다. state/submission unknown이고 ACK/주입 주장과 keep_waiting이 없는 일반 handoff를 반환할 수 있습니다. 원장 누락/손상 또는 알 수 없는 ID는 그 맥락을 복구할 수 없으므로 handoff나 만들어 낸 epoch 없이 아래의 별도 조회 오류 봉투를 반드시 반환해야 합니다. 기존 원장은 절대로 자동 재생성하지 않습니다. 첫 사용에는 명시적 로컬 초기화와 새 epoch가 필요합니다. 알려진 복원은 모든 이전 의도를 격리하여 보존된 증거 조정 전까지 효과와 수신 확인 수락을 비활성화합니다. 새 epoch에는 명시적 운영자 초기화와 생성된 새 ID가 필요하며 이전 요청에 대해서는 어떤 주장도 하지 않습니다. 로컬 전용 설계로는 표시되지 않은 롤백이나 기기 간 원장 복제를 신뢰성 있게 감지할 수 없습니다. 펜싱 보장은 온전하고 보존되어 있으며 롤백되지 않았음이 입증된 이력으로 한정됩니다. 외부 단조 앵커/복제 방지 저장소는 v1 범위 밖입니다. 이 제한은 보편적인 exactly-once 주장 뒤에 숨기지 않고 계속 드러나야 합니다. + +제안된 조회 오류는 종료 코드 1을 사용합니다. handoffQuery는 표시된 필드만 갖는 닫힌 스키마이며 context는 ledger_missing, ledger_corrupt 또는 id_unknown입니다. 호출자가 제공한 문법적으로 유효한 correlationId는 그대로 반환하지만 이력의 증거로 취급하지 않습니다. ledgerEpoch, handoff, 최상위 status/submitted/consumptionConfirmed 또는 합성 네이티브 스냅샷을 추가하지 않습니다. 외부 host/command는 조회 대상과 handoff이며 reason은 handoff_history_unavailable입니다. 이는 옵트인 조회 결과이며 일반 레거시 오류를 변경하지 않습니다. + +```json +{ + "schemaVersion": 1, + "ok": false, + "host": "fixture", + "command": "handoff", + "reason": "handoff_history_unavailable", + "handoffQuery": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "status": "unknown", + "context": "ledger_missing", + "retry": {"allowed": false, "reason": "history_unavailable"} + } +} +``` + +## 대기 이력과 늦은 수신 확인 + +각 대기에는 별개의 작업 ID, 목표, 단조 시계 기한, 불변의 종결 결과가 있습니다. status는 해당 의도에 대해 가장 최근에 시작한 대기(직렬화된 순서)를 반환하며 대기가 없으면 not_requested를 반환합니다. 향후 별도의 대기 이력 API를 암시하지 않습니다. 현재 수신 확인/상태는 독립적으로 전진할 수 있습니다. 따라서 늦은 ACK는 현재 상태를 acknowledged로 바꿀 수 있지만 반환되는 원래 대기는 timed_out_unknown으로 남습니다. 그 대기를 satisfied로 다시 쓰지 않습니다. + +수신 확인이 원래 send 대기의 관찰 기한 시각 또는 이후에 수락되면 늦은 것으로 판단합니다. send 대기가 없으면 가장 이른 명시적 대기와 비교합니다. 대기가 없으면 late는 false입니다. 이후 대기는 이미 수락된 늦은 수신 확인으로 충족될 수 있으며 ack late 플래그는 true를 유지합니다. 활성 상태에서는 수신 확인 수락과 기한 전이를 같은 단조 시계 기준으로 직렬화하며, 입증된 순서/분류를 원자적으로 영속 커밋합니다. 완전히 커밋된 유효 ACK는 원래 분류, assurance, late 및 불변의 대기 사실을 복구합니다. 재시작한 시계가 달라졌다는 이유만으로 절대로 하향 조정하지 않습니다. 커밋 전 비정상 종료나 그 밖의 미커밋/미분류 타이밍 증거는 acknowledged가 되거나 합성 late 플래그를 제공할 수 없습니다. 필수 대기를 미충족으로, 기존 pending/unknown ACK를 그대로 유지한 채 미분류 증거로 보존합니다. 수동 확인은 위의 입증 또는 거절 규칙을 따르는 새 이벤트이며 소급하여 다시 쓰지 않습니다. UTC 타임스탬프는 진단 전용입니다. 반복 대기는 새 네이티브 제출을 만들지 않습니다. + +## 예산과 한계 + +기본 예산은 대상당 30초입니다. CLI는 선행 0, 부호, 공백, 소수점, 지수 없이 ASCII 십진 정수 1부터 60까지만 허용합니다. 새 플래그는 레거시 파싱을 변경하지 않습니다. 전체 단조 시계 예산은 설정, 사전 검사, 준비, 효과, 관찰보다 먼저 시작합니다. 그 안에서 정리를 위해 정확히 5초를 예약합니다. 예산이 5초 이하이면 insufficient_budget으로 효과 발생 전에 거절합니다. 관찰/효과 마감은 시작 시각에 예산을 더하고 5초를 뺀 값입니다. 정리가 시작 시각에 예산을 더한 한계를 연장할 수 없습니다. 대기 기한 진단은 이 마감을 설명하며 갱신된 원격 예산이 아닙니다. + +SSH는 남은 기간만 전달하고 원래 호출자 기한을 유지합니다. 원격 핸들러는 예산을 초기화하거나 UTC로 효과를 승인할 수 없습니다. 남은 전체 예산이 정리 예약 시간 이하이면 효과 발생 전에 거절합니다. 마감 경계에서 now >= cutoff를 만료로 사용합니다. 마감보다 엄격히 앞서 커밋된 수신 확인은 해당 대기를 충족할 수 있으며 마감 시각 또는 이후의 확인은 늦은 확인입니다. 효과 발생 가능성이 생기면 시간 초과는 unknown입니다. 재시도/폴백 또는 새 제출은 허용되지 않습니다. 순서가 있는 fanout은 각 대상에 자체적으로 제한된 예산을 부여하며 한 호스트의 예산을 반복하지 않습니다. 제한된 정리/소유 리소스를 보장할 수 없는 구현은 해당 경로를 지원한다고 표시할 수 없습니다. + +```text +safe-integer = JSON integer token, 0..9007199254740991; booleans/fractions/exponents rejected +safe-id = 1..128 UTF-8 bytes, no U+0000..001F or U+007F..009F, valid Unicode scalars +safe-generation = 1..256 UTF-8 bytes, same control/scalar rule +receipt/confirmation frame = raw strict UTF-8 JSON input, at most 4096 bytes including LF/whitespace +public handoff size = compact UTF-8 encoding of the handoff subtree, at most 8192 bytes +opt-in destination stdout frame = at most 1048576 bytes including LF/whitespace +opt-in fanout = at most 32 destinations and 34603008 total stdout bytes including LF/whitespace +duplicate JSON keys, unknown nested keys, NaN/Infinity and trailing data rejected +turn/native IDs are opaque; only correlation/epoch/wait/receipt IDs use UUID-v4 syntax +``` + +JSON 숫자 토큰은 손실이 있는 파싱 전에 검증해야 합니다. JavaScript Number가 1과 같더라도 어휘 형태 1.0과 1e0은 거절합니다. 어휘 제한은 handoff/handoffQuery 하위 트리와 비공개 수신 확인/확인 스키마에만 적용하며 관계없는 레거시 외부 필드에는 적용하지 않습니다. 기존 레거시 파싱 및 중복 키/비유한 수 규칙을 유지합니다. handoff 요청이 있다는 이유만으로 레거시 필드가 무효가 되지 않습니다. handoff 하위 트리 크기는 정규 간결 인코딩으로 계산합니다(선택적 공백 없음, ASCII 이스케이프가 아닌 UTF-8 스칼라 문자, 쉼표/콜론 구분자). 이를 감싼 전송 형식의 하위 트리 공백은 대신 외부 프레임 한도에 포함됩니다. 비공개 수신 확인/확인의 원시 입력은 선택적 후행 JSON 공백과 LF를 포함한 모든 바이트를 포함합니다. 뒤따르는 공백 아닌 데이터는 거절합니다. 개행을 위한 추가 허용량은 없습니다. 대상/집계 한도는 새 옵트인 모드에만 적용합니다. 효과 발생 전에 과도한 대상 수를 거절하고 일반 옵트아웃 전송/출력 한도는 바꾸지 않습니다. 할당 전에 stdout을 점진적으로 제한합니다. 제한된 집계 형식은 간결한 JSON과 선택적 LF입니다. 제한된 완전한 외부 프레임 안의 선택적 하위 트리가 잘못되거나 크기 한도를 초과하면 독립적으로 검증된 네이티브 증거를 보존하고 ACK/명시적 대기를 거절하며 절대로 재제출하지 않습니다. 외부 프레임 한도를 초과하면 부분 파싱으로 성공을 만들지 않습니다. 이미 독립적으로 검증된 증거만 보존하고 그렇지 않으면 unknown을 반환합니다. 한계는 잘라낸 후가 아닌 영속 저장 전에 검사합니다. 송신자/수집기의 일반 stdout, 진단, 오류/ACK 결과와 자체 저널에는 권한이나 페이로드 다이제스트가 절대로 포함되지 않습니다. 수신자의 트랜스크립트/본문은 읽지 않습니다. 제한된 메타데이터 관찰이 출력 필터링 뒤에 본문 반환 API를 숨겨서는 안 됩니다. + +## 제안된 CLI와 무제출 작업 + +이 표기는 제안일 뿐 게시된 Python에서 사용할 수 있는 명령이 아닙니다. 준비는 제출 없이 send와 같은 대상/메시지 입력에 결합하며 확인은 메타데이터만 담은 비공개 stdin을 받습니다. 명시적 로컬 원장 초기화는 별도의 옵트인 설정 선행 조건이며 자동 복구 동작이 아닙니다. + +```text +handoff init +handoff prepare --to TARGET --message-file PRIVATE_FILE +send --to TARGET --message-file PRIVATE_FILE --correlation-id PREPARED_UUID +send --to TARGET --request-ack +send --to TARGET --observe-delivery +send --to TARGET --wait-for delivered|acknowledged --wait-timeout SECONDS +handoff status --correlation-id UUID +handoff wait --correlation-id UUID --wait-for delivered|acknowledged --wait-timeout SECONDS +handoff confirm --receipt - +ack --receipt - +``` + +correlation-id가 없으면 request-ack/observe-delivery/wait-for는 자동으로 새 의도를 예약합니다. 그 외의 일반 send는 변경되지 않습니다. 상관관계만 있는 send는 최선 노력 증거를 사용하고 명시적 대기를 하지 않습니다. status, wait, confirm, ack, init, prepare는 네이티브 제출을 전혀 하지 않습니다. 수신 확인 선택자에는 비밀이 포함되지 않습니다. 확인 stdin에는 schemaVersion, ledgerEpoch, correlationId, targetGeneration과 호출자의 confirmed=true 진술이 들어갑니다. 추가 필드/본문/권한은 거절하고 operator_confirmed만 기록합니다. 플래그 비호환성과 지원되지 않는 경로 요구 사항은 영속 효과 의도 전에 검사합니다. 원격 설정과 호스트 옵션은 임의 수신 확인 라우팅이 아닌 기존 SSH 신뢰 제한을 따라야 합니다. + +ACK 증거에도 null이 아닌 원래 targetGeneration과 submitted submission이 필요하며, 유효한 수신 확인을 수락하면 현재 상태는 acknowledged입니다. 관찰된 주입은 항상 submitted submission을 뜻합니다. 제공된 준비 ID는 효과 발생 전 종결 거절을 포함한 이전 실제 send 호출에 속하지 않아야 합니다. dry-run은 첫 제출 권한을 소모하지 않습니다. dry-run은 request-ack, observe-delivery, wait-for와 함께 사용할 수 없으며 사용법 종료 코드 2를 반환합니다. correlation-only dry-run은 이미 준비된 ID를 사용합니다. + +재시작한 수집기가 권한 수명의 단조 시계 연속성을 증명할 수 없으면 TTL을 연장하거나 권한을 재생성하지 않고 사용되지 않은 새 수신 확인 권한을 보수적으로 만료시킵니다. 유효하게 커밋된 수신 확인의 분류/assurance/late는 계속 복구할 수 있습니다. 인증된 동일 수신 확인의 읽기 전용 중복은 명시된 만료 예외를 따릅니다. 이미 시도한 의도에 대한 새 대기의 예산이 부족하면 해당 대기만 실패하며 제출/주입 사실은 보존합니다. 원래 의도를 refused로 다시 분류하지 않습니다. reconcile 동작은 상태/증거 대조이며 새 제출이나 암묵적 복구 명령이 아닙니다. + +## 확정과 구현 관문 + +이 제안은 확정 전에 정확한 커밋과 픽스처에 대한 TypeScript 검토가 필요합니다. 새 prepare/confirm 표기, ledgerEpoch와 대기 operationId 필드, 5초 정리 예약, 숫자 토큰 검증, 복원 제한은 명시적인 v0.2 개정 사항이며 이미 수락되거나 출시된 기능이 아닙니다. + +픽스처는 런타임별 선택적 부재, 요청 맥락으로 허용되는 queued true 명시적 대기 시간 초과, 위조된 옵트아웃 handoff, 잘못된 네이티브 대상, 인증된/잘못된 토큰/만료된 중복, 커밋된/커밋 전/순서 불명 수신 확인 복구, 수동 확인 거절, 원래/새/null 세대, 비밀 반사 없음, 맥락 누락 조회 오류와 알려진 tombstone의 구분, 대기 이력, 누락/복원/만료/가득 찬 원장, 사용자 지정 알 수 없는 ID, 기한/프레임 경계, 잘못된 스키마, SSH 보존을 다뤄야 합니다. 엄격한 SSH 옵트인 검증은 handoff를 거절하더라도 유효한 네이티브 스냅샷을 보존해야 합니다. 레거시 Claude 재구성이나 일반 실패 경로는 이를 버릴 수 없습니다. 완전한 응답은 원래 요청을 인식하는 동일한 엄격한 파서를 통해서만 전송 시간 초과/0이 아닌 종료 코드 이후에도 보존됩니다. 부분 프레임은 unknown으로 남습니다. + +구현 수락 전에는 생산자 부트스트랩과 수집기 소유권/재시작, 영속 의도와 용량 예약, 지원되는 각 플랫폼/버전/home에서의 정리, 메타데이터 전용 Codex 관찰, 엄격한 로컬/SSH 튜플, 무제출 status/wait/ack/confirm 작업을 검증해야 합니다. 이 설계 PR은 네이티브/실제 실행 증거를 제공하지 않습니다. Side Session과 wake 실험은 선행 조건이 아닌 별도의 향후 작업입니다. 페어링 기기/Relay 중복 제거와 운영자 모니터링을 암묵적으로 추가하지 않습니다. diff --git a/docs/zh-CN/contracts/handoff-v1.md b/docs/zh-CN/contracts/handoff-v1.md new file mode 100644 index 00000000..8c0e2ccf --- /dev/null +++ b/docs/zh-CN/contracts/handoff-v1.md @@ -0,0 +1,203 @@ +# Handoff v1 契约 — 设计提案 v0.2 + +## 状态与来源 + +这是面向 Python #181 和 TypeScript 0.4.0 设计审查的未实现设计。它不是已冻结的协议、发布承诺,也不是实际 ACK 支持的证据。合并本文档不授权运行时实现、发布、原生提交或运营变更。夹具包含的是合成示例,而非已记录的投递。 + +已发布的 Python 参考版本是 [v1.0.3](https://github.com/abruption/session-peer/tree/0d252550ab40c26d1ac4a19193df6ca2f84d830b)。常规输出的源代码是 [adapters](https://github.com/abruption/session-peer/blob/0d252550ab40c26d1ac4a19193df6ca2f84d830b/session_peer_core/adapters.py)、[Codex](https://github.com/abruption/session-peer/blob/0d252550ab40c26d1ac4a19193df6ca2f84d830b/session_peer_core/codex.py)、[SSH](https://github.com/abruption/session-peer/blob/0d252550ab40c26d1ac4a19193df6ca2f84d830b/session_peer_core/ssh.py) 和 [output](https://github.com/abruption/session-peer/blob/0d252550ab40c26d1ac4a19193df6ca2f84d830b/session_peer_core/output.py)。[共享夹具](https://github.com/abruption/session-peer/blob/main/tests/fixtures/handoff-v1.json) 区分了规范化的旧版示例与提议的显式启用示例。运行时参考版本与设计夹具的固定版本相互独立:不会将 TypeScript 已发布的历史参考版本悄然改为尚未发布的 Python 版本。 + +## 证据与兼容性 + +提交指原生传输层的接受;投递指准确的原始用户消息 ID 被注入准确的原始目标代次的证据。确认应答是具有明确关联的回执,不代表完成、承担责任,也不证明模型读过每个字节。删除队列、进程退出、轮次完成、没有回复以及成功的状态查询都不是 ACK。未知的原生轮次状态绝不会变成 completed。 + +常规 send 输出继续保留各运行时的格式。Python Claude 成功时省略 status、submitted 和 consumptionConfirmed;Codex 的 queued 成功包含 submitted true 和 consumptionConfirmed false。submitted false 的旧版 unknown 不是未发生任何效果的普遍证明。不要添加缺失的 false/null 字段、重新解释可选字段的缺失、覆盖 consumptionConfirmed 或更改常规退出码。规范化的夹具值只隐藏身份信息;每个示例都记录来源,但不构成完整的输出模式。 + +显式启用会在现有外层 schemaVersion 1 下,向每个目标结果添加一个 handoff 对象。不存在 fanout 层级的 handoff。显式等待只能更改外层 ok 和退出码:它保留原生 status、target、queueId、submitted、consumptionConfirmed,以及所有独立有效的提交证据。结果接收方必须使用自己的原始请求上下文来选择验证模式和运行时/代理原生配置,不能使用不可信的返回字段。只有原始请求是显式等待、handoff 经过完整验证、具有肯定的原生配置且匹配原始 target/home/context 时,才允许等待失败例外。超时、失败和提交后不支持使用 ok false/退出码 1;中断待处理的等待使用 ok false/退出码 130。仅收到 handoff 绝不会在未启用请求中允许任一种例外。尽力而为的观测失败不能改变原生提交成功。无效或超限的 handoff 不能抹除单独验证且目标正确的原生快照,也不能将其提升为 ACK;显式等待会验证失败,且不会重新提交。错误的原生 target/home 或被修改的消费事实不构成独立有效的原生证据。有效的常规未启用成功在忽略未请求的 handoff 后仍然有效。 + +肯定的原生配置与共用的 handoff submission 枚举相互独立。不要从原生成功不输出的外层 status/submitted 字段推断 Python Claude 的肯定提交,也不要仅为满足守卫而添加这些字段。显式启用适配器必须保留经过验证的等待前原生成功快照;消费方验证该配置和准确的原始上下文,而非只信任 handoff.submission。 + +| 原始运行时 / 代理 | 肯定的原生快照 | 目标/上下文匹配 | +| --- | --- | --- | +| Python / Claude | status、submitted 和 consumptionConfirmed 缺失;保留现有肯定套接字写入的 target 字段 | 依据 Python 现有规则的原始已解析目标 pid/name | +| TypeScript / Claude | status posted、submitted true、consumptionConfirmed false;queueId 缺失 | 已解析的 pid;依据现有 TS 模式,agent 可缺失,name 可为 null | +| Python / Codex | status queued、submitted true、consumptionConfirmed false;保留原生 queueId 和 codexHome | 依据 Python 现有规则的原始 thread 和规范化 home/context | +| TypeScript / Codex | status queued、submitted true、consumptionConfirmed false;保留原生 queueId 和 codexHome | UUID 比较保持不区分大小写;agent 可缺失;规范化 home/context 必须匹配 | + +此表定义合成的肯定配置夹具,不是完整的运行时封装,也不授权放宽各运行时现有验证器。status 为 posted 或 consumptionConfirmed true 的 Codex 快照无效。目标比较须考虑配置,不能直接比较整个字典:允许的可选字段缺失和 null 仍然允许,但错误的 agent、id/pid 或规范化 home 必须失败。对于当前 SSH 验证器尚未强制要求的运行时,规范化 home 匹配是新增的提议 handoff 条件,不是已发布保护的声明。原生快照验证独立于 handoff 是否存在或有效。 + +## 公开模式 + +以下语法是本提案的规范。除运行时特有的外层封装外,所有对象都是封闭对象。问号表示可选且不可用时省略;可为 null 的字段在下方明确列出。条件规则列于语法之后。 + +```text +handoff.schemaVersion = 1 +handoff.correlationId = UUID-v4 lowercase canonical +handoff.ledgerEpoch = UUID-v4 lowercase canonical +handoff.state = validated | refused | submitted | delivered | acknowledged | unknown | timed_out_unknown +handoff.submission.status = not_attempted | submitted | refused | unknown +handoff.observation.status = not_requested | pending | observed | unsupported | failed +handoff.observation.injectionObserved = boolean +handoff.observation.clientUserMessageId? = safe-id +handoff.observation.turn? = {id: safe-id, status: running | completed | failed | interrupted | unknown} +handoff.ack.status = not_requested | pending | acknowledged | unsupported +handoff.ack.assurance? = token_possession | operator_confirmed +handoff.ack.receivedAtUtcMs? = safe-integer +handoff.ack.late? = boolean +handoff.wait.for = none | delivered | acknowledged +handoff.wait.status = not_requested | pending | satisfied | timed_out_unknown | stopped | unsupported | failed +handoff.wait.operationId? = UUID-v4 lowercase canonical +handoff.wait.deadlineAtUtcMs? = safe-integer +handoff.wait.reason? = insufficient_budget | deadline_before_effect | evidence_unsupported | evidence_failed | history_unavailable | stopped_by_operator | invalid_handoff +handoff.targetGeneration = safe-generation | null +handoff.decisionOwner = sender_operator +handoff.retry = {allowed: false, reason: receiver_dedup_unavailable} +handoff.nextActions = unique subset of [keep_waiting, reconcile, stop_waiting] +``` + +投递需要观测到注入、准确的 clientUserMessageId,以及非 null 的原始 targetGeneration。轮次证据需要观测到注入;轮次与回执状态相互独立。接受回执不会凭空产生注入证据。acknowledged 状态需要 acknowledged ack,并带有 assurance、回执时间和 late 标志;其他 ACK 状态省略这些字段。submitted、delivered 和 acknowledged 状态需要 submitted submission。validated 需要 not_attempted;refused 需要 refused;unknown 需要 unknown。超时状态允许 submitted 或 unknown,绝不允许 refused/not_attempted。 + +每次显式等待都具有 operationId 和截止时间,即使是在效果发生前拒绝。无等待的记录两者都没有。satisfied 的 delivered 等待需要注入证据;satisfied 的 acknowledged 等待需要有效 ACK。stopped/failed/unsupported 等待保留已知的效果证据。nextActions 取决于有效且经过验证的通道以及保留的历史:不受支持或已过期的通道绝不提供 keep_waiting。stop_waiting 只结束观测,不取消原生工作。所有原生本地/SSH 结果都禁止重试。v1 中没有 resend_same_id 操作;仅有关联和用户批准不能确立安全重发。接收方去重(#182)不是实现此禁止重发契约的前提条件。 + +## 状态与退出码元组 + +缺失表示旧版字段仍然不存在。原生表示各运行时未更改的快照。同一张表适用于本地和 SSH,不适用于尚未实现的配对设备传输。 + +| 模式 / 证据 | state | submission | wait status | 外层 status / submitted | ok / 退出码 | +| --- | --- | --- | --- | --- | --- | +| 常规 send | 缺失 | 缺失 | 缺失 | 原生 / 原生或缺失 | 原生 | +| 显式启用的 dry-run | validated | not_attempted | not_requested | 原生 / 原生或缺失 | true / 0 | +| 效果发生前所需通道不可用 | refused | refused | unsupported | 存在时为原生 / 无合成字段 | false / 1 | +| 确定在效果发生前耗尽预算 | refused | refused | failed | 存在时为原生 / 无合成字段 | false / 1 | +| 已知入队,尽力而为的观测器失败 | submitted | submitted | not_requested | queued / true | true / 0 | +| 已知入队,显式 ACK 截止时间到期 | timed_out_unknown | submitted | timed_out_unknown | queued / true | false / 1 | +| 肯定的原生配置,提交后通道变为不支持 | submitted 或 delivered | submitted | unsupported | 原生 / 原生或缺失 | false / 1 | +| 操作员中断待处理的显式等待 | submitted、delivered 或 unknown | submitted 或 unknown | stopped | 原生 / 原生或缺失 | false / 130 | +| 已知注入,ACK 截止时间到期 | timed_out_unknown | submitted | timed_out_unknown | 原生 / 原生或缺失 | false / 1 | +| 可能已发生效果,无回执 | unknown | unknown | not_requested | 原生 / 原生或缺失 | false / 1 | +| 有效回执满足显式等待 | acknowledged | submitted | satisfied | 原生 / 原生或缺失 | true / 0 | +| 成功查询先前超时的状态 | timed_out_unknown | submitted | timed_out_unknown | 缺失 / 缺失 | true / 0 | +| 成功查询已停止等待的状态 | 保留的状态 | 保留的提交 | stopped | 缺失 / 缺失 | true / 0 | + +无效标志、无效超时词法形式、缺失目标和格式错误的 ID 保留用法/无目标退出码 2,也可能完全没有 JSON/handoff。不会为这些失败编造合成的关联 ID 或旧版 false/null。中断待处理的显式 send/wait 操作(包括 SIGINT)会终态记录 wait stopped 和 reason stopped_by_operator,且不取消/重发。如果有完整的结构化输出,该操作返回 ok false/退出码 130,并保留已知原生/提交/注入事实;SSH 仅对原始显式等待请求、经过验证的 stopped 等待以及匹配的 target/home/绑定接受这一例外。操作员有意停止待处理的等待也遵循同一规则,不新增 API 拼写。已进入终态的等待不可变:中断不能将已提交的超时、满足或其他终态结果改写为 stopped。成功查询该停止记录的状态使用 ok true/退出码 0。部分/无中断输出保留独立已知的证据,其余保持不确定,绝不编造 false 字段、回退或重发。成功的状态查询报告查询成功,而非确认应答。 + +## 能力与引导 + +每项能力都是未来的验收条件,不是当前的产品支持。在声明支持 ACK 之前,路由必须独立验证回执消费方、生成方以及不可变的原始目标代次。null 代次不能生成回执授权,也不能支持必需的 delivered/acknowledged 等待;该要求会在效果发生前以 wait unsupported 拒绝。引导失败表示 unsupported,不意味着允许尝试通用消息投递。新发现的当前代次绝不替代原始绑定。目标重启会阻止新提交/重新绑定,但本身不会使绑定至原始代次的延迟回执失效。经过验证的原始元组和原始能力凭证证明的是委托的令牌持有,而非原始代理/模型身份。后继者的新代次被拒绝。持有复制的原始元组和原始能力凭证两者的后继者或同一用户的读取者,在 token_possession 保证下无法区分;不得声称本方案通过认证模型身份排除了这种重放。 + +| 路由 / 观测器 | 注入观测 | 回执 ACK | +| --- | --- | --- | +| 同机私有收集器与兼容生成方 | 仅在 version/home/writer/ID 验证后支持 Codex | 私有引导后支持 | +| 通往已安装兼容回执处理器的明确受信任反向 SSH | 单独验证的 Codex 路由 | 处理器/配置验证后支持 | +| 未安装兼容处理器的源码流式 SSH | 单独验证的 Codex 路由 | 不支持 | +| Claude 原生观测器 | 默认不支持 | 仅兼容的显式回执生成方 | +| 配对设备、Relay、Side Session、wake | 初始路由范围之外 | 不支持 | + +如果通道不受支持,要求 delivered 或 acknowledged 的等待会在效果发生前拒绝。尽力而为的关联 send 可以在 observation/ack unsupported 的情况下提交,而不生成未使用的秘密能力凭证。收集器不调用投递、wake、模型提示或批准。原生 app-server 回退至队列,仅在原始原生提交尚不可能已经开始时允许;尝试后的不确定性和观测器失败绝不触发另一次提交。version、platform、home 和原始 writer 身份检查为强制要求。不扫描转录内容或任意回复正文。 + +## 回执线格式与生命周期 + +Receipt v1 与 Reply-To v1 分离,后者的解析器和权限保持不变。回执句柄是非秘密的本地选择器,不是 URI 或授权。实际 bearer 能力凭证只通过已授权的私有效果输入和私有处理器 stdin 传递。不含秘密的保证覆盖发送方/收集器的 argv、URI、环境、常规 JSON、诊断、错误/ACK 结果及其自身的原始日志/持久化。该保证不覆盖预期接收方的原生队列、历史或转录内容:原生效果输入可能在 CLI 控制之外保留于其中,同一用户的读取者可能获得委托的回执专用授权。token_possession 不是独立的代理认证。我们的观测器仍然绝不读取转录内容。撤销、24 小时 TTL 和原子的首个回执接受限制的是新的回执授权,而非接收方的存储寿命。 + +```json +{ + "schemaVersion": 1, + "kind": "receipt", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "correlationId": "22222222-2222-4222-8222-222222222222", + "targetGeneration": "fixture-generation", + "receiptId": "33333333-3333-4333-8333-333333333333", + "capability": "" +} +``` + +能力凭证是 32 个密码学随机字节,编码为 43 个无填充的 base64url 字符。示例占位符不是有效的线格式输入。受保护的收集器仅存储绑定至 epoch、关联和原始代次的能力凭证 SHA-256 哈希;每次处理器请求都必须执行恒定时间验证,包括只读的重复请求。原子且持久的首个回执提交包含回执 ID/绑定、已证明的分类、assurance、late 标志、收集器接收时间,以及支撑它的等待顺序/截止时间事实。状态及相关等待转换与该记录一致地提交,而不通过后续单独写入完成。错误 ID、错误令牌、后继代次、冲突回执、过期且未提交的授权和无效帧不能推进状态。ACK 到达时间由收集器指定;调用方时间戳不具备权威性。 + +收集器是用户拥有的进程,独立于发送方会话。文件/目录为私有 0600/0700;端点为本地经过所有者检查的私有 IPC,或明确配置的受信任反向 SSH 处理器。不允许公开监听器、自动安装 SSH 密钥、更改配置文件、读取转录内容、导出秘密或通用 CLI exec。重启恢复已提交的回执哈希和已分类的回执;对于已尝试的意图,绝不重新生成丢失的原始能力凭证。能力凭证寿命为意图提交后 24 小时,并在详情过期、撤销、隔离或首个有效回执时终止。唯一的过期例外是只读获取已经提交的同一回执:必须提供原始 epoch、关联、代次、receiptId,以及原始能力凭证哈希的恒定时间证明。TTL 过期/首个回执消耗授权不允许接受新回执或创建/提升状态。撤销、隔离、详情缺失/过期或错误令牌连这一例外也会拒绝。仅凭标识符不会获得通用状态/查询权限。已授权的发送方操作员状态查询使用单独的、经过所有者检查的本地接口。 + +手动生成方是提议的无提交 handoff confirm 操作。它要求准确的 epoch/ID/原始代次,以及本地发送方操作员关于已观测到明确关联回复的声明;stdin 只接收元数据,不接收转录/正文。它记录 operator_confirmed,绝不记录 token_possession。确认是新的当前证明/事件,不是旧回执到达时间的追溯证明。只有原始等待的顺序/迟到分类可被证明时,例如依据持久的终态等待事实,才可成为 acknowledged。否则以 ok false、退出码 1 和固定 reason receipt_order_unprovable 拒绝确认;保留原始 pending/unknown ACK 和原始等待,不提交已接受的 ACK,也不编造 late 布尔值。已提交的有效回执绝不会因这次失败的确认而降级或被替换。令牌持有生成方必须是已安装且兼容的回执专用处理器,读取私有输入;它不能执行任意命令。两种生成方均仍是实现验收条件。在验证生成方之前,不声明任何 assurance。 + +## 发送方账本、新鲜性与保留 + +单一私有、串行化的持久账本拥有一个随机 epoch。其绑定目标的已准备意图在效果发生前提交并同步;效果意图屏障提交后,在该完整 epoch 中只授权一次原生尝试。该提交后的崩溃,包括实际效果开始前的崩溃,均恢复为 unknown,绝不再次提交。绑定包含 agent、目标、home/writer 身份、代次和受保护的内部载荷摘要;摘要不是公开输出。冲突或目标重启绝不悄然重新绑定意图。 + +新 ID 通过自动准备或新的无效果 handoff prepare 操作生成并预留。调用方提供的未知 UUID 因无法证明新鲜性,在效果发生前拒绝。提供的已知准备 ID 必须匹配其准确绑定,且尚未尝试。提供的已尝试 ID 仅用于查询/核对,绝不用于另一次 send。有序 fanout 为每个目标仅生成并持久关联一个意图;调用方不能将同一 ID 复用于多个目标。核对绝不重新生成 ID 或能力凭证。 + +每个 epoch 的上限为保留的 10,000 条意图/屏障记录和 32 MiB 日志,以先达到者为准。准入在效果发生前为终态记录及一个回执预留有限存储,而非在提交后填满容量。不允许屏障淘汰/LRU。详情自准备起保留 30 天,能力凭证寿命为 24 小时,每个意图最多 64 次等待;过期详情变为紧凑的目标绑定屏障 tombstone,在整个 epoch 内保留。tombstone 计入配额。等待配额耗尽时拒绝新的等待,不改变原生效果事实。清理详情绝不恢复提交权限;配额耗尽时拒绝准备/效果,直到操作员明确归档,不进行自动重置。 + +缺失、损坏、详情过期或已知被恢复的历史使 status/reconcile 返回 unknown,并禁止重试。已知的 tombstone 保留其原始 epoch 和目标绑定:它可以返回常规 handoff,state/submission 为 unknown,不声明 ACK/注入,也不提供 keep_waiting。缺失/损坏的账本或未知 ID 无法恢复该上下文,必须返回下方单独的查询错误封装,不包含 handoff,也不编造 epoch。已有账本绝不自动重建;首次使用要求明确的本地初始化和新的 epoch。已知恢复会隔离所有旧意图,在核对保留证据之前禁用效果和回执接受。新 epoch 要求操作员明确初始化并生成新的 ID;它不对旧请求作任何保证。仅本地的设计无法可靠检测未标记的回滚或跨机器账本克隆:屏障保证仅限于已证明完整、保留且未回滚的历史。外部单调锚点/防克隆存储不在 v1 范围内;这一限制必须保持可见,不能隐藏在普遍的 exactly-once 声明背后。 + +提议的查询错误使用退出码 1。其 handoffQuery 是只包含所示字段的封闭模式;context 为 ledger_missing、ledger_corrupt 或 id_unknown。调用方语法有效的 correlationId 会原样返回,不被视为历史证明。不添加 ledgerEpoch、handoff、顶层 status/submitted/consumptionConfirmed 或合成的原生快照。外层 host/command 为查询目标和 handoff,reason 为 handoff_history_unavailable。这是显式启用的查询结果,不改变常规旧版错误。 + +```json +{ + "schemaVersion": 1, + "ok": false, + "host": "fixture", + "command": "handoff", + "reason": "handoff_history_unavailable", + "handoffQuery": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "status": "unknown", + "context": "ledger_missing", + "retry": {"allowed": false, "reason": "history_unavailable"} + } +} +``` + +## 等待历史与迟到回执 + +每次等待都有独立的操作 ID、目标、单调时钟截止时间以及不可变的终态结果。status 返回该意图最近开始的等待(按串行化序列顺序),若没有则返回 not_requested;这不暗示未来会有单独的等待历史 API。当前回执/状态可以独立推进。因此,迟到 ACK 可以将当前状态设置为 acknowledged,而返回的原始等待仍保持 timed_out_unknown。它绝不将该等待改写为 satisfied。 + +如果回执在原始 send 等待的观测截止时间或之后被接受,则为迟到;若没有 send 等待,则与最早的显式等待比较。若没有等待,late 为 false。之后的等待可以由已接受的迟到回执满足;ack 的 late 标志仍为 true。在运行期间,回执接受与截止时间转换按同一单调时钟串行化,已证明的顺序/分类以原子方式持久提交。完整提交的有效 ACK 恢复其原始分类、assurance、late 及不可变的等待事实:绝不会仅因重启后的时钟变化而降级。提交前崩溃或其他未提交/未分类的时序证据不能成为 acknowledged,也不能提供合成的 late 标志。将其保留为未分类证据,必需的等待保持未满足,现有 pending/unknown ACK 保持不变。手动确认是遵循上述证明或拒绝规则的新事件,绝不追溯改写。UTC 时间戳仅用于诊断。重复等待不会产生新的原生提交。 + +## 预算与边界 + +默认预算为每个目标 30 秒;CLI 仅接受 1 至 60 的 ASCII 十进制整数,不允许前导零、符号、空白、小数点或指数。新标志不改变旧版解析。总单调时钟预算在设置、预检、准备、效果和观测之前开始。在其中为清理精确预留 5 秒;预算不超过 5 秒时,以 insufficient_budget 在效果发生前拒绝。观测/效果截止点为开始时间加预算再减 5 秒;清理不能延长开始时间加预算的上限。等待截止时间诊断描述这一截止点,而非重新开始的远程预算。 + +SSH 只传递剩余时长,并保持调用方原始截止时间;远程处理器不能重置预算,也不能用 UTC 授权效果。如果剩余总预算不超过清理预留时间,则在效果发生前拒绝。在截止点边界,以 now >= cutoff 判定过期;严格在截止点之前提交的回执可以满足该等待,在截止点或之后提交的回执为迟到。一旦效果可能发生,超时就是 unknown;不允许重试/回退或新的提交。有序 fanout 为每个目标分配各自有界的预算,而不是反复使用某个主机的预算。无法保证清理/自有资源受限的实现不能声明支持该路由。 + +```text +safe-integer = JSON integer token, 0..9007199254740991; booleans/fractions/exponents rejected +safe-id = 1..128 UTF-8 bytes, no U+0000..001F or U+007F..009F, valid Unicode scalars +safe-generation = 1..256 UTF-8 bytes, same control/scalar rule +receipt/confirmation frame = raw strict UTF-8 JSON input, at most 4096 bytes including LF/whitespace +public handoff size = compact UTF-8 encoding of the handoff subtree, at most 8192 bytes +opt-in destination stdout frame = at most 1048576 bytes including LF/whitespace +opt-in fanout = at most 32 destinations and 34603008 total stdout bytes including LF/whitespace +duplicate JSON keys, unknown nested keys, NaN/Infinity and trailing data rejected +turn/native IDs are opaque; only correlation/epoch/wait/receipt IDs use UUID-v4 syntax +``` + +JSON 数字令牌必须在有损解析前验证;即使 JavaScript Number 等于 1,词法形式 1.0 和 1e0 仍被拒绝。词法限制仅适用于 handoff/handoffQuery 子树及私有回执/确认模式,不适用于无关的旧版外层字段。保留现有旧版解析及重复键/非有限数规则;不会仅因存在 handoff 请求就使旧版字段无效。handoff 子树大小使用规范的紧凑编码计算(无可选空白、使用 UTF-8 标量字符而非 ASCII 转义、以逗号/冒号分隔);封装线格式中的子树空白则计入外层帧限制。私有回执/确认的原始输入包含每个字节,包括可选的尾随 JSON 空白和 LF;尾随的非空白数据被拒绝。不额外允许任何换行字节。目标/总量限制只适用于新的显式启用模式;在任何效果发生前拒绝过多目标,不改变常规未启用模式的线格式/输出限制。在分配前逐步限制 stdout;有界汇总格式为紧凑 JSON 加可选 LF。有界且完整的外层帧中,若可选子树格式错误或超限,保留独立验证的原生证据,拒绝 ACK/显式等待,绝不重新提交。若超过外层帧限制,不得通过部分解析判为成功;仅保留此前已独立验证的证据,否则返回 unknown。边界在持久化前检查,而非截断后。发送方/收集器的常规 stdout、诊断、错误/ACK 结果及自身日志绝不包含能力凭证或载荷摘要;它们不读取接收方的转录/正文。有界元数据观测不得通过输出过滤来隐藏返回正文的 API。 + +## 提议的 CLI 与零提交操作 + +这些是提议的写法,不是已发布 Python 中可用的命令。准备绑定与 send 相同的目标/消息输入,但不提交;确认接受仅含元数据的私有 stdin。明确的本地账本初始化是单独的显式启用设置前提,绝不是自动恢复操作。 + +```text +handoff init +handoff prepare --to TARGET --message-file PRIVATE_FILE +send --to TARGET --message-file PRIVATE_FILE --correlation-id PREPARED_UUID +send --to TARGET --request-ack +send --to TARGET --observe-delivery +send --to TARGET --wait-for delivered|acknowledged --wait-timeout SECONDS +handoff status --correlation-id UUID +handoff wait --correlation-id UUID --wait-for delivered|acknowledged --wait-timeout SECONDS +handoff confirm --receipt - +ack --receipt - +``` + +没有 correlation-id 时,request-ack/observe-delivery/wait-for 自动预留新意图;其余常规 send 保持不变。仅关联的 send 使用尽力而为的证据,不显式等待。status、wait、confirm、ack、init 和 prepare 不产生任何原生提交。回执选择器不包含秘密。确认 stdin 包含 schemaVersion、ledgerEpoch、correlationId、targetGeneration,以及调用方的 confirmed=true 声明;它拒绝额外字段/正文/能力凭证,且仅记录 operator_confirmed。标志不兼容和不受支持的路由要求在持久效果意图之前检查。远程设置与主机选项必须遵循现有 SSH 信任限制,而非任意回执路由。 + +ACK 证据还要求非 null 的原始 targetGeneration 和 submitted submission;接受有效回执后,当前状态为 acknowledged。观察到注入始终意味着 submitted submission。提供的已准备 ID 不得属于先前真实的 send 调用,包括产生副作用前的终止拒绝。dry-run 不消耗首次提交权限。dry-run 不可与 request-ack、observe-delivery、wait-for 一起使用,否则返回用法退出码 2;correlation-only dry-run 使用已准备的 ID。 + +如果重启后的收集器无法证明能力凭证寿命的单调时钟连续性,它会保守地使未使用的新回执授权过期,而不是延长 TTL 或重新生成授权。有效提交的回执分类/assurance/late 仍可恢复;经过认证的同一回执只读重复请求遵循明确的过期例外。对已尝试意图开始新等待时,如果预算不足,只使该等待失败,保留提交和注入事实,不把原意图重新归类为 refused。reconcile 操作表示状态和证据比对,而非新提交或隐式修复命令。 + +## 冻结与实现验收条件 + +本提案在冻结前要求 TypeScript 审查其准确提交和夹具。新的 prepare/confirm 写法、ledgerEpoch 和等待 operationId 字段、5 秒清理预留、数字令牌验证以及恢复限制,都是明确的 v0.2 修订,不是已经接受或发布的能力。 + +夹具必须覆盖各运行时的可选字段缺失、由请求上下文允许的 queued true 显式等待超时、伪造的未启用 handoff、错误原生目标、认证成功/错误令牌/过期的重复请求、已提交/提交前/顺序未知的回执恢复、手动确认拒绝、原始/新/null 代次、不反射秘密、上下文缺失的查询错误与已知 tombstone 的区别、等待历史、缺失/恢复/过期/已满账本、自定义未知 ID、截止时间/帧边界、格式错误的模式以及 SSH 保留。严格的 SSH 显式启用验证必须在 handoff 被拒绝时仍保留有效的原生快照;旧版 Claude 重建或通用失败路径都不得丢弃它。完整响应只有通过同一严格且识别原始请求的解析器,才能在传输超时/非零退出后保留;部分帧仍为 unknown。 + +实现验收前须验证:生成方引导与收集器所有权/重启、持久意图与容量预留、各受支持 platform/version/home 下的清理、仅元数据的 Codex 观测、严格的本地/SSH 元组,以及零提交 status/wait/ack/confirm 操作。本设计 PR 不提供原生/实际运行证据。Side Session 与 wake 实验是单独的未来工作,不是前提条件。不会悄然加入配对设备/Relay 去重和操作员监控。 diff --git a/tests/docs/test_docs_i18n.py b/tests/docs/test_docs_i18n.py index 956620dc..becae99a 100644 --- a/tests/docs/test_docs_i18n.py +++ b/tests/docs/test_docs_i18n.py @@ -64,7 +64,7 @@ class DocumentationTranslationsTest(unittest.TestCase): def test_every_canonical_document_has_each_translation(self): sources = canonical_documents() - self.assertEqual(42, len(sources), "Update the translation manifest for new canonical docs") + self.assertEqual(43, len(sources), "Update the translation manifest for new canonical docs") canonical_docs = { path.relative_to(ROOT / "docs") for path in (ROOT / "docs").rglob("*.md") diff --git a/tests/docs/test_handoff_contract.py b/tests/docs/test_handoff_contract.py new file mode 100644 index 00000000..27eb0526 --- /dev/null +++ b/tests/docs/test_handoff_contract.py @@ -0,0 +1,827 @@ +"""Synthetic proposal/fixture checks, NOT a shipped Handoff v1 validator. + +These standard-library helpers exist only in tests. They do not exercise native +submission, collectors, durable fencing, receipt authentication, SSH or clocks. +Behavior vectors below check design coverage/consistency, not implementation. +""" + +import base64 +import copy +from decimal import Decimal +import json +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +FIXTURE = ROOT / "tests/fixtures/handoff-v1.json" +CONTRACT = ROOT / "docs/contracts/handoff-v1.md" +SAFE_INTEGER_MAX = 9007199254740991 +UUID4 = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\Z") +ENUMS = { + "state": {"validated", "refused", "submitted", "delivered", "acknowledged", "unknown", "timed_out_unknown"}, + "submission": {"not_attempted", "submitted", "refused", "unknown"}, + "observation": {"not_requested", "pending", "observed", "unsupported", "failed"}, + "ack": {"not_requested", "pending", "acknowledged", "unsupported"}, + "assurance": {"token_possession", "operator_confirmed"}, + "waitFor": {"none", "delivered", "acknowledged"}, + "waitStatus": {"not_requested", "pending", "satisfied", "timed_out_unknown", "stopped", "unsupported", "failed"}, + "turn": {"running", "completed", "failed", "interrupted", "unknown"}, + "waitReason": {"insufficient_budget", "deadline_before_effect", "evidence_unsupported", "evidence_failed", "history_unavailable", "stopped_by_operator", "invalid_handoff"}, + "nextActions": {"keep_waiting", "reconcile", "stop_waiting"}, +} + + +class ProposalError(ValueError): + pass + + +def require(condition, reason="schema"): + if not condition: + raise ProposalError(reason) + + +def closed(value, required, optional=()): + require(type(value) is dict) + require(set(required) <= value.keys()) + require(value.keys() <= set(required) | set(optional)) + + +def integer(value): + require(type(value) is int and 0 <= value <= SAFE_INTEGER_MAX) + + +def uuid4(value): + require(type(value) is str and UUID4.fullmatch(value) is not None) + + +def identifier(value, limit=128): + require(type(value) is str) + require(all(not (ord(c) <= 31 or 127 <= ord(c) <= 159 or 0xD800 <= ord(c) <= 0xDFFF) for c in value)) + require(1 <= len(value.encode("utf-8")) <= limit) + + +def enum(value, name): + require(type(value) is str and value in ENUMS[name]) + + +def strict_object(raw, limit, preserve_float_tokens=False): + """Reject token ambiguity BEFORE converting any JSON number to a value.""" + if type(raw) is str: + try: + raw = raw.encode("utf-8", errors="strict") + except UnicodeEncodeError as error: + raise ProposalError("invalid_utf8") from error + require(type(raw) is bytes, "invalid_utf8") + require(len(raw) <= limit, "frame_size") + try: + text = raw.decode("utf-8", errors="strict") + except UnicodeDecodeError as error: + raise ProposalError("invalid_utf8") from error + + def pairs(items): + obj = {} + for key, value in items: + require(key not in obj, "duplicate_key") + obj[key] = value + return obj + + def float_token(token): + if preserve_float_tokens: + return Decimal(token) + raise ProposalError("integer_token") + + def constant(token): + raise ProposalError("nonfinite") + + try: + value = json.loads(text, object_pairs_hook=pairs, parse_float=float_token, parse_constant=constant) + except json.JSONDecodeError as error: + reason = "trailing_data" if error.msg == "Extra data" else "json_syntax" + raise ProposalError(reason) from error + require(type(value) is dict, "object_required") + return value + + +def validate_handoff(handoff): + """Test-only interpretation of the public proposal's static constraints. + + Channel liveness, history integrity, exact native binding and authentication + are external evidence; this function cannot validate those acceptance gates. + """ + def integer_tokens(value): + require(not isinstance(value, (float, Decimal)), "integer_token") + if type(value) is dict: + for child in value.values(): + integer_tokens(child) + elif type(value) is list: + for child in value: + integer_tokens(child) + integer_tokens(handoff) + closed(handoff, ("schemaVersion", "correlationId", "ledgerEpoch", "state", "submission", "observation", "ack", "wait", "targetGeneration", "decisionOwner", "retry", "nextActions")) + try: + encoded = json.dumps(handoff, ensure_ascii=False, separators=(",", ":")).encode("utf-8", errors="strict") + except UnicodeEncodeError as error: + raise ProposalError("invalid_utf8") from error + require(len(encoded) <= 8192, "handoff_size") + integer(handoff["schemaVersion"]) + require(handoff["schemaVersion"] == 1) + uuid4(handoff["correlationId"]) + uuid4(handoff["ledgerEpoch"]) + enum(handoff["state"], "state") + submission = handoff["submission"] + closed(submission, ("status",)) + enum(submission["status"], "submission") + observation = handoff["observation"] + closed(observation, ("status", "injectionObserved"), ("clientUserMessageId", "turn")) + enum(observation["status"], "observation") + require(type(observation["injectionObserved"]) is bool) + if "clientUserMessageId" in observation: + identifier(observation["clientUserMessageId"]) + generation = handoff["targetGeneration"] + if generation is not None: + identifier(generation, 256) + injection = observation["injectionObserved"] + if injection: + require(submission["status"] == "submitted") + require(observation["status"] == "observed") + require("clientUserMessageId" in observation and generation is not None) + if "turn" in observation: + require(injection) + closed(observation["turn"], ("id", "status")) + identifier(observation["turn"]["id"]) + enum(observation["turn"]["status"], "turn") + ack = handoff["ack"] + closed(ack, ("status",), ("assurance", "receivedAtUtcMs", "late")) + enum(ack["status"], "ack") + if ack["status"] == "pending": + require(generation is not None) + if ack["status"] == "acknowledged": + require(handoff["state"] == "acknowledged") + require({"assurance", "receivedAtUtcMs", "late"} <= ack.keys()) + require(generation is not None and submission["status"] == "submitted") + enum(ack["assurance"], "assurance") + integer(ack["receivedAtUtcMs"]) + require(type(ack["late"]) is bool) + else: + require(set(ack) == {"status"}) + state_submissions = { + "validated": {"not_attempted"}, "refused": {"refused"}, + "submitted": {"submitted"}, "delivered": {"submitted"}, + "acknowledged": {"submitted"}, "unknown": {"unknown"}, + "timed_out_unknown": {"submitted", "unknown"}, + } + require(submission["status"] in state_submissions[handoff["state"]]) + if handoff["state"] == "delivered": + require(injection) + if handoff["state"] == "acknowledged": + require(ack["status"] == "acknowledged") + wait = handoff["wait"] + closed(wait, ("for", "status"), ("operationId", "deadlineAtUtcMs", "reason")) + enum(wait["for"], "waitFor") + enum(wait["status"], "waitStatus") + if "reason" in wait: + enum(wait["reason"], "waitReason") + if "operationId" in wait: + uuid4(wait["operationId"]) + if "deadlineAtUtcMs" in wait: + integer(wait["deadlineAtUtcMs"]) + if wait["for"] == "none": + require(wait["status"] == "not_requested") + require("operationId" not in wait and "deadlineAtUtcMs" not in wait) + else: + require({"operationId", "deadlineAtUtcMs"} <= wait.keys()) + require(wait["status"] != "not_requested") + if wait["status"] == "satisfied": + require(injection if wait["for"] == "delivered" else ack["status"] == "acknowledged") + if wait["status"] == "stopped": + require(wait.get("reason") == "stopped_by_operator") + require(handoff["decisionOwner"] == "sender_operator") + closed(handoff["retry"], ("allowed", "reason")) + require(handoff["retry"]["allowed"] is False) + require(handoff["retry"]["reason"] == "receiver_dedup_unavailable") + actions = handoff["nextActions"] + require(type(actions) is list) + for action in actions: + enum(action, "nextActions") + require(len(actions) == len(set(actions))) + if wait["status"] == "unsupported" or wait.get("reason") == "history_unavailable": + require("keep_waiting" not in actions) + + +def validate_public_wire(raw): + # A standalone example of the handoff subtree still has a bounded input + # frame, but its subtree quota counts compact UTF-8, not pretty whitespace. + value = strict_object(raw, 1048576) + validate_handoff(value) + return value + + +def validate_query_error(result): + """Static schema for absent context; no fabricated epoch/native fields.""" + closed(result, ("schemaVersion", "ok", "host", "command", "reason", "handoffQuery")) + integer(result["schemaVersion"]) + require(result["schemaVersion"] == 1 and result["ok"] is False) + identifier(result["host"], 256) + require(result["command"] == "handoff") + require(result["reason"] == "handoff_history_unavailable") + query = result["handoffQuery"] + closed(query, ("schemaVersion", "correlationId", "status", "context", "retry")) + integer(query["schemaVersion"]) + require(query["schemaVersion"] == 1) + uuid4(query["correlationId"]) + require(query["status"] == "unknown") + require(type(query["context"]) is str and query["context"] in {"ledger_missing", "ledger_corrupt", "id_unknown"}) + closed(query["retry"], ("allowed", "reason")) + require(query["retry"]["allowed"] is False and query["retry"]["reason"] == "history_unavailable") + + +def synthetic_positive_native_profile(result, request): + """Only a static positive-profile check; never reconstruct native fields. + + The original adapter chooses this profile and resolves canonical context + before effect. This is a proposed opt-in gate, not today's SSH validator. + queueId is optional in both Codex runtimes, and does not prove consumption. + """ + profile = request["nativeProfile"] + target = result.get("target") + expected = request["target"] + require(type(target) is dict) + require(result.get("host") == request["host"]) + require(result.get("dryRun") is False) + if profile in {"python_codex", "typescript_codex"}: + target_id = target.get("id") + expected_id = expected["id"] + require(type(target_id) is str and type(expected_id) is str) + # Native UUID normalization is distinct from lowercase-only handoff IDs. + native_uuid = r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}" + require(re.fullmatch(native_uuid, target_id.lower()) is not None) + require(re.fullmatch(native_uuid, expected_id.lower()) is not None) + require(target_id.lower() == expected_id.lower()) + require(target.get("agent") == "codex" if profile == "python_codex" + else "agent" not in target or target["agent"] == "codex") + require(type(request["codexHome"]) is str and bool(request["codexHome"])) + require(result.get("codexHome") == request["codexHome"]) + require(result.get("status") == "queued") + require(result.get("submitted") is True) + require(result.get("consumptionConfirmed") is False) + if "queueId" in result: + identifier(result["queueId"]) + elif profile in {"python_claude", "typescript_claude"}: + require(type(target.get("pid")) is int and 0 < target["pid"] <= SAFE_INTEGER_MAX) + require(target["pid"] == expected["pid"]) + require("queueId" not in result and "codexHome" not in result) + if profile == "python_claude": + require("agent" not in target) + require(type(target.get("name")) is str and target["name"] == expected["name"]) + require(all(key not in result for key in ("status", "submitted", "consumptionConfirmed"))) + else: + require("agent" not in target or target["agent"] == "claude") + require("name" in target and (type(target["name"]) is str or target["name"] is None)) + require(result.get("status") == "posted") + require(result.get("submitted") is True) + require(result.get("consumptionConfirmed") is False) + else: + raise ProposalError("native_profile") + + +def synthetic_wire_guard(raw, request, exit_code): + """Request-aware SYNTHETIC guard, not the runtime SSH/adapter parser. + + A matching native snapshot is assessed independently of optional handoff. + The tuple exception additionally requires the original explicit-wait mode. + This does not authenticate target ownership, capabilities or channel liveness. + """ + result = strict_object(raw, 1048576, preserve_float_tokens=True) + require(type(exit_code) is int) + require(type(result.get("schemaVersion")) is int and result["schemaVersion"] == 1) + require(type(result.get("ok")) is bool and result.get("command") == "send") + native = {key: value for key, value in result.items() if key != "handoff"} + known_submission = False + try: + synthetic_positive_native_profile(result, request) + known_submission = True + except ProposalError: + pass + handoff_valid = False + if request["optIn"] and "handoff" in result: + try: + validate_handoff(result["handoff"]) + handoff_valid = ( + known_submission + and all(result["handoff"][key] == request[key] for key in ("correlationId", "ledgerEpoch", "targetGeneration")) + and result["handoff"]["wait"]["for"] == request["waitFor"] + ) + except (ProposalError, UnicodeEncodeError): + pass + failed_wait = ( + handoff_valid and exit_code == 1 + and result["handoff"]["wait"]["status"] in {"timed_out_unknown", "failed", "unsupported"} + ) + interrupted_wait = ( + handoff_valid and exit_code == 130 + and result["handoff"]["wait"]["status"] == "stopped" + and result["handoff"]["wait"].get("reason") == "stopped_by_operator" + ) + explicit_exception = ( + request["explicitWait"] and request["optIn"] and handoff_valid + and known_submission and result["ok"] is False and (failed_wait or interrupted_wait) + and result["handoff"]["wait"]["for"] == request["waitFor"] + and result["handoff"]["submission"]["status"] == "submitted" + ) + ordinary_tuple = known_submission and result["ok"] is True and exit_code == 0 + if request["explicitWait"]: + ordinary_tuple = ordinary_tuple and handoff_valid and result["handoff"]["wait"]["for"] == request["waitFor"] and result["handoff"]["wait"]["status"] == "satisfied" + wire_accepted = bool(ordinary_tuple or explicit_exception) + return { + "wireAccepted": wire_accepted, + "nativeEvidencePreserved": bool(known_submission), + "handoffValidated": bool(handoff_valid), + "ackPromoted": bool(wire_accepted and handoff_valid and result["handoff"]["ack"]["status"] == "acknowledged"), + "resubmit": False, + "nativeSnapshot": native if known_submission else None, + } + + +def validate_private_wire(raw, confirmation=False): + """Synthetic shape checks only; no capability authentication or persistence.""" + value = strict_object(raw, 4096) + required = {"schemaVersion", "ledgerEpoch", "correlationId", "targetGeneration"} + required |= {"confirmed"} if confirmation else {"kind", "receiptId", "capability"} + closed(value, required) + integer(value["schemaVersion"]) + require(value["schemaVersion"] == 1) + uuid4(value["ledgerEpoch"]) + uuid4(value["correlationId"]) + identifier(value["targetGeneration"], 256) + if confirmation: + require(value["confirmed"] is True) + else: + require(value["kind"] == "receipt") + uuid4(value["receiptId"]) + capability = value["capability"] + require(type(capability) is str and re.fullmatch(r"[A-Za-z0-9_-]{43}", capability) is not None) + decoded = base64.urlsafe_b64decode(capability + "=") + require(len(decoded) == 32) + require(base64.urlsafe_b64encode(decoded).decode("ascii").rstrip("=") == capability) + return value + + +def timeout(lexeme): + require(type(lexeme) is str and re.fullmatch(r"[1-9][0-9]?", lexeme, flags=re.ASCII) is not None) + require(1 <= int(lexeme) <= 60) + return int(lexeme) + + +def changed(base, mutations, removed=()): + value = copy.deepcopy(base) + for path, replacement in mutations.items(): + keys = path.split(".") + node = value + for key in keys[:-1]: + node = node[key] + node[keys[-1]] = replacement + for path in removed: + keys = path.split(".") + node = value + for key in keys[:-1]: + node = node[key] + del node[keys[-1]] + return value + + +class HandoffProposalContract(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.fixture = json.loads(FIXTURE.read_text(encoding="utf-8")) + cls.accepted = {case["name"]: case for case in cls.fixture["accepted"]} + + def sample(self, name="queued_best_effort_observer_failed"): + return copy.deepcopy(self.accepted[name]["result"]["handoff"]) + + def test_unimplemented_provenance_bounds_and_enums(self): + fixture = self.fixture + self.assertEqual(fixture["schemaVersion"], 1) + self.assertEqual(fixture["status"], "draft_unimplemented") + self.assertEqual(fixture["provenance"]["exampleKind"], "synthetic_normalized_not_live") + self.assertEqual(fixture["provenance"]["pythonRuntimeVersion"], "1.0.3") + self.assertRegex(fixture["provenance"]["pythonRuntimeCommit"], r"\A[0-9a-f]{40}\Z") + self.assertEqual({key: set(values) for key, values in fixture["enums"].items()}, ENUMS) + self.assertEqual(fixture["bounds"], { + "safeIntegerMax": SAFE_INTEGER_MAX, "nativeIdUtf8Bytes": 128, + "generationUtf8Bytes": 256, "receiptFrameBytes": 4096, + "handoffFrameBytes": 8192, "timeoutMinS": 1, "timeoutMaxS": 60, + "defaultBudgetS": 30, "cleanupReserveS": 5, "ledgerIntentQuota": 10000, + "ledgerByteQuota": 33554432, "detailRetentionDays": 30, + "capabilityTtlHours": 24, "maxWaitsPerIntent": 64, "capabilityEntropyBytes": 32, + "optInOuterFrameBytes": 1048576, "optInFanoutDestinations": 32, + "optInFanoutStdoutBytes": 34603008, + }) + document = CONTRACT.read_text(encoding="utf-8") + for marker in ("unimplemented design", "synthetic examples", "numeric token validation", "No local-only design", "ORIGINAL-request-aware parser"): + self.assertIn(marker, document) + self.assertEqual(len(self.accepted), len(fixture["accepted"])) + + def test_every_accepted_handoff_and_rejected_mutation(self): + for case in self.fixture["accepted"]: + with self.subTest(accepted=case["name"]): + self.assertIs(case["valid"], True) + validate_public_wire(json.dumps(case["result"]["handoff"], ensure_ascii=False)) + for case in self.fixture["rejected"]: + with self.subTest(rejected=case["name"]): + self.assertIs(case["valid"], False) + handoff = changed(self.sample(case["base"]), case["set"], case.get("remove", ())) + with self.assertRaises(ProposalError): + validate_handoff(handoff) + + def test_required_and_nested_closed_keys(self): + sample = self.sample("delivered_ack_timeout") + for key in sample: + with self.subTest(missing=key), self.assertRaises(ProposalError): + validate_handoff(changed(sample, {}, (key,))) + for path in ("extra", "submission.extra", "observation.extra", "observation.turn.extra", "ack.extra", "wait.extra", "retry.extra"): + with self.subTest(extra=path), self.assertRaises(ProposalError): + validate_handoff(changed(sample, {path: "unexpected"})) + + def test_exact_boolean_and_safe_integer_boundaries(self): + sample = self.sample("receipt_satisfies_wait_without_injection_claim") + for path in ("wait.deadlineAtUtcMs", "ack.receivedAtUtcMs"): + for valid in (0, SAFE_INTEGER_MAX): + with self.subTest(path=path, valid=valid): + validate_handoff(changed(sample, {path: valid})) + for invalid in (True, False, -1, SAFE_INTEGER_MAX + 1, 1.0, 1.5, "1", None): + with self.subTest(path=path, invalid=invalid), self.assertRaises(ProposalError): + validate_handoff(changed(sample, {path: invalid})) + for path in ("observation.injectionObserved", "ack.late", "retry.allowed"): + for invalid in (0, 1, "false", None): + with self.subTest(path=path, invalid=invalid), self.assertRaises(ProposalError): + validate_handoff(changed(sample, {path: invalid})) + + def test_all_public_uuid_fields_require_canonical_version_four(self): + sample = self.sample("queued_explicit_ack_timeout") + for path in ("correlationId", "ledgerEpoch", "wait.operationId"): + for invalid in ("opaque-id", "22222222-2222-1222-8222-222222222222", "22222222-2222-4222-7222-222222222222", "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA", sample["correlationId"] + "\n", True): + with self.subTest(path=path, invalid=invalid), self.assertRaises(ProposalError): + validate_handoff(changed(sample, {path: invalid})) + + def test_utf8_scalar_controls_and_byte_boundaries(self): + sample = self.sample("delivered_ack_timeout") + for path, limit in (("observation.clientUserMessageId", 128), ("observation.turn.id", 128), ("targetGeneration", 256)): + for valid in ("x" * limit, "😀" * (limit // 4), "opaque:id/非UUID"): + with self.subTest(path=path, valid=valid): + validate_handoff(changed(sample, {path: valid})) + invalids = ("", "x" * (limit + 1), "😀" * (limit // 4) + "x", "\ud800", "\udfff") + invalids += tuple("id" + chr(code) for code in list(range(32)) + list(range(127, 160))) + for invalid in invalids: + with self.subTest(path=path, invalid=ascii(invalid)), self.assertRaises(ProposalError): + validate_handoff(changed(sample, {path: invalid})) + + def test_state_submission_and_evidence_conditions(self): + states = {"validated": {"not_attempted"}, "refused": {"refused"}, "submitted": {"submitted"}, "delivered": {"submitted"}, "acknowledged": {"submitted"}, "unknown": {"unknown"}, "timed_out_unknown": {"submitted", "unknown"}} + bases = {"validated": "dry_run_preserves_codex_false", "refused": "deadline_before_effect", "delivered": "delivered_wait_satisfied", "acknowledged": "manual_operator_receipt", "unknown": "unknown_preserves_legacy_unknown_false", "timed_out_unknown": "queued_explicit_ack_timeout"} + for state, allowed in states.items(): + base = self.sample(bases.get(state, "queued_best_effort_observer_failed")) + for submission in ENUMS["submission"]: + value = changed(base, {"submission.status": submission}) + with self.subTest(state=state, submission=submission): + if submission in allowed: + validate_handoff(value) + else: + with self.assertRaises(ProposalError): + validate_handoff(value) + delivered = self.sample("delivered_wait_satisfied") + for mutation, removed in (({"targetGeneration": None}, ()), ({"observation.injectionObserved": False}, ()), ({}, ("observation.clientUserMessageId",))): + with self.subTest(mutation=mutation, removed=removed), self.assertRaises(ProposalError): + validate_handoff(changed(delivered, mutation, removed)) + # Turn completion stays independent of ACK; unknown is a valid turn enum. + for turn in ENUMS["turn"]: + value = changed(self.sample("delivered_ack_timeout"), {"observation.turn.status": turn}) + validate_handoff(value) + self.assertEqual(value["ack"]["status"], "pending") + + def test_ack_and_wait_conditions_without_rewriting_history(self): + acked = self.sample("receipt_satisfies_wait_without_injection_claim") + with self.assertRaises(ProposalError): + validate_handoff(changed(acked, {"targetGeneration": None})) + for key in ("assurance", "receivedAtUtcMs", "late"): + with self.subTest(missing_ack=key), self.assertRaises(ProposalError): + validate_handoff(changed(acked, {}, ("ack." + key,))) + for status in ENUMS["ack"] - {"acknowledged"}: + with self.subTest(status=status), self.assertRaises(ProposalError): + validate_handoff(changed(acked, {"ack.status": status})) + explicit = self.sample("queued_explicit_ack_timeout") + for key in ("operationId", "deadlineAtUtcMs"): + with self.subTest(missing_wait=key), self.assertRaises(ProposalError): + validate_handoff(changed(explicit, {}, ("wait." + key,))) + for path, value in (("wait.operationId", explicit["wait"]["operationId"]), ("wait.deadlineAtUtcMs", 0), ("wait.status", "pending")): + with self.subTest(no_wait=path), self.assertRaises(ProposalError): + validate_handoff(changed(self.sample(), {path: value})) + for status in ("stopped", "failed", "unsupported"): + mutations = {"wait.status": status} + if status == "stopped": + mutations["wait.reason"] = "stopped_by_operator" + value = changed(self.sample("delivered_ack_timeout"), mutations) + validate_handoff(value) + self.assertIs(value["observation"]["injectionObserved"], True) + with self.assertRaises(ProposalError): + validate_handoff(changed(explicit, {"wait.status": "satisfied"})) + with self.assertRaises(ProposalError): + validate_handoff(changed(explicit, {"wait.for": "delivered", "wait.status": "satisfied"})) + late = self.sample("late_receipt_preserves_timeout_history") + validate_handoff(late) + self.assertEqual(late["state"], "acknowledged") + self.assertEqual(late["wait"]["status"], "timed_out_unknown") + self.assertIs(late["ack"]["late"], True) + with self.assertRaises(ProposalError): + validate_handoff(changed(self.sample("required_ack_channel_unsupported"), {"nextActions": ["keep_waiting"]})) + + def test_every_wire_rejection_has_the_expected_parser_reason(self): + for case in self.fixture["wireRejected"]: + raw = bytes.fromhex(case["bytesHex"]) if "bytesHex" in case else case["raw"] + with self.subTest(name=case["name"]), self.assertRaises(ProposalError) as raised: + strict_object(raw, 8192) + self.assertEqual(str(raised.exception), case["reason"]) + for raw in ('{"nested":{"a":1,"a":2}}', '{"x":Infinity}', '{"x":-Infinity}', '[]', '{}\x00', '{', '\ufeff{}'): + with self.subTest(raw=raw), self.assertRaises(ProposalError): + strict_object(raw, 8192) + sample = json.dumps(self.sample(), separators=(",", ":")) + for token in ("1.0", "1e0", "1E+0"): + raw = sample.replace('"schemaVersion":1,', '"schemaVersion":' + token + ',', 1) + with self.subTest(token=token), self.assertRaisesRegex(ProposalError, "integer_token"): + validate_public_wire(raw) + + def test_private_receipt_confirmation_and_frame_byte_limits(self): + sample = self.sample() + receipt = {key: sample[key] for key in ("schemaVersion", "ledgerEpoch", "correlationId", "targetGeneration")} + receipt.update(kind="receipt", receiptId="33333333-3333-4333-8333-333333333333", capability=base64.urlsafe_b64encode(bytes(range(32))).decode("ascii").rstrip("=")) + confirmation = {key: sample[key] for key in ("schemaVersion", "ledgerEpoch", "correlationId", "targetGeneration")} + confirmation["confirmed"] = True + for value, confirm in ((receipt, False), (confirmation, True)): + validate_private_wire(json.dumps(value), confirmation=confirm) + for key in value: + with self.subTest(confirm=confirm, missing=key), self.assertRaises(ProposalError): + validate_private_wire(json.dumps(changed(value, {}, (key,))), confirmation=confirm) + for key in ("body", "extra", "receivedAtUtcMs"): + with self.subTest(confirm=confirm, extra=key), self.assertRaises(ProposalError): + validate_private_wire(json.dumps(changed(value, {key: "synthetic"})), confirmation=confirm) + for capability in ("", "A" * 42, "A" * 44, "+" + "A" * 42, "A" * 42 + "B", True): + with self.subTest(capability=capability), self.assertRaises(ProposalError): + validate_private_wire(json.dumps(changed(receipt, {"capability": capability}))) + for mutations in ({"confirmed": 1}, {"confirmed": False}, {"capability": "A" * 43}, {"body": "synthetic"}): + with self.subTest(confirmation=mutations), self.assertRaises(ProposalError): + validate_private_wire(json.dumps(changed(confirmation, mutations)), confirmation=True) + for value, limit, validator in ((receipt, 4096, validate_private_wire), (confirmation, 4096, lambda raw: validate_private_wire(raw, confirmation=True))): + raw = json.dumps(value, ensure_ascii=False).encode("utf-8") + padded = raw + b" " * (limit - len(raw) - 1) + b"\n" + validator(padded) + with self.assertRaisesRegex(ProposalError, "frame_size"): + validator(padded + b" ") + + def test_subtree_framing_and_scoped_numeric_lexemes(self): + # Whitespace counts toward outer bytes, not compact subtree bytes. + raw = json.dumps(self.sample(), ensure_ascii=False).encode("utf-8") + validate_public_wire(raw + b" " * (8193 - len(raw))) + validate_public_wire(raw + b" " * (1048576 - len(raw) - 1) + b"\n") + with self.assertRaisesRegex(ProposalError, "frame_size"): + validate_public_wire(raw + b" " * (1048577 - len(raw))) + request = self.request() + outer = copy.deepcopy(self.accepted["queued_explicit_ack_timeout"]["result"]) + # A legacy sibling is not retroactively constrained to integer tokens. + outer["legacyDiagnosticFraction"] = 1.5 + wire = json.dumps(outer, ensure_ascii=False, separators=(",", ":")) + guarded = synthetic_wire_guard(wire, request, 1) + self.assertTrue(guarded["wireAccepted"]) + self.assertEqual(guarded["nativeSnapshot"]["legacyDiagnosticFraction"], Decimal("1.5")) + for token in ("1.0", "1e0", "1E+0"): + invalid = wire.replace('"handoff":{"schemaVersion":1,', '"handoff":{"schemaVersion":' + token + ',', 1) + guarded = synthetic_wire_guard(invalid, request, 1) + self.assertFalse(guarded["wireAccepted"]) + self.assertFalse(guarded["handoffValidated"]) + self.assertFalse(guarded["ackPromoted"]) + self.assertTrue(guarded["nativeEvidencePreserved"]) + # Oversized optional subtree must not erase the matching native receipt. + outer["handoff"]["observation"]["extra"] = "x" * 8192 + guarded = synthetic_wire_guard(json.dumps(outer), request, 1) + self.assertFalse(guarded["handoffValidated"]) + self.assertTrue(guarded["nativeEvidencePreserved"]) + self.assertFalse(guarded["ackPromoted"]) + self.assertFalse(guarded["resubmit"]) + + def request(self): + return {"nativeProfile": "python_codex", "host": "fixture", "codexHome": "/fixture/codex-home", "optIn": True, "explicitWait": True, "waitFor": "acknowledged", "target": {"agent": "codex", "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"}, "correlationId": "22222222-2222-4222-8222-222222222222", "ledgerEpoch": "11111111-1111-4111-8111-111111111111", "targetGeneration": "fixture-generation"} + + def test_request_aware_synthetic_wire_guard(self): + for case in self.fixture["requestAwareWireCases"]: + with self.subTest(case=case["name"]): + result = changed(self.accepted[case["sample"]]["result"], case.get("set", {}), case.get("remove", ())) + request = self.request() + request.update({key: case[key] for key in ("optIn", "explicitWait", "waitFor")}) + request.update(case.get("request", {})) + guarded = synthetic_wire_guard(json.dumps(result, ensure_ascii=False), request, case["exit"]) + self.assertEqual({key: guarded[key] for key in case["expected"]}, case["expected"]) + if guarded["nativeEvidencePreserved"]: + self.assertEqual(guarded["nativeSnapshot"], {key: value for key, value in result.items() if key != "handoff"}) + self.assertIn("request_opt_out_forged_handoff", {case["name"] for case in self.fixture["requestAwareWireCases"]}) + self.assertIn("wrong_native_target", {case["name"] for case in self.fixture["requestAwareWireCases"]}) + + def test_missing_context_query_schema_and_closed_fields(self): + for case in self.fixture["queryErrors"]: + with self.subTest(case=case["name"]): + self.assertEqual(case["exit"], 1) + validate_query_error(case["result"]) + for key in ("handoff", "ledgerEpoch", "status", "submitted", "consumptionConfirmed"): + self.assertNotIn(key, case["result"]) + with self.assertRaises(ProposalError): + validate_query_error(changed(case["result"], {key: "invented"})) + for path in ("handoffQuery.ledgerEpoch", "handoffQuery.extra", "handoffQuery.retry.extra"): + with self.assertRaises(ProposalError): + validate_query_error(changed(case["result"], {path: "invented"})) + for path, value in (("handoffQuery.schemaVersion", True), ("handoffQuery.schemaVersion", Decimal("1e0")), ("handoffQuery.retry.allowed", True), ("handoffQuery.context", "detail_expired"), ("handoffQuery.context", []), ("handoffQuery.correlationId", "bad")): + with self.assertRaises(ProposalError): + validate_query_error(changed(case["result"], {path: value})) + self.assertEqual({case["result"]["handoffQuery"]["context"] for case in self.fixture["queryErrors"]}, {"ledger_missing", "ledger_corrupt", "id_unknown"}) + tombstone = self.fixture["knownTombstone"] + self.assertEqual(tombstone["exit"], 0) + self.assertIs(tombstone["result"]["ok"], True) + self.assertNotIn("handoffQuery", tombstone["result"]) + validate_handoff(tombstone["result"]["handoff"]) + self.assertEqual(tombstone["result"]["handoff"]["state"], "unknown") + self.assertEqual(tombstone["result"]["handoff"]["ledgerEpoch"], self.request()["ledgerEpoch"]) + self.assertNotIn("keep_waiting", tombstone["result"]["handoff"]["nextActions"]) + + def test_fanout_bounds_are_opt_in_vector_arithmetic_only(self): + # Does not exercise stream allocation, fanout execution or preflight. + for count in (1, 31, 32, 33): + allowed = count <= self.fixture["bounds"]["optInFanoutDestinations"] + self.assertEqual(allowed, count <= 32) + for measured in (34603007, 34603008, 34603009): + allowed = measured <= self.fixture["bounds"]["optInFanoutStdoutBytes"] + self.assertEqual(allowed, measured <= 33 * 1024 * 1024) + self.assertIn("raw complete per-destination stdout frame including whitespace and LF", self.fixture["framing"]["optInOuter"]) + self.assertIn("no normalization of ordinary legacy mode", self.fixture["framing"]["optInFanout"]) + + def test_generation_and_secret_static_constraints_only(self): + # This validates schemas only, NOT original-generation ownership proof. + with self.assertRaises(ProposalError): + validate_handoff(changed(self.sample("queued_explicit_ack_timeout"), {"targetGeneration": None})) + null_unsupported = changed(self.sample("required_ack_channel_unsupported"), {"targetGeneration": None}) + validate_handoff(null_unsupported) + self.assertEqual(null_unsupported["submission"]["status"], "refused") + for case in self.fixture["accepted"] + self.fixture["queryErrors"]: + result = case["result"] + def fields(value): + if type(value) is dict: + for key, child in value.items(): + self.assertNotIn(key, {"capability", "capabilityHash", "payloadDigest"}) + fields(child) + elif type(value) is list: + for child in value: + fields(child) + fields(result) + receipt = self.fixture["receiptSchema"] + self.assertEqual(receipt["commitClassificationAtomically"], ["assurance", "late", "originatingWaitOrdering"]) + self.assertIs(receipt["recipientNativeStorageMayPersistCapability"], True) + self.assertIs(receipt["independentAgentAuthentication"], False) + + def test_cli_timeout_lexemes_and_synthetic_budget_arithmetic(self): + for lexeme in self.fixture["timeoutLexemes"]["accepted"]: + with self.subTest(accepted=lexeme): + timeout(lexeme) + for lexeme in self.fixture["timeoutLexemes"]["rejected"] + ["", "١", "1\n", "\t1", "0001", 1, True]: + with self.subTest(rejected=lexeme), self.assertRaises(ProposalError): + timeout(lexeme) + for value in range(1, 61): + self.assertEqual(timeout(str(value)), value) + # This is vector arithmetic, not evidence of a bounded runtime/SSH wait. + reserve = Decimal(self.fixture["bounds"]["cleanupReserveS"]) + for case in self.fixture["budgets"]: + with self.subTest(budget=case["name"]): + total = Decimal(str(case["totalS"])) + elapsed = Decimal(str(case["elapsedS"])) + allowed = total > reserve and elapsed < total - reserve + self.assertIs(case["effectAllowed"], allowed) + if "remainingS" in case: + self.assertEqual(Decimal(str(case["remainingS"])), total - elapsed) + if not allowed: + self.assertEqual(case["submission"], "refused") + self.assertEqual(case["reason"], "insufficient_budget" if total <= reserve else "deadline_before_effect") + self.assertEqual(case["exit"], 1) + + def test_native_snapshots_preserve_optional_absence_and_evidence(self): + for case in self.fixture["legacySnapshots"]: + with self.subTest(snapshot=case["name"]): + self.assertTrue(case["source"]) + for key in case["mustRemainAbsent"]: + self.assertNotIn(key, case["result"]) + legacy = {case["name"]: case for case in self.fixture["legacySnapshots"]} + claude = self.accepted["claude_success_preserves_optional_absence"]["result"] + self.assertEqual({key: value for key, value in claude.items() if key != "handoff" and key != "host"}, {key: value for key, value in legacy["python_claude_success"]["result"].items() if key != "host"}) + for name in ("queued_best_effort_observer_failed", "queued_explicit_ack_timeout", "delivered_ack_timeout", "delivered_wait_satisfied", "receipt_satisfies_wait_without_injection_claim"): + with self.subTest(queued=name): + result = self.accepted[name]["result"] + for key in ("status", "submitted", "consumptionConfirmed", "queueId", "target"): + self.assertEqual(result[key], legacy["python_codex_queued"]["result"][key]) + self.assertIs(legacy["legacy_unknown_false_not_no_effect"]["noEffectProof"], False) + dry = self.accepted["dry_run_preserves_codex_false"]["result"] + self.assertNotIn("queueId", dry) + self.assertIs(dry["submitted"], False) + self.assertIs(dry["consumptionConfirmed"], False) + for name in ("late_receipt_preserves_timeout_history", "manual_operator_receipt", "status_query_of_timeout_is_not_ack"): + result = self.accepted[name]["result"] + for key in ("status", "submitted", "consumptionConfirmed"): + self.assertNotIn(key, result) + self.assertIs(result["ok"], True) + self.assertEqual(self.accepted[name]["exit"], 0) + + def test_accepted_example_wait_and_exit_tuples(self): + # These are assertions about declared examples, not CLI execution. + for name, case in self.accepted.items(): + with self.subTest(example=name): + handoff = case["result"]["handoff"] + if case["mode"] == "status": + self.assertEqual(case["exit"], 0) + self.assertIs(case["result"]["ok"], True) + elif handoff["wait"]["status"] == "stopped": + self.assertEqual(case["exit"], 130) + self.assertIs(case["result"]["ok"], False) + self.assertEqual(handoff["wait"].get("reason"), "stopped_by_operator") + elif handoff["wait"]["status"] in {"timed_out_unknown", "unsupported", "failed"} or handoff["state"] == "unknown": + self.assertEqual(case["exit"], 1) + self.assertIs(case["result"]["ok"], False) + else: + self.assertEqual(case["exit"], 0) + self.assertIs(case["result"]["ok"], True) + if handoff["state"] == "refused": + for key in ("status", "submitted", "consumptionConfirmed"): + self.assertNotIn(key, case["result"]) + + def test_behavior_fixture_coverage_only_not_a_receipt_or_crash_engine(self): + expected = { + "unknown_custom_uuid": {"nativeSubmissions": 0, "submission": "refused", "freshIdAllocated": False}, + "intent_crash_before_effect": {"nativeSubmissionsOnRecovery": 0, "submission": "unknown", "retryAllowed": False}, + "duplicate_valid_receipt": {"receiptCommitCount": 1, "state": "acknowledged"}, + "wrong_receipt_id_or_binding": {"stateAdvances": False, "nativeSubmissions": 0}, + "target_restart": {"newGenerationBound": False, "resubmit": False}, + "ledger_missing": {"state": "unknown", "resubmit": False, "autoInit": False}, + "ledger_restored": {"quarantined": True, "resubmit": False, "receiptAccept": False}, + "detail_expired": {"state": "unknown", "resubmit": False, "fenceRetained": True}, + "ledger_capacity_full": {"newEffect": False, "evictFence": False}, + "wait_quota_full": {"newWait": False, "submissionFactsPreserved": True}, + "late_receipt_new_wait": {"originalWait": "timed_out_unknown", "newWait": "satisfied", "ackLate": True, "nativeSubmissions": 0}, + "unsupported_no_keep_waiting": {"keepWaiting": False, "mintCapability": False}, + "utc_clock_jump": {"monotonicBudgetUnchanged": True}, + "collector_restart_without_raw_capability": {"capabilityRegenerated": False, "nativeSubmissions": 0}, + "fanout_reconcile": {"newIdsAllocated": False, "newSubmissions": 0}, + "malformed_flags_without_id": {"exit": 2, "handoffPresent": False, "synthesizeSubmitted": False}, + "opt_out_shape": {"handoffPresent": False, "legacyExitUnchanged": True}, + "duplicate_wrong_token": {"storedReceiptReturned": False, "stateAdvances": False, "receiptCommitCount": 1}, + "duplicate_expired_capability": {"storedReceiptReturned": True, "stateAdvances": False, "receiptCommitCount": 1}, + "duplicate_expired_receipt_detail": {"storedReceiptReturned": False, "stateAdvances": False, "fenceRetained": True}, + "duplicate_revoked_or_quarantined": {"storedReceiptReturned": False, "stateAdvances": False}, + "committed_ack_restart": {"state": "acknowledged", "classificationPreserved": True, "originalWaitRewritten": False}, + "receipt_precommit_crash": {"ackPromoted": False, "inventLate": False, "originalWaitRewritten": False}, + "receipt_unknown_ordering": {"ackPromoted": False, "inventLate": False, "originalWaitRewritten": False}, + "manual_confirmation_unproven_order": {"exit": 1, "reason": "receipt_order_unprovable", "ackPromoted": False, "originalWaitRewritten": False}, + "original_generation_late_receipt": {"state": "acknowledged", "ackLate": True, "generationRebound": False, "modelIdentityProven": False, "copiedTokenExcluded": False}, + "successor_generation_receipt": {"ackPromoted": False, "generationRebound": False}, + "missing_generation_required_wait": {"submission": "refused", "wait": "unsupported", "mintCapability": False, "exit": 1}, + "recipient_native_storage_retention": {"recipientStorageMayRetainCapability": True, "sameUserReadersMayAcquireAuthority": True, "observerReadsTranscript": False, "independentAgentAuthentication": False}, + "secret_not_reflected_in_errors_or_ack": {"secretReflected": False, "rawCapabilityPersisted": False, "authority": "receipt_only"}, + "first_receipt_restriction": {"newReceiptCommitted": False, "stateAdvances": False}, + "copied_original_authority_indistinguishable": {"distinguishableFromOriginalProducer": False, "assurance": "token_possession", "modelIdentityProven": False}, + } + cases = self.fixture["behaviorCases"] + self.assertEqual({case["name"] for case in cases}, set(expected)) + self.assertEqual(len(cases), len(expected)) + for case in cases: + with self.subTest(design_vector=case["name"]): + self.assertTrue(case["given"].strip()) + self.assertEqual(case["expected"], expected[case["name"]]) + + def test_ssh_fixture_coverage_only_not_transport_execution(self): + expected = { + "complete_queued_wait_timeout": {"preserveNativeSnapshot": True, "explicitWaitExit": 1, "additionalSubmission": False}, + "claude_reconstruction": {"preserveHandoff": True, "preserveLegacyAbsence": True}, + "malformed_handoff_valid_native": {"nativeEvidencePreserved": True, "ackPromoted": False, "explicitWaitExit": 1, "additionalSubmission": False}, + "incomplete_stdout": {"submission": "unknown", "additionalSubmission": False}, + "stderr_non_utf8": {"stdoutStrict": True, "stderrReplacement": True, "preserveNativeSnapshot": True}, + } + cases = self.fixture["sshCases"] + self.assertEqual({case["name"] for case in cases}, set(expected)) + self.assertEqual(len(cases), len(expected)) + for case in cases: + with self.subTest(design_vector=case["name"]): + self.assertEqual(case["expected"], expected[case["name"]]) + if "sample" in case: + self.assertIn(case["sample"], self.accepted) + self.assertEqual(self.fixture["receiptSchema"]["required"], ["schemaVersion", "kind", "ledgerEpoch", "correlationId", "targetGeneration", "receiptId", "capability"]) + self.assertEqual(self.fixture["operatorConfirmationSchema"]["required"], ["schemaVersion", "ledgerEpoch", "correlationId", "targetGeneration", "confirmed"]) + self.assertIs(self.fixture["receiptSchema"]["persistRawCapability"], False) + for name in ("receiptSchema", "operatorConfirmationSchema"): + self.assertIs(self.fixture[name]["unknownKeysAllowed"], False) + self.assertIn("no live/runtime claims", self.fixture["freezeGates"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/fixtures/handoff-v1.json b/tests/fixtures/handoff-v1.json new file mode 100644 index 00000000..ad72f5da --- /dev/null +++ b/tests/fixtures/handoff-v1.json @@ -0,0 +1,1882 @@ +{ + "schemaVersion": 1, + "proposalVersion": "0.2", + "status": "draft_unimplemented", + "provenance": { + "pythonRuntimeVersion": "1.0.3", + "pythonRuntimeCommit": "0d252550ab40c26d1ac4a19193df6ca2f84d830b", + "designBaseCommit": "817d92ab780f657ec754d40fcf86087b5b01a883", + "issue": 181, + "correlation": "STS-040-COMMON-CONTRACT-20261006-A", + "exampleKind": "synthetic_normalized_not_live", + "legacySources": [ + "session_peer_core/adapters.py", + "session_peer_core/codex.py", + "session_peer_core/antigravity.py", + "session_peer_core/ssh.py", + "session_peer_core/output.py" + ], + "legacyExampleScope": "normalized projections; not exhaustive responses or live captures", + "typescriptNativeProfileCommit": "6b48092a0e42992d2d970dd2ff5cf457f62d3ff2", + "typescriptNativeProfileSources": ["src/send.ts:send", "src/cli.ts:remote target validation", "src/writer.ts:uuid"], + "typescriptSourceScope": "published v0.3.1 positive profile evidence only; no TS parity/reference marker repin" + }, + "bounds": { + "safeIntegerMax": 9007199254740991, + "nativeIdUtf8Bytes": 128, + "generationUtf8Bytes": 256, + "receiptFrameBytes": 4096, + "handoffFrameBytes": 8192, + "timeoutMinS": 1, + "timeoutMaxS": 60, + "defaultBudgetS": 30, + "cleanupReserveS": 5, + "ledgerIntentQuota": 10000, + "ledgerByteQuota": 33554432, + "detailRetentionDays": 30, + "capabilityTtlHours": 24, + "maxWaitsPerIntent": 64, + "capabilityEntropyBytes": 32, + "optInOuterFrameBytes": 1048576, + "optInFanoutDestinations": 32, + "optInFanoutStdoutBytes": 34603008 + }, + "enums": { + "state": [ + "validated", + "refused", + "submitted", + "delivered", + "acknowledged", + "unknown", + "timed_out_unknown" + ], + "submission": [ + "not_attempted", + "submitted", + "refused", + "unknown" + ], + "observation": [ + "not_requested", + "pending", + "observed", + "unsupported", + "failed" + ], + "ack": [ + "not_requested", + "pending", + "acknowledged", + "unsupported" + ], + "assurance": [ + "token_possession", + "operator_confirmed" + ], + "waitFor": [ + "none", + "delivered", + "acknowledged" + ], + "waitStatus": [ + "not_requested", + "pending", + "satisfied", + "timed_out_unknown", + "stopped", + "unsupported", + "failed" + ], + "turn": [ + "running", + "completed", + "failed", + "interrupted", + "unknown" + ], + "waitReason": [ + "insufficient_budget", + "deadline_before_effect", + "evidence_unsupported", + "evidence_failed", + "history_unavailable", + "stopped_by_operator", + "invalid_handoff" + ], + "nextActions": [ + "keep_waiting", + "reconcile", + "stop_waiting" + ] + }, + "legacySnapshots": [ + { + "name": "python_claude_success", + "exit": 0, + "source": "session_peer_core/adapters.py:ClaudeAdapter.submit", + "result": { + "schemaVersion": 1, + "ok": true, + "host": "local", + "command": "send", + "target": { + "pid": 1, + "name": "fixture" + }, + "chars": 7, + "dryRun": false + }, + "mustRemainAbsent": [ + "status", + "submitted", + "consumptionConfirmed", + "handoff" + ] + }, + { + "name": "python_codex_queued", + "exit": 0, + "source": "session_peer_core/codex.py:queue_codex", + "result": { + "schemaVersion": 1, + "ok": true, + "host": "local", + "command": "send", + "status": "queued", + "submitted": true, + "consumptionConfirmed": false, + "queueId": "queue-fixture", + "target": { + "agent": "codex", + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "codexHome": "/fixture/codex-home", + "dryRun": false + }, + "mustRemainAbsent": [ + "handoff" + ] + }, + { + "name": "python_codex_validated", + "exit": 0, + "source": "session_peer_core/codex.py:queue_codex", + "result": { + "schemaVersion": 1, + "ok": true, + "host": "local", + "command": "send", + "status": "validated", + "submitted": false, + "consumptionConfirmed": false, + "target": { + "agent": "codex", + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "codexHome": "/fixture/codex-home", + "dryRun": true + }, + "mustRemainAbsent": [ + "handoff" + ] + }, + { + "name": "legacy_unknown_false_not_no_effect", + "exit": 1, + "source": "session_peer_core/antigravity.py", + "result": { + "schemaVersion": 1, + "ok": false, + "host": "local", + "command": "send", + "status": "unknown", + "submitted": false, + "consumptionConfirmed": false, + "agent": "antigravity", + "retryAllowed": false, + "requestId": "22222222-2222-4222-8222-222222222222", + "generation": "fixture-generation" + }, + "mustRemainAbsent": [ + "handoff" + ], + "noEffectProof": false + } + ], + "accepted": [ + { + "name": "queued_best_effort_observer_failed", + "mode": "opt_in", + "valid": true, + "exit": 0, + "result": { + "schemaVersion": 1, + "ok": true, + "host": "fixture", + "command": "send", + "status": "queued", + "submitted": true, + "consumptionConfirmed": false, + "queueId": "queue-fixture", + "target": { + "agent": "codex", + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "submitted", + "submission": { + "status": "submitted" + }, + "observation": { + "status": "failed", + "injectionObserved": false + }, + "ack": { + "status": "not_requested" + }, + "wait": { + "for": "none", + "status": "not_requested" + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [ + "reconcile" + ] + }, + "codexHome": "/fixture/codex-home", + "dryRun": false + } + }, + { + "name": "queued_explicit_ack_timeout", + "mode": "opt_in", + "valid": true, + "exit": 1, + "result": { + "schemaVersion": 1, + "ok": false, + "host": "fixture", + "command": "send", + "status": "queued", + "submitted": true, + "consumptionConfirmed": false, + "queueId": "queue-fixture", + "target": { + "agent": "codex", + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "timed_out_unknown", + "submission": { + "status": "submitted" + }, + "observation": { + "status": "not_requested", + "injectionObserved": false + }, + "ack": { + "status": "pending" + }, + "wait": { + "for": "acknowledged", + "status": "timed_out_unknown", + "operationId": "33333333-3333-4333-8333-333333333333", + "deadlineAtUtcMs": 1000 + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [ + "keep_waiting", + "reconcile" + ] + }, + "codexHome": "/fixture/codex-home", + "dryRun": false + } + }, + { + "name": "delivered_ack_timeout", + "mode": "opt_in", + "valid": true, + "exit": 1, + "result": { + "schemaVersion": 1, + "ok": false, + "host": "fixture", + "command": "send", + "status": "queued", + "submitted": true, + "consumptionConfirmed": false, + "queueId": "queue-fixture", + "target": { + "agent": "codex", + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "timed_out_unknown", + "submission": { + "status": "submitted" + }, + "observation": { + "status": "observed", + "injectionObserved": true, + "clientUserMessageId": "message-fixture", + "turn": { + "id": "turn-fixture", + "status": "completed" + } + }, + "ack": { + "status": "pending" + }, + "wait": { + "for": "acknowledged", + "status": "timed_out_unknown", + "operationId": "33333333-3333-4333-8333-333333333333", + "deadlineAtUtcMs": 1000 + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [ + "reconcile" + ] + }, + "codexHome": "/fixture/codex-home", + "dryRun": false + } + }, + { + "name": "delivered_wait_satisfied", + "mode": "opt_in", + "valid": true, + "exit": 0, + "result": { + "schemaVersion": 1, + "ok": true, + "host": "fixture", + "command": "send", + "status": "queued", + "submitted": true, + "consumptionConfirmed": false, + "queueId": "queue-fixture", + "target": { + "agent": "codex", + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "delivered", + "submission": { + "status": "submitted" + }, + "observation": { + "status": "observed", + "injectionObserved": true, + "clientUserMessageId": "message-fixture" + }, + "ack": { + "status": "not_requested" + }, + "wait": { + "for": "delivered", + "status": "satisfied", + "operationId": "33333333-3333-4333-8333-333333333333", + "deadlineAtUtcMs": 1000 + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [ + "reconcile" + ] + }, + "codexHome": "/fixture/codex-home", + "dryRun": false + } + }, + { + "name": "receipt_satisfies_wait_without_injection_claim", + "mode": "opt_in", + "valid": true, + "exit": 0, + "result": { + "schemaVersion": 1, + "ok": true, + "host": "fixture", + "command": "send", + "status": "queued", + "submitted": true, + "consumptionConfirmed": false, + "queueId": "queue-fixture", + "target": { + "agent": "codex", + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "acknowledged", + "submission": { + "status": "submitted" + }, + "observation": { + "status": "not_requested", + "injectionObserved": false + }, + "ack": { + "status": "acknowledged", + "assurance": "token_possession", + "receivedAtUtcMs": 999, + "late": false + }, + "wait": { + "for": "acknowledged", + "status": "satisfied", + "operationId": "33333333-3333-4333-8333-333333333333", + "deadlineAtUtcMs": 1000 + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [ + "reconcile" + ] + }, + "codexHome": "/fixture/codex-home", + "dryRun": false + } + }, + { + "name": "late_receipt_preserves_timeout_history", + "mode": "status", + "valid": true, + "exit": 0, + "result": { + "schemaVersion": 1, + "ok": true, + "host": "fixture", + "command": "handoff", + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "acknowledged", + "submission": { + "status": "submitted" + }, + "observation": { + "status": "not_requested", + "injectionObserved": false + }, + "ack": { + "status": "acknowledged", + "assurance": "token_possession", + "receivedAtUtcMs": 1001, + "late": true + }, + "wait": { + "for": "acknowledged", + "status": "timed_out_unknown", + "operationId": "33333333-3333-4333-8333-333333333333", + "deadlineAtUtcMs": 1000 + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [ + "reconcile" + ] + } + } + }, + { + "name": "manual_operator_receipt", + "mode": "status", + "valid": true, + "exit": 0, + "result": { + "schemaVersion": 1, + "ok": true, + "host": "fixture", + "command": "handoff", + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "acknowledged", + "submission": { + "status": "submitted" + }, + "observation": { + "status": "not_requested", + "injectionObserved": false + }, + "ack": { + "status": "acknowledged", + "assurance": "operator_confirmed", + "receivedAtUtcMs": 1001, + "late": false + }, + "wait": { + "for": "none", + "status": "not_requested" + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [ + "reconcile" + ] + } + } + }, + { + "name": "status_query_of_timeout_is_not_ack", + "mode": "status", + "valid": true, + "exit": 0, + "result": { + "schemaVersion": 1, + "ok": true, + "host": "fixture", + "command": "handoff", + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "timed_out_unknown", + "submission": { + "status": "submitted" + }, + "observation": { + "status": "not_requested", + "injectionObserved": false + }, + "ack": { + "status": "pending" + }, + "wait": { + "for": "acknowledged", + "status": "timed_out_unknown", + "operationId": "33333333-3333-4333-8333-333333333333", + "deadlineAtUtcMs": 1000 + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [ + "reconcile" + ] + } + } + }, + { + "name": "required_ack_channel_unsupported", + "mode": "opt_in", + "valid": true, + "exit": 1, + "result": { + "schemaVersion": 1, + "ok": false, + "host": "fixture", + "command": "send", + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "refused", + "submission": { + "status": "refused" + }, + "observation": { + "status": "not_requested", + "injectionObserved": false + }, + "ack": { + "status": "unsupported" + }, + "wait": { + "for": "acknowledged", + "status": "unsupported", + "reason": "evidence_unsupported", + "operationId": "33333333-3333-4333-8333-333333333333", + "deadlineAtUtcMs": 1000 + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [] + } + } + }, + { + "name": "deadline_before_effect", + "mode": "opt_in", + "valid": true, + "exit": 1, + "result": { + "schemaVersion": 1, + "ok": false, + "host": "fixture", + "command": "send", + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "refused", + "submission": { + "status": "refused" + }, + "observation": { + "status": "not_requested", + "injectionObserved": false + }, + "ack": { + "status": "not_requested" + }, + "wait": { + "for": "delivered", + "status": "failed", + "reason": "deadline_before_effect", + "operationId": "33333333-3333-4333-8333-333333333333", + "deadlineAtUtcMs": 1000 + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [] + } + } + }, + { + "name": "dry_run_preserves_codex_false", + "mode": "opt_in", + "valid": true, + "exit": 0, + "result": { + "schemaVersion": 1, + "ok": true, + "host": "fixture", + "command": "send", + "status": "validated", + "submitted": false, + "consumptionConfirmed": false, + "target": { + "agent": "codex", + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "validated", + "submission": { + "status": "not_attempted" + }, + "observation": { + "status": "not_requested", + "injectionObserved": false + }, + "ack": { + "status": "not_requested" + }, + "wait": { + "for": "none", + "status": "not_requested" + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [] + }, + "codexHome": "/fixture/codex-home", + "dryRun": true + } + }, + { + "name": "unknown_preserves_legacy_unknown_false", + "mode": "opt_in", + "valid": true, + "exit": 1, + "result": { + "schemaVersion": 1, + "ok": false, + "host": "fixture", + "command": "send", + "status": "unknown", + "submitted": false, + "consumptionConfirmed": false, + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "unknown", + "submission": { + "status": "unknown" + }, + "observation": { + "status": "not_requested", + "injectionObserved": false + }, + "ack": { + "status": "not_requested" + }, + "wait": { + "for": "none", + "status": "not_requested" + }, + "targetGeneration": null, + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [ + "reconcile" + ] + } + } + }, + { + "name": "claude_success_preserves_optional_absence", + "mode": "opt_in", + "valid": true, + "exit": 0, + "result": { + "schemaVersion": 1, + "ok": true, + "host": "fixture", + "command": "send", + "target": { + "pid": 1, + "name": "fixture" + }, + "chars": 7, + "dryRun": false, + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "submitted", + "submission": { + "status": "submitted" + }, + "observation": { + "status": "not_requested", + "injectionObserved": false + }, + "ack": { + "status": "not_requested" + }, + "wait": { + "for": "none", + "status": "not_requested" + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [ + "reconcile" + ] + } + } + }, + { + "name": "new_wait_insufficient_budget_preserves_submission", + "mode": "wait", + "valid": true, + "exit": 1, + "result": { + "schemaVersion": 1, + "ok": false, + "host": "fixture", + "command": "handoff", + "status": "queued", + "submitted": true, + "consumptionConfirmed": false, + "queueId": "queue-fixture", + "target": { + "agent": "codex", + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "submitted", + "submission": { + "status": "submitted" + }, + "observation": { + "status": "failed", + "injectionObserved": false + }, + "ack": { + "status": "not_requested" + }, + "wait": { + "for": "acknowledged", + "status": "failed", + "reason": "insufficient_budget", + "operationId": "44444444-4444-4444-8444-444444444444", + "deadlineAtUtcMs": 1000 + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [ + "reconcile" + ] + }, + "codexHome": "/fixture/codex-home", + "dryRun": false + } + }, + { + "name": "python_claude_explicit_ack_timeout", + "mode": "opt_in", + "valid": true, + "exit": 1, + "result": { + "schemaVersion": 1, + "ok": false, + "host": "fixture", + "command": "send", + "target": { + "pid": 1, + "name": "fixture" + }, + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "timed_out_unknown", + "submission": { + "status": "submitted" + }, + "observation": { + "status": "not_requested", + "injectionObserved": false + }, + "ack": { + "status": "pending" + }, + "wait": { + "for": "acknowledged", + "status": "timed_out_unknown", + "operationId": "33333333-3333-4333-8333-333333333333", + "deadlineAtUtcMs": 1000 + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [ + "keep_waiting", + "reconcile" + ] + }, + "dryRun": false, + "chars": 7 + } + }, + { + "name": "typescript_claude_explicit_ack_timeout", + "mode": "opt_in", + "valid": true, + "exit": 1, + "result": { + "schemaVersion": 1, + "ok": false, + "host": "fixture", + "command": "send", + "status": "posted", + "submitted": true, + "consumptionConfirmed": false, + "target": { + "agent": "claude", + "pid": 1, + "name": "fixture" + }, + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "timed_out_unknown", + "submission": { + "status": "submitted" + }, + "observation": { + "status": "not_requested", + "injectionObserved": false + }, + "ack": { + "status": "pending" + }, + "wait": { + "for": "acknowledged", + "status": "timed_out_unknown", + "operationId": "33333333-3333-4333-8333-333333333333", + "deadlineAtUtcMs": 1000 + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [ + "keep_waiting", + "reconcile" + ] + }, + "dryRun": false, + "chars": 7 + } + }, + { + "name": "queued_post_submission_unsupported", + "mode": "opt_in", + "valid": true, + "exit": 1, + "result": { + "schemaVersion": 1, + "ok": false, + "host": "fixture", + "command": "send", + "status": "queued", + "submitted": true, + "consumptionConfirmed": false, + "queueId": "queue-fixture", + "target": { + "agent": "codex", + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "submitted", + "submission": { + "status": "submitted" + }, + "observation": { + "status": "not_requested", + "injectionObserved": false + }, + "ack": { + "status": "unsupported" + }, + "wait": { + "for": "acknowledged", + "status": "unsupported", + "operationId": "33333333-3333-4333-8333-333333333333", + "deadlineAtUtcMs": 1000, + "reason": "evidence_unsupported" + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [ + "reconcile" + ] + }, + "codexHome": "/fixture/codex-home", + "dryRun": false + } + }, + { + "name": "queued_interrupted_pending_wait", + "mode": "opt_in", + "valid": true, + "exit": 130, + "result": { + "schemaVersion": 1, + "ok": false, + "host": "fixture", + "command": "send", + "status": "queued", + "submitted": true, + "consumptionConfirmed": false, + "queueId": "queue-fixture", + "target": { + "agent": "codex", + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "submitted", + "submission": { + "status": "submitted" + }, + "observation": { + "status": "not_requested", + "injectionObserved": false + }, + "ack": { + "status": "pending" + }, + "wait": { + "for": "acknowledged", + "status": "stopped", + "operationId": "33333333-3333-4333-8333-333333333333", + "deadlineAtUtcMs": 1000, + "reason": "stopped_by_operator" + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [ + "keep_waiting", + "reconcile" + ] + }, + "codexHome": "/fixture/codex-home", + "dryRun": false + } + }, + { + "name": "stopped_status_query", + "mode": "status", + "valid": true, + "exit": 0, + "result": { + "schemaVersion": 1, + "ok": true, + "host": "fixture", + "command": "handoff", + "handoff": { + "schemaVersion": 1, + "correlationId": "22222222-2222-4222-8222-222222222222", + "ledgerEpoch": "11111111-1111-4111-8111-111111111111", + "state": "submitted", + "submission": { + "status": "submitted" + }, + "observation": { + "status": "not_requested", + "injectionObserved": false + }, + "ack": { + "status": "pending" + }, + "wait": { + "for": "acknowledged", + "status": "stopped", + "operationId": "33333333-3333-4333-8333-333333333333", + "deadlineAtUtcMs": 1000, + "reason": "stopped_by_operator" + }, + "targetGeneration": "fixture-generation", + "decisionOwner": "sender_operator", + "retry": { + "allowed": false, + "reason": "receiver_dedup_unavailable" + }, + "nextActions": [ + "keep_waiting", + "reconcile" + ] + } + } + } + ], + "knownTombstone": { + "name": "known_tombstone_retains_original_epoch", "exit": 0, + "result": {"schemaVersion": 1, "ok": true, "host": "fixture", "command": "handoff", "handoff": {"schemaVersion": 1, "correlationId": "22222222-2222-4222-8222-222222222222", "ledgerEpoch": "11111111-1111-4111-8111-111111111111", "state": "unknown", "submission": {"status": "unknown"}, "observation": {"status": "not_requested", "injectionObserved": false}, "ack": {"status": "not_requested"}, "wait": {"for": "none", "status": "not_requested"}, "targetGeneration": "fixture-generation", "decisionOwner": "sender_operator", "retry": {"allowed": false, "reason": "receiver_dedup_unavailable"}, "nextActions": ["reconcile"]}} + }, + "rejected": [ + { + "name": "null_original_generation_pending_ack", + "base": "queued_explicit_ack_timeout", + "set": {"targetGeneration": null}, + "valid": false + }, + { + "name": "boolean_schema_version", + "base": "queued_best_effort_observer_failed", + "set": { + "schemaVersion": true + }, + "valid": false + }, + { + "name": "fraction_schema_version", + "base": "queued_best_effort_observer_failed", + "set": { + "schemaVersion": 1.5 + }, + "valid": false + }, + { + "name": "wrong_schema_version", + "base": "queued_best_effort_observer_failed", + "set": { + "schemaVersion": 2 + }, + "valid": false + }, + { + "name": "noncanonical_uuid", + "base": "queued_best_effort_observer_failed", + "set": { + "correlationId": "22222222-2222-4222-8222-22222222222A" + }, + "valid": false + }, + { + "name": "invalid_epoch", + "base": "queued_best_effort_observer_failed", + "set": { + "ledgerEpoch": "not-an-epoch" + }, + "valid": false + }, + { + "name": "unknown_state", + "base": "queued_best_effort_observer_failed", + "set": { + "state": "completed" + }, + "valid": false + }, + { + "name": "resend_action", + "base": "queued_best_effort_observer_failed", + "set": { + "nextActions": [ + "resend_same_id" + ] + }, + "valid": false + }, + { + "name": "duplicate_action", + "base": "queued_best_effort_observer_failed", + "set": { + "nextActions": [ + "reconcile", + "reconcile" + ] + }, + "valid": false + }, + { + "name": "native_retry_allowed", + "base": "queued_best_effort_observer_failed", + "set": { + "retry.allowed": true + }, + "valid": false + }, + { + "name": "future_dedup_claim", + "base": "queued_best_effort_observer_failed", + "set": { + "retry.reason": "verified_receiver_dedup" + }, + "valid": false + }, + { + "name": "boolean_injection", + "base": "queued_best_effort_observer_failed", + "set": { + "observation.injectionObserved": 1 + }, + "valid": false + }, + { + "name": "injection_without_id", + "base": "queued_best_effort_observer_failed", + "set": { + "observation.injectionObserved": true + }, + "valid": false + }, + { + "name": "native_id_control", + "base": "queued_best_effort_observer_failed", + "set": { + "observation.clientUserMessageId": "id\u001b[31m" + }, + "valid": false + }, + { + "name": "native_id_too_long", + "base": "queued_best_effort_observer_failed", + "set": { + "observation.clientUserMessageId": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + "valid": false + }, + { + "name": "generation_too_long", + "base": "queued_best_effort_observer_failed", + "set": { + "targetGeneration": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + "valid": false + }, + { + "name": "generation_control", + "base": "queued_best_effort_observer_failed", + "set": { + "targetGeneration": "x…" + }, + "valid": false + }, + { + "name": "unpaired_surrogate", + "base": "queued_best_effort_observer_failed", + "set": { + "targetGeneration": "\ud800" + }, + "valid": false + }, + { + "name": "ack_without_receipt", + "base": "queued_best_effort_observer_failed", + "set": { + "state": "acknowledged" + }, + "valid": false + }, + { + "name": "secret_extra_field", + "base": "queued_best_effort_observer_failed", + "set": { + "capability": "placeholder" + }, + "valid": false + }, + { + "name": "unknown_nested_field", + "base": "queued_best_effort_observer_failed", + "set": { + "submission.extra": true + }, + "valid": false + }, + { + "name": "integer_boolean_utc", + "base": "queued_best_effort_observer_failed", + "set": { + "wait.deadlineAtUtcMs": true + }, + "valid": false + }, + { + "name": "negative_utc", + "base": "queued_best_effort_observer_failed", + "set": { + "wait.deadlineAtUtcMs": -1 + }, + "valid": false + }, + { + "name": "unsafe_utc", + "base": "queued_best_effort_observer_failed", + "set": { + "wait.deadlineAtUtcMs": 9007199254740992 + }, + "valid": false + }, + { + "name": "turn_without_observation", + "base": "queued_best_effort_observer_failed", + "set": { + "observation.turn": { + "id": "turn", + "status": "completed" + } + }, + "valid": false + }, + { + "name": "completed_as_ack", + "base": "queued_best_effort_observer_failed", + "set": { + "ack.status": "completed" + }, + "valid": false + }, + { + "name": "refused_with_submitted", + "base": "queued_best_effort_observer_failed", + "set": { + "state": "refused" + }, + "valid": false + }, + { + "name": "accepted_receipt_wrong_current_state", + "base": "queued_best_effort_observer_failed", + "set": { + "ack": { + "status": "acknowledged", + "assurance": "token_possession", + "receivedAtUtcMs": 999, + "late": false + } + }, + "valid": false + }, + { + "name": "observed_injection_with_unknown_submission", + "base": "queued_best_effort_observer_failed", + "set": { + "state": "unknown", + "submission.status": "unknown", + "observation": { + "status": "observed", + "injectionObserved": true, + "clientUserMessageId": "message-fixture" + } + }, + "valid": false + } + ], + "wireRejected": [ + { + "name": "duplicate_key", + "raw": "{\"schemaVersion\":1,\"schemaVersion\":1}", + "reason": "duplicate_key" + }, + { + "name": "fraction_integer_token", + "raw": "{\"schemaVersion\":1.0}", + "reason": "integer_token" + }, + { + "name": "exponent_integer_token", + "raw": "{\"schemaVersion\":1e0}", + "reason": "integer_token" + }, + { + "name": "nonfinite_number", + "raw": "{\"schemaVersion\":NaN}", + "reason": "nonfinite" + }, + { + "name": "trailing_data", + "raw": "{} {}", + "reason": "trailing_data" + }, + { + "name": "invalid_utf8", + "bytesHex": "ff", + "reason": "invalid_utf8" + } + ], + "timeoutLexemes": { + "accepted": [ + "1", + "6", + "30", + "60" + ], + "rejected": [ + "0", + "61", + "01", + " 1", + "1 ", + "+1", + "-1", + "1.0", + "1e0", + "true", + "1" + ] + }, + "budgets": [ + { + "name": "minimum_budget_valid_but_no_effect", + "totalS": 1, + "elapsedS": 0, + "effectAllowed": false, + "submission": "refused", + "reason": "insufficient_budget", + "exit": 1 + }, + { + "name": "cleanup_only_budget_no_effect", + "totalS": 5, + "elapsedS": 0, + "effectAllowed": false, + "submission": "refused", + "reason": "insufficient_budget", + "exit": 1 + }, + { + "name": "strictly_before_cutoff", + "totalS": 30, + "elapsedS": 24.999, + "effectAllowed": true + }, + { + "name": "at_cutoff", + "totalS": 30, + "elapsedS": 25, + "effectAllowed": false, + "submission": "refused", + "reason": "deadline_before_effect", + "exit": 1 + }, + { + "name": "remote_remaining_not_reset", + "totalS": 30, + "elapsedS": 26, + "effectAllowed": false, + "remainingS": 4, + "submission": "refused", + "reason": "deadline_before_effect", + "exit": 1 + } + ], + "behaviorCases": [ + { + "name": "unknown_custom_uuid", + "given": "supplied ID absent from intact ledger", + "expected": { + "nativeSubmissions": 0, + "submission": "refused", + "freshIdAllocated": false + } + }, + { + "name": "intent_crash_before_effect", + "given": "effect intent synced, no native outcome recorded", + "expected": { + "nativeSubmissionsOnRecovery": 0, + "submission": "unknown", + "retryAllowed": false + } + }, + { + "name": "duplicate_valid_receipt", + "given": "same committed receipt ID, original epoch/correlation/generation, constant-time proof of original capability hash, retained receipt detail, no revocation/quarantine", + "expected": { + "receiptCommitCount": 1, + "state": "acknowledged" + } + }, + { + "name": "wrong_receipt_id_or_binding", + "given": "invalid epoch/correlation/generation/capability", + "expected": { + "stateAdvances": false, + "nativeSubmissions": 0 + } + }, + { + "name": "target_restart", + "given": "original generation changes", + "expected": { + "newGenerationBound": false, + "resubmit": false + } + }, + { + "name": "ledger_missing", + "given": "previous ledger unavailable", + "expected": { + "state": "unknown", + "resubmit": false, + "autoInit": false + } + }, + { + "name": "ledger_restored", + "given": "operator marks known restore", + "expected": { + "quarantined": true, + "resubmit": false, + "receiptAccept": false + } + }, + { + "name": "detail_expired", + "given": "30 day detail pruning; fence retained", + "expected": { + "state": "unknown", + "resubmit": false, + "fenceRetained": true + } + }, + { + "name": "ledger_capacity_full", + "given": "intent or byte quota exhausted", + "expected": { + "newEffect": false, + "evictFence": false + } + }, + { + "name": "wait_quota_full", + "given": "64 committed wait records", + "expected": { + "newWait": false, + "submissionFactsPreserved": true + } + }, + { + "name": "late_receipt_new_wait", + "given": "originating wait timed out; receipt late; second wait starts", + "expected": { + "originalWait": "timed_out_unknown", + "newWait": "satisfied", + "ackLate": true, + "nativeSubmissions": 0 + } + }, + { + "name": "unsupported_no_keep_waiting", + "given": "permanently unsupported ACK producer", + "expected": { + "keepWaiting": false, + "mintCapability": false + } + }, + { + "name": "utc_clock_jump", + "given": "UTC diagnostic clock moves", + "expected": { + "monotonicBudgetUnchanged": true + } + }, + { + "name": "collector_restart_without_raw_capability", + "given": "only credential hash and original receipt binding retained", + "expected": { + "capabilityRegenerated": false, + "nativeSubmissions": 0 + } + }, + { + "name": "fanout_reconcile", + "given": "original per-destination intents stored", + "expected": { + "newIdsAllocated": false, + "newSubmissions": 0 + } + }, + { + "name": "malformed_flags_without_id", + "given": "invalid CLI before canonical intent", + "expected": { + "exit": 2, + "handoffPresent": false, + "synthesizeSubmitted": false + } + }, + { + "name": "opt_out_shape", + "given": "no opt-in flags", + "expected": { + "handoffPresent": false, + "legacyExitUnchanged": true + } + }, + { + "name": "duplicate_wrong_token", + "given": "committed receipt with correct IDs but failed constant-time capability hash proof", + "expected": {"storedReceiptReturned": false, "stateAdvances": false, "receiptCommitCount": 1} + }, + { + "name": "duplicate_expired_capability", + "given": "original capability expired or spent by first receipt; valid original hash proof and retained committed receipt detail; not revoked/quarantined", + "expected": {"storedReceiptReturned": true, "stateAdvances": false, "receiptCommitCount": 1} + }, + { + "name": "duplicate_expired_receipt_detail", + "given": "original binding and token proof valid but receipt detail retention expired", + "expected": {"storedReceiptReturned": false, "stateAdvances": false, "fenceRetained": true} + }, + { + "name": "duplicate_revoked_or_quarantined", + "given": "correct original binding and token proof but authority revoked or epoch quarantined", + "expected": {"storedReceiptReturned": false, "stateAdvances": false} + }, + { + "name": "committed_ack_restart", + "given": "atomic durable receipt includes proven assurance/late and originating wait ordering; collector monotonic clock restarts", + "expected": {"state": "acknowledged", "classificationPreserved": true, "originalWaitRewritten": false} + }, + { + "name": "receipt_precommit_crash", + "given": "collector crashes before classification and atomic durable receipt commit", + "expected": {"ackPromoted": false, "inventLate": false, "originalWaitRewritten": false} + }, + { + "name": "receipt_unknown_ordering", + "given": "receipt proof is valid but original wait monotonic ordering cannot be proven and no committed classification exists", + "expected": {"ackPromoted": false, "inventLate": false, "originalWaitRewritten": false} + }, + { + "name": "manual_confirmation_unproven_order", + "given": "operator now confirms a prior receipt, but ordering against the originating wait cannot be proven", + "expected": {"exit": 1, "reason": "receipt_order_unprovable", "ackPromoted": false, "originalWaitRewritten": false} + }, + { + "name": "original_generation_late_receipt", + "given": "valid hash proof plus immutable original epoch/correlation/generation; original receipt delayed until current owner has restarted; ordered late classification proven", + "expected": {"state": "acknowledged", "ackLate": true, "generationRebound": false, "modelIdentityProven": false, "copiedTokenExcluded": false} + }, + { + "name": "successor_generation_receipt", + "given": "receipt explicitly claims current successor generation instead of immutable original generation", + "expected": {"ackPromoted": false, "generationRebound": false} + }, + { + "name": "missing_generation_required_wait", + "given": "original generation is null and required delivered or acknowledged wait requested", + "expected": {"submission": "refused", "wait": "unsupported", "mintCapability": false, "exit": 1} + }, + { + "name": "recipient_native_storage_retention", + "given": "authorized capability is delegated through native effect input that the recipient may store in its own history", + "expected": {"recipientStorageMayRetainCapability": true, "sameUserReadersMayAcquireAuthority": true, "observerReadsTranscript": false, "independentAgentAuthentication": false} + }, + { + "name": "secret_not_reflected_in_errors_or_ack", + "given": "private capability submitted with malformed frame or valid ACK; sender/collector ordinary outputs and own raw journal inspected", + "expected": {"secretReflected": false, "rawCapabilityPersisted": false, "authority": "receipt_only"} + }, + { + "name": "first_receipt_restriction", + "given": "already committed first receipt; later receipt uses a different receiptId even with valid token", + "expected": {"newReceiptCommitted": false, "stateAdvances": false} + }, + { + "name": "copied_original_authority_indistinguishable", + "given": "successor or same-user reader possesses the complete immutable original tuple AND copied original capability", + "expected": {"distinguishableFromOriginalProducer": false, "assurance": "token_possession", "modelIdentityProven": false} + } + ], + "sshCases": [ + { + "name": "complete_queued_wait_timeout", + "sample": "queued_explicit_ack_timeout", + "transport": "nonzero_or_timeout", + "expected": { + "preserveNativeSnapshot": true, + "explicitWaitExit": 1, + "additionalSubmission": false + } + }, + { + "name": "claude_reconstruction", + "sample": "claude_success_preserves_optional_absence", + "expected": { + "preserveHandoff": true, + "preserveLegacyAbsence": true + } + }, + { + "name": "malformed_handoff_valid_native", + "given": "valid native queued receipt plus invalid new object", + "expected": { + "nativeEvidencePreserved": true, + "ackPromoted": false, + "explicitWaitExit": 1, + "additionalSubmission": false + } + }, + { + "name": "incomplete_stdout", + "given": "truncated single JSON frame", + "expected": { + "submission": "unknown", + "additionalSubmission": false + } + }, + { + "name": "stderr_non_utf8", + "given": "valid stdout frame and ff diagnostic stderr", + "expected": { + "stdoutStrict": true, + "stderrReplacement": true, + "preserveNativeSnapshot": true + } + } + ], + "queryErrors": [ + { + "name": "missing_ledger_no_invented_epoch", "exit": 1, + "result": {"schemaVersion": 1, "ok": false, "host": "fixture", "command": "handoff", "reason": "handoff_history_unavailable", "handoffQuery": {"schemaVersion": 1, "correlationId": "22222222-2222-4222-8222-222222222222", "status": "unknown", "context": "ledger_missing", "retry": {"allowed": false, "reason": "history_unavailable"}}} + }, + { + "name": "corrupt_ledger_no_invented_epoch", "exit": 1, + "result": {"schemaVersion": 1, "ok": false, "host": "fixture", "command": "handoff", "reason": "handoff_history_unavailable", "handoffQuery": {"schemaVersion": 1, "correlationId": "22222222-2222-4222-8222-222222222222", "status": "unknown", "context": "ledger_corrupt", "retry": {"allowed": false, "reason": "history_unavailable"}}} + }, + { + "name": "unknown_id_no_invented_epoch", "exit": 1, + "result": {"schemaVersion": 1, "ok": false, "host": "fixture", "command": "handoff", "reason": "handoff_history_unavailable", "handoffQuery": {"schemaVersion": 1, "correlationId": "22222222-2222-4222-8222-222222222222", "status": "unknown", "context": "id_unknown", "retry": {"allowed": false, "reason": "history_unavailable"}}} + } + ], + "requestAwareWireCases": [ + {"name":"explicit_wait_matching_target","sample":"queued_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"expected":{"wireAccepted":true,"nativeEvidencePreserved":true,"handoffValidated":true,"ackPromoted":false,"resubmit":false}}, + {"name":"request_opt_out_forged_handoff","sample":"queued_explicit_ack_timeout","optIn":false,"explicitWait":false,"waitFor":"none","exit":1,"expected":{"wireAccepted":false,"nativeEvidencePreserved":true,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"best_effort_cannot_enable_wait_exception","sample":"queued_explicit_ack_timeout","optIn":true,"explicitWait":false,"waitFor":"none","exit":1,"expected":{"wireAccepted":false,"nativeEvidencePreserved":true,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"wrong_native_target","sample":"queued_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{"target.id":"wrong-target"},"expected":{"wireAccepted":false,"nativeEvidencePreserved":false,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"malformed_handoff_preserves_native","sample":"queued_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{"handoff.ack.status":"acknowledged"},"expected":{"wireAccepted":false,"nativeEvidencePreserved":true,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"wrong_wait_goal","sample":"queued_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"delivered","exit":1,"expected":{"wireAccepted":false,"nativeEvidencePreserved":true,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"wrong_correlation","sample":"queued_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{"handoff.correlationId":"33333333-3333-4333-8333-333333333333"},"expected":{"wireAccepted":false,"nativeEvidencePreserved":true,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"posted_explicit_wait_timeout","sample":"typescript_claude_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{},"expected":{"wireAccepted":true,"nativeEvidencePreserved":true,"handoffValidated":true,"ackPromoted":false,"resubmit":false},"request":{"nativeProfile":"typescript_claude","target":{"agent":"claude","pid":1,"name":"fixture"}}}, + {"name":"posted_opt_out_forged_handoff","sample":"typescript_claude_explicit_ack_timeout","optIn":false,"explicitWait":false,"waitFor":"none","exit":1,"set":{},"expected":{"wireAccepted":false,"nativeEvidencePreserved":true,"handoffValidated":false,"ackPromoted":false,"resubmit":false},"request":{"nativeProfile":"typescript_claude","target":{"agent":"claude","pid":1,"name":"fixture"}}}, + {"name":"posted_wrong_native_target","sample":"typescript_claude_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{"target.pid":2},"expected":{"wireAccepted":false,"nativeEvidencePreserved":false,"handoffValidated":false,"ackPromoted":false,"resubmit":false},"request":{"nativeProfile":"typescript_claude","target":{"agent":"claude","pid":1,"name":"fixture"}}}, + {"name":"malformed_handoff_ok_true_cannot_satisfy_wait","sample":"queued_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":0,"set":{"ok":true,"handoff.ack.status":"acknowledged"},"expected":{"wireAccepted":false,"nativeEvidencePreserved":true,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"ack_wrong_wait_goal","sample":"receipt_satisfies_wait_without_injection_claim","optIn":true,"explicitWait":true,"waitFor":"delivered","exit":0,"expected":{"wireAccepted":false,"nativeEvidencePreserved":true,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"ack_forged_opt_out","sample":"receipt_satisfies_wait_without_injection_claim","optIn":false,"explicitWait":false,"waitFor":"none","exit":0,"expected":{"wireAccepted":true,"nativeEvidencePreserved":true,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"ack_wrong_native_target","sample":"receipt_satisfies_wait_without_injection_claim","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":0,"set":{"target.id":"wrong-target"},"expected":{"wireAccepted":false,"nativeEvidencePreserved":false,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"codex_posted_is_invalid","sample":"queued_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{"status":"posted"},"expected":{"wireAccepted":false,"nativeEvidencePreserved":false,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"codex_consumption_overwrite","sample":"queued_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{"consumptionConfirmed":true},"expected":{"wireAccepted":false,"nativeEvidencePreserved":false,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"wrong_codex_home","sample":"queued_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{"codexHome":"/fixture/other-home"},"expected":{"wireAccepted":false,"nativeEvidencePreserved":false,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"wrong_native_host","sample":"queued_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{"host":"other-fixture"},"expected":{"wireAccepted":false,"nativeEvidencePreserved":false,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"python_claude_absent_fields_timeout","sample":"python_claude_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"request":{"nativeProfile":"python_claude","target":{"pid":1,"name":"fixture"}},"expected":{"wireAccepted":true,"nativeEvidencePreserved":true,"handoffValidated":true,"ackPromoted":false,"resubmit":false}}, + {"name":"python_claude_invented_submitted","sample":"python_claude_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{"submitted":true},"request":{"nativeProfile":"python_claude","target":{"pid":1,"name":"fixture"}},"expected":{"wireAccepted":false,"nativeEvidencePreserved":false,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"typescript_claude_omitted_agent_null_name","sample":"typescript_claude_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{"target.name":null},"request":{"nativeProfile":"typescript_claude","target":{"agent":"claude","pid":1,"name":"fixture"}},"expected":{"wireAccepted":true,"nativeEvidencePreserved":true,"handoffValidated":true,"ackPromoted":false,"resubmit":false},"remove":["target.agent"]}, + {"name":"typescript_codex_case_insensitive_agent_absent","sample":"queued_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{"target.id":"AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA"},"request":{"nativeProfile":"typescript_codex"},"expected":{"wireAccepted":true,"nativeEvidencePreserved":true,"handoffValidated":true,"ackPromoted":false,"resubmit":false},"remove":["target.agent"]}, + {"name":"typescript_codex_native_non_v4_uuid","sample":"queued_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{"target.id":"AAAAAAAA-AAAA-7AAA-8AAA-AAAAAAAAAAAA"},"request":{"nativeProfile":"typescript_codex","target":{"agent":"codex","id":"aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa"}},"expected":{"wireAccepted":true,"nativeEvidencePreserved":true,"handoffValidated":true,"ackPromoted":false,"resubmit":false}}, + {"name":"optional_codex_queue_id","sample":"queued_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"expected":{"wireAccepted":true,"nativeEvidencePreserved":true,"handoffValidated":true,"ackPromoted":false,"resubmit":false},"remove":["queueId"]}, + {"name":"post_submission_channel_unsupported","sample":"queued_post_submission_unsupported","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"expected":{"wireAccepted":true,"nativeEvidencePreserved":true,"handoffValidated":true,"ackPromoted":false,"resubmit":false}}, + {"name":"post_submission_unsupported_opt_out","sample":"queued_post_submission_unsupported","optIn":false,"explicitWait":false,"waitFor":"none","exit":1,"expected":{"wireAccepted":false,"nativeEvidencePreserved":true,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"post_submission_unsupported_wrong_binding","sample":"queued_post_submission_unsupported","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{"handoff.targetGeneration":"other-generation"},"expected":{"wireAccepted":false,"nativeEvidencePreserved":true,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"interrupted_queued_success_snapshot","sample":"queued_interrupted_pending_wait","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":130,"expected":{"wireAccepted":true,"nativeEvidencePreserved":true,"handoffValidated":true,"ackPromoted":false,"resubmit":false}}, + {"name":"interrupted_opt_out_forgery","sample":"queued_interrupted_pending_wait","optIn":false,"explicitWait":false,"waitFor":"none","exit":130,"expected":{"wireAccepted":false,"nativeEvidencePreserved":true,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"interrupted_not_explicitly_requested","sample":"queued_interrupted_pending_wait","optIn":true,"explicitWait":false,"waitFor":"none","exit":130,"expected":{"wireAccepted":false,"nativeEvidencePreserved":true,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"interrupted_wrong_exit_one","sample":"queued_interrupted_pending_wait","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"expected":{"wireAccepted":false,"nativeEvidencePreserved":true,"handoffValidated":true,"ackPromoted":false,"resubmit":false}}, + {"name":"interrupted_wrong_exit_zero","sample":"queued_interrupted_pending_wait","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":0,"set":{"ok":true},"expected":{"wireAccepted":false,"nativeEvidencePreserved":true,"handoffValidated":true,"ackPromoted":false,"resubmit":false}}, + {"name":"interrupted_wrong_native_target","sample":"queued_interrupted_pending_wait","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":130,"set":{"target.id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"},"expected":{"wireAccepted":false,"nativeEvidencePreserved":false,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"interrupted_wrong_wait_reason","sample":"queued_interrupted_pending_wait","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":130,"set":{"handoff.wait.reason":"evidence_failed"},"expected":{"wireAccepted":false,"nativeEvidencePreserved":true,"handoffValidated":false,"ackPromoted":false,"resubmit":false}}, + {"name":"valid_ack_profile","sample":"receipt_satisfies_wait_without_injection_claim","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":0,"expected":{"wireAccepted":true,"nativeEvidencePreserved":true,"handoffValidated":true,"ackPromoted":true,"resubmit":false}}, + {"name":"ack_wrong_exit","sample":"receipt_satisfies_wait_without_injection_claim","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{"ok":false},"expected":{"wireAccepted":false,"nativeEvidencePreserved":true,"handoffValidated":true,"ackPromoted":false,"resubmit":false}}, + {"name":"unsupported_preserves_injection","sample":"delivered_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{"handoff.state":"delivered","handoff.wait.status":"unsupported","handoff.wait.reason":"evidence_unsupported","handoff.ack.status":"unsupported"},"expected":{"wireAccepted":true,"nativeEvidencePreserved":true,"handoffValidated":true,"ackPromoted":false,"resubmit":false}}, + {"name":"interrupted_preserves_injection","sample":"delivered_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":130,"set":{"handoff.state":"delivered","handoff.wait.status":"stopped","handoff.wait.reason":"stopped_by_operator"},"expected":{"wireAccepted":true,"nativeEvidencePreserved":true,"handoffValidated":true,"ackPromoted":false,"resubmit":false}}, + {"name":"wrong_runtime_agent_profile","sample":"queued_explicit_ack_timeout","optIn":true,"explicitWait":true,"waitFor":"acknowledged","exit":1,"set":{"target.agent":"claude"},"expected":{"wireAccepted":false,"nativeEvidencePreserved":false,"handoffValidated":false,"ackPromoted":false,"resubmit":false}} + ], + "framing": { + "privateInput": "raw UTF-8 bytes including all whitespace and LF; <=4096", + "handoffSubtree": "compact ensure_ascii=false UTF-8 encoding of handoff object only; <=8192", + "optInOuter": "raw complete per-destination stdout frame including whitespace and LF; <=1048576", + "optInFanout": "at most32 destinations before effect and aggregate stdout <=34603008 bytes; no normalization of ordinary legacy mode", + "lexicalScope": "new-schema subtrees only; legacy native sibling numeric tokens follow their existing rules", + "oversizedHandoff": "preserve independently valid matching native snapshot; no ACK promotion and no resubmission" + }, + "receiptSchema": { + "schemaVersion": 1, + "kind": "receipt", + "required": [ + "schemaVersion", + "kind", + "ledgerEpoch", + "correlationId", + "targetGeneration", + "receiptId", + "capability" + ], + "unknownKeysAllowed": false, + "capabilityInput": "private_stdin_only", + "capabilityEncoding": "base64url_unpadded_43_chars", + "persistRawCapability": false, + "issuer": "compatible_receipt_only_handler", + "assurance": "token_possession", + "duplicateProof": "original epoch/correlation/generation/receiptId plus constant-time original capability hash proof", + "duplicateExpiryException": "only return retained already committed receipt; deny revoked/quarantined/detail-expired authority", + "commitClassificationAtomically": ["assurance", "late", "originatingWaitOrdering"], + "recipientNativeStorageMayPersistCapability": true, + "independentAgentAuthentication": false + }, + "operatorConfirmationSchema": { + "required": [ + "schemaVersion", + "ledgerEpoch", + "correlationId", + "targetGeneration", + "confirmed" + ], + "confirmed": true, + "unknownKeysAllowed": false, + "input": "private_metadata_stdin_only", + "assurance": "operator_confirmed", + "eventSemantics": "new current attestation, not retroactive receipt timing proof", + "unprovableOriginatingWaitOrdering": {"exit": 1, "reason": "receipt_order_unprovable", "ackPromoted": false} + }, + "freezeGates": [ + "TS exact commit review", + "prepare/confirm/epoch/wait identity amendments", + "collector bootstrap and restart continuity", + "intact-history restoration limitation", + "bounded resource cleanup platform validation", + "strict opt-in SSH preservation", + "token-aware JSON numeric validation", + "no live/runtime claims" + ] +}