diff --git a/.agents/pm/features/pm-rust-portable-native-adapter-abi.toon b/.agents/pm/features/pm-rust-portable-native-adapter-abi.toon new file mode 100644 index 0000000..6db0ecc --- /dev/null +++ b/.agents/pm/features/pm-rust-portable-native-adapter-abi.toon @@ -0,0 +1,130 @@ +id: pm-rust-portable-native-adapter-abi +title: Use authorized remote backends through a portable native adapter ABI +description: "Core owns backend contracts; pm-web and MCP own transports. Rust interoperability only: no new backend or replacement pm-web client." +type: Feature +pm_format_version: 2 +status: in_progress +priority: 2 +tags[4]: adapter,"gh:unbraind/pm-rust#113",interop,sdk +created_at: "2026-10-24T11:48:18.127Z" +updated_at: "2026-10-24T16:58:04.700Z" +author: codex-agent +acceptance_criteria: "Synthetic remote adapters preserve refusal and receipt semantics; Unsupported capabilities fail before writes; ABI compatibility is checked; Typed SDK and bounded TOON/JSON CLI with actionable refusals, docs and example; Read-only public tracker copy validated without Git; npm and Bun release gates pass" +parent: pm-rust-u4al +comments[17]{created_at,author,text}: + "2026-10-24T11:48:21.365Z",codex-agent,"Overlap check: searched existing tracker items for adapter ABI and portable; no duplicate. Inspected pinned SDK 2026.10.21, current 2026.10.24 public exports, core workspace/runtime contracts, pm-web PmRunResult/complete-list receipt handling, and core MCP adapters. Core owns command/backend semantics, authorization and mutation receipts; pm-web/MCP own network transports. Implement a versioned bounded JSON byte boundary for caller-supplied authorized native adapters. No backend, HTTP client, shared cache, persistent lease or outbox is introduced. Rust SDK changes follow this native repository; validation tooling remains erasable TypeScript." + "2026-10-24T12:02:04.204Z",codex-agent,"Implemented the versioned bounded JSON byte ABI, typed get/list/claim/release operations, pre-dispatch admission, unchanged backend envelopes, and explicit dispatched/unknown-outcome refusals with no retries. Added offline adapter-check CLI; it reports compatibility only, never execution. No shared tracker state is written by the boundary. The authoritative native release SDK follows tracker policy, so release capability requires strict owner enforcement; the synthetic remote adapter tests enforce this inside core locking. Tests inject competing claims and release-and-claim handoffs at every one of six adapter boundaries on one non-git tracker. Added readonly public-copy validation using the real complete-list SDK certificate and native children without Git or Node." + "2026-10-24T12:07:55.763Z",codex-agent,"Read-only public-copy validation passed at public source ec39af2b06d3884c466b5770b41e1cdb745f1cd6: 83 certified items, 172 authoritative files, 4234972 bytes, 9 pages, 92 adapter calls, 184 JSON/TOON CLI checks, 880680 response bytes, 88935-byte largest frame, 193-byte largest CLI receipt, 5687 ms. Native children had neither Git nor Node on PATH. Source SHA-256 remained 4efdd31a9fd67caefced2b9852a38c6709f4452e2ecb519433be00bbd61d3607. Targeted suite now has 8 passing tests including 12 shared-tracker interleavings and a foreign-owner release guard. Revert proof independently removed capability admission, exact ABI comparison and outcome preservation; each selected regression failed by assertion, then source was restored. Cargo check, deny-warning clippy, fmt, strict TypeScript and ESLint pass. No SDK bump or shared state was introduced. Full npm/Bun gates will run in CI because local CPU is saturated." + "2026-10-24T12:31:21.192Z",codex-agent,"Initial CI run 40092834320 passed all three platform test suites and all latency checks, but release coverage refused two unexecuted manifest-error propagation regions in the new ABI client/preflight. Keep all thresholds unchanged; add targeted incompatible-manifest constructor/admission paths and rerun CI. No production mutation or receipt regression was reported." + "2026-10-24T12:45:53.397Z",codex-agent,"Coverage correction complete: incompatible manifest constructor/preflight paths now prove zero backend dispatch. Added two CLI tests covering all 19 missing-tracker command paths and injected stdin, initial write, trailing newline, flush and codec failures. CLI writes now propagate pipe errors. Generic clients delegate to one shared validated exchange path without changing the public API or wire ABI. Targeted instrumented run passes 8 ABI tests plus 2 CLI tests; native_adapter.rs has 146/146 lines, 17/17 functions, 211/211 regions and 20/20 branches covered. New CLI helper regions and tracker-discovery error propagations are covered; full-repository coverage remains for CI. Read-only public-copy validation repeated: identical 83 items, 9 pages, 92 calls, 184 checks, 193-byte maximum CLI receipt and unchanged source hash; latest elapsed sample 5301 ms." + "2026-10-24T12:50:25.942Z",codex-agent,"Final behavioral proof expansion passed 5/5: removing capability admission, exact ABI checking, canonical outcome preservation, tracker-free CLI dispatch, or CLI flush-error propagation each causes the selected regression to fail by assertion. Both source files are restored byte-for-byte after proof; the 8-test ABI suite and 2-test CLI suite pass. Strict TypeScript and ESLint pass for both validation tools. Retaining unchanged full release gates for final CI." + "2026-10-24T13:14:09.954Z",codex-agent,"Final-head CI 40095607106 confirms ABI coverage passes, but the complete gate reports 21 uncovered CLI regions despite pooled source spans showing none. Diagnosis: adapter command dispatch and successful receipt paths were exercised only by CLI processes, while unit tests covered only refusal paths. Added direct dispatcher tests with caller-owned streams for successful and refused JSON/TOON checks in a plain directory. All existing workspace behavior remains delegated unchanged. Rechecking the complete CLI unit target and ABI target under instrumentation before another full CI run; thresholds remain unchanged." + "2026-10-24T13:21:24.608Z",codex-agent,"Dispatcher correction verified: direct JSON/TOON command-dispatch success and refusal cases now exercise the same injected-stream path as process dispatch. Targeted instrumentation passes the complete CLI unit target (37), black-box CLI dispatch (17) and ABI contract (8); ABI module remains 100 percent in all four required dimensions. All four linked pm test commands pass, including 5/5 behavioral revert proofs and the two focused adapter CLI tests. Rebuilt both native CLI and example, then repeated readonly public-copy validation: unchanged 83 items, 172 files, 4234972 bytes, 9 pages, 92 adapter calls, 184 CLI checks, 880680 response bytes, largest frame 88935, largest CLI receipt 193; elapsed sample 4117 ms; authoritative source hash unchanged. Static checks and formatting pass. Full repository npm/Bun evidence remains the CI acceptance gate." + "2026-10-24T13:46:42.181Z",codex-agent,"CI 40097795157 passed all platform tests and latency checks, but full coverage identified 18 remaining discovery-error regions in the process executable. Added a black-box matrix of all 19 workspace commands in one plain directory with neither Git nor Node on PATH. All nine ABI contract tests pass. Targeted instrumentation now confirms all 19 discovery-error propagation points have nonzero counts in BOTH dispatcher instances, addressing the exact remaining CI gaps. Production source and public-data measurements are unchanged. Retain unchanged gate thresholds and rerun full CI." + "2026-10-24T15:58:48.802Z",codex-agent,"Review round: reproduce offline/live frame admission divergence, replace decoded transcript replies with exact recorded reply_bytes, and route both paths through check_adapter_reply. Add a deterministic raw-byte differential corpus, prove failure with only the fix reverted, run targeted Rust gates locally, and use CI for full npm/Bun release gates. Existing branch is current with origin/main; no merge needed." + "2026-10-24T16:02:16.006Z",codex-agent,"Raw-byte review fix: check_adapter_transcript and NativeAdapterClient now share check_adapter_reply on exact recorded reply_bytes. No response re-encoding occurs before size/UTF-8/JSON/schema/ABI/correlation admission. Decoded reply transcripts are refused; the validation harness records response.stdout directly. A separate bounded 16 MiB transcript envelope permits a full 1 MiB frame encoded as a decimal byte array. Differential test raw_frame_seeded_corpus_matches_live_client covers 768 frames with seed 0x05eed113, 240 accepted and 528 refused, including limit -1/0/+1, the 5094-byte padded reproduction, escapes, Unicode, invalid UTF-8, truncation, oversized nesting, trailing data, duplicate/unknown fields, recursion, ABI and correlation errors. Verdict and refusal code match for every offline/live pair; live dispatch uncertainty remains explicit. Revert proof: node scripts/native-adapter-revert.mts temporarily restores ONLY offline decode/re-encode behavior; the corpus fails an assertion at frame 2 (4097 bytes at a 4096-byte ceiling: offline accepted versus live FrameLimit). It restores exact source bytes in finally. All 6 behavioral proofs failed as required, then the restored ABI suite passed 11/11; native CLI unit checks passed 2/2. Full npm/Bun release gates will run in CI at unchanged thresholds." + "2026-10-24T16:03:18.887Z",codex-agent,"Local verification complete: pm test --run --progress executed all 6 linked commands successfully: ABI contract 11 passed, targeted native CLI 2 passed, black-box CLI dispatch 17 passed, all 6 behavioral revert assertions failed as required with restoration, deny-warning clippy passed, fmt passed. Strict TypeScript and ESLint also passed. Tracker test-result tracking is disabled, so the sanitized results are recorded here. No item closure or merge; release the claim for orchestrator verification and push this review fix for full CI gates." + "2026-10-24T16:41:00.776Z",codex-agent,"Round 2: pulled the current PR head; origin/main is already contained. Audit every production JSON decode, add one recursive strict value reader before typed deserialization, extend offline/live differential coverage for duplicate keys at all depths, then demonstrate fix-only revert failures. Run targeted Rust tests, clippy and fmt locally; use CI for the full aggregate gates. No merge or review-thread replies." + "2026-10-24T16:47:03.037Z",codex-agent,"Implemented strict_json::from_slice/from_str: recursive JSON value admission compares decoded keys before insertion, preserves parser recursion limits and requires end-of-input, then uses serde_json::from_value for typed deserialization. Routed every production raw JSON decode (36 sites across 17 source/example files) through it. Added 392 offline/live/direct corpus frames (196 accepted, 196 refused), plus escaped transcript/request duplicates and strict tracker tests on a real non-git directory. Adapter tests pass 13/13; strict reader/tracker tests pass 3/3. Fix-only behavioral reverts and local quality checks are next. Local pinned-oracle health reports extension version skew and merge-driver configuration drift; track CI independently." + "2026-10-24T16:47:52.783Z",codex-agent,"Revert proof: node scripts/native-adapter-revert.mts passed 9/9 independent behavioral cases. For each of the three new cases, reverted only strict_json::from_slice to serde_json::from_slice while keeping tests and all routed call sites unchanged. Rebuilt targeted tests failed by assertion (not compilation): strict_json_duplicate_corpus_matches_live_client accepted a duplicate receipt instead of InvalidFrame; strict_json_validates_before_typed_fields_are_ignored accepted a duplicate inside an ignored field; strict_json_tracker_decodes_refuse_duplicates_without_git accepted ambiguous settings. The script restores exact original source bytes in finally. The six earlier regression reverts also failed as required. Decode audit correction: 34 raw JSON decode sites across 16 production source/example files (the earlier estimate of 36/17 was incorrect). No external input decoder remains outside the strict reader; output-only RawValue serialization retains the existing scalar escape contract." + "2026-10-24T16:54:50.336Z",codex-agent,"The PM-linked repeat of the revert harness hit its inherited 120-second timeout under local build contention, after the earlier direct 9/9 proof passed. Inspected source diffs immediately and restored the single in-progress ABI-comparison sentinel; all intended JSON routing remained intact. Increased only the linked revert-command timeout to 600 seconds and will rerun serially, then rerun admission tests and quality checks after restoration. No test failure is being counted as a successful proof." + "2026-10-24T16:58:03.171Z",codex-agent,"Final local verification: serial pm test --run --progress passes all 8 linked commands with zero failure categories. ABI tests 13/13, strict reader/tracker integration 3/3, strict reader unit 1/1, native CLI unit selection 2/2, CLI dispatch 17/17; node scripts/native-adapter-revert.mts completes all 9 assertion proofs and restores the intended implementation. Restored tests pass. Deny-warning clippy, fmt, private-item documentation, strict TypeScript and ESLint pass. Targeted instrumentation covers strict_json.rs at 100% lines/functions/regions/branches: 50/12/79/6. The earlier timeout is superseded by the serial 75-second successful proof with its documented 600-second allowance. Whole-repository npm/Bun and platform gates remain CI evidence for the pushed revision. Retain in_progress for orchestrator verification; no merge or closure." +files[10]: + - path: scripts/native-adapter-revert.mts + scope: project + - path: scripts/native-adapter-validate.mts + scope: project + - path: src/main.rs + scope: project + - path: src/native_adapter.rs + scope: project + - path: src/strict_json.rs + scope: project + note: Shared recursive strict JSON admission + - path: tests/cli_dispatch.rs + scope: project + - path: tests/native_adapter_contract.rs + scope: project + - path: tests/strict_json_contract.rs + scope: project + note: Reader and non-git tracker regressions + - path: tests/support/main_unit.rs + scope: project + - path: tests/support/strict_json_unit.rs + scope: project + note: Unsupported serde input error contract +tests[8]: + - command: CARGO_BUILD_JOBS=1 cargo test --locked --test native_adapter_contract + scope: project + provenance: + author: codex-agent + created_at: "2026-10-24T12:02:06.859Z" + source_kind: local_mutation + source_ref: feat/portable-native-adapter-abi + - command: node scripts/native-adapter-revert.mts + scope: project + timeout_seconds: 600 + provenance: + author: codex-agent + created_at: "2026-10-24T12:07:11.592Z" + source_kind: local_mutation + source_ref: feat/portable-native-adapter-abi + updated_by: codex-agent + updated_at: "2026-10-24T16:54:48.505Z" + - command: CARGO_BUILD_JOBS=1 cargo test --locked --bin pm-rust native_adapter + scope: project + provenance: + author: codex-agent + created_at: "2026-10-24T12:37:25.039Z" + source_kind: local_mutation + source_ref: feat/portable-native-adapter-abi + - command: CARGO_BUILD_JOBS=1 cargo test --locked --test cli_dispatch + scope: project + provenance: + author: codex-agent + created_at: "2026-10-24T13:19:06.879Z" + source_kind: local_mutation + source_ref: feat/portable-native-adapter-abi + - command: CARGO_BUILD_JOBS=1 cargo clippy --locked --all-targets --all-features -- -D warnings + scope: project + provenance: + author: codex-agent + created_at: "2026-10-24T16:01:31.558Z" + source_kind: local_mutation + source_ref: feat/portable-native-adapter-abi + - command: cargo fmt --all -- --check + scope: project + provenance: + author: codex-agent + created_at: "2026-10-24T16:01:32.625Z" + source_kind: local_mutation + source_ref: feat/portable-native-adapter-abi + - command: cargo +1.90.0 test --locked --test native_adapter_contract --test strict_json_contract + scope: project + timeout_seconds: 300 + provenance: + author: codex-agent + created_at: "2026-10-24T16:47:54.283Z" + source_kind: local_mutation + source_ref: feat/portable-native-adapter-abi + - command: cargo +1.90.0 test --locked --lib strict_json + scope: project + timeout_seconds: 300 + provenance: + author: codex-agent + created_at: "2026-10-24T16:52:01.725Z" + source_kind: local_mutation + source_ref: feat/portable-native-adapter-abi +docs[5]: + - path: docs/evidence/native-adapter-2026-10-24.md + scope: project + - path: docs/json-decode-audit.md + scope: project + note: Complete production decode audit and corpus design + - path: docs/native-adapter-abi.md + scope: project + - path: examples/native_adapter.rs + scope: project + - path: README.md + scope: project +body: "" diff --git a/.agents/pm/history/pm-rust-portable-native-adapter-abi.jsonl b/.agents/pm/history/pm-rust-portable-native-adapter-abi.jsonl new file mode 100644 index 0000000..051749e --- /dev/null +++ b/.agents/pm/history/pm-rust-portable-native-adapter-abi.jsonl @@ -0,0 +1,52 @@ +{"hash_algorithm":"sha256","ts":"2026-10-24T11:48:18.127Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-rust-portable-native-adapter-abi"},{"op":"add","path":"/metadata/title","value":"Use authorized remote backends through a portable native adapter ABI"},{"op":"add","path":"/metadata/description","value":"Core owns backend contracts; pm-web and MCP own transports. Rust interoperability only: no new backend or replacement pm-web client."},{"op":"add","path":"/metadata/type","value":"Feature"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":["adapter","interop","sdk"]},{"op":"add","path":"/metadata/created_at","value":"2026-10-24T11:48:18.127Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-10-24T11:48:18.127Z"},{"op":"add","path":"/metadata/author","value":"codex-agent"},{"op":"add","path":"/metadata/acceptance_criteria","value":"Synthetic remote adapters preserve refusal and receipt semantics; Unsupported capabilities fail before writes; ABI compatibility is checked; Typed SDK and bounded TOON/JSON CLI with actionable refusals, docs and example; Read-only public tracker copy validated without Git; npm and Bun release gates pass"},{"op":"add","path":"/metadata/parent","value":"pm-rust-u4al"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"a46e0823bac36f017fd6fa60b175d12654305f673ce76afbcdda59cae20404a6","item_hash_version":3,"message":"","context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"7a9f71428e81881b4a5a65da51bbc8f911271d3afc8ed0c2fd6c95ddf6354bed"} +{"hash_algorithm":"sha256","ts":"2026-10-24T11:48:19.342Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T11:48:19.342Z"},{"op":"add","path":"/metadata/assignee","value":"codex-agent"},{"op":"add","path":"/metadata/claim_principal","value":"codex-agent"}],"before_hash":"a46e0823bac36f017fd6fa60b175d12654305f673ce76afbcdda59cae20404a6","after_hash":"b203d2f664a71f2b05af0957cbed3127dd68ddfe20dc963e18f89f1c54d1e58d","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"39b32817af928057b6ae8baed4c4696a5b68a428a1ac29ae6d2bd77fae7f5103"} +{"hash_algorithm":"sha256","ts":"2026-10-24T11:48:20.497Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T11:48:20.497Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"b203d2f664a71f2b05af0957cbed3127dd68ddfe20dc963e18f89f1c54d1e58d","after_hash":"ccc8479e18764709ae5c9d81755de9628b86a7e6a8a922867c96da614553fe5a","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"545634bb5966d747c8abd0a14cf43ef28cf0ee0de82b802115d865710d4354e2"} +{"hash_algorithm":"sha256","ts":"2026-10-24T11:48:21.365Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T11:48:21.365Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-10-24T11:48:21.365Z","author":"codex-agent","text":"Overlap check: searched existing tracker items for adapter ABI and portable; no duplicate. Inspected pinned SDK 2026.10.21, current 2026.10.24 public exports, core workspace/runtime contracts, pm-web PmRunResult/complete-list receipt handling, and core MCP adapters. Core owns command/backend semantics, authorization and mutation receipts; pm-web/MCP own network transports. Implement a versioned bounded JSON byte boundary for caller-supplied authorized native adapters. No backend, HTTP client, shared cache, persistent lease or outbox is introduced. Rust SDK changes follow this native repository; validation tooling remains erasable TypeScript."}]}],"before_hash":"ccc8479e18764709ae5c9d81755de9628b86a7e6a8a922867c96da614553fe5a","after_hash":"18e2a42acd65a16b7efd2721ea44fbd4a63b2058428a4c9506bcc581d97906f2","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"7ab10ae759878c933d5be2ee7a40d60d057144a8888dbe28cf59d82ede074ef1"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:02:04.204Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-10-24T12:02:04.204Z","author":"codex-agent","text":"Implemented the versioned bounded JSON byte ABI, typed get/list/claim/release operations, pre-dispatch admission, unchanged backend envelopes, and explicit dispatched/unknown-outcome refusals with no retries. Added offline adapter-check CLI; it reports compatibility only, never execution. No shared tracker state is written by the boundary. The authoritative native release SDK follows tracker policy, so release capability requires strict owner enforcement; the synthetic remote adapter tests enforce this inside core locking. Tests inject competing claims and release-and-claim handoffs at every one of six adapter boundaries on one non-git tracker. Added readonly public-copy validation using the real complete-list SDK certificate and native children without Git or Node."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:02:04.204Z"}],"before_hash":"18e2a42acd65a16b7efd2721ea44fbd4a63b2058428a4c9506bcc581d97906f2","after_hash":"b645d247170a90b5af40d22d7d7736b8a8bb9c38debfd01a4d2d2a50617c98cd","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"9b495583cfcdec72f1f927f7e8360c8b6c3dc3aeff93ded23acf8a531bf18431"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:02:05.062Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:02:05.062Z"},{"op":"add","path":"/metadata/files","value":[{"path":"scripts/native-adapter-revert.mts","scope":"project"},{"path":"scripts/native-adapter-validate.mts","scope":"project"},{"path":"src/main.rs","scope":"project"},{"path":"src/native_adapter.rs","scope":"project"},{"path":"tests/native_adapter_contract.rs","scope":"project"}]}],"before_hash":"b645d247170a90b5af40d22d7d7736b8a8bb9c38debfd01a4d2d2a50617c98cd","after_hash":"bd5c57ec32bb95bc42e926fd2a8cf93a61b6c20ac3441c4f357ceca9c3f1fd4a","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"3158e43675a83a2b20bed7958aa7a52064cd82cd18d1696dab998dc2dad9da79"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:02:06.025Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"docs_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:02:06.025Z"},{"op":"add","path":"/metadata/docs","value":[{"path":"docs/native-adapter-abi.md","scope":"project"},{"path":"examples/native_adapter.rs","scope":"project"},{"path":"README.md","scope":"project"}]}],"before_hash":"bd5c57ec32bb95bc42e926fd2a8cf93a61b6c20ac3441c4f357ceca9c3f1fd4a","after_hash":"0dd7c9b16166e1c5354beeb2f574332ca1a6bad133426f5c998f7f3b87d4a58b","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"292957c1570d0ffe038d9b23257da422cb269b3851c5642b9f44e856d8b4ad6d"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:02:06.887Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:02:06.887Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"CARGO_BUILD_JOBS=1 cargo test --locked --test native_adapter_contract","scope":"project","provenance":{"author":"codex-agent","created_at":"2026-10-24T12:02:06.859Z","source_kind":"local_mutation","source_ref":"feat/portable-native-adapter-abi"}}]}],"before_hash":"0dd7c9b16166e1c5354beeb2f574332ca1a6bad133426f5c998f7f3b87d4a58b","after_hash":"36b90edf747e3e81096ef36ae644cec7f4993a8826e45b12b2d3fd5d09c317aa","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"9aef0722705c839210fc187071592ed6ef4742f50fbcaa3176cb87eb48ff4cbe"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:07:11.625Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"node scripts/native-adapter-revert.mts","scope":"project","provenance":{"author":"codex-agent","created_at":"2026-10-24T12:07:11.592Z","source_kind":"local_mutation","source_ref":"feat/portable-native-adapter-abi"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:07:11.625Z"}],"before_hash":"36b90edf747e3e81096ef36ae644cec7f4993a8826e45b12b2d3fd5d09c317aa","after_hash":"538cb5920e346ae0d7d171a6d508ac8296f11831cab7491b14801bfa366e51c3","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"528b0a3c7d2b7057d22a487e7ee73002de1bb4b3f1db033b9296b3d37346a2f4"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:07:54.052Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"docs_add","patch":[{"op":"replace","path":"/metadata/docs/2/path","value":"examples/native_adapter.rs"},{"op":"replace","path":"/metadata/docs/1/path","value":"docs/native-adapter-abi.md"},{"op":"replace","path":"/metadata/docs/0/path","value":"docs/evidence/native-adapter-2026-10-24.md"},{"op":"add","path":"/metadata/docs/3","value":{"path":"README.md","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:07:54.052Z"}],"before_hash":"538cb5920e346ae0d7d171a6d508ac8296f11831cab7491b14801bfa366e51c3","after_hash":"a1641225f03a32e375438d9c188ab45b0821cb8b26873bf07d72f3a3d71fa6ba","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"bb63e5d4a62fb67ccd6df31cb2e5d1bff038e34da7ed2d4db776753c84b1bf94"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:07:55.763Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/2","value":{"created_at":"2026-10-24T12:07:55.763Z","author":"codex-agent","text":"Read-only public-copy validation passed at public source ec39af2b06d3884c466b5770b41e1cdb745f1cd6: 83 certified items, 172 authoritative files, 4234972 bytes, 9 pages, 92 adapter calls, 184 JSON/TOON CLI checks, 880680 response bytes, 88935-byte largest frame, 193-byte largest CLI receipt, 5687 ms. Native children had neither Git nor Node on PATH. Source SHA-256 remained 4efdd31a9fd67caefced2b9852a38c6709f4452e2ecb519433be00bbd61d3607. Targeted suite now has 8 passing tests including 12 shared-tracker interleavings and a foreign-owner release guard. Revert proof independently removed capability admission, exact ABI comparison and outcome preservation; each selected regression failed by assertion, then source was restored. Cargo check, deny-warning clippy, fmt, strict TypeScript and ESLint pass. No SDK bump or shared state was introduced. Full npm/Bun gates will run in CI because local CPU is saturated."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:07:55.763Z"}],"before_hash":"a1641225f03a32e375438d9c188ab45b0821cb8b26873bf07d72f3a3d71fa6ba","after_hash":"15830d64ec61f04a14a5a2598eef9fea448aba7edbd9afe32c3f79601f87e559","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"36398b09bdb93f2c6b703d965bf594bc0bd172409e7f17608231f13c5d8a56b0"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:07:57.570Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:07:57.570Z"}],"before_hash":"15830d64ec61f04a14a5a2598eef9fea448aba7edbd9afe32c3f79601f87e559","after_hash":"4b87f3dcab82a00ad3e2436b0738a8ba32e6bb53d5eeed9d70e302150e7f47fe","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"d49bd9152a88e585e374e17418ca39b31c837e3791a868f3e12b68f9a6fa6836"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:31:20.226Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:31:20.226Z"},{"op":"add","path":"/metadata/assignee","value":"codex-agent"},{"op":"add","path":"/metadata/claim_principal","value":"codex-agent"}],"before_hash":"4b87f3dcab82a00ad3e2436b0738a8ba32e6bb53d5eeed9d70e302150e7f47fe","after_hash":"c88163a43a7ee76015ae17eee4eab91e8aa0267336b96695c869085fcbd9d97b","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"dda1ab714ddc82cda976455a0752eef70dee31f87b568fd9711621121be3e794"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:31:21.192Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/3","value":{"created_at":"2026-10-24T12:31:21.192Z","author":"codex-agent","text":"Initial CI run 40092834320 passed all three platform test suites and all latency checks, but release coverage refused two unexecuted manifest-error propagation regions in the new ABI client/preflight. Keep all thresholds unchanged; add targeted incompatible-manifest constructor/admission paths and rerun CI. No production mutation or receipt regression was reported."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:31:21.192Z"}],"before_hash":"c88163a43a7ee76015ae17eee4eab91e8aa0267336b96695c869085fcbd9d97b","after_hash":"41e80c01b0de8433713af1155aba3c398ab6885cabf535f9b131e182884567f7","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"964f7fa4e59c916267d7687041188a5e753966bfd8215894eb78bdae7235b113"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:37:23.788Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"add","path":"/metadata/files/5","value":{"path":"tests/support/main_unit.rs","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:37:23.788Z"}],"before_hash":"41e80c01b0de8433713af1155aba3c398ab6885cabf535f9b131e182884567f7","after_hash":"c2de0620996d9602802c488a9761191ba7aad16e791c9cd372a0ccf645dfb751","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"f34027d7db0443b1d988e8355810670b37ffac5596a8a34d35a996a23ae666fa"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:37:25.079Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/2","value":{"command":"CARGO_BUILD_JOBS=1 cargo test --locked --bin pm-rust native_adapter","scope":"project","provenance":{"author":"codex-agent","created_at":"2026-10-24T12:37:25.039Z","source_kind":"local_mutation","source_ref":"feat/portable-native-adapter-abi"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:37:25.079Z"}],"before_hash":"c2de0620996d9602802c488a9761191ba7aad16e791c9cd372a0ccf645dfb751","after_hash":"4f7b6d61c38aa4201adca3970d63977f68ae8a6d5b0b2a477cd985f3e880f1e4","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"6772fadf48c125bb0c3081a9c5385aa1e7cd9a996cbf24fe46250970a1aceff5"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:45:53.397Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/4","value":{"created_at":"2026-10-24T12:45:53.397Z","author":"codex-agent","text":"Coverage correction complete: incompatible manifest constructor/preflight paths now prove zero backend dispatch. Added two CLI tests covering all 19 missing-tracker command paths and injected stdin, initial write, trailing newline, flush and codec failures. CLI writes now propagate pipe errors. Generic clients delegate to one shared validated exchange path without changing the public API or wire ABI. Targeted instrumented run passes 8 ABI tests plus 2 CLI tests; native_adapter.rs has 146/146 lines, 17/17 functions, 211/211 regions and 20/20 branches covered. New CLI helper regions and tracker-discovery error propagations are covered; full-repository coverage remains for CI. Read-only public-copy validation repeated: identical 83 items, 9 pages, 92 calls, 184 checks, 193-byte maximum CLI receipt and unchanged source hash; latest elapsed sample 5301 ms."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:45:53.397Z"}],"before_hash":"4f7b6d61c38aa4201adca3970d63977f68ae8a6d5b0b2a477cd985f3e880f1e4","after_hash":"edc547ef9e942894f9966b4f95543a2a40d539abe40b50e57f2defeec20e272c","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"6e2aa415bc0e2b32796c274675096db197c6d3f294bf235230c29fe959d41636"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:47:25.589Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:47:25.589Z"}],"before_hash":"edc547ef9e942894f9966b4f95543a2a40d539abe40b50e57f2defeec20e272c","after_hash":"ad3d0614ca3aa49771dc83449dc5e68a7577e5fde24530bdcf646d8b4cd97eca","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"60d5a82966e87e940f129e7a9ed1abacb62ff5f989c2804076c0d54023702de7"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:49:16.285Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:49:16.285Z"},{"op":"add","path":"/metadata/assignee","value":"codex-agent"},{"op":"add","path":"/metadata/claim_principal","value":"codex-agent"}],"before_hash":"ad3d0614ca3aa49771dc83449dc5e68a7577e5fde24530bdcf646d8b4cd97eca","after_hash":"cfd28a15780e8cc18f5c12a44334e5e70fa8e975cf78e1c5c975bc9f7c43720d","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"0048470bc9dd8cc3c27a9d6161291f759f1213e84c993755a1989ffd0cc70b6a"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:50:25.942Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/5","value":{"created_at":"2026-10-24T12:50:25.942Z","author":"codex-agent","text":"Final behavioral proof expansion passed 5/5: removing capability admission, exact ABI checking, canonical outcome preservation, tracker-free CLI dispatch, or CLI flush-error propagation each causes the selected regression to fail by assertion. Both source files are restored byte-for-byte after proof; the 8-test ABI suite and 2-test CLI suite pass. Strict TypeScript and ESLint pass for both validation tools. Retaining unchanged full release gates for final CI."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:50:25.942Z"}],"before_hash":"cfd28a15780e8cc18f5c12a44334e5e70fa8e975cf78e1c5c975bc9f7c43720d","after_hash":"dc76b61f366233f6904992210b0c316b42361765736733c8b354384209212103","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"c8f64a9f0b898780302acc1ef0ecea5669ac20c1b1c82fe9006a6a2cc8703b73"} +{"hash_algorithm":"sha256","ts":"2026-10-24T12:50:26.803Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T12:50:26.803Z"}],"before_hash":"dc76b61f366233f6904992210b0c316b42361765736733c8b354384209212103","after_hash":"ac9ea49c7a794d2be505abe2f7234690dd7529e1f4c3b4040911442e9c51e9eb","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"6d7dd29e49bfecff0cebf29f30f5c6334dff2a72c5b461e0d627f78506b7a864"} +{"hash_algorithm":"sha256","ts":"2026-10-24T13:14:08.436Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T13:14:08.436Z"},{"op":"add","path":"/metadata/assignee","value":"codex-agent"},{"op":"add","path":"/metadata/claim_principal","value":"codex-agent"}],"before_hash":"ac9ea49c7a794d2be505abe2f7234690dd7529e1f4c3b4040911442e9c51e9eb","after_hash":"2f2cc49b66453b6aa91daf94c2c9189b122c0d71e1e0622a3b5d27669d1623ea","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"c6cdb3559a923baf7782e9bcf94df2fc4c7613041f65cf88d3b0a1b581ab83fe"} +{"hash_algorithm":"sha256","ts":"2026-10-24T13:14:09.954Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/6","value":{"created_at":"2026-10-24T13:14:09.954Z","author":"codex-agent","text":"Final-head CI 40095607106 confirms ABI coverage passes, but the complete gate reports 21 uncovered CLI regions despite pooled source spans showing none. Diagnosis: adapter command dispatch and successful receipt paths were exercised only by CLI processes, while unit tests covered only refusal paths. Added direct dispatcher tests with caller-owned streams for successful and refused JSON/TOON checks in a plain directory. All existing workspace behavior remains delegated unchanged. Rechecking the complete CLI unit target and ABI target under instrumentation before another full CI run; thresholds remain unchanged."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T13:14:09.954Z"}],"before_hash":"2f2cc49b66453b6aa91daf94c2c9189b122c0d71e1e0622a3b5d27669d1623ea","after_hash":"2bd12109eefa37e37de01f591fb939df68bbacf562ac5ee6d916e6bf2ef63043","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"29fd6ceabf0bbba39769fdd38be60636ffdc7a3359c46c93feb8b29ff77b3a84"} +{"hash_algorithm":"sha256","ts":"2026-10-24T13:19:07.006Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/3","value":{"command":"CARGO_BUILD_JOBS=1 cargo test --locked --test cli_dispatch","scope":"project","provenance":{"author":"codex-agent","created_at":"2026-10-24T13:19:06.879Z","source_kind":"local_mutation","source_ref":"feat/portable-native-adapter-abi"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T13:19:07.006Z"}],"before_hash":"2bd12109eefa37e37de01f591fb939df68bbacf562ac5ee6d916e6bf2ef63043","after_hash":"ee6392e5f4a3f4a7d66e56f05b31b9831a7b930acc9371dccec9fb72fbfe93c6","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"052741521fddcc615e6e741eddc67a72a0e7bb495babf69efd55ad1ab2f08f0a"} +{"hash_algorithm":"sha256","ts":"2026-10-24T13:19:08.821Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/files/5/path","value":"tests/native_adapter_contract.rs"},{"op":"replace","path":"/metadata/files/4/path","value":"tests/cli_dispatch.rs"},{"op":"add","path":"/metadata/files/6","value":{"path":"tests/support/main_unit.rs","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T13:19:08.821Z"}],"before_hash":"ee6392e5f4a3f4a7d66e56f05b31b9831a7b930acc9371dccec9fb72fbfe93c6","after_hash":"c52e3b3e33d70bf47da10c50e033df5b9b15ba54b46b8cf8ffc8aac03e02fd65","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"baf1f6da25779c2ec918c9ab778c5c376dcab7adfe77ac6b37ccfb73f8de494d"} +{"hash_algorithm":"sha256","ts":"2026-10-24T13:21:24.608Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/7","value":{"created_at":"2026-10-24T13:21:24.608Z","author":"codex-agent","text":"Dispatcher correction verified: direct JSON/TOON command-dispatch success and refusal cases now exercise the same injected-stream path as process dispatch. Targeted instrumentation passes the complete CLI unit target (37), black-box CLI dispatch (17) and ABI contract (8); ABI module remains 100 percent in all four required dimensions. All four linked pm test commands pass, including 5/5 behavioral revert proofs and the two focused adapter CLI tests. Rebuilt both native CLI and example, then repeated readonly public-copy validation: unchanged 83 items, 172 files, 4234972 bytes, 9 pages, 92 adapter calls, 184 CLI checks, 880680 response bytes, largest frame 88935, largest CLI receipt 193; elapsed sample 4117 ms; authoritative source hash unchanged. Static checks and formatting pass. Full repository npm/Bun evidence remains the CI acceptance gate."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T13:21:24.608Z"}],"before_hash":"c52e3b3e33d70bf47da10c50e033df5b9b15ba54b46b8cf8ffc8aac03e02fd65","after_hash":"be9c75a73fa7ea8d12f8b23f9bce721b141baeb8edd1c12095ac8f823b274f75","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"d46af1eb0942c38a70d6467ab2559e1a30079de744c52af50ff2c81d1a8392db"} +{"hash_algorithm":"sha256","ts":"2026-10-24T13:21:25.564Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T13:21:25.564Z"}],"before_hash":"be9c75a73fa7ea8d12f8b23f9bce721b141baeb8edd1c12095ac8f823b274f75","after_hash":"439d4d04a1ce2ca276415a78fb150c65388792a97628144cb42ca341b99d647c","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"a18b35ed586977684b93bf5953e6718a965f966084cca6fd34a18aed5b2a3113"} +{"hash_algorithm":"sha256","ts":"2026-10-24T13:46:40.824Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T13:46:40.824Z"},{"op":"add","path":"/metadata/assignee","value":"codex-agent"},{"op":"add","path":"/metadata/claim_principal","value":"codex-agent"}],"before_hash":"439d4d04a1ce2ca276415a78fb150c65388792a97628144cb42ca341b99d647c","after_hash":"f083f00798b47694f0c1b9a60a80f60899a08876cbf98e945bdaa12e18f9a146","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"d936b0bfe608858f89de739af76222ab3ea804c04061deef635ef3b22d0e8367"} +{"hash_algorithm":"sha256","ts":"2026-10-24T13:46:42.181Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/8","value":{"created_at":"2026-10-24T13:46:42.181Z","author":"codex-agent","text":"CI 40097795157 passed all platform tests and latency checks, but full coverage identified 18 remaining discovery-error regions in the process executable. Added a black-box matrix of all 19 workspace commands in one plain directory with neither Git nor Node on PATH. All nine ABI contract tests pass. Targeted instrumentation now confirms all 19 discovery-error propagation points have nonzero counts in BOTH dispatcher instances, addressing the exact remaining CI gaps. Production source and public-data measurements are unchanged. Retain unchanged gate thresholds and rerun full CI."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T13:46:42.181Z"}],"before_hash":"f083f00798b47694f0c1b9a60a80f60899a08876cbf98e945bdaa12e18f9a146","after_hash":"2d146512acfa6e008cc3229273f67a88e0b6ac28a9f5d253a228314881b79898","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"1017cc00927ae2591522d7a14f7da67e95026a35da841d387d0c311590954c9f"} +{"hash_algorithm":"sha256","ts":"2026-10-24T13:48:06.229Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"6dd06d9ec328f39ed99caa31","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T13:48:06.229Z"}],"before_hash":"2d146512acfa6e008cc3229273f67a88e0b6ac28a9f5d253a228314881b79898","after_hash":"7f829ab84ea07cbc8b2a63fcddab958ff616f51e217455ca0570d36c7c9e8f70","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"cad591353d6d14d3f53e41176198bcbfcbdd206401a2ac7a699e69b6daddf455"} +{"hash_algorithm":"sha256","ts":"2026-10-24T15:58:46.741Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"4bf494ace5f1edce25987306","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T15:58:46.741Z"},{"op":"add","path":"/metadata/assignee","value":"codex-agent"},{"op":"add","path":"/metadata/claim_principal","value":"codex-agent"}],"before_hash":"7f829ab84ea07cbc8b2a63fcddab958ff616f51e217455ca0570d36c7c9e8f70","after_hash":"3d18999b80eecc553db1169b486a9bdd70c2f0635f7ece8292ceb1ecec5dedf3","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"cf0ac7e3994000c9499832a19d1aa4fd4da40b216fbf8be9938762d9dea846d7"} +{"hash_algorithm":"sha256","ts":"2026-10-24T15:58:47.881Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"4bf494ace5f1edce25987306","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T15:58:47.881Z"},{"op":"replace","path":"/metadata/tags/2","value":"interop"},{"op":"replace","path":"/metadata/tags/1","value":"gh:unbraind/pm-rust#113"},{"op":"add","path":"/metadata/tags/3","value":"sdk"}],"before_hash":"3d18999b80eecc553db1169b486a9bdd70c2f0635f7ece8292ceb1ecec5dedf3","after_hash":"b7a13d29c86231b9df5e959d33f3769d52b00470547bbdeafd60b96d89dc53ad","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"051a1e45e0d18dc79ff625ac311233216289262fae7b9c2847eb97c56fb0ff0d"} +{"hash_algorithm":"sha256","ts":"2026-10-24T15:58:48.802Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"4bf494ace5f1edce25987306","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/9","value":{"created_at":"2026-10-24T15:58:48.802Z","author":"codex-agent","text":"Review round: reproduce offline/live frame admission divergence, replace decoded transcript replies with exact recorded reply_bytes, and route both paths through check_adapter_reply. Add a deterministic raw-byte differential corpus, prove failure with only the fix reverted, run targeted Rust gates locally, and use CI for full npm/Bun release gates. Existing branch is current with origin/main; no merge needed."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T15:58:48.802Z"}],"before_hash":"b7a13d29c86231b9df5e959d33f3769d52b00470547bbdeafd60b96d89dc53ad","after_hash":"37b5556664dbdc3d64e10126a9c3e7624138c8d621610174769c07903a2f8871","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"1973fe6b18327d3e1a865eac06f0f17008b1abb378127d2e30f3a974cd963de1"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:01:31.628Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"4bf494ace5f1edce25987306","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/4","value":{"command":"CARGO_BUILD_JOBS=1 cargo clippy --locked --all-targets --all-features -- -D warnings","scope":"project","provenance":{"author":"codex-agent","created_at":"2026-10-24T16:01:31.558Z","source_kind":"local_mutation","source_ref":"feat/portable-native-adapter-abi"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:01:31.628Z"}],"before_hash":"37b5556664dbdc3d64e10126a9c3e7624138c8d621610174769c07903a2f8871","after_hash":"f388920ec0924e01b088c70ed784322ba33e4bde37bee2226c77cd51684324ae","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"2a5440cf17c6e92164f41efb0cbc843b5bb5579305a3d264b8a0f774aa4c5345"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:01:32.662Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"4bf494ace5f1edce25987306","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/5","value":{"command":"cargo fmt --all -- --check","scope":"project","provenance":{"author":"codex-agent","created_at":"2026-10-24T16:01:32.625Z","source_kind":"local_mutation","source_ref":"feat/portable-native-adapter-abi"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:01:32.662Z"}],"before_hash":"f388920ec0924e01b088c70ed784322ba33e4bde37bee2226c77cd51684324ae","after_hash":"5f5a5f6567ad4ee28b19fb91fad492882b9cf9cd2156052e42d7b29a52261a6a","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"4032cebbd90dc501ab6bdc7e5dcbed224f780eba546477a5add78f17fd4ffc6f"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:02:16.006Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"4bf494ace5f1edce25987306","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/10","value":{"created_at":"2026-10-24T16:02:16.006Z","author":"codex-agent","text":"Raw-byte review fix: check_adapter_transcript and NativeAdapterClient now share check_adapter_reply on exact recorded reply_bytes. No response re-encoding occurs before size/UTF-8/JSON/schema/ABI/correlation admission. Decoded reply transcripts are refused; the validation harness records response.stdout directly. A separate bounded 16 MiB transcript envelope permits a full 1 MiB frame encoded as a decimal byte array. Differential test raw_frame_seeded_corpus_matches_live_client covers 768 frames with seed 0x05eed113, 240 accepted and 528 refused, including limit -1/0/+1, the 5094-byte padded reproduction, escapes, Unicode, invalid UTF-8, truncation, oversized nesting, trailing data, duplicate/unknown fields, recursion, ABI and correlation errors. Verdict and refusal code match for every offline/live pair; live dispatch uncertainty remains explicit. Revert proof: node scripts/native-adapter-revert.mts temporarily restores ONLY offline decode/re-encode behavior; the corpus fails an assertion at frame 2 (4097 bytes at a 4096-byte ceiling: offline accepted versus live FrameLimit). It restores exact source bytes in finally. All 6 behavioral proofs failed as required, then the restored ABI suite passed 11/11; native CLI unit checks passed 2/2. Full npm/Bun release gates will run in CI at unchanged thresholds."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:02:16.006Z"}],"before_hash":"5f5a5f6567ad4ee28b19fb91fad492882b9cf9cd2156052e42d7b29a52261a6a","after_hash":"1d744a7ad80024dc2eb26bc25216699cbed0004e6d8f4aaabadc4c8b0e493fbc","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"150a8f6afca0d62750ff6913d88afa9ea8e72f6d4c52a65d1b912e7efad6fb5f"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:03:18.887Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"4bf494ace5f1edce25987306","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/11","value":{"created_at":"2026-10-24T16:03:18.887Z","author":"codex-agent","text":"Local verification complete: pm test --run --progress executed all 6 linked commands successfully: ABI contract 11 passed, targeted native CLI 2 passed, black-box CLI dispatch 17 passed, all 6 behavioral revert assertions failed as required with restoration, deny-warning clippy passed, fmt passed. Strict TypeScript and ESLint also passed. Tracker test-result tracking is disabled, so the sanitized results are recorded here. No item closure or merge; release the claim for orchestrator verification and push this review fix for full CI gates."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:03:18.887Z"}],"before_hash":"1d744a7ad80024dc2eb26bc25216699cbed0004e6d8f4aaabadc4c8b0e493fbc","after_hash":"7df5dff582aabb8a5aab57471e9072e4844427bf8f3de2ceb3e8e42fe39024f6","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"10d6c8ab6cdc80bdf565854618e27c126be300d4946d1dc4269b7dfd8f77a5ad"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:03:20.133Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"4bf494ace5f1edce25987306","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:03:20.133Z"}],"before_hash":"7df5dff582aabb8a5aab57471e9072e4844427bf8f3de2ceb3e8e42fe39024f6","after_hash":"d6bab73cf70e1c2304448ba5805a0d353a1661a9474b3030bb95495cefd34f99","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"5abd0fd25b4f37c521580ca7cff80f91b3fcecd14ae396a31927e26776922ebb"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:40:56.607Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"d737732ff95929a178bba892","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:40:56.607Z"},{"op":"add","path":"/metadata/assignee","value":"codex-agent"},{"op":"add","path":"/metadata/claim_principal","value":"codex-agent"}],"before_hash":"d6bab73cf70e1c2304448ba5805a0d353a1661a9474b3030bb95495cefd34f99","after_hash":"7fffee790f2062a5a9593e2d9b2edcb3b9afa2d6099e07f97beed69d2664c9c4","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"d897d55c57adc03f1de9e08d3a76feed1cf7b0bad6d9b11f2b7d488f1b610259"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:41:00.776Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"d737732ff95929a178bba892","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/12","value":{"created_at":"2026-10-24T16:41:00.776Z","author":"codex-agent","text":"Round 2: pulled the current PR head; origin/main is already contained. Audit every production JSON decode, add one recursive strict value reader before typed deserialization, extend offline/live differential coverage for duplicate keys at all depths, then demonstrate fix-only revert failures. Run targeted Rust tests, clippy and fmt locally; use CI for the full aggregate gates. No merge or review-thread replies."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:41:00.776Z"}],"before_hash":"7fffee790f2062a5a9593e2d9b2edcb3b9afa2d6099e07f97beed69d2664c9c4","after_hash":"3058904b6c731b06981bf6df46927b30b70eaad36ad54842c823c246082ae133","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"ee3e5d517c8832423e00ba5a034a6726d83de8dbc05226a4f482133f7cdbcf39"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:47:03.038Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"d737732ff95929a178bba892","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/13","value":{"created_at":"2026-10-24T16:47:03.037Z","author":"codex-agent","text":"Implemented strict_json::from_slice/from_str: recursive JSON value admission compares decoded keys before insertion, preserves parser recursion limits and requires end-of-input, then uses serde_json::from_value for typed deserialization. Routed every production raw JSON decode (36 sites across 17 source/example files) through it. Added 392 offline/live/direct corpus frames (196 accepted, 196 refused), plus escaped transcript/request duplicates and strict tracker tests on a real non-git directory. Adapter tests pass 13/13; strict reader/tracker tests pass 3/3. Fix-only behavioral reverts and local quality checks are next. Local pinned-oracle health reports extension version skew and merge-driver configuration drift; track CI independently."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:47:03.038Z"}],"before_hash":"3058904b6c731b06981bf6df46927b30b70eaad36ad54842c823c246082ae133","after_hash":"2eafa646f87869ead618b35338653ea32117a461cf99ae8a454fa34751d2a4b9","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"c1358586db62a98389003ddcf1966ca799f819d7d4c5be60d67f19fc28246161"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:47:52.783Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"d737732ff95929a178bba892","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/14","value":{"created_at":"2026-10-24T16:47:52.783Z","author":"codex-agent","text":"Revert proof: node scripts/native-adapter-revert.mts passed 9/9 independent behavioral cases. For each of the three new cases, reverted only strict_json::from_slice to serde_json::from_slice while keeping tests and all routed call sites unchanged. Rebuilt targeted tests failed by assertion (not compilation): strict_json_duplicate_corpus_matches_live_client accepted a duplicate receipt instead of InvalidFrame; strict_json_validates_before_typed_fields_are_ignored accepted a duplicate inside an ignored field; strict_json_tracker_decodes_refuse_duplicates_without_git accepted ambiguous settings. The script restores exact original source bytes in finally. The six earlier regression reverts also failed as required. Decode audit correction: 34 raw JSON decode sites across 16 production source/example files (the earlier estimate of 36/17 was incorrect). No external input decoder remains outside the strict reader; output-only RawValue serialization retains the existing scalar escape contract."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:47:52.783Z"}],"before_hash":"2eafa646f87869ead618b35338653ea32117a461cf99ae8a454fa34751d2a4b9","after_hash":"e8b1931ffc65decef9aa23495650ed947e6d83ceea0fb1abab1a676c22cbefcf","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"5ce83fbed650fb86a4e5ae8e1664d3b0d622946275d0efbbf19be8d720431948"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:47:54.327Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"d737732ff95929a178bba892","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/6","value":{"command":"cargo +1.90.0 test --locked --test native_adapter_contract --test strict_json_contract","scope":"project","timeout_seconds":300,"provenance":{"author":"codex-agent","created_at":"2026-10-24T16:47:54.283Z","source_kind":"local_mutation","source_ref":"feat/portable-native-adapter-abi"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:47:54.327Z"}],"before_hash":"e8b1931ffc65decef9aa23495650ed947e6d83ceea0fb1abab1a676c22cbefcf","after_hash":"4db179edf78c55c0d1e04f258dd1cb1564fab6f013e58a7bdd877854f78a17c4","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"068ad9ed3e0540bedf75ad4f5742a0bd0ca01a9d31adb952c19695c87a86acb5"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:48:47.514Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"d737732ff95929a178bba892","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/files/6/path","value":"tests/native_adapter_contract.rs"},{"op":"replace","path":"/metadata/files/5/path","value":"tests/cli_dispatch.rs"},{"op":"add","path":"/metadata/files/4/note","value":"Shared recursive strict JSON admission"},{"op":"replace","path":"/metadata/files/4/path","value":"src/strict_json.rs"},{"op":"add","path":"/metadata/files/7","value":{"path":"tests/support/main_unit.rs","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:48:47.514Z"}],"before_hash":"4db179edf78c55c0d1e04f258dd1cb1564fab6f013e58a7bdd877854f78a17c4","after_hash":"4f0c5fd6f4c956ccd5b4f739a2a4edcedeaa027ef315b83089b457f2357b84a8","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"eda43f345c09a03a1b221da1efbfe859debc66a4a1fe04c7754db551b0b61f29"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:48:48.546Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"d737732ff95929a178bba892","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"add","path":"/metadata/files/7/note","value":"Reader and non-git tracker regressions"},{"op":"replace","path":"/metadata/files/7/path","value":"tests/strict_json_contract.rs"},{"op":"add","path":"/metadata/files/8","value":{"path":"tests/support/main_unit.rs","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:48:48.546Z"}],"before_hash":"4f0c5fd6f4c956ccd5b4f739a2a4edcedeaa027ef315b83089b457f2357b84a8","after_hash":"1e099ae08c1741a64e5b4e2b3df5c6ca8c653790dc85fe2d71d817bdbaa8ee86","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"c280894210f140a05c74ac107a5a16bf0e1215d5b4aab5458517a4fd190dbecb"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:48:49.874Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"d737732ff95929a178bba892","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"docs_add","patch":[{"op":"replace","path":"/metadata/docs/3/path","value":"examples/native_adapter.rs"},{"op":"replace","path":"/metadata/docs/2/path","value":"docs/native-adapter-abi.md"},{"op":"add","path":"/metadata/docs/1/note","value":"Complete production decode audit and corpus design"},{"op":"replace","path":"/metadata/docs/1/path","value":"docs/json-decode-audit.md"},{"op":"add","path":"/metadata/docs/4","value":{"path":"README.md","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:48:49.874Z"}],"before_hash":"1e099ae08c1741a64e5b4e2b3df5c6ca8c653790dc85fe2d71d817bdbaa8ee86","after_hash":"896165d7e552204fd73df82b844f96497e62373a883ab00a2d35b22cb499f084","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"b3483091039d6bf1e8deca498de8188d26035789cf3a6f45823edc30c2947657"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:51:59.041Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"d737732ff95929a178bba892","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"add","path":"/metadata/files/9","value":{"path":"tests/support/strict_json_unit.rs","scope":"project","note":"Unsupported serde input error contract"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:51:59.041Z"}],"before_hash":"896165d7e552204fd73df82b844f96497e62373a883ab00a2d35b22cb499f084","after_hash":"b4283a871aa6d18a63ea1d1bb57bfa8fabec747c69bccfbcf652cf737b3a0ecd","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"90d87657d122d2358fe4c6a69089372f0247e00b186825f703b07ef4dce1f88e"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:52:01.836Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"d737732ff95929a178bba892","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/7","value":{"command":"cargo +1.90.0 test --locked --lib strict_json","scope":"project","timeout_seconds":300,"provenance":{"author":"codex-agent","created_at":"2026-10-24T16:52:01.725Z","source_kind":"local_mutation","source_ref":"feat/portable-native-adapter-abi"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:52:01.836Z"}],"before_hash":"b4283a871aa6d18a63ea1d1bb57bfa8fabec747c69bccfbcf652cf737b3a0ecd","after_hash":"81c6d58f57559594cadf9604153fb9bd50c07e3448725e3d9cb149a99674e433","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"a3b828af7894038252147fbc5852edd7dd6299b92ef71e77417ca9504cd5f3ab"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:54:48.508Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"d737732ff95929a178bba892","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_update","patch":[{"op":"add","path":"/metadata/tests/1/timeout_seconds","value":600},{"op":"add","path":"/metadata/tests/1/updated_by","value":"codex-agent"},{"op":"add","path":"/metadata/tests/1/updated_at","value":"2026-10-24T16:54:48.505Z"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:54:48.508Z"},{"op":"add","path":"/metadata/pm_format_version","value":2}],"before_hash":"81c6d58f57559594cadf9604153fb9bd50c07e3448725e3d9cb149a99674e433","after_hash":"2c0070301a7117f6773b797f8edfe34b84b007cf787fa9c84cef260d4d80ca42","item_hash_version":3,"context":{"linked_test_update":{"index":2,"fields":["timeout_seconds"]},"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"b1f959609f979e4a8971dffd69a493b8a4242cab0a998a6f7dfb165d3f238059"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:54:50.340Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"d737732ff95929a178bba892","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/15","value":{"created_at":"2026-10-24T16:54:50.336Z","author":"codex-agent","text":"The PM-linked repeat of the revert harness hit its inherited 120-second timeout under local build contention, after the earlier direct 9/9 proof passed. Inspected source diffs immediately and restored the single in-progress ABI-comparison sentinel; all intended JSON routing remained intact. Increased only the linked revert-command timeout to 600 seconds and will rerun serially, then rerun admission tests and quality checks after restoration. No test failure is being counted as a successful proof."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:54:50.340Z"}],"before_hash":"2c0070301a7117f6773b797f8edfe34b84b007cf787fa9c84cef260d4d80ca42","after_hash":"47df0d3eaececc36c6d3bf01cdf368dc52f96c291877f24ea5ddac7219431c0c","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"e5830d123da36017edff4f0b833e519459cdef5a6a86a628fbaad3c711cfdfdf"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:58:03.173Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"d737732ff95929a178bba892","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/16","value":{"created_at":"2026-10-24T16:58:03.171Z","author":"codex-agent","text":"Final local verification: serial pm test --run --progress passes all 8 linked commands with zero failure categories. ABI tests 13/13, strict reader/tracker integration 3/3, strict reader unit 1/1, native CLI unit selection 2/2, CLI dispatch 17/17; node scripts/native-adapter-revert.mts completes all 9 assertion proofs and restores the intended implementation. Restored tests pass. Deny-warning clippy, fmt, private-item documentation, strict TypeScript and ESLint pass. Targeted instrumentation covers strict_json.rs at 100% lines/functions/regions/branches: 50/12/79/6. The earlier timeout is superseded by the serial 75-second successful proof with its documented 600-second allowance. Whole-repository npm/Bun and platform gates remain CI evidence for the pushed revision. Retain in_progress for orchestrator verification; no merge or closure."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:58:03.173Z"}],"before_hash":"47df0d3eaececc36c6d3bf01cdf368dc52f96c291877f24ea5ddac7219431c0c","after_hash":"32ca41467d9668637e60ed00c141dc3dee719ce3d2a65dbcc51d356cad52f644","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"0fb7b3d683386f83d30f3c38696ae58a1d81980db0c92712b9ef9215c1dc6f05"} +{"hash_algorithm":"sha256","ts":"2026-10-24T16:58:04.700Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"d737732ff95929a178bba892","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-24T16:58:04.700Z"}],"before_hash":"32ca41467d9668637e60ed00c141dc3dee719ce3d2a65dbcc51d356cad52f644","after_hash":"8fbad34c0199c63561dbd093c7b36ebaa3c9e5090eff1693e4308f208f37c12d","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"882a914c71724ae5f1802cd8f85f4f422c778139f2fbd4c073781fe30708d3a5"} diff --git a/README.md b/README.md index 503fd1f..70e150e 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,8 @@ accounting limits, cancellation latency, integrity boundaries and typed remedies ## Without git -Read sessions and workspace discovery, list/get/search/context/history/dependencies, item mutations, +Adapter compatibility checks work without a tracker or Git. Read sessions and +workspace discovery, list/get/search/context/history/dependencies, item mutations, ownership and linked artifacts work in plain directories. Concurrent writers share the same tracker and retain every admitted item edit and append-only history entry. The multi-process write contract tests 50 writers against one such tracker without @@ -281,3 +282,39 @@ node tests/oracle/node_modules/@unbrained/pm-cli/dist/cli.js health --check-only ## License MIT. See [LICENSE](LICENSE). + +## Portable native adapter ABI + +Rust embeddings can use an explicitly supplied `NativeAdapter` through +`native_adapter::NativeAdapterClient`. The `pm-native-adapter/1` contract +exchanges bounded UTF-8 JSON bytes, with typed get, paginated list, claim and +release requests. It requires neither Git nor Node. The caller supplies an +existing authorized backend transport; core SDK semantics and pm-web/MCP +transports retain their ownership. + +The client checks the exact ABI, declared capability, arguments and frame +ceiling before dispatch. It makes one exchange, preserves canonical backend +output (including refusal, omission and mutation receipts), and never retries. +A successful response can describe a skipped claim or a peer's success; inspect +the canonical receipt before proceeding. `AdapterRefusal.dispatched` distinguishes +preflight refusal from an uncertain dispatched outcome. Reconcile authoritative +receipts before retrying an interrupted mutation. + +`pm-rust adapter-check [--json]` reads a transcript from stdin and emits a compact +TOON or JSON compatibility receipt. It works without a tracker. It validates +recorded responses supplied as exact `reply_bytes` arrays without replaying them; `dispatched: false` and +`receipt_scope: abi_compatibility_only` are explicit. It does not certify backend +authorization, durable writes or corpus completeness. + +```sh +pm-rust adapter-check --json <<'JSON' +{"manifest":{"abi":"pm-native-adapter/1","capabilities":["get"],"max_frame_bytes":1048576},"request":{"abi":"pm-native-adapter/1","request_id":"read-1","operation":{"action":"get","id":"demo-1"}}} +JSON +``` + +See the [ABI and adapter obligations](docs/native-adapter-abi.md) and the +[compilable read adapter example](examples/native_adapter.rs). Build the example +with `cargo build --locked --example native_adapter`, then pass an explicitly +authorized tracker directory as its argument and one `AdapterRequest` on stdin. +Replace its `exchange` implementation with the existing authorized transport +when embedding against a remote backend. The example grants only get and list. diff --git a/docs/evidence/native-adapter-2026-10-24.md b/docs/evidence/native-adapter-2026-10-24.md new file mode 100644 index 0000000..57ec50d --- /dev/null +++ b/docs/evidence/native-adapter-2026-10-24.md @@ -0,0 +1,150 @@ +# Native adapter ABI validation — 2026-10-24 + +Source: the tracked public pm-rust tracker at +`ec39af2b06d3884c466b5770b41e1cdb745f1cd6` (origin/main), exported with +`git archive` and copied into an exclusively owned non-git scratch directory. +No hosted data or private tracker was accessed. The pinned published SDK +2026.10.21 certified the complete source list; the current published CLI version +was independently resolved as 2026.10.24. Existing pins were retained. + +The validation program made source files and directories read-only for native +reads, compared the SDK's complete identity set and each page's rows/omission +receipt, compared identity/title/status on every get, and checked authoritative +source hashes before and after. All native children had a PATH containing +neither Git nor Node and a Git discovery ceiling above the scratch project. + +| Measurement | Result | +| --- | ---: | +| Certified source items | 83 | +| Authoritative files | 172 | +| Authoritative bytes | 4,234,972 | +| List pages, requested 10 rows/page | 9 | +| Adapter calls: list + every item get | 92 | +| CLI transcript checks: JSON + TOON | 184 | +| Total adapter response bytes | 880,680 | +| Largest response frame | 88,935 bytes | +| Largest CLI compatibility receipt | 193 bytes | +| Validation elapsed time | 4,117 ms | +| Source changed | No | + +Unchanged authoritative source SHA-256: +`4efdd31a9fd67caefced2b9852a38c6709f4452e2ecb519433be00bbd61d3607`. +Timing is one debug-build validation sample on the development host, not a +latency SLO or a remote transport benchmark. No network backend deployment is +claimed: remote adapter protocol/refusal behavior uses synthetic authorized peers. + +Reproduction after building the native example and CLI: + +```sh +node scripts/native-adapter-validate.mts --source \ + --example --binary +``` + +The targeted ABI suite passes nine tests. Its shared-tracker test executes +12 deterministic interleavings: both ownership mutation paths at all six adapter +boundaries, plus a foreign-owner release guard. It delegates mutations to the +real native SDK on a tracker initialized by the pinned CLI. The eight other +protocol tests exercise outcomes, admission, ABI versions, field/frame limits, +uncertain outcomes/no retries, offline transcript validation and the CLI without +Git, Node or tracker prerequisites. Two additional CLI unit tests cover all +19 missing-tracker dispatch paths, successful and refused full command dispatch +with caller-owned streams in JSON and TOON, plus injected stdin, broken-pipe, +trailing newline, flush and TOON codec failures. The complete CLI unit target +passes 37 tests, and black-box CLI dispatch passes 17 tests. A separate CLI +process matrix checks all 19 missing-tracker commands without Git or Node; +instrumentation confirms every discovery error propagation point executes in +both process and unit-test dispatcher instances. Constructor/preflight refusal tests also +prove an incompatible manifest never reaches the backend. + +`node scripts/native-adapter-revert.mts` independently replaces only the relevant +behavior for each proof, requires a real failed test assertion, and restores the +exact source bytes in a finally block: + +| Reverted behavior | Regression required to fail | Result | +| --- | --- | --- | +| Pre-dispatch capability admission | `admission_refuses_before_transport_or_writes` | Failed as required | +| Exact ABI compatibility | `versions_fields_and_limits_are_fail_closed` | Failed as required | +| Unmodified canonical backend outcome | `canonical_outcomes_survive_the_byte_boundary` | Failed as required | +| Tracker-free CLI entry point | `cli_validates_without_tracker_git_node_or_backend_execution` | Failed as required | +| CLI flush-error propagation | `tests::native_adapter_stdio_refusals` | Failed as required | + +The targeted instrumented run covers the ABI module at 100 percent in every +required dimension: 146 lines, 17 functions, 211 regions and 20 branches, all +covered. New CLI helper regions and missing-tracker propagation paths were +checked separately; this scoped run does not claim full-repository coverage. + +Local validation also includes `cargo check --locked --all-targets`, +`cargo clippy --locked --all-targets --all-features -- -D warnings`, formatting, +and the existing strict TypeScript and ESLint harness checks. Full npm and Bun +release evidence is provided by the PR's CI run at unchanged thresholds. + +## Raw-byte review regression + +The offline transcript now records `reply_bytes` as a byte array, rather than a +decoded `reply` object. Offline checks and live client calls invoke the same +`check_adapter_reply` function on the exact recorded bytes. Size admission +precedes UTF-8/JSON parsing, schema, ABI and correlation checks. The CLI's +separate bounded 16 MiB transcript envelope accommodates decimal byte-array +expansion without reducing the 1 MiB response-frame ceiling. The public-data +validation harness supplies the actual response stdout bytes. + +`raw_frame_seeded_corpus_matches_live_client` generates **768 frames**, seed +**0x05eed113**, using a fixed wrapping LCG. Every frame goes through the offline +checker and a `NativeAdapterClient` transport exchange; both verdict and refusal +code must agree, and each verdict is also checked against its expected result. +There are **240 accepted and 528 refused** frames. Categories include 4,096-byte +limit minus one/exact/plus one, the 5,094-byte whitespace-padded reproduction, +escapes, Unicode, invalid UTF-8, truncation, oversized nested values, trailing +data, ABI mismatch, correlation mismatch, duplicate fields, unknown fields, +excess recursion and backend refusals. Live failures retain `dispatched: true`; +offline compatibility receipts remain `dispatched: false`. + +`cli_transcript_preserves_full_frame_limit_and_raw_errors` separately checks +that an exact 1 MiB recorded reply fits the CLI envelope, that an oversized +reply returns `FrameLimit`, invalid UTF-8 returns `InvalidFrame`, and the decoded +legacy `reply` field refuses rather than silently certifying normalized data. + +The sixth case in `node scripts/native-adapter-revert.mts` (listed first) +restores only offline decode/re-encode behavior, retaining the new transcript +format and every live check. The differential test fails by assertion on frame +2: 4,097 recorded bytes with a 4,096-byte limit, offline accepted versus live +`FrameLimit`. The script restores exact source bytes in `finally`. All **6/6** +behavioral revert cases fail as required; after restoration the ABI suite passes +**11/11** and targeted native CLI unit checks pass **2/2**. Deny-warning clippy, +formatting, strict TypeScript and ESLint pass locally. Full release-gate evidence +for this revision belongs to its CI run, rather than the earlier run above. + +## Recursive strict JSON review regression + +The shared frame validator now uses the same strict JSON reader as all other +production raw decoders: 34 sites across 16 source/example files. It rejects +duplicate decoded keys recursively before constructing typed structs from the +validated value. The [decode audit](../json-decode-audit.md) lists each site and +documents optional-input fallback behavior and the output-only scalar serializer. + +`strict_json_duplicate_corpus_matches_live_client` adds **392 frames** with +**196 accepted and 196 refused**, checking direct admission, offline admission +and live client results against independent expectations. It covers wire depths +0 through 7 (including payload depths 0 through 5), escaped-equal ASCII and +Unicode keys, arrays of objects, both output and refusal details, and 16,384-field +objects with late duplicates. Accepted values retain the complete backend +envelope. Dispatched failures retain uncertainty and never retry. The earlier +768 seeded frames remain unchanged, for **1,160 differential frames total** +(436 accepted, 724 refused). + +`strict_json_transcripts_and_requests_reject_escaped_duplicates` also checks four +raw transcript/manifest/request/operation duplicates through SDK and CLI entry +points. The three tests in `strict_json_contract` cover primitive preservation, +invalid syntax/UTF-8/trailing data/recursion, typed-field filtering and typed maps, +and real non-git settings/history/registry reads that retain source bytes on refusal. + +The revert script now has **9 independent proofs**. Its three new cases replace +only the shared reader with serde's default decoder, retain every routed call +site and test, rebuild and require assertion failures. The recursive corpus, +ignored-field and tracker-input regressions each fail as required; exact source +bytes are restored in `finally`. Revert evidence is recorded in the PM item. + +The targeted strict-reader instrumented run passes six selected tests and covers +`src/strict_json.rs` at **100% lines/functions/regions/branches**: **50 lines, +12 functions, 79 regions, 6 branches**. This scoped result does not claim +whole-repository coverage; unchanged full-release thresholds run in CI. diff --git a/docs/json-decode-audit.md b/docs/json-decode-audit.md new file mode 100644 index 0000000..5b02367 --- /dev/null +++ b/docs/json-decode-audit.md @@ -0,0 +1,66 @@ +# Strict JSON decode audit + +All 34 JSON value/typed decodes across 16 production source/example files now use +`strict_json::from_slice` or `strict_json::from_str`. The text entry point calls +the byte entry point. It builds a recursive JSON value with unique decoded +object keys, retains serde's recursion limit, requires complete consumption, +and only then calls `serde_json::from_value` for the requested type. Unknown +fields cannot bypass admission even when the final type deliberately ignores them. +Errors omit the repeated key so receipt/config data is not echoed in diagnostics. + +| Source | Decode sites | Inputs | +| --- | ---: | --- | +| `src/native_adapter.rs` | 2 | Shared reply admission and transcript envelope | +| `examples/native_adapter.rs` | 2 | Stdin request and backend request | +| `src/main.rs` | 2 | CLI settings | +| `src/mutation.rs` | 10 | Settings, lock owner records, mutation journals, history records, warning and role configuration | +| `src/ownership.rs` | 2 | Session state, settings and schema objects | +| `src/read_session.rs` | 1 | Frozen settings snapshot | +| `src/orientation.rs` | 1 | Authoritative history JSONL | +| `src/orientation_context.rs` | 5 | Session state, history, configuration and checkpoints | +| `src/selection.rs` | 1 | History-derived selection receipts | +| `src/identity_probes.rs` | 1 | Provider provenance JSONL rows | +| `src/assurance.rs` | 1 | Assurance registry and nested declarations | +| `src/list_registry.rs` | 1 | External schema definitions | +| `src/pagination.rs` | 1 | Decoded opaque cursor bytes | +| `src/list_intent.rs` | 1 | Cursor classification | +| `src/read_output.rs` | 1 | Output continuation cursor | +| `src/collections.rs` | 2 | Quoted collection strings and internally generated numeric text | + +Callers retain their existing invalid-JSON behavior: authoritative inputs refuse, +while optional hints may be ignored or replaced by the existing fallback. None +consumes a last-key-wins value. This change does not make optional hints authoritative. + +Two other serde JSON operations are not raw input decoders. The existing +`serde_json::from_value` in orientation constructs item metadata from a value +already in memory. `RawValue::from_string` in `json_output::serialize_raw` is +an output-only serializer for numeric text and explicit lossless raw overrides, +including the existing lone-surrogate escape contract. It validates raw syntax +without decoding an object or collapsing any key; it never admits backend +frames or tracker documents. JSON value/typed input parsing exists only in +`strict_json`. + +Regression evidence: + +- `strict_json_duplicate_corpus_matches_live_client`: 392 frames, 196 accepted + and 196 refused; direct validation, offline checking and live client verdicts + match independently expected results. Envelope depths 0 and 1, payload depths + 0 through 5 (wire depths 2 through 7), literal/escaped-equal ASCII and Unicode + keys, arrays of objects, both success output and refusal details, and late + duplicates after 16,384 unrelated fields. Accepted replies retain their whole + value. Refused dispatched mutations retain reconciliation guidance and never retry. +- `strict_json_transcripts_and_requests_reject_escaped_duplicates`: four malformed + transcript/manifest/request/operation inputs reject through the SDK and CLI. +- `strict_json_preserves_values_and_rejects_invalid_documents`: primitive values, + insertion order, full unsigned integer precision, separate-object keys, malformed + syntax/UTF-8, trailing data, excessive nesting and typed-map admission. +- `strict_json_validates_before_typed_fields_are_ignored`: duplicate keys inside + a forward-compatible type's ignored field still refuse. +- `strict_json_tracker_decodes_refuse_duplicates_without_git`: real non-git + settings, history and assurance reads refuse duplicates and retain source bytes. + +`node scripts/native-adapter-revert.mts` replaces only the strict byte reader with +serde's default raw decoder for three independent tests: recursive wire corpus, +ignored typed fields, and tracker input admission. All fail by assertion after +rebuilding. It restores exact source bytes in `finally`. The six earlier +behavioral revert cases also fail as required, for nine proofs total. diff --git a/docs/native-adapter-abi.md b/docs/native-adapter-abi.md new file mode 100644 index 0000000..e4e5653 --- /dev/null +++ b/docs/native-adapter-abi.md @@ -0,0 +1,146 @@ +# Portable native adapter ABI v1 + +This is a serialized byte ABI, rather than a compiler-specific Rust object +layout or unsafe C pointer interface. `NativeAdapter` is the in-process Rust +integration point; JSON bytes are the versioned compatibility boundary for +native implementations in other languages. No executable, library or network +endpoint is auto-discovered. The embedding application chooses its adapter and +owns transport timeouts, cancellation, connection lifetime and authorization. + +## Scope and overlap + +Core owns item schema, storage contracts, validation, lifecycle, lock-scoped +mutations and receipts. pm-web and MCP own their transports and authorization. +The current published SDK provides `PmClient.run` and typed operation methods; +it does not publish a generic native storage plugin interface. This package +adds Rust interoperability over those authorized operations. It adds no backend, +HTTP client, authentication system or replacement pm-web client. Existing SDK +and Rust compatibility pins remain unchanged. + +## Wire frames + +All frames are single UTF-8 JSON objects. The exact ABI string is +`pm-native-adapter/1`; any different revision refuses. Unknown envelope and +operation fields refuse; backend output/details remain opaque JSON and retain +extension fields. Duplicate object keys refuse at every nesting depth, including +inside opaque output/details and objects in arrays. Keys are compared after JSON +unescaping, so `"a"` and `"\u0061"` conflict. Trailing non-whitespace and invalid +JSON also refuse. Frame lengths count encoded bytes, with a hard ceiling of 1,048,576. +The adapter may declare a smaller positive ceiling. Transport implementations +must stop reading once that ceiling is exceeded, before allocating an unbounded +response. The offline CLI envelope has a separate 16 MiB ceiling plus one +detection byte, allowing decimal byte-array expansion without reducing the 1 MiB frame ceiling. + +The shared frame validator uses `strict_json::from_slice`: it reads one complete +recursive value, rejects duplicates before insertion, checks end-of-input, then +deserializes typed structs from that validated value. Typed field filtering +cannot hide ambiguous nested data. The transcript, example request decoder and +all tracker JSON readers use the same admission reader; see the +[decode audit](json-decode-audit.md). Size admission still precedes JSON parsing. + +Manifest fields are `abi`, `capabilities`, and `max_frame_bytes`. Capability +values are `get`, `list`, `claim`, and `release`; absent capabilities refuse +before `exchange` is called. A manifest is immutable for a client instance. +Backend permission revocation must still be enforced at every operation. + +A request has `abi`, `request_id`, and `operation`. Correlation IDs, item IDs and +asserted authors must be nonblank and at most 256 encoded bytes. Correlation is +not idempotency: request IDs never authorize retrying a write. Operations are: + +| Action | Fields | Authoritative behavior | +| --- | --- | --- | +| `get` | `id` | Canonical SDK item read | +| `list` | `limit`, optional `after`, `output_cursor` | Canonical page, 1–100 requested rows; preserve continuation and omissions | +| `claim` | `id`, `author` | Atomic SDK claim, skipping foreign ownership with its reason | +| `release` | `id`, `author` | Atomic owner-checked SDK release; refuse or skip foreign ownership with its reason | + +The backend binds tracker and authenticated principal itself. Request fields +cannot select paths, credentials, runtimes, force overrides or bypasses. An +asserted `author` is attribution, never authentication. Advertise claim/release +only when the backend can verify the authenticated caller and enforce these +ownership rules inside its authoritative lease or compare-and-set. For the +native Workspace SDK this requires strict ownership enforcement for release; +permissive release policies do not meet this capability contract. Decline that +capability rather than emulate it with a read followed by a write. + +A response has `abi`, the matching `request_id`, and `outcome`: + +```json +{"abi":"pm-native-adapter/1","request_id":"read-1","outcome":{"status":"success","output":{"items":[],"omission_receipt":{"has_omissions":false}}}} +``` + +`success.output` is the entire canonical backend envelope. `refused` instead +carries `code`, `message`, `remedy`, and `details`, retaining original structured +backend context and partial-operation receipts. `NativeAdapterClient.call` +returns backend refusal as an `Ok(AdapterReply)`, so a foreign record does not +become an ABI exception. Neither success nor ABI validity proves a write changed +anything, a claim belongs to the caller, or a read is complete. The embedding +application must interpret canonical receipts, including peer-success and skip +semantics. Complete reads require the core complete-list certificate, not a +locally invented completeness flag. + +## Local refusals and uncertain mutations + +Local refusal codes are `incompatible_abi`, `unsupported_capability`, +`invalid_frame`, `frame_limit`, and `outcome_unknown`. Every refusal carries a +message, remedy and `dispatched` indicator. Unsupported capability or malformed +request refuses before exchange, with `dispatched: false`. Once exchange starts, +a transport error, oversized response, incompatible reply or correlation failure +has `dispatched: true`; a mutation may already be committed. There are no retries, +fallback writes or client-side compensation. Read authoritative receipts before +deciding how to recover. + +`adapter-check` validates an offline transcript containing `manifest`, `request`, +and optional `reply_bytes`, an array of integers from 0 through 255 containing +the exact recorded frame. Omit it or use null when no reply was recorded. The +previous decoded `reply` field is rejected: re-encoding a JSON object loses wire +size, whitespace, duplicate fields and encoding errors. Both offline checking +and live calls pass the original bytes to `check_adapter_reply`, which checks +size before UTF-8/JSON decoding, schema, ABI and correlation. Padding, escapes, +trailing data and invalid UTF-8 are never normalized before admission. Its small +compatibility receipt intentionally excludes backend bodies, secrets and large histories. A recorded backend refusal is valid +ABI data and exits successfully with `reply_status: refused`; malformed ABI input +exits nonzero with a typed local refusal. The CLI's `dispatched: false` describes +this offline check, not whether a recorded operation previously ran. + +## Concurrency and evidence + +The client owns no tracker files, locks, claims, leases, checkpoint, budget, +outbox or promotion state. Adapter methods receive `&mut self` and each call +performs exactly one exchange. Backend operations remain the sole shared-state +transitions. Adapters must read what they modify inside their authoritative +lease, release last, and delete only state belonging to the authenticated caller. +Foreign records are skipped or refused as canonical results with a reason. + +`tests/native_adapter_contract.rs` runs synthetic protocol peers and a real SDK +adapter on one non-git tracker initialized by the pinned CLI. It injects a +competing claim or release-and-claim at all six observable adapter boundaries: +before/after manifest, before/after request decode, after authoritative operation, +and after reply encoding. Both mutation paths preserve peer ownership, no-op +claim receipts and historical success. The wrapper has no read-modify-write gap +between those boundaries. Malformed, oversized and disconnected peers exercise +unknown outcomes and prove no automatic retry. + +Run targeted checks with: + +```sh +cargo test --locked --test native_adapter_contract +cargo test --locked --bin pm-rust native_adapter +node scripts/native-adapter-revert.mts +cargo check --locked --all-targets +cargo clippy --locked --all-targets --all-features -- -D warnings +``` + +For public-data validation, select only a verified public tracked snapshot, +then run `node scripts/native-adapter-validate.mts --source +--example --binary `. The tool creates its own +non-git copy, certifies all item identities with the pinned SDK, makes tracker +files read-only, exchanges all list pages and item reads through the byte ABI, +validates every transcript in both CLI formats, and checks source hashes. +Native children run with a PATH containing neither Git nor Node. Only aggregate +counts, sizes, duration and hashes are printed; source contents stay in scratch. + +The npm and Bun CI release gates retain all existing checks and 100 percent +coverage thresholds. Transport-specific remote deployment acceptance remains +with the integrating backend; this feature's remote evidence uses synthetic +authorized peers, not hosted services or private data. diff --git a/examples/native_adapter.rs b/examples/native_adapter.rs new file mode 100644 index 0000000..549fe9c --- /dev/null +++ b/examples/native_adapter.rs @@ -0,0 +1,94 @@ +//! Minimal authorized read adapter; replace only exchange with an existing remote transport. +use pm_rust::native_adapter::{ + AdapterCapability, AdapterManifest, AdapterOperation, AdapterOutcome, AdapterReply, + AdapterRequest, NATIVE_ADAPTER_ABI, NATIVE_ADAPTER_MAX_BYTES, NativeAdapter, + NativeAdapterClient, +}; +use pm_rust::{ItemFilter, ListOptions, Workspace}; +use std::{ + error::Error, + io::{self, Read}, + path::Path, +}; + +/// Explicitly selected read-only workspace; no discovery from request fields. +struct AuthorizedReadAdapter(Workspace); + +impl NativeAdapter for AuthorizedReadAdapter { + fn manifest(&self) -> AdapterManifest { + AdapterManifest { + abi: NATIVE_ADAPTER_ABI.into(), + capabilities: vec![AdapterCapability::Get, AdapterCapability::List], + max_frame_bytes: NATIVE_ADAPTER_MAX_BYTES, + } + } + + fn exchange(&mut self, bytes: &[u8], response_limit: usize) -> io::Result> { + let request: AdapterRequest = pm_rust::strict_json::from_slice(bytes)?; + let result = match request.operation { + AdapterOperation::Get { id } => self.0.get(&id).map(|item| serde_json::json!(item)), + AdapterOperation::List { + limit, + after, + output_cursor, + } => self + .0 + .list_page( + &ItemFilter::default(), + &ListOptions { + all: true, + brief: true, + limit: Some(limit.to_string()), + after, + output_cursor, + output_budget: Some("4000".into()), + ..ListOptions::default() + }, + &pm_rust::current_timestamp(), + ) + .map(|output| serde_json::json!(output)), + // Admission in NativeAdapterClient prevents these operations reaching this peer. + _ => return Err(io::Error::other("read-only adapter")), + }; + let outcome = match result { + Ok(output) => AdapterOutcome::Success { output }, + Err(error) => AdapterOutcome::Refused { + code: "native_read_refused".into(), + message: error.to_string(), + remedy: "check the requested identity and tracker integrity".into(), + details: serde_json::Value::Null, + }, + }; + let reply = AdapterReply { + abi: NATIVE_ADAPTER_ABI.into(), + request_id: request.request_id, + outcome, + }; + let encoded = pm_rust::stringify_json(&reply, false).into_bytes(); + if encoded.len() > response_limit { + return Err(io::Error::other("response limit")); + } + Ok(encoded) + } +} + +/// Exchanges one typed request on stdin and emits one canonical reply on stdout. +fn main() -> Result<(), Box> { + let path = std::env::args() + .nth(1) + .ok_or("pass an explicitly authorized tracker directory")?; + let workspace = Workspace::discover(Path::new(&path))?; + let mut client = NativeAdapterClient::new(AuthorizedReadAdapter(workspace))?; + let mut bytes = Vec::new(); + io::stdin() + .lock() + .take((NATIVE_ADAPTER_MAX_BYTES + 1) as u64) + .read_to_end(&mut bytes)?; + if bytes.len() > NATIVE_ADAPTER_MAX_BYTES { + return Err("request limit exceeded".into()); + } + let request: AdapterRequest = pm_rust::strict_json::from_slice(&bytes)?; + let reply = client.call(&request)?; + println!("{}", pm_rust::stringify_json(&reply, false)); + Ok(()) +} diff --git a/justfile b/justfile index 676aadf..6d682a5 100644 --- a/justfile +++ b/justfile @@ -50,7 +50,7 @@ crash *args: crash-check: npm ci --prefix tests/harness --ignore-scripts tests/harness/node_modules/.bin/tsc -p tests/harness/tsconfig.json - tests/harness/node_modules/.bin/eslint -c tests/harness/eslint.config.mjs scripts/crash-*.mts + tests/harness/node_modules/.bin/eslint -c tests/harness/eslint.config.mjs scripts/crash-*.mts scripts/native-adapter-*.mts node --test scripts/crash-harness.test.mts # Reproduce large synthetic non-git trackers against the requested current oracle. diff --git a/scripts/native-adapter-revert.mts b/scripts/native-adapter-revert.mts new file mode 100644 index 0000000..f2d5924 --- /dev/null +++ b/scripts/native-adapter-revert.mts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; + +const abi = new URL('../src/native_adapter.rs', import.meta.url); +const cli = new URL('../src/main.rs', import.meta.url); +const strict = new URL('../src/strict_json.rs', import.meta.url); +const originals = new Map([abi, cli, strict].map(path => [path, readFileSync(path, 'utf8')])); +const strictBefore = ' let mut reader = serde_json::Deserializer::from_slice(bytes);\n let StrictValue(value) = StrictValue::deserialize(&mut reader)?;\n reader.end()?;\n serde_json::from_value(value)'; +const strictAfter = ' serde_json::from_slice(bytes)'; +const cases = [ + { path: strict, target: ['--test', 'native_adapter_contract'], name: 'recursive duplicate admission', test: 'strict_json_duplicate_corpus_matches_live_client', before: strictBefore, after: strictAfter }, + { path: strict, target: ['--test', 'strict_json_contract'], name: 'validation before typed field filtering', test: 'strict_json_validates_before_typed_fields_are_ignored', before: strictBefore, after: strictAfter }, + { path: strict, target: ['--test', 'strict_json_contract'], name: 'strict tracker decode audit', test: 'strict_json_tracker_decodes_refuse_duplicates_without_git', before: strictBefore, after: strictAfter }, + { path: abi, target: ['--test', 'native_adapter_contract'], name: 'raw reply admission', test: 'raw_frame_seeded_corpus_matches_live_client', + before: 'let checked =\n check_adapter_reply(&transcript.manifest, &transcript.request, &recorded)?;', + after: 'let decoded: AdapterReply = serde_json::from_slice(&recorded).map_err(|_| refusal(AdapterRefusalCode::InvalidFrame))?;\n let encoded = crate::stringify_json(&decoded, false).into_bytes();\n let checked = check_adapter_reply(&transcript.manifest, &transcript.request, &encoded)?;' }, + { path: abi, target: ['--test', 'native_adapter_contract'], name: 'capability admission', test: 'admission_refuses_before_transport_or_writes', + before: '!manifest\n .capabilities\n .contains(&request.operation.capability())', after: 'manifest.capabilities.is_empty()' }, + { path: abi, target: ['--test', 'native_adapter_contract'], name: 'ABI compatibility', test: 'versions_fields_and_limits_are_fail_closed', + before: 'abi != NATIVE_ADAPTER_ABI', after: 'abi == "unsupported-by-fixtures"' }, + { path: abi, target: ['--test', 'native_adapter_contract'], name: 'receipt preservation', test: 'canonical_outcomes_survive_the_byte_boundary', + before: 'Ok(reply)\n}', after: 'Ok(AdapterReply { outcome: AdapterOutcome::Success { output: Value::Null }, ..reply })\n}' }, + { path: cli, target: ['--test', 'native_adapter_contract'], name: 'tracker-free CLI', test: 'cli_validates_without_tracker_git_node_or_backend_execution', + before: ' let discover = || Workspace::discover(&cli.workspace);', + after: ' Workspace::discover(&cli.workspace)?;\n let discover = || Workspace::discover(&cli.workspace);' }, + { path: cli, target: ['--bin', 'pm-rust'], name: 'CLI flush failure', test: 'tests::native_adapter_stdio_refusals', + before: ' writer.flush()?;\n Ok(())\n}\n\n/// Dispatches one parsed command', + after: ' let _ = writer.flush();\n Ok(())\n}\n\n/// Dispatches one parsed command' }, +]; +try { + for (const proof of cases) { + const original = originals.get(proof.path); + assert.ok(original); + assert.equal(original.split(proof.before).length, 2, `Unique behavioral edit required: ${proof.name}`); + writeFileSync(proof.path, original.replace(proof.before, proof.after)); + const result = spawnSync('cargo', ['test', '--locked', ...proof.target, proof.test, '--', '--exact'], { + env: { ...process.env, CARGO_BUILD_JOBS: '1' }, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, + }); + assert.notEqual(result.status, 0, `${proof.name} must fail when behavior is reverted`); + assert.match(result.stdout, /test result: FAILED/, 'A compile or launch failure is not revert proof'); + console.log(JSON.stringify({ behavior: proof.name, test: proof.test, failed_as_required: true })); + writeFileSync(proof.path, original); + } +} finally { + for (const [path, original] of originals) writeFileSync(path, original); +} diff --git a/scripts/native-adapter-validate.mts b/scripts/native-adapter-validate.mts new file mode 100644 index 0000000..e5f2fde --- /dev/null +++ b/scripts/native-adapter-validate.mts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { chmodSync, cpSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { PmClient, certifyCompleteListResult } from '../tests/oracle/node_modules/@unbrained/pm-cli/dist/sdk/index.js'; +import { fingerprint, snapshot } from './crash-storage.mts'; + +/** Read a required explicit validation argument. */ +function argument(flag: string): string { + const index = process.argv.indexOf(flag); + const value = index < 0 ? undefined : process.argv[index + 1]; + assert.ok(value, `${flag} is required`); + return resolve(value); +} + +/** Change permissions only on this program's exclusively owned scratch copy. */ +function permissions(root: string, readonly: boolean): void { + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) permissions(path, readonly); + else { assert.ok(entry.isFile()); chmodSync(path, readonly ? 0o400 : 0o600); } + } + chmodSync(root, readonly ? 0o500 : 0o700); +} + +interface Frame { + abi: string; + request_id: string; + outcome: { status: 'success'; output: Record }; +} + +const source = argument('--source'); +const example = argument('--example'); +const binary = argument('--binary'); +const scratch = mkdtempSync(join(tmpdir(), 'native-adapter-validation-')); +const root = join(scratch, 'copy'); +const tracker = join(root, '.agents/pm'); +const manifest = { abi: 'pm-native-adapter/1', capabilities: ['get', 'list'], max_frame_bytes: 1048576 }; +let calls = 0; +let wireBytes = 0; +let cliChecks = 0; +let maxCliBytes = 0; +let maxFrameBytes = 0; + +/** Exchange with a real native adapter with no Git or Node executable available. */ +function exchange(operation: Record): Record { + const request = { abi: manifest.abi, request_id: `validation-${++calls}`, operation }; + const response = spawnSync(example, [root], { + input: JSON.stringify(request), cwd: root, timeout: 30000, maxBuffer: 2 * manifest.max_frame_bytes, + env: { PATH: scratch, HOME: join(scratch, 'empty-home'), GIT_CEILING_DIRECTORIES: dirname(root) }, + }); + assert.equal(response.status, 0, 'Native adapter exchange must succeed'); + wireBytes += response.stdout.length; + maxFrameBytes = Math.max(maxFrameBytes, response.stdout.length); + const reply: Frame = JSON.parse(response.stdout.toString()); + assert.equal(reply.request_id, request.request_id); + assert.equal(reply.outcome.status, 'success'); + assert.equal(reply.abi, manifest.abi); + // Check every transcript in both CLI encodings, without leaking result bodies. + for (const json of [false, true]) { + const checked = spawnSync(binary, ['adapter-check', ...(json ? ['--json'] : [])], { + input: JSON.stringify({ manifest, request, reply_bytes: [...response.stdout] }), cwd: root, timeout: 30000, + env: { PATH: scratch, GIT_CEILING_DIRECTORIES: dirname(root) }, + }); + assert.equal(checked.status, 0, 'ABI transcript must validate'); + maxCliBytes = Math.max(maxCliBytes, checked.stdout.length); + assert.ok(checked.stdout.length < 512); + if (json) assert.equal((JSON.parse(checked.stdout.toString()) as { dispatched: boolean }).dispatched, false); + cliChecks++; + } + return reply.outcome.output; +} + +try { + mkdirSync(join(root, '.agents'), { recursive: true }); + cpSync(source, tracker, { recursive: true, filter: path => !['runtime', 'search', 'locks', 'transactions', 'checkpoints'].includes(path.split('/').at(-1) ?? '') }); + const original = fingerprint(snapshot(tracker)); + const client = new PmClient({ cwd: root, pmRoot: tracker, noExtensions: true }); + const complete = certifyCompleteListResult(await client.listAllComplete()); + assert.equal(fingerprint(snapshot(tracker)), original, 'SDK reference must preserve authoritative bytes'); + const before = snapshot(tracker); + permissions(tracker, true); + const started = performance.now(); + const ids: string[] = []; + let after: string | undefined; + let outputCursor: string | undefined; + let sdkAfter: string | undefined; + let sdkOutputCursor: string | undefined; + let pages = 0; + do { + const output = exchange({ action: 'list', limit: 10, ...(after ? { after } : {}), ...(outputCursor ? { output_cursor: outputCursor } : {}) }); + assert.ok(Array.isArray(output.items)); + ids.push(...output.items.map((row: { id: string }) => row.id)); + const expected = await client.list({ excludeTerminal: false, brief: true, limit: '10', + outputBudget: '4000', ...(sdkAfter ? { after: sdkAfter } : {}), ...(sdkOutputCursor ? { outputCursor: sdkOutputCursor } : {}) }); + assert.ok('items' in expected); + assert.deepEqual(output.items.map((row: { id: string }) => row.id), expected.items.map(row => row.id)); + assert.deepEqual(output.omission_receipt, ('omission_receipt' in expected ? expected.omission_receipt : undefined), 'Canonical omission receipt must survive, including its absence'); + // SDK compact defaults and native CLI defaults have separate cursor fingerprints. + const sdkNext = expected.next_cursor ?? undefined; + const sdkOutputContinuation = 'continuation_kind' in expected && expected.continuation_kind === 'output_cursor'; + sdkAfter = sdkOutputContinuation ? undefined : sdkNext; + sdkOutputCursor = sdkOutputContinuation ? sdkNext : undefined; + const next = typeof output.next_cursor === 'string' ? output.next_cursor : undefined; + after = output.continuation_kind === 'output_cursor' ? undefined : next; + outputCursor = output.continuation_kind === 'output_cursor' ? next : undefined; + assert.ok(++pages <= complete.complete_list.item_count + 1, 'Continuation must make bounded progress'); + } while (after || outputCursor); + assert.deepEqual([...ids].sort(), complete.items.map(item => item.id).sort()); + assert.equal(new Set(ids).size, ids.length); + for (const id of ids) { + const actual = exchange({ action: 'get', id }); + const expected = await client.get(id); + assert.ok('item' in expected); + assert.equal(actual.id, expected.item.id); + assert.equal(actual.title, expected.item.title); + assert.equal(actual.status, expected.item.status); + } + assert.equal(fingerprint(snapshot(tracker)), fingerprint(before), 'Read-only source must remain unchanged'); + console.log(JSON.stringify({ protocol: manifest.abi, source: 'public-fleet-tracker-copy', + source_items: ids.length, source_files: before.size, + source_bytes: [...before.values()].reduce((sum, value) => sum + value.length, 0), + pages, adapter_calls: calls, cli_checks: cliChecks, output_bytes: wireBytes, + max_frame_bytes: maxFrameBytes, max_cli_receipt_bytes: maxCliBytes, + elapsed_ms: Math.round(performance.now() - started), + unchanged_source_sha256: original, no_git_or_node_on_native_path: true, + }, null, 2)); +} finally { + permissions(root, false); + rmSync(scratch, { recursive: true, force: true }); +} diff --git a/src/assurance.rs b/src/assurance.rs index a747bd2..09aa2c2 100644 --- a/src/assurance.rs +++ b/src/assurance.rs @@ -37,7 +37,7 @@ impl Workspace { }; let path = self.pm_root().join("assurance.json"); let document: Value = match std::fs::read_to_string(&path) { - Ok(raw) => match serde_json::from_str(&raw) { + Ok(raw) => match crate::strict_json::from_str(&raw) { Ok(value) => value, Err(_) => return Err(invalid_registry()), }, diff --git a/src/collections.rs b/src/collections.rs index f6aec1c..84f2295 100644 --- a/src/collections.rs +++ b/src/collections.rs @@ -220,7 +220,8 @@ fn unquote(value: &str) -> String { .strip_prefix('"') .and_then(|value| value.strip_suffix('"')) { - serde_json::from_str::(value).unwrap_or_else(|_| inner.replace("\\\"", "\"")) + crate::strict_json::from_str::(value) + .unwrap_or_else(|_| inner.replace("\\\"", "\"")) } else { value.to_owned() } @@ -489,7 +490,7 @@ fn normalize_test_fields(row: &mut Value) -> Result<(), PmRustError> { } row.as_object_mut().map(|map| map.shift_remove("timeout")); row["timeout_seconds"] = - serde_json::from_str(&crate::history::javascript_float(number.floor())) + crate::strict_json::from_str(&crate::history::javascript_float(number.floor())) .unwrap_or_default(); } for (key, domain) in [ diff --git a/src/identity_probes.rs b/src/identity_probes.rs index 4b866ed..f4a721a 100644 --- a/src/identity_probes.rs +++ b/src/identity_probes.rs @@ -230,7 +230,7 @@ fn row(raw: &str, codex: bool, found: &mut BTreeMap) { if raw.is_empty() || raw.encode_utf16().count() > 262_144 { return; } - let Ok(value) = serde_json::from_str::(raw) else { + let Ok(value) = crate::strict_json::from_str::(raw) else { return; }; if codex { diff --git a/src/lib.rs b/src/lib.rs index 7e23246..b7e8090 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ mod list_intent; mod list_registry; mod lock_owner; mod mutation; +pub mod native_adapter; mod orientation; mod orientation_context; mod orientation_deps; @@ -30,6 +31,7 @@ mod read_projection; mod read_session; mod search; mod selection; +pub mod strict_json; mod toon_output; mod workspace; diff --git a/src/list_intent.rs b/src/list_intent.rs index 29ba88e..bb42a98 100644 --- a/src/list_intent.rs +++ b/src/list_intent.rs @@ -107,7 +107,7 @@ pub(crate) fn attach( let cursor = raw.and_then(|raw| { let bytes = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, raw).ok()?; - let cursor = serde_json::from_slice::(&bytes).ok()?; + let cursor = crate::strict_json::from_slice::(&bytes).ok()?; // A legacy cursor may omit after_index. Treating the omission as zero // rebases a final page onto the first rows; the published CLI skips // compaction unless the index is present. diff --git a/src/list_registry.rs b/src/list_registry.rs index 0b780b2..4379466 100644 --- a/src/list_registry.rs +++ b/src/list_registry.rs @@ -155,7 +155,7 @@ fn optional_schema(root: &Path, schema: &Value, name: &str) -> Result Ok(serde_json::from_slice(&bytes).unwrap_or(Value::Null)), + Ok(bytes) => Ok(crate::strict_json::from_slice(&bytes).unwrap_or(Value::Null)), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(if name == "statuses" { json!({"statuses": defaults()}) } else { diff --git a/src/main.rs b/src/main.rs index 5afa3be..290439d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ //! `pm-rust` command-line interface. -use std::io::Write; +use std::io::{Read, Write}; use std::path::PathBuf; use std::process::ExitCode; @@ -23,6 +23,12 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { + /// Validate a native adapter transcript from stdin without executing backend operations. + AdapterCheck { + /// Emit compact JSON instead of TOON. + #[arg(long)] + json: bool, + }, /// Stream a strictly validated immutable tracker snapshot under resource admission limits. ReadSession { /// Reproducible read clock for conformance fixtures. @@ -606,11 +612,63 @@ fn write_session_page_to( #[path = "../tests/support/main_unit.rs"] mod tests; -/// Dispatches one parsed command against its discovered workspace. -#[allow(clippy::too_many_lines)] +/// Checks stdin with bounded admission and renders a compact receipt or typed refusal. +fn run_adapter_check( + reader: &mut dyn std::io::Read, + writer: &mut dyn Write, + json: bool, +) -> Result<(), Box> { + let mut bytes = Vec::new(); + reader + .take((pm_rust::native_adapter::NATIVE_ADAPTER_MAX_TRANSCRIPT_BYTES + 1) as u64) + .read_to_end(&mut bytes)?; + let result = pm_rust::native_adapter::check_adapter_transcript(&bytes); + let value = match &result { + Ok(value) => value.clone(), + Err(error) => serde_json::json!({"ok": false, "refusal": error}), + }; + let output = pm_rust::ListOutput::from(value); + write_adapter_receipt_to(writer, &output, json)?; + result?; + Ok(()) +} + +/// Writes a compact ABI receipt, propagating codec, pipe and flush failures. +fn write_adapter_receipt_to( + writer: &mut dyn Write, + output: &pm_rust::ListOutput, + json: bool, +) -> Result<(), Box> { + let rendered = if json { + pm_rust::stringify_json(output, false) + } else { + pm_rust::stringify_toon(output)? + }; + writer.write_all(rendered.as_bytes())?; + writer.write_all(b"\n")?; + writer.flush()?; + Ok(()) +} + +/// Dispatches one parsed command using process-owned standard streams. fn run(cli: Cli) -> Result<(), Box> { - let workspace = Workspace::discover(&cli.workspace)?; + run_with_io( + cli, + &mut std::io::stdin().lock(), + &mut std::io::stdout().lock(), + ) +} + +/// Dispatches workspace commands or an adapter check with caller-owned streams. +#[allow(clippy::too_many_lines)] +fn run_with_io( + cli: Cli, + reader: &mut dyn Read, + writer: &mut dyn Write, +) -> Result<(), Box> { + let discover = || Workspace::discover(&cli.workspace); match cli.command { + Command::AdapterCheck { json } => run_adapter_check(reader, writer, json)?, Command::ReadSession { timestamp, memory_bytes, @@ -619,6 +677,7 @@ fn run(cli: Cli) -> Result<(), Box> { cancel_file, json, } => { + let workspace = discover()?; let cancellation = pm_rust::SessionCancellation::default(); if cancel_file.as_ref().is_some_and(|path| path.exists()) { cancellation.cancel(); @@ -655,6 +714,7 @@ fn run(cli: Cli) -> Result<(), Box> { output_cursor, json, } => { + let workspace = discover()?; let id = if action == "show" { Some(id.ok_or_else(|| pm_rust::PmRustError::InvalidReadRequest { reason: "assurance show requires an id".to_owned(), @@ -685,6 +745,7 @@ fn run(cli: Cli) -> Result<(), Box> { json, timestamp, } => { + let workspace = discover()?; let now = timestamp.unwrap_or_else(pm_rust::current_timestamp); let result = workspace.context( &pm_rust::ContextOptions { @@ -709,6 +770,7 @@ fn run(cli: Cli) -> Result<(), Box> { strict_exit, json, } => { + let workspace = discover()?; if full && compact { return Err(Box::new(pm_rust::PmRustError::CommandRefusal {detail:"History projection options are mutually exclusive. Use --compact, --provenance, or --full.".to_owned(),exit_code:2,context:serde_json::json!({})})); } @@ -752,6 +814,7 @@ fn run(cli: Cli) -> Result<(), Box> { output_format, json, } => { + let workspace = discover()?; let json = output_format .as_deref() .map_or(json, |format| format == "json"); @@ -786,6 +849,7 @@ fn run(cli: Cli) -> Result<(), Box> { output_limit, json, } => { + let workspace = discover()?; let result = workspace.search( &query.join(" "), &pm_rust::SearchOptions { @@ -805,9 +869,9 @@ fn run(cli: Cli) -> Result<(), Box> { )?; return write_search_to(&mut std::io::stdout().lock(), &result, json); } - Command::Files(args) => run_collection(&workspace, "files", args)?, - Command::Docs(args) => run_collection(&workspace, "docs", args)?, - Command::Test(args) => run_collection(&workspace, "tests", args)?, + Command::Files(args) => run_collection(&discover()?, "files", args)?, + Command::Docs(args) => run_collection(&discover()?, "docs", args)?, + Command::Test(args) => run_collection(&discover()?, "tests", args)?, Command::List { json, output_budget, @@ -831,6 +895,7 @@ fn run(cli: Cli) -> Result<(), Box> { sort, order, } => { + let workspace = discover()?; let filters = ItemFilter { status, item_type, @@ -868,7 +933,7 @@ fn run(cli: Cli) -> Result<(), Box> { } } } - Command::Get { id } => write_json(&workspace.get(&id)?)?, + Command::Get { id } => write_json(&discover()?.get(&id)?)?, Command::Create { id, title, @@ -883,6 +948,7 @@ fn run(cli: Cli) -> Result<(), Box> { message, force_stale_lock, } => { + let workspace = discover()?; write_json(&create_payload( &workspace, CreateItem { @@ -915,6 +981,7 @@ fn run(cli: Cli) -> Result<(), Box> { message, force_stale_lock, } => { + let workspace = discover()?; write_json(&update_payload( &workspace, UpdateItem { @@ -953,6 +1020,7 @@ fn run(cli: Cli) -> Result<(), Box> { message, force_stale_lock, } => { + let workspace = discover()?; write_json(&comment_payload( &workspace, &CommentItem { @@ -973,6 +1041,7 @@ fn run(cli: Cli) -> Result<(), Box> { start, selection, } => { + let workspace = discover()?; if start && args.id.is_none() { return Err(Box::new(pm_rust::PmRustError::CommandRefusal { detail: "Specify an item id or pass --next".to_owned(), @@ -1024,6 +1093,7 @@ fn run(cli: Cli) -> Result<(), Box> { return write_json(&workspace.claim_with_options(&request, &options)?); } Command::Release { args, pause } => { + let workspace = discover()?; let (request, options) = ownership_request(&workspace, args, false)?; if pause { return write_json(&workspace.pause_task(&request, &options)?); @@ -1031,11 +1101,13 @@ fn run(cli: Cli) -> Result<(), Box> { return write_json(&workspace.release_with_options(&request, &options)?); } Command::StartTask(args) => { + let workspace = discover()?; let (mut request, options) = ownership_request(&workspace, args, false)?; request.provenance_role = None; return write_json(&workspace.start_task(&request, &options)?); } Command::PauseTask(args) => { + let workspace = discover()?; let (mut request, options) = ownership_request(&workspace, args, false)?; request.provenance_role = None; return write_json(&workspace.pause_task(&request, &options)?); @@ -1049,6 +1121,7 @@ fn run(cli: Cli) -> Result<(), Box> { force_stale_lock, fields, } => { + let workspace = discover()?; let close_author = author.clone(); let result = close_payload( &workspace, @@ -1124,7 +1197,7 @@ fn run_collection_with_cwd( args: CollectionArgs, cwd: std::io::Result, ) -> Result<(), Box> { - let settings: serde_json::Value = serde_json::from_str(&std::fs::read_to_string( + let settings: serde_json::Value = pm_rust::strict_json::from_str(&std::fs::read_to_string( workspace.pm_root().join("settings.json"), )?)?; let identity = mutation_identity(args.author.as_deref(), settings["author_default"].as_str()); @@ -1248,8 +1321,9 @@ fn ownership_request( reason: "author must not be empty".to_owned(), })); } - let settings: serde_json::Value = - serde_json::from_slice(&std::fs::read(workspace.pm_root().join("settings.json"))?)?; + let settings: serde_json::Value = pm_rust::strict_json::from_slice(&std::fs::read( + workspace.pm_root().join("settings.json"), + )?)?; let identity = mutation_identity(args.author.as_deref(), settings["author_default"].as_str()); let request = OwnershipItem { id: args.id.unwrap_or_default(), diff --git a/src/mutation.rs b/src/mutation.rs index 13206da..edd65fa 100644 --- a/src/mutation.rs +++ b/src/mutation.rs @@ -523,7 +523,7 @@ fn read_settings(pm_root: &Path) -> Result { }); } }; - match serde_json::from_str(&raw) { + match crate::strict_json::from_str(&raw) { Ok(settings) => Ok(settings), Err(error) => Err(invalid(format!("invalid settings.json: {error}"))), } @@ -968,7 +968,7 @@ fn acquire_lock_attempt( #[cfg(target_os = "linux")] let abandoned = abandoned || read_lock_owner(&path).is_ok_and(|(raw, _)| { - confirmed_dead_owner(&serde_json::from_str(&raw).unwrap_or(Value::Null)) + confirmed_dead_owner(&crate::strict_json::from_str(&raw).unwrap_or(Value::Null)) }); if !abandoned { return Err(PmRustError::LockConflict { id: id.to_owned() }); @@ -1082,7 +1082,7 @@ fn create_or_reclaim_lock( }; #[cfg(target_os = "linux")] { - let existing: Value = serde_json::from_str(&existing_raw).unwrap_or(Value::Null); + let existing: Value = crate::strict_json::from_str(&existing_raw).unwrap_or(Value::Null); if confirmed_dead_owner(&existing) { record_dead_owner(path, &existing)?; return remove_stale_lock(path, &existing_raw, Some(&observed), id) @@ -1490,7 +1490,7 @@ fn recover(pm_root: &Path, id: &str) -> Result<(), PmRustError> { let Some(raw) = read_optional(&journal_path)? else { return Ok(()); }; - let journal: MutationJournal = match serde_json::from_str(&raw) { + let journal: MutationJournal = match crate::strict_json::from_str(&raw) { Ok(value) => value, Err(error) => { return Err(PmRustError::RecoveryConflict { @@ -1560,7 +1560,7 @@ fn recover_mutation( ) }); }; - let journal: MutationJournal = match serde_json::from_str(&raw) { + let journal: MutationJournal = match crate::strict_json::from_str(&raw) { Ok(value) => value, Err(error) => { return Err(PmRustError::RecoveryConflict { @@ -2772,7 +2772,7 @@ fn ownership_refusal(detail: String, context: Value) -> PmRustError { fn ownership_phrase(history_path: &Path, assignee: &str) -> Result { let raw = read_optional(history_path)?.unwrap_or_default(); for line in raw.lines().rev() { - let Ok(entry) = serde_json::from_str::(line) else { + let Ok(entry) = crate::strict_json::from_str::(line) else { continue; }; if let Some(Value::Array(patches)) = entry.get("patch") { @@ -2795,7 +2795,7 @@ fn ownership_phrase(history_path: &Path, assignee: &str) -> Result bool { - let entry: Value = serde_json::from_str(raw).unwrap_or(Value::Null); + let entry: Value = crate::strict_json::from_str(raw).unwrap_or(Value::Null); let created = entry["created_at"].as_str().unwrap_or_default(); match OffsetDateTime::parse(created, &Rfc3339) { Ok(created) => { @@ -2829,7 +2829,7 @@ fn ownership_lock_refusal( serde_json::json!({}), ); } - let value: Value = serde_json::from_str(&raw).unwrap_or(Value::Null); + let value: Value = crate::strict_json::from_str(&raw).unwrap_or(Value::Null); let owner = value["owner"].as_str().unwrap_or_default(); let owner_suffix = if owner.is_empty() { String::new() @@ -3457,7 +3457,7 @@ fn session_lock_refusal( return error; } let raw = fs::read_to_string(pm_root.join("locks/session-state.lock")).unwrap_or_default(); - let warning = match serde_json::from_str::(&raw) { + let warning = match crate::strict_json::from_str::(&raw) { Err(_) => Some("lock_info_invalid_json"), Ok(value) => { if value["id"].is_string() @@ -3525,7 +3525,7 @@ pub(crate) fn mutate_session_state( let path = pm_root.join("runtime/session.json"); let mut state = fs::read_to_string(&path) .ok() - .and_then(|text| serde_json::from_str::>(&text).ok()) + .and_then(|text| crate::strict_json::from_str::>(&text).ok()) .unwrap_or_default(); update(&mut state); atomic_replace(&path, &crate::stringify_json(&Value::Object(state), false)) diff --git a/src/native_adapter.rs b/src/native_adapter.rs new file mode 100644 index 0000000..791aacc --- /dev/null +++ b/src/native_adapter.rs @@ -0,0 +1,393 @@ +//! Versioned, transport-neutral byte ABI for authorized native adapters. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Wire protocol identity; incompatible versions refuse before dispatch. +pub const NATIVE_ADAPTER_ABI: &str = "pm-native-adapter/1"; +/// Hard admission bound for each JSON frame, independent of adapter claims. +pub const NATIVE_ADAPTER_MAX_BYTES: usize = 1_048_576; +/// Offline envelope ceiling, allowing byte-array expansion of full-sized frames. +/// This bounds transcript input independently of each frame's admission ceiling. +pub const NATIVE_ADAPTER_MAX_TRANSCRIPT_BYTES: usize = 16 * NATIVE_ADAPTER_MAX_BYTES; + +/// Narrow SDK capabilities; an adapter grants each operation explicitly. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AdapterCapability { + /// Read one canonical item. + Get, + /// Read a bounded canonical list, retaining omission receipts. + List, + /// Claim through the backend's authoritative lock-scoped SDK operation. + Claim, + /// Release only the caller's claim through the authoritative SDK operation. + Release, +} + +/// Adapter declaration supplied by the embedding application, never auto-discovered. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AdapterManifest { + /// Exact supported wire ABI. + pub abi: String, + /// Operations authorized for this adapter instance. + pub capabilities: Vec, + /// Maximum encoded request or response frame accepted by the adapter. + pub max_frame_bytes: usize, +} + +/// Narrow typed operations; tracker identity and credentials belong to the adapter. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] +pub enum AdapterOperation { + /// Read a canonical item with the backend SDK's get operation. + Get { + /// Exact item ID. + id: String, + }, + /// Read a bounded list with canonical omission and continuation receipts. + List { + /// Requested row ceiling, from one through one hundred. + limit: u16, + /// Opaque canonical producer continuation from the preceding SDK page. + #[serde(default, skip_serializing_if = "Option::is_none")] + after: Option, + /// Opaque canonical output continuation, when `continuation_kind` requires it. + #[serde(default, skip_serializing_if = "Option::is_none")] + output_cursor: Option, + }, + /// Claim using the authoritative SDK, without force or read-then-write emulation. + Claim { + /// Exact item ID. + id: String, + /// Authorized caller identity; the backend must verify this identity. + author: String, + }, + /// Release only a claim belonging to this authorized caller. + Release { + /// Exact item ID. + id: String, + /// Authorized caller identity; foreign claims must be skipped with a reason. + author: String, + }, +} + +impl AdapterOperation { + /// Returns the required capability without inspecting backend data. + #[must_use] + pub const fn capability(&self) -> AdapterCapability { + match self { + Self::Get { .. } => AdapterCapability::Get, + Self::List { .. } => AdapterCapability::List, + Self::Claim { .. } => AdapterCapability::Claim, + Self::Release { .. } => AdapterCapability::Release, + } + } +} + +/// One independently correlated call; identifiers do not imply replay safety. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AdapterRequest { + /// Exact wire ABI. + pub abi: String, + /// Caller-owned correlation ID, never a retry or transaction key. + pub request_id: String, + /// Typed SDK operation. + pub operation: AdapterOperation, +} + +/// Successful backend output or a typed backend refusal, without projection loss. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] +pub enum AdapterOutcome { + /// Authoritative SDK output, including every mutation and omission receipt. + Success { + /// Unmodified SDK result. Success does not imply changed, complete or owned. + output: Value, + }, + /// Authoritative refusal retained with backend guidance and original details. + Refused { + /// Stable backend code. + code: String, + /// Backend explanation. + message: String, + /// Backend recovery guidance. + remedy: String, + /// Original structured refusal, including partial-operation receipts. + details: Value, + }, +} + +/// Exactly one correlated response frame; adapters never stream unbounded data. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AdapterReply { + /// Exact wire ABI. + pub abi: String, + /// Echo of the caller's request ID. + pub request_id: String, + /// Backend outcome, retained even when it is a refusal or peer success. + pub outcome: AdapterOutcome, +} + +/// Stable local refusal classifications, distinct from backend domain refusals. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AdapterRefusalCode { + /// Manifest, request or reply uses another ABI. + IncompatibleAbi, + /// The instance does not authorize this operation. + UnsupportedCapability, + /// A frame is malformed or cannot be correlated. + InvalidFrame, + /// Encoded input or output exceeds its admission ceiling. + FrameLimit, + /// Transport failed; a mutation's outcome may be unknown. + OutcomeUnknown, +} + +/// Actionable ABI refusal; a transport failure never proves a write did not occur. +#[derive(Clone, Debug, Deserialize, Serialize, thiserror::Error)] +#[error("{message}; {remedy}")] +pub struct AdapterRefusal { + /// Stable local classification. + pub code: AdapterRefusalCode, + /// Whether exchange started; true means a mutation may already have committed. + pub dispatched: bool, + /// Concise explanation. + pub message: String, + /// Explicit caller recovery instruction. + pub remedy: String, +} + +/// Creates a stable ABI diagnostic without retaining potentially sensitive frame bytes. +fn refusal(code: AdapterRefusalCode) -> AdapterRefusal { + let (message, remedy) = match code { + AdapterRefusalCode::IncompatibleAbi => ( + "incompatible native adapter ABI", + "use an adapter and caller implementing pm-native-adapter/1", + ), + AdapterRefusalCode::UnsupportedCapability => ( + "adapter capability unavailable; no call dispatched", + "select an authorized adapter declaring the requested operation", + ), + AdapterRefusalCode::InvalidFrame => ( + "invalid native adapter frame", + "use the documented typed fields and matching request ID; reconcile a dispatched mutation before retrying", + ), + AdapterRefusalCode::FrameLimit => ( + "native adapter frame exceeds admission limits", + "use smaller canonical pages; reconcile a dispatched mutation before retrying", + ), + AdapterRefusalCode::OutcomeUnknown => ( + "adapter transport failed; mutation outcome may be unknown", + "read authoritative backend receipts before deciding whether to retry", + ), + }; + AdapterRefusal { + code, + dispatched: false, + message: message.into(), + remedy: remedy.into(), + } +} + +/// Marks an untrustworthy dispatched result without hiding its ABI classification. +fn dispatched_refusal(mut error: AdapterRefusal) -> AdapterRefusal { + error.dispatched = true; + error + .remedy + .push_str("; reconcile authoritative mutation receipts before retrying"); + error +} + +/// Caller-owned native transport, implemented with the backend's authorized SDK. +/// +/// No Git, Node, C layout, dynamic loading or network transport is assumed. +/// Implementations must bound reads by `response_limit`, authorize the caller, +/// preserve canonical receipts/refusals, and use authoritative claim/release +/// locking. Do not emulate mutations with a client-side read then write. +/// Never retry here: an interrupted mutation can already have committed. +pub trait NativeAdapter { + /// Declares the immutable capabilities of this authorized adapter instance. + fn manifest(&self) -> AdapterManifest; + /// Exchanges exactly one bounded UTF-8 JSON frame without automatic retries. + /// + /// # Errors + /// Return transport failure when no trustworthy response is available. + /// The client converts transport failures to an unknown-outcome refusal. + fn exchange( + &mut self, + request: &[u8], + response_limit: usize, + ) -> Result, std::io::Error>; +} + +/// Validates a manifest before any backend call. +/// +/// # Errors +/// Refuses incompatible ABI versions or invalid frame admission ceilings. +pub fn check_adapter_manifest(manifest: &AdapterManifest) -> Result<(), AdapterRefusal> { + check_abi(&manifest.abi)?; + if manifest.max_frame_bytes == 0 || manifest.max_frame_bytes > NATIVE_ADAPTER_MAX_BYTES { + return Err(refusal(AdapterRefusalCode::FrameLimit)); + } + Ok(()) +} + +/// Checks the exact ABI identity; unknown revisions are never optimistically accepted. +fn check_abi(abi: &str) -> Result<(), AdapterRefusal> { + if abi != NATIVE_ADAPTER_ABI { + return Err(refusal(AdapterRefusalCode::IncompatibleAbi)); + } + Ok(()) +} + +/// Checks a nonblank bounded caller string without imposing backend ID syntax. +fn valid_text(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= 256 +} + +/// Validates and encodes a request before transport access or writes. +/// +/// # Errors +/// Refuses incompatible versions, unsupported operations, invalid fields or size. +pub fn prepare_adapter_request( + manifest: &AdapterManifest, + request: &AdapterRequest, +) -> Result, AdapterRefusal> { + check_adapter_manifest(manifest)?; + check_abi(&request.abi)?; + if !manifest + .capabilities + .contains(&request.operation.capability()) + { + return Err(refusal(AdapterRefusalCode::UnsupportedCapability)); + } + let valid = match &request.operation { + AdapterOperation::Get { id } => valid_text(id), + AdapterOperation::List { limit, .. } => (1..=100).contains(limit), + AdapterOperation::Claim { id, author } | AdapterOperation::Release { id, author } => { + valid_text(id) && valid_text(author) + } + }; + if !valid_text(&request.request_id) || !valid { + return Err(refusal(AdapterRefusalCode::InvalidFrame)); + } + let encoded = crate::stringify_json(request, false).into_bytes(); + check_size(encoded.len(), manifest.max_frame_bytes)?; + Ok(encoded) +} + +/// Refuses excess bytes before decoding a frame. +fn check_size(length: usize, maximum: usize) -> Result<(), AdapterRefusal> { + if length > maximum { + return Err(refusal(AdapterRefusalCode::FrameLimit)); + } + Ok(()) +} + +/// Validates one bounded response and preserves its canonical backend envelope. +/// +/// # Errors +/// Refuses invalid requests, oversized replies, malformed frames or mismatched identity. +pub fn check_adapter_reply( + manifest: &AdapterManifest, + request: &AdapterRequest, + bytes: &[u8], +) -> Result { + prepare_adapter_request(manifest, request)?; + check_size(bytes.len(), manifest.max_frame_bytes)?; + let reply: AdapterReply = crate::strict_json::from_slice(bytes) + .map_err(|_| refusal(AdapterRefusalCode::InvalidFrame))?; + check_abi(&reply.abi)?; + if reply.request_id != request.request_id { + return Err(refusal(AdapterRefusalCode::InvalidFrame)); + } + Ok(reply) +} + +/// Stateless native SDK client; no retries, shared mutable tracker state or local claims. +pub struct NativeAdapterClient { + /// Embedding application's transport and authorization boundary. + adapter: A, + /// Fixed declaration validated at construction. + manifest: AdapterManifest, +} + +impl NativeAdapterClient { + /// Admits an explicit adapter without invoking its backend. + /// + /// # Errors + /// Refuses incompatible ABI declarations and invalid frame ceilings. + pub fn new(adapter: A) -> Result { + let manifest = adapter.manifest(); + check_adapter_manifest(&manifest).map(|()| Self { adapter, manifest }) + } + + /// Dispatches once after preflight, preserving authoritative results and refusals. + /// + /// # Errors + /// Refuses invalid or unsupported calls before dispatch. Any failure after + /// exchange starts must be reconciled at the backend before mutation retry. + pub fn call(&mut self, request: &AdapterRequest) -> Result { + exchange_adapter(&mut self.adapter, &self.manifest, request) + } +} + +/// Applies one shared admission/receipt path to every native transport implementation. +fn exchange_adapter( + adapter: &mut dyn NativeAdapter, + manifest: &AdapterManifest, + request: &AdapterRequest, +) -> Result { + let bytes = prepare_adapter_request(manifest, request)?; + let response = adapter + .exchange(&bytes, manifest.max_frame_bytes) + .map_err(|_| dispatched_refusal(refusal(AdapterRefusalCode::OutcomeUnknown)))?; + check_adapter_reply(manifest, request, &response).map_err(dispatched_refusal) +} + +/// Offline CLI transcript: validates compatibility without executing any operation. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdapterTranscript { + /// Authorized adapter declaration. + pub manifest: AdapterManifest, + /// Prospective operation. + pub request: AdapterRequest, + /// Exact recorded response bytes, including padding and malformed encodings. + /// Never decode or normalize these before shared frame admission. + pub reply_bytes: Option>, +} + +/// Validates a bounded transcript and returns an agent-sized compatibility receipt. +/// +/// # Errors +/// Refuses malformed input, unsupported capabilities, or incompatible recorded replies. +pub fn check_adapter_transcript(bytes: &[u8]) -> Result { + check_size(bytes.len(), NATIVE_ADAPTER_MAX_TRANSCRIPT_BYTES)?; + let transcript: AdapterTranscript = crate::strict_json::from_slice(bytes) + .map_err(|_| refusal(AdapterRefusalCode::InvalidFrame))?; + let request_bytes = prepare_adapter_request(&transcript.manifest, &transcript.request)?; + let outcome = match transcript.reply_bytes { + Some(recorded) => { + let checked = + check_adapter_reply(&transcript.manifest, &transcript.request, &recorded)?; + match checked.outcome { + AdapterOutcome::Success { .. } => "success", + AdapterOutcome::Refused { .. } => "refused", + } + } + None => "not_supplied", + }; + Ok(serde_json::json!({ + "ok": true, "abi": NATIVE_ADAPTER_ABI, "dispatched": false, + "request_id": transcript.request.request_id, + "capability": transcript.request.operation.capability(), + "request_bytes": request_bytes.len(), "reply_status": outcome, + "receipt_scope": "abi_compatibility_only" + })) +} diff --git a/src/orientation.rs b/src/orientation.rs index 1bb833a..483968c 100644 --- a/src/orientation.rs +++ b/src/orientation.rs @@ -178,7 +178,7 @@ pub(crate) fn records(workspace: &Workspace, id: &str) -> Result, PmR json!({"code":"history_merge_conflict_markers_detected","required":"Repair the history stream by resolving merge-conflict markers.","why":"Conflict markers break JSONL parsing and invalidate deterministic audit history.","examples":[format!("pm history {id}"),format!("pm restore {id} ")],"next_steps":["Resolve or restore the history file, then rerun the command."]}), )); } - result.push(serde_json::from_str(line).map_err(|_| refusal(format!("History for {id} contains invalid JSON at line {}. Repair or restore the history stream and retry.",index+1),1,json!({"code":"history_json_invalid","next_steps":[format!("Run pm history-repair {id} --salvage-tail --dry-run to check whether only an invalid suffix is damaged.")]})))?); + result.push(crate::strict_json::from_str(line).map_err(|_| refusal(format!("History for {id} contains invalid JSON at line {}. Repair or restore the history stream and retry.",index+1),1,json!({"code":"history_json_invalid","next_steps":[format!("Run pm history-repair {id} --salvage-tail --dry-run to check whether only an invalid suffix is damaged.")]})))?); } Ok(result) } diff --git a/src/orientation_context.rs b/src/orientation_context.rs index 796990a..4e2cb18 100644 --- a/src/orientation_context.rs +++ b/src/orientation_context.rs @@ -148,7 +148,7 @@ fn affinity(workspace: &Workspace, identity: &crate::AuthorIdentity, now: &str) let runtime = workspace.pm_root().join("runtime"); let session = std::fs::read(runtime.join("session.json")) .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|bytes| crate::strict_json::from_slice::(&bytes).ok()) .unwrap_or(Value::Null); let key = identity.instance.clone().unwrap_or_else(|| { format!( @@ -207,7 +207,7 @@ fn affinity(workspace: &Workspace, identity: &crate::AuthorIdentity, now: &str) let now = crate::list::timestamp_key(now).0.unwrap_or(0); let retained: Vec = raw .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) + .filter_map(|line| crate::strict_json::from_str::(line).ok()) .filter(|event| { crate::list::timestamp_key(text(event, "at")) .0 @@ -872,7 +872,7 @@ fn context_state_boundary( } let manifest = std::fs::read(entry.path().join("manifest.json")) .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|bytes| crate::strict_json::from_slice::(&bytes).ok()) .unwrap_or(Value::Null); if manifest["contributions"]["service_overrides"] .as_array() @@ -894,7 +894,7 @@ fn context_state_boundary( let load = |name: &str| { std::fs::read(runtime.join(name)) .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|bytes| crate::strict_json::from_slice::(&bytes).ok()) }; let snapshot = load("context-signals-context.json"); let invalid = runtime.join("context-signals-context.json").is_file() @@ -994,7 +994,7 @@ fn extension_health( } let manifest = std::fs::read(path.join("manifest.json")) .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|bytes| crate::strict_json::from_slice::(&bytes).ok()) .unwrap_or(Value::Null); let configured = ["enabled", "disabled"].iter().any(|key| { settings["extensions"][key] diff --git a/src/ownership.rs b/src/ownership.rs index 2947eaa..64a45e0 100644 --- a/src/ownership.rs +++ b/src/ownership.rs @@ -180,7 +180,7 @@ impl AuthorIdentity { let mut identity = self.clone(); let state = fs::read_to_string(pm_root.join("runtime/session.json")) .ok() - .and_then(|text| serde_json::from_str::(&text).ok()); + .and_then(|text| crate::strict_json::from_str::(&text).ok()); if let Some(instance) = &identity.instance && let Some(semantic) = state .as_ref() @@ -245,7 +245,7 @@ pub(crate) fn read_json(path: &Path) -> Result { }); } }; - match serde_json::from_slice::(&bytes) { + match crate::strict_json::from_slice::(&bytes) { Ok(value) if value.is_object() => Ok(value), Ok(_) => Err(PmRustError::InvalidReadRequest { reason: "Expected a settings or schema object".to_owned(), diff --git a/src/pagination.rs b/src/pagination.rs index d8e5453..38ad305 100644 --- a/src/pagination.rs +++ b/src/pagination.rs @@ -31,7 +31,7 @@ fn decode(raw: &str, code: &'static str, detail: &str) -> Result) -> Con let bytes = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, raw) .ok()?; - let cursor: Value = serde_json::from_slice(&bytes).ok()?; + let cursor: Value = crate::strict_json::from_slice(&bytes).ok()?; cursor["after_index"].as_u64()?; cursor["fingerprint"].as_str()?; Some(cursor) diff --git a/src/read_session.rs b/src/read_session.rs index 2239907..b6c010f 100644 --- a/src/read_session.rs +++ b/src/read_session.rs @@ -214,7 +214,7 @@ impl Workspace { false, )?; let settings: serde_json::Value = - serde_json::from_slice(&fs::read(target.join("settings.json"))?) + crate::strict_json::from_slice(&fs::read(target.join("settings.json"))?) .map_err(|_| SessionRefusal::UnsupportedSource)?; for name in ["statuses", "workflows"] { let fallback = format!("schema/{name}.json"); diff --git a/src/selection.rs b/src/selection.rs index c001f48..e237a7b 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -385,7 +385,7 @@ fn recency(workspace: &Workspace, item: &ItemDocument) -> String { history .lines() .rev() - .filter_map(|line| serde_json::from_str::(line).ok()) + .filter_map(|line| crate::strict_json::from_str::(line).ok()) .find(|entry| entry["event_class"] == "substantive") .and_then(|entry| entry["ts"].as_str().map(str::to_owned)) }) diff --git a/src/strict_json.rs b/src/strict_json.rs new file mode 100644 index 0000000..1760017 --- /dev/null +++ b/src/strict_json.rs @@ -0,0 +1,99 @@ +//! JSON admission shared by wire frames, tracker files and opaque receipts. +//! +//! Object keys are compared after JSON unescaping at every nesting depth. +//! Typed deserialization only sees the already-validated value, so serde's +//! default last-key-wins object decoding cannot erase an authoritative field. + +use serde::{Deserialize, Deserializer, de}; +use serde_json::{Map, Value}; +use std::fmt; + +/// Decodes one complete JSON document, rejecting duplicate keys recursively. +/// +/// # Errors +/// Returns a JSON error for invalid syntax, duplicate keys, excessive nesting, +/// trailing data or a value that does not match the requested type. +pub fn from_slice(bytes: &[u8]) -> serde_json::Result { + let mut reader = serde_json::Deserializer::from_slice(bytes); + let StrictValue(value) = StrictValue::deserialize(&mut reader)?; + reader.end()?; + serde_json::from_value(value) +} + +/// Decodes JSON text through the same strict byte reader as wire frames. +/// +/// # Errors +/// Returns the same syntax, duplicate-key and typed decoding errors as +/// [`from_slice`]. +pub fn from_str(text: &str) -> serde_json::Result { + from_slice(text.as_bytes()) +} + +/// Recursive value wrapper that refuses a repeated decoded object key. +struct StrictValue(Value); + +impl<'de> Deserialize<'de> for StrictValue { + fn deserialize>(reader: D) -> Result { + reader.deserialize_any(StrictVisitor) + } +} + +/// Builds JSON values while keeping serde's parser and recursion limits. +struct StrictVisitor; + +impl<'de> de::Visitor<'de> for StrictVisitor { + type Value = StrictValue; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("JSON with unique object keys") + } + + fn visit_unit(self) -> Result { + Ok(StrictValue(Value::Null)) + } + + fn visit_bool(self, value: bool) -> Result { + Ok(StrictValue(Value::Bool(value))) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(StrictValue(Value::from(value))) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(StrictValue(Value::from(value))) + } + + fn visit_f64(self, value: f64) -> Result { + Ok(StrictValue(Value::from(value))) + } + + fn visit_str(self, value: &str) -> Result { + Ok(StrictValue(Value::String(value.into()))) + } + + fn visit_seq>(self, mut sequence: A) -> Result { + let mut values = Vec::new(); + while let Some(StrictValue(value)) = sequence.next_element()? { + values.push(value); + } + Ok(StrictValue(Value::Array(values))) + } + + fn visit_map>(self, mut object: A) -> Result { + let mut values = Map::new(); + while let Some(key) = object.next_key::()? { + if values.contains_key(&key) { + // Do not echo potentially sensitive object keys into diagnostics. + return Err(de::Error::custom("duplicate JSON object key")); + } + let StrictValue(value) = object.next_value()?; + values.insert(key, value); + } + Ok(StrictValue(Value::Object(values))) + } +} + +#[cfg(test)] +#[path = "../tests/support/strict_json_unit.rs"] +mod tests; diff --git a/tests/harness/eslint.config.mjs b/tests/harness/eslint.config.mjs index cbea818..4b38a22 100644 --- a/tests/harness/eslint.config.mjs +++ b/tests/harness/eslint.config.mjs @@ -1,6 +1,6 @@ import tseslint from 'typescript-eslint'; export default tseslint.config( - { files: ['scripts/crash-*.mts'], extends: [tseslint.configs.recommended], + { files: ['scripts/crash-*.mts', 'scripts/native-adapter-*.mts'], extends: [tseslint.configs.recommended], rules: { '@typescript-eslint/no-non-null-assertion': 'off' } }, ); diff --git a/tests/harness/tsconfig.json b/tests/harness/tsconfig.json index 3120212..671c6a3 100644 --- a/tests/harness/tsconfig.json +++ b/tests/harness/tsconfig.json @@ -9,5 +9,5 @@ "types": ["node"], "skipLibCheck": true }, - "include": ["../../scripts/crash-*.mts"] + "include": ["../../scripts/crash-*.mts", "../../scripts/native-adapter-*.mts"] } diff --git a/tests/native_adapter_contract.rs b/tests/native_adapter_contract.rs new file mode 100644 index 0000000..95ed243 --- /dev/null +++ b/tests/native_adapter_contract.rs @@ -0,0 +1,1046 @@ +//! ABI conformance, pre-write admission and real non-git SDK interleavings. +use pm_rust::native_adapter::{ + AdapterCapability as Capability, AdapterManifest, AdapterOperation as Operation, + AdapterOutcome, AdapterRefusalCode as Code, AdapterReply, AdapterRequest, + NATIVE_ADAPTER_ABI as ABI, NATIVE_ADAPTER_MAX_BYTES as MAX, + NATIVE_ADAPTER_MAX_TRANSCRIPT_BYTES as TRANSCRIPT_MAX, NativeAdapter, NativeAdapterClient, + check_adapter_manifest, check_adapter_reply, check_adapter_transcript, prepare_adapter_request, +}; +use pm_rust::{OwnershipItem, PmRustError, Workspace}; +use serde_json::{Value, json}; +use std::{cell::Cell, fmt::Write as _, fs, io, path::Path, process::Command, rc::Rc}; + +/// Fallible assertion result. +type TestResult = Result<(), Box>; + +/// Declares the synthetic authorized instance's narrow operations. +fn manifest() -> AdapterManifest { + AdapterManifest { + abi: ABI.into(), + capabilities: vec![ + Capability::Get, + Capability::List, + Capability::Claim, + Capability::Release, + ], + max_frame_bytes: MAX, + } +} + +/// Creates an independently correlated request. +fn request(operation: Operation) -> AdapterRequest { + AdapterRequest { + abi: ABI.into(), + request_id: "call-1".into(), + operation, + } +} + +/// Creates a canonical response without modifying its backend payload. +fn reply(outcome: AdapterOutcome) -> AdapterReply { + AdapterReply { + abi: ABI.into(), + request_id: "call-1".into(), + outcome, + } +} + +/// Encodes known serializable protocol values. +fn bytes(value: &impl serde::Serialize) -> Vec { + pm_rust::stringify_json(value, false).into_bytes() +} + +/// Protocol peer for malformed-frame tests; it does not mock any SDK operation. +struct Peer { + /// Explicit declaration, including incompatible-constructor test cases. + declaration: AdapterManifest, + /// Exact response or a disconnected transport. + response: Option>, + /// Number of dispatches observed across client ownership. + calls: Rc>, +} +impl NativeAdapter for Peer { + fn manifest(&self) -> AdapterManifest { + self.declaration.clone() + } + fn exchange(&mut self, _: &[u8], limit: usize) -> io::Result> { + assert_eq!(limit, self.declaration.max_frame_bytes); + self.calls.set(self.calls.get() + 1); + self.response + .clone() + .ok_or_else(|| io::Error::other("disconnected after commit")) + } +} + +/// Retains complete canonical success and refusal envelopes across the byte ABI. +#[test] +fn canonical_outcomes_survive_the_byte_boundary() -> TestResult { + let output = json!({"claimed_by":"peer", "skipped":true,"warnings":["claim_skipped_held_by:peer"],"mutation_receipt":{"changed_count":0},"omission_receipt":{"has_omissions":true},"next_cursor":"opaque","large":9_007_199_254_740_991_u64,"unicode":"ä\n雪"}); + let outcomes = [ + AdapterOutcome::Success { + output: output.clone(), + }, + AdapterOutcome::Refused { + code: "ownership_conflict".into(), + message: "foreign owner".into(), + remedy: "select another item".into(), + details: output, + }, + ]; + for outcome in outcomes { + let response = reply(outcome); + let calls = Rc::new(Cell::new(0)); + let mut client = NativeAdapterClient::new(Peer { + declaration: manifest(), + response: Some(bytes(&response)), + calls: calls.clone(), + })?; + let actual = client.call(&request(Operation::Get { + id: "demo-1".into(), + }))?; + assert_eq!( + serde_json::to_value(actual)?, + serde_json::to_value(response)? + ); + assert_eq!(calls.get(), 1); + } + Ok(()) +} + +/// Rejects unsupported or invalid requests without invoking a backend. +#[test] +fn admission_refuses_before_transport_or_writes() -> TestResult { + /// A read-only adapter cannot receive a mutating operation. + struct ReadOnly(Peer); + impl NativeAdapter for ReadOnly { + fn manifest(&self) -> AdapterManifest { + AdapterManifest { + capabilities: vec![Capability::Get], + ..manifest() + } + } + fn exchange(&mut self, input: &[u8], limit: usize) -> io::Result> { + self.0.exchange(input, limit) + } + } + let calls = Rc::new(Cell::new(0)); + let mut client = NativeAdapterClient::new(ReadOnly(Peer { + declaration: manifest(), + response: None, + calls: calls.clone(), + }))?; + for operation in [ + Operation::Claim { + id: "demo-1".into(), + author: "caller".into(), + }, + Operation::Release { + id: "demo-1".into(), + author: "caller".into(), + }, + ] { + assert_eq!( + client + .call(&request(operation)) + .err() + .ok_or("must refuse")? + .code, + Code::UnsupportedCapability + ); + } + let mut invalid = request(Operation::Get { + id: "demo-1".into(), + }); + invalid.abi = "pm-native-adapter/2".into(); + assert_eq!( + client.call(&invalid).err().ok_or("must refuse")?.code, + Code::IncompatibleAbi + ); + invalid.abi = ABI.into(); + invalid.request_id.clear(); + assert_eq!( + client.call(&invalid).err().ok_or("must refuse")?.code, + Code::InvalidFrame + ); + assert_eq!(calls.get(), 0); + Ok(()) +} + +/// Refuses incompatible declarations before transport access. +#[test] +fn versions_fields_and_limits_are_fail_closed() -> TestResult { + let invalid = AdapterManifest { + abi: "other".into(), + ..manifest() + }; + assert_eq!( + prepare_adapter_request( + &invalid, + &request(Operation::Get { + id: "demo-1".into() + }) + ) + .err() + .ok_or("manifest must refuse")? + .code, + Code::IncompatibleAbi + ); + let calls = Rc::new(Cell::new(0)); + assert!( + NativeAdapterClient::new(Peer { + declaration: invalid, + response: None, + calls: calls.clone() + }) + .is_err() + ); + assert_eq!(calls.get(), 0); + for abi in [ + "", + "pm-native-adapter/0", + "pm-native-adapter/1.1", + "pm-native-adapter/2", + ] { + assert_eq!( + check_adapter_manifest(&AdapterManifest { + abi: abi.into(), + ..manifest() + }) + .err() + .ok_or("version")? + .code, + Code::IncompatibleAbi + ); + } + for size in [0, MAX + 1] { + assert_eq!( + check_adapter_manifest(&AdapterManifest { + max_frame_bytes: size, + ..manifest() + }) + .err() + .ok_or("size")? + .code, + Code::FrameLimit + ); + } + assert!( + check_adapter_manifest(&AdapterManifest { + max_frame_bytes: 1, + ..manifest() + }) + .is_ok() + ); + Ok(()) +} + +/// Checks typed operation fields and exact encoded frame ceilings. +#[test] +fn operation_fields_and_lengths_fail_closed() -> TestResult { + for operation in [ + Operation::Get { id: " ".into() }, + Operation::Get { + id: "x".repeat(257), + }, + Operation::List { + limit: 0, + after: None, + output_cursor: None, + }, + Operation::List { + limit: 101, + after: None, + output_cursor: None, + }, + Operation::Claim { + id: String::new(), + author: "caller".into(), + }, + Operation::Claim { + id: "demo-1".into(), + author: String::new(), + }, + Operation::Release { + id: String::new(), + author: "caller".into(), + }, + Operation::Release { + id: "demo-1".into(), + author: String::new(), + }, + ] { + assert_eq!( + prepare_adapter_request(&manifest(), &request(operation)) + .err() + .ok_or("invalid operation")? + .code, + Code::InvalidFrame + ); + } + for operation in [ + Operation::List { + limit: 1, + after: None, + output_cursor: None, + }, + Operation::List { + limit: 100, + after: None, + output_cursor: None, + }, + Operation::Claim { + id: "demo-1".into(), + author: "caller".into(), + }, + Operation::Release { + id: "demo-1".into(), + author: "caller".into(), + }, + ] { + prepare_adapter_request(&manifest(), &request(operation))?; + } + let mut call = request(Operation::Get { + id: "demo-1".into(), + }); + call.request_id = "x".repeat(257); + assert_eq!( + prepare_adapter_request(&manifest(), &call) + .err() + .ok_or("id limit")? + .code, + Code::InvalidFrame + ); + call.request_id = "x".repeat(256); + let encoded = prepare_adapter_request(&manifest(), &call)?; + prepare_adapter_request( + &AdapterManifest { + max_frame_bytes: encoded.len(), + ..manifest() + }, + &call, + )?; + assert_eq!( + prepare_adapter_request( + &AdapterManifest { + max_frame_bytes: encoded.len() - 1, + ..manifest() + }, + &call + ) + .err() + .ok_or("request size")? + .code, + Code::FrameLimit + ); + Ok(()) +} + +/// Preserves uncertainty after dispatch and never repeats a transport call. +#[test] +fn dispatched_failures_are_never_retried_or_reported_as_no_write() -> TestResult { + let call = request(Operation::Claim { + id: "demo-1".into(), + author: "caller".into(), + }); + let mut wrong_version = reply(AdapterOutcome::Success { output: json!({}) }); + wrong_version.abi = "other".into(); + let mut wrong_id = reply(AdapterOutcome::Success { output: json!({}) }); + wrong_id.request_id = "peer-call".into(); + for (response, code) in [ + (None, Code::OutcomeUnknown), + (Some(b"{".to_vec()), Code::InvalidFrame), + (Some(bytes(&wrong_version)), Code::IncompatibleAbi), + (Some(bytes(&wrong_id)), Code::InvalidFrame), + (Some(vec![b' '; MAX + 1]), Code::FrameLimit), + ] { + let calls = Rc::new(Cell::new(0)); + let mut client = NativeAdapterClient::new(Peer { + declaration: manifest(), + response, + calls: calls.clone(), + })?; + let error = client.call(&call).err().ok_or("must refuse")?; + assert_eq!(error.code, code); + assert!(error.dispatched); + assert!(!error.remedy.is_empty()); + assert!(!error.to_string().contains("disconnected after commit")); + assert_eq!(calls.get(), 1); + } + Ok(()) +} + +/// Keeps offline compatibility receipts separate from operation success. +#[test] +fn transcript_checks_do_not_confuse_validation_with_execution() -> TestResult { + let call = request(Operation::Get { + id: "demo-1".into(), + }); + for outcome in [ + None, + Some(AdapterOutcome::Success { output: json!({}) }), + Some(AdapterOutcome::Refused { + code: "denied".into(), + message: "not authorized".into(), + remedy: "request access".into(), + details: json!({"exit_code":3}), + }), + ] { + let recorded = outcome.map(|outcome| bytes(&reply(outcome))); + let receipt = check_adapter_transcript(&bytes( + &json!({"manifest":manifest(),"request":call,"reply_bytes":recorded}), + ))?; + assert_eq!(receipt["dispatched"], false); + assert_eq!(receipt["receipt_scope"], "abi_compatibility_only"); + assert!(bytes(&receipt).len() < 400); + } + for invalid in [ + json!({}), + json!({"manifest":manifest(),"request":call,"reply_bytes":null,"foreign":true}), + json!({"manifest":manifest(),"request":{"abi":ABI,"request_id":"call-1","operation":{"action":"get","id":"demo-1","force":true}}}), + ] { + assert_eq!( + check_adapter_transcript(&bytes(&invalid)) + .err() + .ok_or("invalid transcript")? + .code, + Code::InvalidFrame + ); + } + assert_eq!( + check_adapter_transcript(&vec![b' '; TRANSCRIPT_MAX + 1]) + .err() + .ok_or("size")? + .code, + Code::FrameLimit + ); + let mut invalid = call.clone(); + invalid.abi = "future".into(); + assert!(check_adapter_reply(&manifest(), &invalid, b"{}").is_err()); + let transcript = json!({"manifest":manifest(),"request":invalid}); + assert!(check_adapter_transcript(&bytes(&transcript)).is_err()); + let mut recorded = reply(AdapterOutcome::Success { output: json!({}) }); + recorded.request_id = "different".into(); + assert!( + check_adapter_transcript(&bytes( + &json!({"manifest":manifest(),"request":call,"reply_bytes":bytes(&recorded)}) + )) + .is_err() + ); + Ok(()) +} + +/// Generates reproducible wire frames without depending on random-library versions. +fn corpus_word(state: &mut u64) -> String { + *state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1); + format!("{state:016x}") +} + +/// Compares exact recorded bytes through offline admission and a real client call. +#[test] +fn raw_frame_seeded_corpus_matches_live_client() -> TestResult { + const SEED: u64 = 0x05ee_d113; + const LIMIT: usize = 4096; + const COUNT: usize = 768; + let declaration = AdapterManifest { + max_frame_bytes: LIMIT, + ..manifest() + }; + let call = request(Operation::Get { + id: "demo-1".into(), + }); + let mut state = SEED; + let mut accepted = 0; + for index in 0..COUNT { + let word = corpus_word(&mut state); + let base = bytes(&reply(AdapterOutcome::Success { + output: json!({"word":word}), + })); + let (frame, expected) = match index % 16 { + // Boundary lengths include every original padding byte in admission. + boundary @ 0..=2 => { + let mut frame = base.clone(); + frame.resize(LIMIT - 1 + boundary, b' '); + (frame, if boundary == 2 { Err(Code::FrameLimit) } else { Ok(()) }) + } + 3 => { + let mut frame = base.clone(); + frame.splice(1..1, vec![b' '; 5094 - base.len()]); + (frame, Err(Code::FrameLimit)) + } + 4 => (format!(r#"{{"abi":"{ABI}","request_id":"call-1","outcome":{{"status":"success","output":"\u96ea\n\t\"\\{word}"}}}}"#).into_bytes(), Ok(())), + 5 => (bytes(&reply(AdapterOutcome::Success { output: json!(["雪ä🦀", word]) })), Ok(())), + 6 => { + let mut frame = base.clone(); + let offset = frame.iter().position(|byte| *byte == b'w').ok_or("word offset")?; + frame[offset] = 0xff; + (frame, Err(Code::InvalidFrame)) + } + 7 => { + let length = usize::from(word.as_bytes()[0]) % (base.len() - 1); + (base[..length].to_vec(), Err(Code::InvalidFrame)) + } + 8 => (bytes(&reply(AdapterOutcome::Success { output: json!({"nested":{"large":word.repeat(300)}}) })), Err(Code::FrameLimit)), + 9 => { + let mut frame = base.clone(); + frame.extend_from_slice(b" {} trailing"); + (frame, Err(Code::InvalidFrame)) + } + 10 => { + let mut response = reply(AdapterOutcome::Success { output: json!(word) }); + response.abi = "future".into(); + (bytes(&response), Err(Code::IncompatibleAbi)) + } + 11 => { + let mut response = reply(AdapterOutcome::Success { output: json!(word) }); + response.request_id = word; + (bytes(&response), Err(Code::InvalidFrame)) + } + 12 => { + let mut frame = base.clone(); + frame.splice(1..1, b"\"request_id\":\"call-1\",".iter().copied()); + (frame, Err(Code::InvalidFrame)) + } + 13 => { + let mut frame = base.clone(); + frame.splice(1..1, b"\"unknown\":true,".iter().copied()); + (frame, Err(Code::InvalidFrame)) + } + 14 => (format!(r#"{{"abi":"{ABI}","request_id":"call-1","outcome":{{"status":"success","output":{}"{word}"{}}}}}"#, "[".repeat(140), "]".repeat(140)).into_bytes(), Err(Code::InvalidFrame)), + _ => (bytes(&reply(AdapterOutcome::Refused { code: word, message: "denied".into(), remedy: "request access".into(), details: json!({"unicode":"雪"}) })), Ok(())), + }; + let offline = check_adapter_transcript(&bytes(&json!({ + "manifest":declaration, "request":call, "reply_bytes":frame + }))); + let calls = Rc::new(Cell::new(0)); + let mut client = NativeAdapterClient::new(Peer { + declaration: declaration.clone(), + response: Some(frame.clone()), + calls: calls.clone(), + })?; + let live = client.call(&call); + let offline_verdict = offline.as_ref().map(|_| ()).map_err(|error| error.code); + let live_verdict = live.as_ref().map(|_| ()).map_err(|error| error.code); + assert_eq!( + offline_verdict, + live_verdict, + "seed={SEED:#x} frame={index} bytes={}", + frame.len() + ); + assert_eq!(live_verdict, expected, "seed={SEED:#x} frame={index}"); + assert_eq!(calls.get(), 1); + if let Ok(receipt) = offline { + assert_eq!(receipt["dispatched"], false); + accepted += 1; + } else { + assert!(live.err().ok_or("live refusal")?.dispatched); + } + } + assert_eq!(accepted, 240); + Ok(()) +} + +/// Checks the shared validator, offline recording and dispatched client independently. +fn strict_frame_verdict(frame: &[u8], valid: bool) -> TestResult { + let call = request(Operation::Claim { + id: "demo-1".into(), + author: "caller".into(), + }); + let direct = check_adapter_reply(&manifest(), &call, frame); + let offline = check_adapter_transcript(&bytes(&json!({ + "manifest":manifest(), "request":call, "reply_bytes":frame + }))); + let calls = Rc::new(Cell::new(0)); + let mut client = NativeAdapterClient::new(Peer { + declaration: manifest(), + response: Some(frame.to_vec()), + calls: calls.clone(), + })?; + let live = client.call(&call); + let expected = if valid { + Ok(()) + } else { + Err(Code::InvalidFrame) + }; + assert_eq!(direct.as_ref().map(|_| ()).map_err(|e| e.code), expected); + assert_eq!(offline.as_ref().map(|_| ()).map_err(|e| e.code), expected); + assert_eq!(live.as_ref().map(|_| ()).map_err(|e| e.code), expected); + assert_eq!(calls.get(), 1); + if valid { + let original: Value = pm_rust::strict_json::from_slice(frame)?; + assert_eq!(serde_json::to_value(live?)?, original); + assert_eq!(offline?["dispatched"], false); + } else { + assert!(!direct.err().ok_or("direct refusal")?.dispatched); + assert!(!offline.err().ok_or("offline refusal")?.dispatched); + let error = live.err().ok_or("live refusal")?; + assert!(error.dispatched); + assert!(error.remedy.contains("reconcile")); + } + Ok(()) +} + +/// Encodes payload text verbatim so duplicate keys cannot disappear in test setup. +fn raw_payload_frame(payload: &str, refused: bool) -> Vec { + let outcome = if refused { + format!( + r#"{{"status":"refused","code":"denied","message":"held","remedy":"skip","details":{payload}}}"# + ) + } else { + format!(r#"{{"status":"success","output":{payload}}}"#) + }; + format!(r#"{{"abi":"{ABI}","request_id":"call-1","outcome":{outcome}}}"#).into_bytes() +} + +/// Extends differential admission to recursive, escaped, array and wide duplicates. +#[test] +fn strict_json_duplicate_corpus_matches_live_client() -> TestResult { + // Envelope duplicates at depths zero and one, including escaped-equal fields. + for valid in [true, false] { + let extra_id = if valid { + "" + } else { + r#", "request\u005fid":"call-1""# + }; + let extra_status = if valid { + "" + } else { + r#", "st\u0061tus":"success""# + }; + strict_frame_verdict(&format!(r#"{{"abi":"{ABI}","request_id":"call-1"{extra_id},"outcome":{{"status":"success","output":{{}}}}}}"#).into_bytes(), valid)?; + strict_frame_verdict(&format!(r#"{{"abi":"{ABI}","request_id":"call-1","outcome":{{"status":"success"{extra_status},"output":{{}}}}}}"#).into_bytes(), valid)?; + } + // Payload depths 0..5 (wire depths 2..7), with identical and escaped-equal keys. + for depth in 0..=5 { + for (first, duplicate) in [ + ("skipped", "skipped"), + ("a", r"\u0061"), + (r"\u0061", "a"), + ("雪", r"\u96ea"), + ] { + for array in [false, true] { + for refused in [false, true] { + for valid in [true, false] { + let second = if valid { "different" } else { duplicate }; + let mut payload = format!(r#"{{"{first}":true,"{second}":false}}"#); + for _ in 0..depth { + payload = format!(r#"{{"child":{payload}}}"#); + } + if array { + payload = format!(r#"[{{"safe":true}},{payload},{{"safe":false}}]"#); + } + strict_frame_verdict(&raw_payload_frame(&payload, refused), valid)?; + } + } + } + } + } + // The repeated first key occurs after 16,384 unrelated fields, below the byte cap. + let mut fields = String::new(); + for index in 0..16_384 { + write!(fields, r#""field{index}":{index},"#)?; + } + for refused in [false, true] { + for valid in [true, false] { + let last = if valid { "tail" } else { "field0" }; + let payload = format!(r#"{{{fields}"{last}":false}}"#); + strict_frame_verdict(&raw_payload_frame(&payload, refused), valid)?; + } + } + Ok(()) +} + +/// Refuses duplicate transcript, manifest and request keys before compatibility admission. +#[test] +fn strict_json_transcripts_and_requests_reject_escaped_duplicates() -> TestResult { + let call = bytes(&request(Operation::Get { + id: "demo-1".into(), + })); + let call = String::from_utf8(call)?; + let declaration = String::from_utf8(bytes(&manifest()))?; + for (manifest_text, request_text, extra) in [ + ( + declaration.clone(), + call.clone(), + format!(r#", "manif\u0065st":{declaration}"#), + ), + ( + declaration.replacen('{', r#"{"max\u005fframe_bytes":1,"#, 1), + call.clone(), + String::new(), + ), + ( + declaration.clone(), + call.replacen('{', r#"{"request\u005fid":"peer","#, 1), + String::new(), + ), + ( + declaration, + call.replace(r#""action":"get""#, r#""action":"get","act\u0069on":"get""#), + String::new(), + ), + ] { + let raw = format!(r#"{{"manifest":{manifest_text},"request":{request_text}{extra}}}"#); + let error = check_adapter_transcript(raw.as_bytes()) + .err() + .ok_or("duplicate transcript")?; + assert_eq!(error.code, Code::InvalidFrame); + assert!(!error.dispatched); + let result = assert_cmd::Command::new(env!("CARGO_BIN_EXE_pm-rust")) + .args(["adapter-check", "--json"]) + .write_stdin(raw) + .output()?; + assert!(!result.status.success()); + let receipt: Value = pm_rust::strict_json::from_slice(&result.stdout)?; + assert_eq!(receipt["refusal"]["code"], "invalid_frame"); + } + Ok(()) +} + +/// Admits a maximum-size recorded frame through the bounded CLI envelope. +#[test] +fn cli_transcript_preserves_full_frame_limit_and_raw_errors() -> TestResult { + let call = request(Operation::Get { + id: "demo-1".into(), + }); + let mut frame = bytes(&reply(AdapterOutcome::Success { output: json!({}) })); + frame.resize(MAX, b' '); + for (recorded, code) in [ + (frame, None), + (vec![b' '; MAX + 1], Some(Code::FrameLimit)), + (vec![0xff], Some(Code::InvalidFrame)), + ] { + let input = bytes(&json!({"manifest":manifest(),"request":call,"reply_bytes":recorded})); + let result = assert_cmd::Command::new(env!("CARGO_BIN_EXE_pm-rust")) + .args(["adapter-check", "--json"]) + .write_stdin(input) + .output()?; + assert_eq!(result.status.success(), code.is_none()); + let receipt: Value = serde_json::from_slice(&result.stdout)?; + if let Some(code) = code { + assert_eq!(receipt["refusal"]["code"], serde_json::to_value(code)?); + } + } + // A decoded reply cannot prove the original wire frame's size or encoding. + assert!(check_adapter_transcript(&bytes(&json!({ + "manifest":manifest(),"request":call,"reply":reply(AdapterOutcome::Success {output:json!({})}) + }))).is_err()); + Ok(()) +} + +/// Initializes a genuine scratch tracker without an enclosing repository. +fn tracker() -> Result> { + let directory = tempfile::tempdir()?; + let initialized = Command::new("node") + .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/oracle/launch.mts")) + .current_dir(directory.path()) + .arg("--path") + .arg(directory.path().join(".agents/pm")) + .args(["init", "--defaults", "--agent-guidance", "skip"]) + .env( + "GIT_CEILING_DIRECTORIES", + directory.path().parent().ok_or("parent")?, + ) + .output()?; + assert!( + initialized.status.success(), + "{}", + String::from_utf8_lossy(&initialized.stderr) + ); + // The authoritative SDK policy must reject non-owner releases under its lock. + let root = directory.path().join(".agents/pm"); + let mut settings: Value = serde_json::from_slice(&fs::read(root.join("settings.json"))?)?; + settings["governance"]["preset"] = json!("strict"); + settings["agent_identity"]["probes_enabled"] = json!(false); + fs::write(root.join("settings.json"), bytes(&settings))?; + Ok(directory) +} + +/// Explicit author request; claim uses the SDK's atomic skip-on-peer path. +fn ownership(id: &str, author: &str) -> OwnershipItem { + OwnershipItem { + id: id.into(), + author: author.into(), + timestamp: None, + message: None, + force: false, + if_available: true, + provenance_role: None, + } +} + +/// Runs the native SDK's real ownership transition inside the synthetic remote peer. +fn transition(workspace: &Workspace, operation: &Operation) -> AdapterOutcome { + let result = match operation { + Operation::Claim { id, author } => workspace.claim(&ownership(id, author)), + Operation::Release { id, author } => workspace.release(&ownership(id, author)), + _ => { + return AdapterOutcome::Success { + output: json!({"read_only":true}), + }; + } + }; + match result { + Ok(value) => AdapterOutcome::Success { + output: json!(value), + }, + Err(PmRustError::OwnershipRefusal { detail, context }) => AdapterOutcome::Refused { + code: "ownership_conflict".into(), + message: detail, + remedy: "skip foreign ownership and select another item".into(), + details: context, + }, + Err(error) => AdapterOutcome::Refused { + code: "sdk_refused".into(), + message: error.to_string(), + remedy: "inspect authoritative tracker".into(), + details: Value::Null, + }, + } +} + +/// Remote adapter that injects a real SDK peer at each boundary it can touch. +struct RacingPeer<'a> { + /// One shared non-git tracker for both agents. + workspace: &'a Workspace, + /// Boundary index for the competing transition. + boundary: usize, + /// Independent peer operation. + competitor: Operation, +} +impl RacingPeer<'_> { + /// Injects the competing SDK transition at one selected boundary. + fn step(&self, index: usize) { + if self.boundary == index { + let _ = transition(self.workspace, &self.competitor); + if let Operation::Release { id, .. } = &self.competitor { + let _ = transition( + self.workspace, + &Operation::Claim { + id: id.clone(), + author: "peer".into(), + }, + ); + } + } + } +} +impl NativeAdapter for RacingPeer<'_> { + fn manifest(&self) -> AdapterManifest { + self.step(0); + let result = manifest(); + self.step(1); + result + } + fn exchange(&mut self, input: &[u8], _: usize) -> io::Result> { + self.step(2); + let call: AdapterRequest = serde_json::from_slice(input)?; + self.step(3); + let outcome = transition(self.workspace, &call.operation); + self.step(4); + let encoded = bytes(&AdapterReply { + abi: ABI.into(), + request_id: call.request_id, + outcome, + }); + self.step(5); + Ok(encoded) + } +} + +/// Injects real competing ownership transitions at every adapter boundary. +#[test] +fn every_adapter_boundary_preserves_authoritative_claim_and_release() -> TestResult { + let directory = tracker()?; + let workspace = Workspace::discover(directory.path())?; + // Two operation paths, all six observable boundaries, the SAME tracker. + for release in [false, true] { + for boundary in 0..6 { + let id = format!("race-{}-{boundary}", u8::from(release)); + let root = directory.path().join(".agents/pm/tasks"); + fs::create_dir_all(&root)?; + fs::write( + root.join(format!("{id}.toon")), + format!( + "id: {id}\ntitle: Race fixture\ntype: Task\nstatus: open\npriority: 1\ncreated_at: \"2026-10-21T12:00:00.000Z\"\nupdated_at: \"2026-10-21T12:00:00.000Z\"\n" + ), + )?; + let operation = if release { + workspace.claim(&ownership(&id, "caller"))?; + Operation::Release { + id: id.clone(), + author: "caller".into(), + } + } else { + Operation::Claim { + id: id.clone(), + author: "caller".into(), + } + }; + let competitor = if release { + Operation::Release { + id: id.clone(), + author: "caller".into(), + } + } else { + Operation::Claim { + id: id.clone(), + author: "peer".into(), + } + }; + let mut client = NativeAdapterClient::new(RacingPeer { + workspace: &workspace, + boundary, + competitor, + })?; + let response = client.call(&request(operation))?; + let stored = workspace.get(&id)?; + if release { + assert_eq!(stored.metadata.extra["assignee"], "peer"); + match response.outcome { + AdapterOutcome::Success { output } => { + assert!(boundary >= 4); + assert_eq!(output["released_by"], "caller"); + } + AdapterOutcome::Refused { code, remedy, .. } => { + assert!(boundary < 4); + assert_eq!(code, "ownership_conflict"); + assert!(remedy.contains("skip foreign")); + } + } + } else { + let expected = if boundary < 4 { "peer" } else { "caller" }; + assert_eq!(stored.metadata.extra["assignee"], expected); + match response.outcome { + AdapterOutcome::Success { output } => { + assert_eq!(output["claimed_by"], expected); + if boundary < 4 { + assert_eq!(output["skipped"], true); + assert_eq!(output["changed_field_count"], 0); + } + } + AdapterOutcome::Refused { .. } => { + return Err("atomic claim should skip peer".into()); + } + } + } + } + } + // A package must not delete foreign claims, even through a transport. + let mut client = NativeAdapterClient::new(RacingPeer { + workspace: &workspace, + boundary: 99, + competitor: Operation::List { + limit: 1, + after: None, + output_cursor: None, + }, + })?; + let response = client.call(&request(Operation::Release { + id: "race-0-0".into(), + author: "caller".into(), + }))?; + assert!( + matches!(response.outcome,AdapterOutcome::Refused {code,..} if code=="ownership_conflict") + ); + assert_eq!( + workspace.get("race-0-0")?.metadata.extra["assignee"], + "peer" + ); + Ok(()) +} + +/// Runs both CLI encodings without a tracker, Git, Node or backend execution. +#[test] +fn cli_validates_without_tracker_git_node_or_backend_execution() -> TestResult { + let directory = tempfile::tempdir()?; + let input = bytes( + &json!({"manifest":manifest(),"request":request(Operation::Get {id:"demo-1".into()})}), + ); + for json_output in [false, true] { + for valid in [false, true] { + let mut command = assert_cmd::Command::new(env!("CARGO_BIN_EXE_pm-rust")); + command + .current_dir(directory.path()) + .env("PATH", directory.path()) + .env( + "GIT_CEILING_DIRECTORIES", + directory.path().parent().ok_or("parent")?, + ) + .arg("adapter-check"); + if json_output { + command.arg("--json"); + } + let output = command + .write_stdin(if valid { input.clone() } else { b"{}".to_vec() }) + .output()?; + assert_eq!(output.status.success(), valid); + let stdout = String::from_utf8(output.stdout)?; + assert!(stdout.len() < 600); + if json_output { + let value: Value = serde_json::from_str(&stdout)?; + assert_eq!(value["ok"], valid); + } else { + assert!(stdout.contains(if valid { + "dispatched: false" + } else { + "invalid_frame" + })); + } + } + } + assert!(!directory.path().join(".agents").exists()); + Ok(()) +} + +/// Exercises every discovery refusal in real CLI processes without Git or Node. +#[test] +fn cli_workspace_commands_refuse_missing_trackers_without_git() -> TestResult { + let directory = tempfile::tempdir()?; + let cases: &[&[&str]] = &[ + &["read-session"], + &["assurance", "list", "schema"], + &["context"], + &["history", "demo-1"], + &["deps", "demo-1"], + &["search", "keyword"], + &["files", "demo-1"], + &["docs", "demo-1"], + &["test", "demo-1"], + &["list"], + &["get", "demo-1"], + &[ + "create", "--id", "demo-1", "--title", "Example", "--type", "Task", "--author", + "caller", + ], + &["update", "demo-1", "--author", "caller"], + &["comment", "demo-1", "text", "--author", "caller"], + &["claim", "demo-1"], + &["release", "demo-1"], + &["start-task", "demo-1"], + &["pause-task", "demo-1"], + &["close", "demo-1", "done", "--author", "caller"], + ]; + for args in cases { + let output = Command::new(env!("CARGO_BIN_EXE_pm-rust")) + .current_dir(directory.path()) + .args(["--workspace", "."]) + .args(*args) + .env("PATH", directory.path()) + .env( + "GIT_CEILING_DIRECTORIES", + directory.path().parent().ok_or("parent")?, + ) + .output()?; + assert!(!output.status.success(), "{args:?}"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("no pm tracker found"), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stdout.is_empty()); + } + assert!(!directory.path().join(".agents").exists()); + Ok(()) +} diff --git a/tests/strict_json_contract.rs b/tests/strict_json_contract.rs new file mode 100644 index 0000000..ad39fc6 --- /dev/null +++ b/tests/strict_json_contract.rs @@ -0,0 +1,118 @@ +//! Recursive JSON admission and real non-git tracker decode boundaries. + +use pm_rust::{HistoryOptions, ItemFilter, ListOptions, Workspace, strict_json}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::fs; + +/// Fallible assertion result. +type TestResult = Result<(), Box>; + +/// Keeps types, order, integer precision and valid repeated keys in separate objects. +#[test] +fn strict_json_preserves_values_and_rejects_invalid_documents() -> TestResult { + let raw = r#"{"z":[null,true,false,-42,18446744073709551615,1.25,"雪\n",{},[],{"a":1},{"a":2}],"a":0}"#; + let value: Value = strict_json::from_str(raw)?; + assert_eq!(value, serde_json::from_str::(raw)?); + assert_eq!( + value.as_object().ok_or("object")?.keys().next(), + Some(&"z".to_owned()) + ); + assert_eq!(value["z"][4].as_u64(), Some(u64::MAX)); + for invalid in [ + b"".as_slice(), + b"{", + b"[1,]", + b"{} {}", + b"{} trailing", + b"\xff", + br#"{"a":1,"a":2}"#, + br#"[{"a":1,"\u0061":2}]"#, + br#"{"outer":{"a":1,"a":1}}"#, + br#"{"a":1,2:3}"#, + br#"{"a":"\ud800"}"#, + b"1e9999", + ] { + assert!(strict_json::from_slice::(invalid).is_err()); + } + let too_deep = format!("{}0{}", "[".repeat(140), "]".repeat(140)); + assert!(strict_json::from_str::(&too_deep).is_err()); + assert!(strict_json::from_str::>("[1]").is_err()); + // Decoding a typed map must not allow serde's default to discard duplicates. + assert!( + strict_json::from_str::>( + r#"{"skipped":true,"skipped":false}"# + ) + .is_err() + ); + Ok(()) +} + +/// Even ignored typed fields must pass strict recursive validation first. +#[test] +fn strict_json_validates_before_typed_fields_are_ignored() -> TestResult { + /// Forward-compatible consumer that deliberately ignores unknown fields. + #[derive(Debug, Deserialize, PartialEq)] + struct Receipt { + /// Authoritative ownership result. + skipped: bool, + } + let valid = r#"{"skipped":true,"future":[{"a":1},{"a":2}]}"#; + assert_eq!( + strict_json::from_str::(valid)?, + Receipt { skipped: true } + ); + let invalid = r#"{"skipped":true,"future":[{"a":1,"\u0061":2}]}"#; + assert!(strict_json::from_str::(invalid).is_err()); + Ok(()) +} + +/// Settings, history and registry inputs reject ambiguous data without rewriting it. +#[test] +fn strict_json_tracker_decodes_refuse_duplicates_without_git() -> TestResult { + let directory = tempfile::tempdir()?; + let root = directory.path().join(".agents/pm"); + fs::create_dir_all(root.join("history"))?; + fs::create_dir_all(root.join("tasks"))?; + fs::write(root.join("settings.json"), "{}")?; + fs::write( + root.join("tasks/demo-1.toon"), + "id: demo-1\ntitle: Demo\ntype: Task\nstatus: open\npriority: 2\n", + )?; + let workspace = Workspace::discover(&root)?; + let settings = r#"{"schema":{"workflow":{"open_status":"open","open\u005fstatus":"closed"}}}"#; + fs::write(root.join("settings.json"), settings)?; + assert!( + workspace + .list_page( + &ItemFilter::default(), + &ListOptions::default(), + "2026-10-24T12:00:00Z" + ) + .is_err() + ); + assert_eq!(fs::read_to_string(root.join("settings.json"))?, settings); + fs::write(root.join("settings.json"), "{}")?; + let history = "{\"action\":\"claim\",\"changes\":[{\"skipped\":true,\"skipped\":false}]}\n"; + fs::write(root.join("history/demo-1.jsonl"), history)?; + assert!( + workspace + .history("demo-1", &HistoryOptions::default()) + .is_err() + ); + assert_eq!( + fs::read_to_string(root.join("history/demo-1.jsonl"))?, + history + ); + let registry = r#"{"measurements":[{"id":"m1","receipt":{"a":1,"\u0061":2}}]}"#; + fs::write(root.join("assurance.json"), registry)?; + assert!(workspace.assurance_declarations("measurement").is_err()); + assert_eq!(fs::read_to_string(root.join("assurance.json"))?, registry); + fs::write(root.join("assurance.json"), r#"{"measurements":[]}"#)?; + assert_eq!( + workspace.assurance_declarations("measurement")?["items"], + json!([]) + ); + assert!(!directory.path().join(".git").exists()); + Ok(()) +} diff --git a/tests/support/main_unit.rs b/tests/support/main_unit.rs index 3488275..5fc40bf 100644 --- a/tests/support/main_unit.rs +++ b/tests/support/main_unit.rs @@ -1579,3 +1579,110 @@ fn session_frame_writer_preserves_errors_and_framing() -> Result<(), Box Result<(), Box> { + use clap::Parser as _; + let directory = tempfile::tempdir()?; + let cases: &[&[&str]] = &[ + &["read-session"], + &["assurance", "list", "schema"], + &["context"], + &["history", "demo-1"], + &["deps", "demo-1"], + &["search", "keyword"], + &["files", "demo-1"], + &["docs", "demo-1"], + &["test", "demo-1"], + &["list"], + &["get", "demo-1"], + &[ + "create", "--id", "demo-1", "--title", "Example", "--type", "Task", "--author", + "caller", + ], + &["update", "demo-1", "--author", "caller"], + &["comment", "demo-1", "text", "--author", "caller"], + &["claim", "demo-1"], + &["release", "demo-1"], + &["start-task", "demo-1"], + &["pause-task", "demo-1"], + &["close", "demo-1", "done", "--author", "caller"], + ]; + for args in cases { + let mut arguments = vec![ + "pm-rust".to_owned(), + "--workspace".to_owned(), + directory.path().to_string_lossy().into_owned(), + ]; + arguments.extend(args.iter().map(ToString::to_string)); + let error = run(Cli::try_parse_from(arguments)?) + .err() + .ok_or("missing tracker must refuse")?; + assert!( + error + .downcast_ref::() + .is_some_and(|error| matches!(error, pm_rust::PmRustError::TrackerNotFound { .. })), + "{args:?}: {error}" + ); + } + assert!(!directory.path().join(".agents").exists()); + Ok(()) +} + +/// Adapter stdin and output failures are recoverable errors, including broken pipes. +#[test] +fn native_adapter_stdio_refusals() -> Result<(), Box> { + /// A caller-owned stream that fails before yielding any input bytes. + struct ReadFailure; + impl io::Read for ReadFailure { + fn read(&mut self, _: &mut [u8]) -> io::Result { + Err(io::Error::other("synthetic read failure")) + } + } + let directory = tempfile::tempdir()?; + assert!(super::run_adapter_check(&mut ReadFailure, &mut Vec::new(), true).is_err()); + for json in [false, true] { + assert!(super::run_adapter_check(&mut &b"{}"[..], &mut WriteFailure, json).is_err()); + let valid = br#"{"manifest":{"abi":"pm-native-adapter/1","capabilities":["get"],"max_frame_bytes":4096},"request":{"abi":"pm-native-adapter/1","request_id":"call-1","operation":{"action":"get","id":"demo-1"}}}"#; + assert!(super::run_adapter_check(&mut &valid[..], &mut FlushFailure, json).is_err()); + for (input, ok) in [(&b"{}"[..], false), (&valid[..], true)] { + let mut output = Vec::new(); + let result = super::run_with_io( + Cli { + workspace: directory.path().to_path_buf(), + command: Command::AdapterCheck { json }, + }, + &mut &input[..], + &mut output, + ); + assert_eq!(result.is_ok(), ok); + if json { + let value: serde_json::Value = serde_json::from_slice(&output)?; + assert_eq!(value["ok"], ok); + } else { + assert!(String::from_utf8(output)?.contains(if ok { + "ok: true" + } else { + "ok: false" + })); + } + } + let output = serde_json::json!({"ok":true}).into(); + // Fail only the trailing newline, after successful frame emission. + let mut only_frame = vec![0u8; pm_rust::stringify_json(&output, false).len()]; + if json { + assert!( + super::write_adapter_receipt_to(&mut only_frame.as_mut_slice(), &output, json) + .is_err() + ); + } + } + let cut = pm_rust::ListOutput::with_json_overrides( + serde_json::json!({"title":"cut"}), + std::collections::BTreeMap::from([("/title".to_owned(), "\"\\ud83d\"".to_owned())]), + ); + assert!(super::write_adapter_receipt_to(&mut Vec::new(), &cut, false).is_err()); + assert!(!directory.path().join(".agents").exists()); + Ok(()) +} diff --git a/tests/support/strict_json_unit.rs b/tests/support/strict_json_unit.rs new file mode 100644 index 0000000..61ee12e --- /dev/null +++ b/tests/support/strict_json_unit.rs @@ -0,0 +1,18 @@ +//! Error contract for unsupported non-JSON serde inputs. + +use super::StrictValue; +use serde::{Deserialize, de::value}; + +/// A binary serde value must produce the strict reader's actionable expectation. +#[test] +fn strict_json_rejects_non_json_serde_values() -> Result<(), Box> { + let input = value::BytesDeserializer::::new(b"opaque bytes"); + let error = StrictValue::deserialize(input) + .err() + .ok_or("must refuse bytes")?; + assert_eq!( + error.to_string(), + "invalid type: byte array, expected JSON with unique object keys" + ); + Ok(()) +}