diff --git a/.agents/pm/features/pm-github-identity-collisions.toon b/.agents/pm/features/pm-github-identity-collisions.toon new file mode 100644 index 00000000..7000c6d5 --- /dev/null +++ b/.agents/pm/features/pm-github-identity-collisions.toon @@ -0,0 +1,37 @@ +id: pm-github-identity-collisions +title: Explore provider identity collisions and approve scoped provenance repairs +description: "Add a read-only graph of conflicting external_ref, GitHub provenance tags and native ownership, with narrowly scoped repair plans." +type: Feature +status: in_progress +priority: 2 +tags[3]: changelog-unreleased,github,identity +created_at: "2026-10-22T19:22:17.645Z" +updated_at: "2026-10-22T21:00:37.645Z" +author: codex-agent +acceptance_criteria: Synthetic copied examples and genuinely multi-provider items remain distinguishable; ambiguous identities refuse apply; approved repairs are audited and idempotent. +comments[13]{created_at,author,text}: + "2026-10-22T19:23:58.055Z",codex-agent,"Overlap recheck: core SDK complete-list certification, metadata validation, similarity duplicates, semantic previews, item mutations and durable writer transactions remain authoritative. pm-github import-fidelity and refresh already reuse rightful owners or refuse multiple owners; they expose no offline collision graph or approved detach surface. pm-jira provenance/export matching and pm-linear provenance indexing retain provider semantics. New scope: bounded offline graph of exact external_ref and GitHub tag/URL claims versus copied prose; one explicitly approved tag/reference detach from an independently declared owner, refusing ambiguous source metadata. Acceptance: copied examples and genuine multi-provider ownership remain distinct; ambiguous identities refuse apply; approved repairs are audited and idempotent. Use real non-git trackers and a read-only public tracker copy; full npm/Bun release gates in CI due host load." + "2026-10-22T19:37:45.190Z",codex-agent,"Implementation reuses refresh scratch reads because complete SDK list calls produce derived runtime caches. Scratch copies now normalize only copied file permissions, supporting read-only input trackers. Production repair scope is exact GitHub tag detach only: the pinned SDK has no audited core external_ref clear operation (unset refuses it), so reference repair returns unsupported_repair with a provider-workflow remedy instead of bypassing core mutation rules. Added native source ownership evidence and single-item contradictory identity diagnosis. Plans use real SDK previews, approval digests and locked rechecks; native comment/history audit binds the exact scope and SDK replay handles interrupted mutation recovery." + "2026-10-22T19:38:38.240Z",codex-agent,"Read-only public corpus validation at main 394129e3a8a607bfc13dd8acc8adfe4704165da2: 296 archived files, 145 all-status items, 9 exact identities, 0 conflicting groups, 3 prose mentions, 3 returned nodes/edges, no continuation. Compact JSON 642 bytes; TOON 551 bytes. All file bytes and inventory unchanged; source files 0444, directories 0555, non-git scratch tracker, PATH without git. Fingerprint and limits recorded in docs/identity-collisions-validation.md. Zero real-corpus collisions is zero-case evidence; synthetic real trackers provide nonempty collision and repair cases." + "2026-10-22T19:46:20.850Z",codex-agent,"Behavioural-revert proof: temporarily removed only plan() ownership refusal guard, then ran node --test --test-reporter=tap --test-name-pattern='source-owned duplicates require manual review \\(github_url\\)' test/identity.test.ts. Exactly 1 test failed (Missing expected rejection); restoring the guard restores the tested refusal. The PM-linked suite passed 14/14 with zero skips; focused V8 coverage measured identity.ts at exact 100/100/100/100. Added final symlink-isolation regression and rerunning the linked suite before commit. Typecheck, lint and docstrings are targeted local checks; unchanged full npm/Bun release gates will run in CI." + "2026-10-22T19:47:02.010Z",codex-agent,"Self-review added SDK updated_at optimistic matching and durable recovery from the exact encoded approved plan in native audit comments. Verified interrupted step_applied recovery, replay scope mismatches, malformed audit hints, reintroduced claims, locked stale-plan refusal, native source_owner protection, copied examples, structured multi-provider links and contradictory identities. Scratch-copy permission normalization now rejects symbolic roots/entries before following them, preventing writes through links. No core SDK pins or gate thresholds changed." + "2026-10-22T19:50:43.577Z",codex-agent,"Final targeted acceptance passed 15/15, zero failed/skipped/canceled/TODO, through pm test --run --progress --timeout 600. Focused coverage: identity.ts exact 100/100/100/100; the shared reader changes are covered by the identity suite while full refresh coverage remains the CI gate responsibility. Local typecheck, test typecheck, lint, docstring and zero-duplication gates passed. Privacy audited 6336 objects with approved identities and no secret/host-path matches. Local strict health reports a host SDK version-skew warning (pinned 2026.10.17 versus another active 2026.10.22 host); tracker integrity and history checks pass. CI runs in a clean environment and remains the required full-gate evidence." + "2026-10-22T19:53:54.063Z",codex-agent,"PR opened ready for review: https://github.com/unbraind/pm-github/pull/167 at 5831e84e3ee24d4691221cb2e613c33bfb61875e. Full unchanged npm and Bun release matrices are running at https://github.com/unbraind/pm-github/actions/runs/39843801687 (Node 22 and 26). Isolated strict health passed with clean HOME and git identity configuration; the local host version-skew warning is environmental. Final public read-only validation reproduced the recorded measurements byte-for-byte after all reader changes. No merge, release or publication is authorized or performed." + "2026-10-22T20:00:52.992Z",codex-agent,"Current CLI 2026.10.22 overlap verification used its complete unbounded contracts --full catalog: 176 commands and no omission receipt findings; related core surfaces are duplicates/item duplicates, with no provider identity explorer or approved detach command. Follow-up focused coverage inspection found three CLI default-option branches absent from the harness receipt. Added one default graph invocation; its targeted registered-command regression passed 1/1 and now covers every new handler statement/branch. Production source is unchanged. Full npm/Bun CI remains the release-gate authority; the corrected test is included in the next head." + "2026-10-22T20:02:14.116Z",codex-agent,"Implementation and targeted validation are complete: production source commit 5831e84 and default-handler regression/evidence commit 4e83f3a. PM-linked acceptance passed 15/15; the additional default-handler check passed 1/1, all new handler statements/branches are covered, and identity.ts remains exact 100/100/100/100. Typecheck, test typecheck, lint, docstrings, zero duplication, privacy, changelog and isolated strict health passed. The superseded CI run 39843801687 was canceled after the test correction; it is not counted as release-gate evidence. The final head must pass the unchanged npm/Bun matrix. Its exact run URL and measured counts will be recorded on the linked PR https://github.com/unbraind/pm-github/pull/167. Releasing this implementation claim for orchestrator verification; keep this item in progress and do not close it. Remaining limitation: external_ref mutation refuses unsupported_repair through the pinned SDK, with an owning-provider remedy." + "2026-10-22T20:50:23.324Z",codex-agent,"Review follow-up on PR 167: remote head is current and contains origin/main. Fixing three blocking defects: forged recovery approval, retained comma tag corruption, and conflicting owner issue numbers. Each receives a focused regression and behavioral revert proof; rerun npm and Bun release gates on the pushed head." + "2026-10-22T20:55:23.954Z",codex-agent,"Implemented review fixes using SDK removeTags, approval-digest reconstruction with exact retained-tag checks, a required SDK recovery journal, and owner github_number consistency. Behavioral revert proofs: reverting only applyGithubIdentityRepair makes the nonexistent-owner forged-audit regression fail (0 pass, 1 fail, Missing expected rejection); reverting only transaction makes the comma-tag regression fail (0 pass, 1 fail, keep,me becomes keep plus me); reverting only plan makes the conflicting-owner-number regression fail (0 pass, 1 fail, Missing expected rejection). All functions restored. Added no-write assertions, malformed before/after validation, altered-plan/retained-tag recovery checks, and missing-journal refusal coverage." + "2026-10-22T20:59:44.669Z",codex-agent,"Exact revert-proof commands (each exited 1 with 0 pass and 1 fail, only named production function reverted): node --test --test-reporter=tap --test-name-pattern='forged audit plans with nonexistent owners' test/identity.test.ts [applyGithubIdentityRepair]; node --test --test-reporter=tap --test-name-pattern='approved tag detach preserves a stored comma tag' test/identity.test.ts [transaction]; node --test --test-reporter=tap --test-name-pattern='an owner with a conflicting github_number' test/identity.test.ts [plan]. Real-data validation rerun unchanged: 296 read-only files, 145 items, 9 identities, 0 collisions, 3 mentions, JSON 642 bytes, TOON 551 bytes, no source writes, non-git tracker and no git executable. Current npm CLI is 2026.10.22; the package SDK pin remains unchanged." + "2026-10-22T21:00:30.880Z",codex-agent,"Review fixes validated: node --test test/identity.test.ts through pm test pm-github-identity-collisions --run --progress --timeout 600 passed 20/20 with zero failed/skipped/canceled/TODO. Focused identity.ts coverage is exact 100/100/100/100. Local build, typecheck, test typecheck, lint, docstring, zero-duplication, privacy and isolated strict tracker health passed. The three function-only revert proofs are recorded above and all fixes are restored. Full unchanged npm and Bun release gates on Node 22 and 26 will run in CI on the final pushed head because the shared host is saturated. Final exact-head run URL and measured counts will be recorded on PR 167: https://github.com/unbraind/pm-github/pull/167. Releasing the implementation claim for orchestrator verification; leave the item in progress. Remaining supported-scope limit: core external_ref clearing refuses unsupported_repair through the pinned SDK." +files[4]{path,scope}: + identity.ts,project + index.ts,project + refresh.ts,project + test/identity.test.ts,project +tests[1]{command,scope,provenance{author,created_at,source_kind,source_ref}}: + node --test test/identity.test.ts,project,codex-agent,"2026-10-22T19:32:07.401Z",local_mutation,feat/identity-collisions +docs[3]{path,scope}: + docs/identity-collisions-validation.md,project + docs/identity-collisions.md,project + README.md,project +body: "" diff --git a/.agents/pm/history/pm-github-identity-collisions.jsonl b/.agents/pm/history/pm-github-identity-collisions.jsonl new file mode 100644 index 00000000..4edc730f --- /dev/null +++ b/.agents/pm/history/pm-github-identity-collisions.jsonl @@ -0,0 +1,23 @@ +{"hash_algorithm":"sha256","ts":"2026-10-22T19:22:17.645Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-github-identity-collisions"},{"op":"add","path":"/metadata/title","value":"Explore provider identity collisions and approve scoped provenance repairs"},{"op":"add","path":"/metadata/description","value":"Add a read-only graph of conflicting external_ref, GitHub provenance tags and native ownership, with narrowly scoped repair plans."},{"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":["changelog-unreleased","github","identity"]},{"op":"add","path":"/metadata/created_at","value":"2026-10-22T19:22:17.645Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-10-22T19:22:17.645Z"},{"op":"add","path":"/metadata/author","value":"codex-agent"},{"op":"add","path":"/metadata/acceptance_criteria","value":"Synthetic copied examples and genuinely multi-provider items remain distinguishable; ambiguous identities refuse apply; approved repairs are audited and idempotent."}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"a35fd880925f029fdce5e651781f1ec4ae8f5d3eff3598d81c9b6d6e0a3c57e5","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":"84e6020666018c3fa40ead9135fb4aa061d80b099161fe1545d6898934a4837d"} +{"hash_algorithm":"sha256","ts":"2026-10-22T19:23:00.723Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T19:23:00.723Z"},{"op":"add","path":"/metadata/assignee","value":"codex-agent"},{"op":"add","path":"/metadata/claim_principal","value":"codex-agent"}],"before_hash":"a35fd880925f029fdce5e651781f1ec4ae8f5d3eff3598d81c9b6d6e0a3c57e5","after_hash":"c6ea9a88830bd456d0fc4dbc22390054a30df6408df359e23d50a5f2ae3ed122","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":"6e59758c473cbea0f342e7c2f94c2b68a922e34c212acd317426fd94311857c5"} +{"hash_algorithm":"sha256","ts":"2026-10-22T19:23:33.610Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T19:23:33.610Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"c6ea9a88830bd456d0fc4dbc22390054a30df6408df359e23d50a5f2ae3ed122","after_hash":"ab371448e2e0972b9ea09536b5456e79cf0c75e8cc6b28ef68d9ba8009a420c5","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":"f162d620bc34a9b11000b4253d9ae64861e6b12c3d9da6665c27a2416c90ca9f"} +{"hash_algorithm":"sha256","ts":"2026-10-22T19:23:58.122Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T19:23:58.122Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-10-22T19:23:58.055Z","author":"codex-agent","text":"Overlap recheck: core SDK complete-list certification, metadata validation, similarity duplicates, semantic previews, item mutations and durable writer transactions remain authoritative. pm-github import-fidelity and refresh already reuse rightful owners or refuse multiple owners; they expose no offline collision graph or approved detach surface. pm-jira provenance/export matching and pm-linear provenance indexing retain provider semantics. New scope: bounded offline graph of exact external_ref and GitHub tag/URL claims versus copied prose; one explicitly approved tag/reference detach from an independently declared owner, refusing ambiguous source metadata. Acceptance: copied examples and genuine multi-provider ownership remain distinct; ambiguous identities refuse apply; approved repairs are audited and idempotent. Use real non-git trackers and a read-only public tracker copy; full npm/Bun release gates in CI due host load."}]}],"before_hash":"ab371448e2e0972b9ea09536b5456e79cf0c75e8cc6b28ef68d9ba8009a420c5","after_hash":"47f328dd8edd57e370b48d38e4640a69b8d4b7c677e3e52be89e7b3a2afc9f01","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":"a5d7d8f0466461ffb1640151f90c8aab34914b07868e5ef36d3a4f2698c95c29"} +{"hash_algorithm":"sha256","ts":"2026-10-22T19:31:08.601Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T19:31:08.601Z"},{"op":"add","path":"/metadata/files","value":[{"path":"identity.ts","scope":"project"},{"path":"index.ts","scope":"project"},{"path":"refresh.ts","scope":"project"},{"path":"test/identity.test.ts","scope":"project"}]}],"before_hash":"47f328dd8edd57e370b48d38e4640a69b8d4b7c677e3e52be89e7b3a2afc9f01","after_hash":"bf93d866f698e4497f2d24886674b256623462d53c8cd0695ffbb10f65405a3f","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":"0f23c1064a893162ea079fad220af2d926407bdae8e5ce0cfc0c1dc595f30070"} +{"hash_algorithm":"sha256","ts":"2026-10-22T19:31:36.931Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T19:31:36.931Z"},{"op":"add","path":"/metadata/docs","value":[{"path":"docs/identity-collisions.md","scope":"project"},{"path":"README.md","scope":"project"}]}],"before_hash":"bf93d866f698e4497f2d24886674b256623462d53c8cd0695ffbb10f65405a3f","after_hash":"c990dae1edf2d1ed13479dd60d1b1b9ee3823cfc72ceed9a957e5c31eb86ccff","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":"131f4170ea022053ad25514a266b7dbcc80f7fdcb84329117c3f9a3c46639c52"} +{"hash_algorithm":"sha256","ts":"2026-10-22T19:32:08.709Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T19:32:08.709Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"node --test test/identity.test.ts","scope":"project","provenance":{"author":"codex-agent","created_at":"2026-10-22T19:32:07.401Z","source_kind":"local_mutation","source_ref":"feat/identity-collisions"}}]}],"before_hash":"c990dae1edf2d1ed13479dd60d1b1b9ee3823cfc72ceed9a957e5c31eb86ccff","after_hash":"efb45459ef08800fea0fed1cf000cd369f82b71b110a2e729304c8f85f0d1e69","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":"b68a2b083d6c6c6436cfc2e34de64df9383d8737d3fa871a5513a47aaf02eec3"} +{"hash_algorithm":"sha256","ts":"2026-10-22T19:37:45.197Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T19:37:45.190Z","author":"codex-agent","text":"Implementation reuses refresh scratch reads because complete SDK list calls produce derived runtime caches. Scratch copies now normalize only copied file permissions, supporting read-only input trackers. Production repair scope is exact GitHub tag detach only: the pinned SDK has no audited core external_ref clear operation (unset refuses it), so reference repair returns unsupported_repair with a provider-workflow remedy instead of bypassing core mutation rules. Added native source ownership evidence and single-item contradictory identity diagnosis. Plans use real SDK previews, approval digests and locked rechecks; native comment/history audit binds the exact scope and SDK replay handles interrupted mutation recovery."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T19:37:45.197Z"}],"before_hash":"efb45459ef08800fea0fed1cf000cd369f82b71b110a2e729304c8f85f0d1e69","after_hash":"e534911cb9e8df53c5a2492d739741ba1f9c548b1e3c0540a31d3c7434c4adc8","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":"eeaa5f72a6ab14acff2b13d23a8e0a4f0c204ea6bbf404cf41cef2e47f7da512"} +{"hash_algorithm":"sha256","ts":"2026-10-22T19:38:18.457Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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/1/path","value":"docs/identity-collisions.md"},{"op":"replace","path":"/metadata/docs/0/path","value":"docs/identity-collisions-validation.md"},{"op":"add","path":"/metadata/docs/2","value":{"path":"README.md","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T19:38:18.457Z"}],"before_hash":"e534911cb9e8df53c5a2492d739741ba1f9c548b1e3c0540a31d3c7434c4adc8","after_hash":"29bf0929a0338f3c5eaf66061d6b6753edbad34c2f170d32e2280589b0fb9d90","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":"f1fb4f7458936f536b171bcdeaffa96d44000526119de9a9073f3c1adef43100"} +{"hash_algorithm":"sha256","ts":"2026-10-22T19:38:38.244Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T19:38:38.240Z","author":"codex-agent","text":"Read-only public corpus validation at main 394129e3a8a607bfc13dd8acc8adfe4704165da2: 296 archived files, 145 all-status items, 9 exact identities, 0 conflicting groups, 3 prose mentions, 3 returned nodes/edges, no continuation. Compact JSON 642 bytes; TOON 551 bytes. All file bytes and inventory unchanged; source files 0444, directories 0555, non-git scratch tracker, PATH without git. Fingerprint and limits recorded in docs/identity-collisions-validation.md. Zero real-corpus collisions is zero-case evidence; synthetic real trackers provide nonempty collision and repair cases."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T19:38:38.244Z"}],"before_hash":"29bf0929a0338f3c5eaf66061d6b6753edbad34c2f170d32e2280589b0fb9d90","after_hash":"0be3769aceae334c5a3113a8e82bd76e32c32b65159013759a4126cbe43482f0","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":"eecd8a662553b4c563e625fb8c9f1f55a868aebb832aabb71af323a7b85564fa"} +{"hash_algorithm":"sha256","ts":"2026-10-22T19:46:20.851Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T19:46:20.850Z","author":"codex-agent","text":"Behavioural-revert proof: temporarily removed only plan() ownership refusal guard, then ran node --test --test-reporter=tap --test-name-pattern='source-owned duplicates require manual review \\(github_url\\)' test/identity.test.ts. Exactly 1 test failed (Missing expected rejection); restoring the guard restores the tested refusal. The PM-linked suite passed 14/14 with zero skips; focused V8 coverage measured identity.ts at exact 100/100/100/100. Added final symlink-isolation regression and rerunning the linked suite before commit. Typecheck, lint and docstrings are targeted local checks; unchanged full npm/Bun release gates will run in CI."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T19:46:20.851Z"}],"before_hash":"0be3769aceae334c5a3113a8e82bd76e32c32b65159013759a4126cbe43482f0","after_hash":"bb5d25dcff1645ced269573cc5366280410a2ec8a0eeac71b1bc825104d97845","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":"f7a224e14621fb0c066c5d9eb90c4682f05411518583766474b9e7e656fec9fd"} +{"hash_algorithm":"sha256","ts":"2026-10-22T19:47:02.010Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T19:47:02.010Z","author":"codex-agent","text":"Self-review added SDK updated_at optimistic matching and durable recovery from the exact encoded approved plan in native audit comments. Verified interrupted step_applied recovery, replay scope mismatches, malformed audit hints, reintroduced claims, locked stale-plan refusal, native source_owner protection, copied examples, structured multi-provider links and contradictory identities. Scratch-copy permission normalization now rejects symbolic roots/entries before following them, preventing writes through links. No core SDK pins or gate thresholds changed."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T19:47:02.010Z"}],"before_hash":"bb5d25dcff1645ced269573cc5366280410a2ec8a0eeac71b1bc825104d97845","after_hash":"0312cf22683dfe8ee0c31a0e0531b648bb9f1436fad8c0f732ad57fc7e762bc6","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":"d73690eb4fcdf3ff419605755caf3d5782271cf8817bd005824dcb1d110647eb"} +{"hash_algorithm":"sha256","ts":"2026-10-22T19:50:43.578Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T19:50:43.577Z","author":"codex-agent","text":"Final targeted acceptance passed 15/15, zero failed/skipped/canceled/TODO, through pm test --run --progress --timeout 600. Focused coverage: identity.ts exact 100/100/100/100; the shared reader changes are covered by the identity suite while full refresh coverage remains the CI gate responsibility. Local typecheck, test typecheck, lint, docstring and zero-duplication gates passed. Privacy audited 6336 objects with approved identities and no secret/host-path matches. Local strict health reports a host SDK version-skew warning (pinned 2026.10.17 versus another active 2026.10.22 host); tracker integrity and history checks pass. CI runs in a clean environment and remains the required full-gate evidence."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T19:50:43.578Z"}],"before_hash":"0312cf22683dfe8ee0c31a0e0531b648bb9f1436fad8c0f732ad57fc7e762bc6","after_hash":"eb165dd204e4a1b8f036d0982b4cf613d53cb2d5b194183532eee439ea513ab0","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":"aca914af8e6a5371fdc4d19aaef686af00ac4855815d10097106cf0c6277f8ad"} +{"hash_algorithm":"sha256","ts":"2026-10-22T19:53:54.076Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T19:53:54.063Z","author":"codex-agent","text":"PR opened ready for review: https://github.com/unbraind/pm-github/pull/167 at 5831e84e3ee24d4691221cb2e613c33bfb61875e. Full unchanged npm and Bun release matrices are running at https://github.com/unbraind/pm-github/actions/runs/39843801687 (Node 22 and 26). Isolated strict health passed with clean HOME and git identity configuration; the local host version-skew warning is environmental. Final public read-only validation reproduced the recorded measurements byte-for-byte after all reader changes. No merge, release or publication is authorized or performed."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T19:53:54.076Z"}],"before_hash":"eb165dd204e4a1b8f036d0982b4cf613d53cb2d5b194183532eee439ea513ab0","after_hash":"b37d27d9e88465b53c8029c2ca5153c412f5d5652c235e22c3a733679a50198e","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":"6be210907080e06f2048701fca0b14cda81fc934b490f77dc26702a77216ed90"} +{"hash_algorithm":"sha256","ts":"2026-10-22T20:00:52.992Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T20:00:52.992Z","author":"codex-agent","text":"Current CLI 2026.10.22 overlap verification used its complete unbounded contracts --full catalog: 176 commands and no omission receipt findings; related core surfaces are duplicates/item duplicates, with no provider identity explorer or approved detach command. Follow-up focused coverage inspection found three CLI default-option branches absent from the harness receipt. Added one default graph invocation; its targeted registered-command regression passed 1/1 and now covers every new handler statement/branch. Production source is unchanged. Full npm/Bun CI remains the release-gate authority; the corrected test is included in the next head."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T20:00:52.992Z"}],"before_hash":"b37d27d9e88465b53c8029c2ca5153c412f5d5652c235e22c3a733679a50198e","after_hash":"e4abc1e9c2976c3cb1a6b6586f1f8c17572efab619cabe66ce96060498328a2e","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":"87ec068e2318078a888de7e8dc7a2bd8872fbd0a90327f25bbd61fb48da6a499"} +{"hash_algorithm":"sha256","ts":"2026-10-22T20:02:14.116Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T20:02:14.116Z","author":"codex-agent","text":"Implementation and targeted validation are complete: production source commit 5831e84 and default-handler regression/evidence commit 4e83f3a. PM-linked acceptance passed 15/15; the additional default-handler check passed 1/1, all new handler statements/branches are covered, and identity.ts remains exact 100/100/100/100. Typecheck, test typecheck, lint, docstrings, zero duplication, privacy, changelog and isolated strict health passed. The superseded CI run 39843801687 was canceled after the test correction; it is not counted as release-gate evidence. The final head must pass the unchanged npm/Bun matrix. Its exact run URL and measured counts will be recorded on the linked PR https://github.com/unbraind/pm-github/pull/167. Releasing this implementation claim for orchestrator verification; keep this item in progress and do not close it. Remaining limitation: external_ref mutation refuses unsupported_repair through the pinned SDK, with an owning-provider remedy."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T20:02:14.116Z"}],"before_hash":"e4abc1e9c2976c3cb1a6b6586f1f8c17572efab619cabe66ce96060498328a2e","after_hash":"eff1dc2b7d1e18112a0493c506b1b86433efd8bd6603aae2237a3c5cad120545","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":"7a6d5d3e605870ce46800e1bdd5916de68086f73867edd1246b83bf28d2fe882"} +{"hash_algorithm":"sha256","ts":"2026-10-22T20:02:15.692Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T20:02:15.692Z"}],"before_hash":"eff1dc2b7d1e18112a0493c506b1b86433efd8bd6603aae2237a3c5cad120545","after_hash":"b08b003d6ad790db7368271ae95f4f34c870aff286c2fef086ba5c3f1d3ea404","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":"b660e411346b46504f02f89be972d4c7aff3905448edf908b3ca72652f52b0bc"} +{"hash_algorithm":"sha256","ts":"2026-10-22T20:50:09.627Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T20:50:09.627Z"},{"op":"add","path":"/metadata/assignee","value":"codex-agent"},{"op":"add","path":"/metadata/claim_principal","value":"codex-agent"}],"before_hash":"b08b003d6ad790db7368271ae95f4f34c870aff286c2fef086ba5c3f1d3ea404","after_hash":"6817ba45baf5ce98a0a943f7e61bd681e7145b5752b6d4a3d404d1949695e5de","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":"2e16baf37824f48d790b3899d1958f28c3f86c61d2ad30324e7ec6126ed06799"} +{"hash_algorithm":"sha256","ts":"2026-10-22T20:50:23.330Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T20:50:23.324Z","author":"codex-agent","text":"Review follow-up on PR 167: remote head is current and contains origin/main. Fixing three blocking defects: forged recovery approval, retained comma tag corruption, and conflicting owner issue numbers. Each receives a focused regression and behavioral revert proof; rerun npm and Bun release gates on the pushed head."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T20:50:23.330Z"}],"before_hash":"6817ba45baf5ce98a0a943f7e61bd681e7145b5752b6d4a3d404d1949695e5de","after_hash":"6488ce34c9f524b06e5d20c47413c9e52f6b5f8168bacdf339e6b0b94c2aca0e","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":"309e148ec68cf6297d062b8d5734952084bfd8de58448b1c2cb6a1c3fcf2b42b"} +{"hash_algorithm":"sha256","ts":"2026-10-22T20:55:23.954Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T20:55:23.954Z","author":"codex-agent","text":"Implemented review fixes using SDK removeTags, approval-digest reconstruction with exact retained-tag checks, a required SDK recovery journal, and owner github_number consistency. Behavioral revert proofs: reverting only applyGithubIdentityRepair makes the nonexistent-owner forged-audit regression fail (0 pass, 1 fail, Missing expected rejection); reverting only transaction makes the comma-tag regression fail (0 pass, 1 fail, keep,me becomes keep plus me); reverting only plan makes the conflicting-owner-number regression fail (0 pass, 1 fail, Missing expected rejection). All functions restored. Added no-write assertions, malformed before/after validation, altered-plan/retained-tag recovery checks, and missing-journal refusal coverage."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T20:55:23.954Z"}],"before_hash":"6488ce34c9f524b06e5d20c47413c9e52f6b5f8168bacdf339e6b0b94c2aca0e","after_hash":"f90f38fdf0f89b0ed5698a1a3f242f8599ff2513170f9646a721995f3f06f09d","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":"67d8f506f13316a08220c9f058cda4c97c7762c88db18614053a149c2a3aa8ee"} +{"hash_algorithm":"sha256","ts":"2026-10-22T20:59:44.669Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T20:59:44.669Z","author":"codex-agent","text":"Exact revert-proof commands (each exited 1 with 0 pass and 1 fail, only named production function reverted): node --test --test-reporter=tap --test-name-pattern='forged audit plans with nonexistent owners' test/identity.test.ts [applyGithubIdentityRepair]; node --test --test-reporter=tap --test-name-pattern='approved tag detach preserves a stored comma tag' test/identity.test.ts [transaction]; node --test --test-reporter=tap --test-name-pattern='an owner with a conflicting github_number' test/identity.test.ts [plan]. Real-data validation rerun unchanged: 296 read-only files, 145 items, 9 identities, 0 collisions, 3 mentions, JSON 642 bytes, TOON 551 bytes, no source writes, non-git tracker and no git executable. Current npm CLI is 2026.10.22; the package SDK pin remains unchanged."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T20:59:44.669Z"}],"before_hash":"f90f38fdf0f89b0ed5698a1a3f242f8599ff2513170f9646a721995f3f06f09d","after_hash":"92c926a9da5d20e965f7c7ee73c44f1225d73b1272e2f2a1e96e510f6dd8dd78","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":"6effaf428ae3d9efe2feaa70c0adaa0f66c3ea2d4c7f43506f66faa2921eb6d2"} +{"hash_algorithm":"sha256","ts":"2026-10-22T21:00:30.882Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T21:00:30.880Z","author":"codex-agent","text":"Review fixes validated: node --test test/identity.test.ts through pm test pm-github-identity-collisions --run --progress --timeout 600 passed 20/20 with zero failed/skipped/canceled/TODO. Focused identity.ts coverage is exact 100/100/100/100. Local build, typecheck, test typecheck, lint, docstring, zero-duplication, privacy and isolated strict tracker health passed. The three function-only revert proofs are recorded above and all fixes are restored. Full unchanged npm and Bun release gates on Node 22 and 26 will run in CI on the final pushed head because the shared host is saturated. Final exact-head run URL and measured counts will be recorded on PR 167: https://github.com/unbraind/pm-github/pull/167. Releasing the implementation claim for orchestrator verification; leave the item in progress. Remaining supported-scope limit: core external_ref clearing refuses unsupported_repair through the pinned SDK."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T21:00:30.882Z"}],"before_hash":"92c926a9da5d20e965f7c7ee73c44f1225d73b1272e2f2a1e96e510f6dd8dd78","after_hash":"5ef12a2169bdd3fad0ce08f118596797d8fc718e9865fd56bb9cb6fe02eab6be","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":"d76d20b2410ce93e6371c39753f02fc6aeb3816e562a34ce01a765bc459d89a6"} +{"hash_algorithm":"sha256","ts":"2026-10-22T21:00:37.645Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"3618d4cc6afb4a8cfd8bfe5d","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-22T21:00:37.645Z"}],"before_hash":"5ef12a2169bdd3fad0ce08f118596797d8fc718e9865fd56bb9cb6fe02eab6be","after_hash":"f65c1e0764750527224534da1e813ffd2f8fdbef6e4bee0abb8d35073f089d66","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":"1ef6cc58548ef48438f2f990c5ef9647f6053a694c495d772a0f37c8ca6d8991"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 227ffe08..3f7ffbe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Explore provider identity collisions and approve scoped provenance repairs ([pm-github-identity-collisions](https://github.com/unbraind/pm-github/blob/main/.agents/pm/features/pm-github-identity-collisions.toon)) - Reconcile authenticated GitHub webhooks through a durable deduplicating inbox ([pm-github-webhook-inbox](https://github.com/unbraind/pm-github/blob/main/.agents/pm/features/pm-github-webhook-inbox.toon)) - Let reusable workflows carry their own release version ([pm-github-znig](https://github.com/unbraind/pm-github/blob/main/.agents/pm/features/pm-github-znig.toon)) - Preserve imported GitHub lifetimes, closure evidence and release attribution while linking cross-provider items ([pm-github-c093](https://github.com/unbraind/pm-github/blob/main/.agents/pm/features/pm-github-c093.toon)) diff --git a/README.md b/README.md index ff80c1db..0659e698 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,8 @@ retention and supported-event details. With an explicit repository, GitHub import, export, refresh, sync, project commands, blocker checks, closeout and webhook receive/reconcile work in a plain pm tracker without a git executable. -They still require their normal GitHub access and credentials. Privacy scanning +`pm github identity` also works there, entirely offline and without credentials. +The provider commands still require their normal GitHub access and credentials. Privacy scanning with `github gate --diff ` also works there. Reading a proposed tracker diff with `github gate` requires a git repository; otherwise pass an explicit diff. Gated imports (`github import --gate`, including the scheduled sync workflow) @@ -918,3 +919,17 @@ Before creating an item, import checks the complete tracker for its provenance t With `--gate` and a nonempty filtered issue list, both preview and apply results include a `plan` with source dates, closure reason, release, target identity, link/create/update action and state-conflict decision. An empty filtered issue list returns without a `plan`, including with `--gate --dry-run`. GitHub wins lifecycle conflicts for GitHub-owned imports. A canceled item that GitHub later reports as completed uses the SDK's explicit repeat-closure transition with the source reason. A linked external owner keeps its local status, and the plan records `preserve-provider-owner`; `github_state` still records the source state. Atomic transaction identity includes the latest external edit or different lifecycle generation in history. Local state edits and repeated state cycles force reconciliation, while staging and compensation keep crash retries on the same journal. Finalization runs before the SDK commits and is compensated with the batch on failure. It is idempotent and runs again after a recovered atomic journal, so an interrupted finalization can be repaired by retrying the same import before the gate permits a push. SDK limitation: native `updated_at` remains the local mutation clock because the bulk SDK uses it to address rollback history. The GitHub update date is retained exactly in `github_updated_at`, and the gated plan discloses this policy. Backdating native `updated_at` safely requires an SDK import mutation whose version identity is independent of source time. Native creation, closure and completion dates still preserve GitHub lifetimes for momentum and cycle-time readers. + +## Provider identity collisions + +Use `pm github identity --json` for a bounded offline graph of conflicting +`external_ref`, GitHub provenance tags and native/provider ownership. Copied +prose appears as a weaker mention; genuine provider links retain distinct +ownership evidence. Inspect one group with +`pm github identity --identity 'gh:example/project#7'`. + +Preview a narrowly scoped repair with `--identity`, `--owner` and `--detach`; +apply only after reviewing its exact `--approve` digest. Ambiguous identities +refuse with typed remedies. The SDK supplies complete reads, semantic preview, +writer transactions, immutable audit history and idempotent replay. See the +[worked example and SDK contracts](docs/identity-collisions.md). diff --git a/docs/identity-collisions-validation.md b/docs/identity-collisions-validation.md new file mode 100644 index 00000000..fe450d4e --- /dev/null +++ b/docs/identity-collisions-validation.md @@ -0,0 +1,35 @@ +# Public tracker validation + +Source: public `unbraind/pm-github` main commit +`394129e3a8a607bfc13dd8acc8adfe4704165da2`, archived with +`git archive .agents/pm` into a disposable non-git directory. No +working tracker or remote provider state was changed. All archived files were +made read-only (0444) and directories 0555. The explorer ran with a PATH +containing no git executable and a git ceiling at the temporary directory's +parent. The source fingerprint covers sorted relative paths and SHA-256 file +hashes, including history. + +| Measurement | Result | +| --- | ---: | +| Tracker files verified before/after | 296 | +| Items scanned, all statuses | 145 | +| Distinct exact identities | 9 | +| Conflicting identity groups | 0 | +| Copied/prose mention edges | 3 | +| Selected / returned groups | 3 / 3 | +| Returned nodes / edges | 3 / 3 | +| Continuation | none | +| Compact JSON bytes | 642 | +| Compact TOON bytes | 551 | +| Files changed or added | 0 | + +Source fingerprint: +`bc5c275eaecbbce251097ac1fca1602f7cc2b0a2520f33ed137ea7cbc2491638`. + +This is real-corpus read-only and no-git evidence, including nonempty prose +mention diagnosis. It is zero-case collision evidence. Nonempty collisions, +structured provider ownership, contradictory identities, approved repairs, +refusals, stale plans, interrupted SDK transactions and byte-identical replay +are validated separately by `node --test test/identity.test.ts` against real +SDK-initialized synthetic trackers. Provider data and metadata mutation rules +remain owned by core and the relevant adapters. diff --git a/docs/identity-collisions.md b/docs/identity-collisions.md new file mode 100644 index 00000000..39d941ce --- /dev/null +++ b/docs/identity-collisions.md @@ -0,0 +1,120 @@ +# Provider identity collision explorer + +`pm github identity` is an offline, read-only graph of exact external references, +GitHub provenance tags and declared GitHub ownership. It needs an initialized +tracker, with no git checkout or provider credentials. The SDK exports the same +operations from `pm-github`: `exploreGithubIdentities`, +`planGithubIdentityRepair` and `applyGithubIdentityRepair`, with typed report, +plan and refusal-code contracts. + +```sh +pm github identity --json +pm github identity --limit 10 --offset 0 +pm github identity --identity 'gh:example/project#7' --json +``` + +The default page contains collisions and copied prose mentions. `scanned`, +`identities`, `collisions` and `mentions` count the complete all-status corpus; +`total` and `next` describe the selected identity groups. Pass an exact +`--identity` to inspect an otherwise unremarkable link. All edges of each +selected group are returned together; a large group can exceed the usual page +size. The host's normal output budgets and omission receipts still apply. Use +JSON for tooling and the default compact TOON for agents. Titles, body text, +credentials and provider payloads are absent from the report. + +Contradictory GitHub claims on one item also count as collisions. The node +includes the tracker's explicit `source_owner` as `native_owner` when present. + +A claim comes from `external_ref`, an exact `gh:owner/repo#N` tag, `github_url`, +or a leading `GitHub issue URL` declaration. GitHub URL case and issue/pull URL +forms normalize to one identity. Other external references remain opaque and +case-sensitive: this command does not reinterpret Jira or Linear identifiers. +URLs elsewhere in prose are `mention` edges and never establish ownership. +A copied example containing a Linear label and an issue URL remains a local +item with a mention. Structured Linear/Jira/ADO id/key/URL fields record real +provider evidence. A provider item with `github_link_only: true` retains +provider ownership; a provider item also claiming native GitHub ownership +without that marker is `mixed` and requires review. These conservative labels +are diagnostic evidence, not a provider migration or a duplicate-close policy. + +## Review and apply one repair + +Suppose `example-owner` declares `github_url` for issue 7, while +`example-copy` accidentally carries only its GitHub tag: + +```sh +pm github identity --identity 'gh:example/project#7' \ + --owner example-owner --detach example-copy --json +# Review changes and copy the full 64-character approval digest from the result. +pm github identity --identity 'gh:example/project#7' \ + --owner example-owner --detach example-copy --approve DIGEST --json +``` + +Preview validates the mutation through the SDK in a disposable tracker. Apply +removes only the matching GitHub tag on the +one selected duplicate through the SDK tag-removal operation. Retained stored tags, +including literal commas, are preserved byte-identical. It preserves provider identities, content, +status, timestamps and release, except the SDK's normal mutation clock. It +never edits GitHub or closes/deletes an item. A unique owner must independently +declare the matching URL and have a consistent `github_number` when present; +tags alone cannot establish the rightful owner. +A duplicate with GitHub source metadata or explicit native source ownership, multiple independent owners, another +conflicting claimant or mixed owner evidence refuses apply. Resolve provider +ownership manually before asking for another plan. + +Approval binds the exact request, graph and item revisions. Any intervening +edit requires a fresh preview. Apply checks again under the SDK workspace +writer lock. The SDK creates durable transaction receipts and append-only item +history attributed to the supplied author (`PM_AUTHOR` for CLI use). Repeating +a completed approval reconstructs its pre-repair graph, verifies the approval +digest and exact retained tags, and requires its SDK transaction journal before +replay. Audit comments alone cannot authorize repairs or supply replacement tags. +Unchanged replay changes no bytes. Ordinary SDK transaction +recovery/compensation semantics remain authoritative. +Keep tracker histories and transaction journals together during recovery. + +The pinned SDK does not expose an audited core `external_ref` clear operation. +Those collisions are diagnosed, but repair refuses with `unsupported_repair`; +use the owning provider workflow to correct the reference. The explorer does +not bypass core metadata mutation rules. + +Refusals expose the expected SDK error `code` and `context.required` remedy: + +| Code | Remedy | +| --- | --- | +| `invalid_options` | Supply valid pagination, complete repair selectors, digest and audit author. | +| `incomplete_read` | Restore a complete readable tracker; omitted items cannot prove ownership. | +| `ambiguous_identity` | Inspect the graph/provider records and resolve competing ownership manually. | +| `unsupported_repair` | Correct core external references through their owning provider workflow. | +| `stale_approval` | Preview again and explicitly review the new digest. | + +Core metadata validation, duplicate discovery, lifecycle rules, preview +validation, locks and audited mutations remain SDK-owned. Provider adapters +retain their own identity semantics. This surface diagnoses local evidence; +it does not certify that a remote issue still exists or transfer ownership. + +## Typed SDK example + +```ts +import { + exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair, + type IdentityRepairPlan, +} from "pm-github"; + +const pmRoot = ".agents/pm"; +const graph = await exploreGithubIdentities(pmRoot, { limit: 10 }); +const preview: IdentityRepairPlan = await planGithubIdentityRepair( + pmRoot, "gh:example/project#7", "example-owner", "example-copy", +); +// Present preview.changes and preview.approval for explicit review first. +// Invoke apply only when that exact digest has been approved. +const receipt = await applyGithubIdentityRepair(pmRoot, { + ...preview, author: "repair-reviewer", +}); +``` + +The SDK also accepts an optional `onTransition` observer on apply for transaction +diagnostics. It does not grant permission or replace approval. The mutation +uses the SDK's `updated_at` match guard in addition to the graph recheck. Input +storage must contain ordinary files and directories; symbolic tracker roots +and entries refuse rather than being followed during scratch-copy isolation. diff --git a/identity.ts b/identity.ts new file mode 100644 index 00000000..5fec76f0 --- /dev/null +++ b/identity.ts @@ -0,0 +1,178 @@ +/** Offline identity diagnosis and explicitly approved, single-item provenance repairs. */ +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import { + resolvePmRoot, createPmCliExpectedError, pathExists, + commitItemMutations, previewItemMutations, getHistoryPath, readHistorySnapshot, + type BulkItemMutation, type CommitItemMutationsOptions, type ItemMetadata, +} from "@unbrained/pm-cli/sdk"; +import { readRefreshItems } from "./refresh.ts"; + +/** Full certified metadata plus the explicitly requested body projection. */ +type IdentityItem = ItemMetadata & { body: string }; + +/** Stable machine-readable reasons for refusing identity work. */ +export type IdentityRefusalCode = "invalid_options" | "incomplete_read" | "ambiguous_identity" | "stale_approval" | "unsupported_repair"; +/** Throw an expected SDK error with a typed code and a concrete recovery instruction. */ +function refuse(code: IdentityRefusalCode, remedy: string): never { + throw createPmCliExpectedError(`GitHub identity explorer: ${code}. ${remedy}`, { + exitCode: 2, context: { code, required: remedy }, + }); +} +/** One item-to-identity edge; mentions never establish ownership. */ +export interface IdentityEdge { item: string; identity: string; source: string; strength: "claim" | "mention" } +/** Compact native ownership evidence without titles, bodies or provider payloads. */ +export interface IdentityNode { id: string; ownership: "github" | "provider" | "mixed" | "local"; providers: string[]; native_owner: string | null } +/** Bounded graph page with complete-corpus counts and explicit continuation. */ +export interface IdentityReport { + scanned: number; identities: number; collisions: number; mentions: number; + total: number; next: number | null; nodes: IdentityNode[]; edges: IdentityEdge[]; +} +/** A reviewable plan detaches only an exact GitHub provenance tag. */ +export interface IdentityRepairPlan { + identity: string; owner: string; detach: string; approval: string; revision: string; + changes: { field: "tags"; before: string[]; after: string[] }[]; +} +/** Canonicalize exact GitHub locators; opaque external references retain provider semantics. */ +function identity(value: string): string { + const match = /^(?:gh:([^/\s]+\/[^#\s]+)#|https?:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/(?:issues|pull)\/)([1-9]\d*)(?:\/?(?:[?#].*)?)?$/i.exec(value); + return match ? `gh:${(match[1] ?? match[2]!).toLowerCase()}#${BigInt(match[3]!).toString()}` : value; +} +/** Build edges from structured identity fields; prose URLs are explicitly weaker evidence. */ +function graph(items: readonly IdentityItem[]): { nodes: IdentityNode[]; edges: IdentityEdge[] } { + const nodes: IdentityNode[] = []; + const edges: IdentityEdge[] = []; + for (const item of items) { + const id = item.id!; + const providers = [...new Set(Object.keys(item).filter(key => /^(linear|jira|ado)_(?:.*_)?(?:id|key|url|identifier)$/i.test(key) && Boolean(item[key]) && (typeof item[key] !== "string" || item[key].trim() !== "")).map(key => key.split("_")[0]!.toLowerCase()))].sort(); + const claims: IdentityEdge[] = []; + for (const tag of item.tags) if (/^gh:[^/\s]+\/[^#\s]+#[1-9]\d*$/i.test(tag)) claims.push({ item: id, identity: identity(tag), source: "tag", strength: "claim" }); + for (const source of ["external_ref", "github_url"]) { + const value = item[source]; + if (typeof value === "string" && value.trim()) claims.push({ item: id, identity: identity(value), source, strength: "claim" }); + } + const declared = /^GitHub issue(?:\s*:\s+|\s+)(https?:\/\/\S+)/i.exec(item.description); + if (declared && identity(declared[1]!).startsWith("gh:")) claims.push({ item: id, identity: identity(declared[1]!), source: "declaration", strength: "claim" }); + const github = claims.some(edge => edge.identity.startsWith("gh:")); + nodes.push({ id, providers, native_owner: typeof item.source_owner === "string" ? item.source_owner : null, ownership: providers.length ? (github && item.github_link_only !== true ? "mixed" : "provider") : github ? "github" : "local" }); + edges.push(...claims); + for (const match of `${item.description}\n${item.body}`.matchAll(/https?:\/\/github\.com\/[^/\s<>]+\/[^/\s<>]+\/(?:issues|pull)\/[1-9]\d*/gi)) { + const key = identity(match[0]); + if (!claims.some(edge => edge.identity === key) && !edges.some(edge => edge.item === id && edge.identity === key && edge.strength === "mention")) edges.push({ item: id, identity: key, source: "prose", strength: "mention" }); + } + } + return { nodes, edges }; +} +/** Read all statuses through the host's certified complete read, never a truncated CLI projection. */ +async function read(pmRoot: string): Promise { + try { + return await readRefreshItems(pmRoot) as IdentityItem[]; + } catch { refuse("incomplete_read", "Restore a complete readable tracker and retry; no repair is safe with omitted items."); } +} +/** Diagnose all exact structured collisions and copied mentions with bounded identity pagination. */ +export async function exploreGithubIdentities(pmRoot: string, options: { limit?: number; offset?: number; identity?: string } = {}): Promise { + const { limit = 20, offset = 0 } = options; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0) refuse("invalid_options", "Use limit 1..100 and a nonnegative integer offset."); + const items = await read(pmRoot); + const { nodes, edges } = graph(items); + const keys = [...new Set(edges.map(edge => edge.identity))].sort(); + const contradictory = new Set(nodes.filter(node => new Set(edges.filter(edge => edge.item === node.id && edge.strength === "claim" && edge.identity.startsWith("gh:")).map(edge => edge.identity)).size > 1).map(node => node.id)); + const collisions = keys.filter(key => new Set(edges.filter(edge => edge.identity === key && edge.strength === "claim").map(edge => edge.item)).size > 1 || edges.some(edge => edge.identity === key && edge.strength === "claim" && contradictory.has(edge.item))); + const selected = options.identity === undefined ? keys.filter(key => collisions.includes(key) || edges.some(edge => edge.identity === key && edge.strength === "mention")) : keys.filter(key => key === identity(options.identity!)); + const page = selected.slice(offset, offset + limit); + const shown = edges.filter(edge => page.includes(edge.identity)); + return { scanned: items.length, identities: keys.length, collisions: collisions.length, mentions: edges.filter(edge => edge.strength === "mention").length, + total: selected.length, next: offset + limit < selected.length ? offset + limit : null, + nodes: nodes.filter(node => shown.some(edge => edge.item === node.id)), edges: shown }; +} +/** Bind approval to exact request, complete graph evidence and item revisions. */ +function digest(value: unknown): string { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); } +/** Refuse ownership ambiguity and build the only supported repair: detach a weak duplicate claim. */ +function plan(items: IdentityItem[], key: string, owner: string, detach: string): IdentityRepairPlan { + const { nodes, edges } = graph(items); + const claims = edges.filter(edge => edge.identity === key && edge.strength === "claim"); + const owners = [...new Set(claims.filter(edge => ["github_url", "declaration"].includes(edge.source)).map(edge => edge.item))]; + const target = items.find(item => item.id === detach); + const ownerItem = items.find(item => item.id === owner); + if (ownerItem?.github_number !== undefined && String(ownerItem.github_number) !== key.split("#")[1]) { + refuse("ambiguous_identity", "The declared owner has a conflicting github_number. Reconcile its provider metadata before previewing another repair."); + } + if (!key.startsWith("gh:") || owner === detach || owners.length !== 1 || owners[0] !== owner || !target || + nodes.find(node => node.id === owner)!.ownership === "mixed" || + claims.some(edge => edge.item !== owner && edge.item !== detach) || + !claims.some(edge => edge.item === detach) || claims.some(edge => edge.item === detach && !["tag", "external_ref"].includes(edge.source)) || + target.source_owner || + edges.some(edge => [owner, detach].includes(edge.item) && edge.strength === "claim" && edge.identity.startsWith("gh:") && edge.identity !== key) || + target.github_link_only === false || Object.keys(target).some(field => /^github_/i.test(field))) { + refuse("ambiguous_identity", "Inspect the graph and provider records. Select one independently declared owner and a duplicate carrying only a GitHub tag; conflicting source metadata requires manual provider review."); + } + if (typeof target.external_ref === "string" && identity(target.external_ref) === key) refuse("unsupported_repair", "The pinned SDK cannot clear core external_ref through audited item mutations. Repair that reference through its owning provider workflow; this command can detach GitHub tags only."); + const tags = target.tags!; + const retained = tags.filter(tag => identity(tag) !== key); + const changes: IdentityRepairPlan["changes"] = [{ field: "tags", before: tags, after: retained }]; + return { identity: key, owner, detach, changes, revision: target.updated_at, approval: digest({ key, owner, detach, changes, nodes, edges, revisions: items.map(item => [item.id, item.updated_at]) }) }; +} +/** Preview a scoped repair through real SDK semantic validation without touching the source tracker. */ +export async function planGithubIdentityRepair(pmRoot: string, key: string, owner: string, detach: string): Promise { + const proposed = plan(await read(pmRoot), identity(key), owner, detach); + await previewItemMutations(transaction(pmRoot, proposed, "pm-github")); + return proposed; +} +/** Retain the scoped plan as an audit hint; recovery independently verifies its approval binding. */ +function repairAudit(proposed: IdentityRepairPlan): string { + return `GitHub identity repair ${Buffer.from(JSON.stringify(proposed)).toString("base64url")}`; +} +/** Translate the narrow plan to a host-owned audited mutation and optimistic item guard. */ +function transaction(pmRoot: string, proposed: IdentityRepairPlan, author: string) { + const options = { removeTags: proposed.changes[0]!.before.filter(tag => identity(tag) === proposed.identity), comment: [repairAudit(proposed)] }; + const mutations: BulkItemMutation[] = [{ op: "update", id: proposed.detach, options, match: { key: "updated_at", value: proposed.revision } }]; + return { pmRoot, author, transactionId: `github-identity-${proposed.approval}`, mutations }; +} +/** Decode the tag-plan shape without treating audit text as permission to mutate. */ +function auditedPlan(encoded: string): IdentityRepairPlan { + try { + const value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as IdentityRepairPlan; + if (typeof value.revision !== "string" || !Number.isFinite(Date.parse(value.revision)) || value.changes.length !== 1 || + value.changes[0]!.field !== "tags" || !Array.isArray(value.changes[0]!.before) || !value.changes[0]!.before.every(tag => typeof tag === "string") || + !Array.isArray(value.changes[0]!.after) || !value.changes[0]!.after.every(tag => typeof tag === "string")) throw new Error("invalid audit"); + return value; + } catch { refuse("stale_approval", "The audit marker cannot reconstruct the approved tag mutation; inspect tracker history before retrying."); } +} +/** Reconstruct the approved pre-state and authenticate audit hints against the original graph digest. */ +function validateRecovery(items: IdentityItem[], target: IdentityItem, saved: IdentityRepairPlan): void { + try { + const restored = items.map(item => item.id === target.id ? { ...item, tags: saved.changes[0]!.before, updated_at: saved.revision } : item); + const approved = plan(restored, saved.identity, saved.owner, saved.detach); + if (digest(approved) !== digest(saved) || digest(target.tags) !== digest(approved.changes[0]!.after)) throw new Error("unbound audit"); + } catch { refuse("stale_approval", "The audit hint does not match the approved graph and exact tag mutation. Inspect history and preview again; no recovery was applied."); } +} +/** Apply an exact approved plan with a writer-lock recheck; replay verifies approval before SDK recovery. */ +export async function applyGithubIdentityRepair(pmRoot: string, request: { identity: string; owner: string; detach: string; approval: string; author: string; onTransition?: CommitItemMutationsOptions["onTransition"] }): Promise<{ changed: boolean; transaction: string }> { + if (!/^[a-f0-9]{64}$/.test(request.approval) || !request.author.trim()) refuse("invalid_options", "Supply the plan's full approval digest and a nonempty audit author."); + pmRoot = resolvePmRoot(process.cwd(), pmRoot); + const transactionId = `github-identity-${request.approval}`; + const items = await read(pmRoot); + const target = items.find(item => item.id === request.detach); + if (target) { + const history = await readHistorySnapshot(getHistoryPath(pmRoot, request.detach), request.detach); + const marker = history.entries.filter(entry => String(entry.message).startsWith(`bulk-item-transaction ${transactionId} `)).at(-1); + const encoded = marker && marker.message!.endsWith(" apply") ? /GitHub identity repair ([A-Za-z0-9_-]+)/.exec(JSON.stringify(marker.patch))?.[1] : undefined; + if (encoded) { + const saved = auditedPlan(encoded); + const key = identity(request.identity); + if (saved.identity !== key || saved.owner !== request.owner || saved.detach !== request.detach || saved.approval !== request.approval || + target.tags!.some(tag => identity(tag) === key) || (typeof target.external_ref === "string" && identity(target.external_ref) === key)) refuse("stale_approval", "The approved scope changed or its repaired claim was reintroduced; inspect and preview again."); + validateRecovery(items, target, saved); + if (!await pathExists(join(pmRoot, "transactions", "sdk", `${transactionId}.json`))) refuse("stale_approval", "The SDK transaction receipt is missing. Restore its verified journal before recovery; an item comment cannot authorize a new transaction."); + await commitItemMutations(transaction(pmRoot, saved, marker!.author)); + return { changed: false, transaction: transactionId }; + } + } + const proposed = plan(items, identity(request.identity), request.owner, request.detach); + if (proposed.approval !== request.approval) refuse("stale_approval", "Re-run the repair preview and review its new digest before applying."); + await commitItemMutations({ ...transaction(pmRoot, proposed, request.author), onTransition: async context => { + await request.onTransition?.(context); + if (context.transition === "prepared" && plan(await read(pmRoot), proposed.identity, proposed.owner, proposed.detach).approval !== request.approval) refuse("stale_approval", "The tracker changed before the writer lock; preview and approve again."); + } }); + return { changed: true, transaction: transactionId }; +} diff --git a/index.ts b/index.ts index 597b7bca..16b02ffb 100644 --- a/index.ts +++ b/index.ts @@ -29,6 +29,9 @@ import { type ImportFidelity, } from "./import-fidelity.ts"; import { refreshChanges, runRefresh } from "./refresh.ts"; +import { exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair } from "./identity.ts"; +export { exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair } from "./identity.ts"; +export type { IdentityRefusalCode, IdentityEdge, IdentityNode, IdentityReport, IdentityRepairPlan } from "./identity.ts"; import { acceptGithubWebhook, reconcileGithubWebhooks, WebhookRefusal } from "./webhooks.ts"; export { acceptGithubWebhook, reconcileGithubWebhooks, WebhookRefusal } from "./webhooks.ts"; export type { GithubWebhookDelivery, WebhookProposal, WebhookReconciliation, WebhookRefusalCode } from "./webhooks.ts"; @@ -40,6 +43,7 @@ import path from "node:path"; import { spawnSync } from "node:child_process"; import { + createPmCliExpectedError, comments as pmCommentsFn, commitItemMutations as sdkCommitItemMutations, isExternalDependencyReference, @@ -6912,6 +6916,36 @@ export default defineExtension({ // ----------------------------------------------------------------------- // Refresh uses the import planner and its guarded update-only transaction path. + api.registerCommand({ + name: "github identity", + description: "Explore exact identity collisions and copied mentions offline. Defaults to a bounded read-only graph; --owner and --detach preview one repair, --approve applies its exact digest.", + flags: [ + { long: "--identity", value_name: "ref", description: "Exact gh:owner/repo#N or external_ref to inspect" }, + { long: "--limit", value_name: "N", description: "Identity groups per page, 1..100 (default 20)" }, + { long: "--offset", value_name: "N", description: "Zero-based identity page offset" }, + { long: "--owner", value_name: "id", description: "Independently declared rightful owner" }, + { long: "--detach", value_name: "id", description: "One duplicate whose GitHub tag may be removed" }, + { long: "--approve", value_name: "digest", description: "Apply the exact reviewed preview digest" }, + ], + async run(ctx: CommandHandlerContext) { + const options = ctx.options; + if (options.owner !== undefined || options.detach !== undefined || options.approve !== undefined) { + if (typeof options.identity !== "string" || typeof options.owner !== "string" || typeof options.detach !== "string" || + (options.approve !== undefined && typeof options.approve !== "string")) { + throw createPmCliExpectedError("Supply --identity, --owner and --detach together; --approve requires the preview digest.", { context: { code: "invalid_options" } }); + } + return options.approve === undefined + ? planGithubIdentityRepair(ctx.pm_root, options.identity, options.owner, options.detach) + : applyGithubIdentityRepair(ctx.pm_root, { identity: options.identity, owner: options.owner, detach: options.detach, approval: options.approve, author: process.env.PM_AUTHOR ?? "pm-github" }); + } + return exploreGithubIdentities(ctx.pm_root, { + limit: options.limit === undefined ? undefined : Number(options.limit), + offset: options.offset === undefined ? undefined : Number(options.offset), + identity: typeof options.identity === "string" ? options.identity : undefined, + }); + }, + }); + api.registerCommand({ name: "github refresh", description: "Refresh existing linked items from GitHub without importing new issues.", diff --git a/refresh.ts b/refresh.ts index a4bf2368..d249c386 100644 --- a/refresh.ts +++ b/refresh.ts @@ -1,5 +1,5 @@ /** Refresh an explicit local GitHub mirror through the importer's existing mapping and transaction. */ -import { mkdtemp, cp, rm } from "node:fs/promises"; +import { mkdtemp, cp, rm, chmod, readdir, lstat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, basename } from "node:path"; import { listAllComplete, certifyCompleteListResult, previewItemMutations } from "@unbrained/pm-cli/sdk"; @@ -120,12 +120,19 @@ export function refreshChanges(entry: PreparedGithubImport): RefreshChange[] { }); } -/** Isolate SDK-derived cache writes for previews and no-op applies, including runtime files. */ -async function readRefreshItems(pmRoot: string): Promise { +/** Isolate SDK-derived cache writes for offline diagnostics, previews and no-op applies. @internal */ +export async function readRefreshItems(pmRoot: string): Promise { const scratch = await mkdtemp(join(tmpdir(), "pm-github-refresh-read-")); try { + if (!(await lstat(pmRoot)).isDirectory()) throw new CommandError("Offline tracker reads need an ordinary tracker directory; replace a linked root with a regular copy."); const root = join(scratch, "pm"); - await cp(pmRoot, root, { recursive: true, filter: source => !["runtime", "locks", "github-webhooks"].includes(basename(source)) }); + await cp(pmRoot, root, { recursive: true, filter: source => !["runtime", "locks", "github-webhooks", "extensions"].includes(basename(source)) }); + // Source permissions are retained by cp; only the disposable copy may host SDK caches. + await chmod(root, 0o700); + for (const entry of await readdir(root, { recursive: true, withFileTypes: true })) { + if (!entry.isDirectory() && !entry.isFile()) throw new CommandError("Offline tracker reads refuse linked or special files; use an ordinary tracker copy."); + await chmod(join(entry.parentPath, entry.name), entry.isDirectory() ? 0o700 : 0o600); + } return decodeCompletePmItems(certifyCompleteListResult(await listAllComplete({ includeBody: true }, { pmRoot: root, cwd: root, noExtensions: true }))); } finally { await rm(scratch, { recursive: true, force: true }); diff --git a/test/identity.test.ts b/test/identity.test.ts new file mode 100644 index 00000000..27e00b9c --- /dev/null +++ b/test/identity.test.ts @@ -0,0 +1,322 @@ +/** Identity exploration uses real SDK storage in non-git directories, including durable audit. */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, mkdirSync, symlinkSync, chmodSync, cpSync, copyFileSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { + init, create, update, get, commitImportedItem, getItemPath, readSettings, + readHistorySnapshot, getHistoryPath, WorkspaceTransactionInterruptedError, type ItemMetadata, +} from "@unbrained/pm-cli/sdk"; +import { createExtensionTestHarness } from "@unbrained/pm-cli/sdk/testing"; +import extension, { exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair } from "../index.ts"; +import { withEnv } from "./helpers/mock-github-server.ts"; + +const key = "gh:fixture/repo#1"; +const url = "https://github.com/fixture/repo/issues/1"; +/** Capture all bytes, including history and journals, for zero-write assertions. */ +function snapshot(root: string) { + return Object.fromEntries(readdirSync(root, { recursive: true, withFileTypes: true }).filter(entry => entry.isFile()).map(entry => { + const file = join(entry.parentPath, entry.name); + return [file, createHash("sha256").update(readFileSync(file)).digest("hex")]; + })); +} +/** Initialize real storage with no enclosing git repository and then remove git from PATH. */ +async function tracker(run: (root: string) => Promise) { + const directory = mkdtempSync(join(tmpdir(), "pm-identities-")); + const root = join(directory, "tracker"); + const bin = join(directory, "bin"); + mkdirSync(bin); + symlinkSync(process.execPath, join(bin, "node")); + try { + await withEnv({ GIT_CEILING_DIRECTORIES: tmpdir(), PM_AUTHOR: "fixture", PM_PATH: root, PATH: bin }, async () => { + await init("fixture", { defaults: true, withPackages: false, agentGuidance: "skip" }, { cwd: directory, pmRoot: root, noExtensions: true }); + await run(root); + }); + } finally { rmSync(directory, { recursive: true, force: true }); } +} +/** Seed provider metadata through the host import primitive with genuine append-only history. */ +async function item(root: string, fields: Record, body = "") { + const created = (await create({ title: "Identity fixture", type: "Task" }, { pmRoot: root })).item; + await commitImportedItem({ pmRoot: root, id: created.id, itemPath: getItemPath(root, "Task", created.id), + document: { metadata: { ...created, ...fields }, body }, settings: await readSettings(root), + author: "fixture", message: "Seed provider identity", conflictWarningPrefix: "fixture" }); + return created.id; +} +/** Verify typed expected refusals without depending on diagnostic prose. */ +function code(expected: string) { return (error: unknown) => { assert.equal((error as { code: string }).code, expected); return true; }; } + +test("copied prose, structured multi-provider owners, exact refs and bounded graph remain distinct without git", async () => tracker(async root => { + const owner = await item(root, { github_url: url, tags: [key], github_link_only: false }); + const copied = await item(root, { tags: ["linear:example"], linear_id: null, jira_key: "", ado_id: " " }, `Example [linear] linear_id=sample ${url} ${url}`); + const provider = await item(root, { github_url: url.replace("/1", "/2"), linear_id: "stable-id", github_link_only: true, tags: ["gh:fixture/repo#2"] }); + const mixed = await item(root, { github_url: url.replace("/1", "/3"), jira_key: "ENG-3" }); + await item(root, { external_ref: key, tags: ["normal-label"] }); + await item(root, { external_ref: "jira:ENG-4", jira_key: 123 }); + await item(root, { external_ref: "jira:ENG-4" }); + await item(root, { description: `GitHub issue: ${url.replace("/1", "/5")}` }); + await item(root, { description: "See https://github.com/fixture/repo/pull/6" }); + await item(root, { description: "GitHub issue https://example.invalid/issue/1" }); + const conflict = await item(root, { github_url: "https://github.com/fixture/repo/issues/8", tags: ["gh:fixture/repo#9"], source_owner: "fixture-provider" }); + for (const entry of readdirSync(root, { recursive: true, withFileTypes: true }).filter(entry => entry.isFile())) chmodSync(join(entry.parentPath, entry.name), 0o444); + const before = snapshot(root); + const report = await exploreGithubIdentities(root); + assert.equal(report.scanned, 11); + assert.equal(report.collisions, 4); + assert.equal(report.mentions, 2); + assert.equal(report.nodes.find(node => node.id === conflict)?.native_owner, "fixture-provider"); + assert.equal(report.nodes.find(node => node.id === copied)?.ownership, "local"); + assert.equal((await exploreGithubIdentities(root, { identity: "gh:fixture/repo#2" })).nodes.find(node => node.id === provider)?.ownership, "provider"); + assert.equal((await exploreGithubIdentities(root, { identity: "gh:fixture/repo#3" })).nodes.find(node => node.id === mixed)?.ownership, "mixed"); + assert.equal(report.nodes.find(node => node.id === owner)?.ownership, "github"); + const first = await exploreGithubIdentities(root, { limit: 1 }); + assert.equal(first.total, 5); + assert.equal(first.next, 1); + assert.equal((await exploreGithubIdentities(root, { limit: 1, offset: 4 })).next, null); + assert.deepEqual((await exploreGithubIdentities(root, { identity: "absent" })).edges, []); + assert.deepEqual(snapshot(root), before); + for (const options of [{ limit: 0 }, { limit: 101 }, { limit: 1.1 }, { offset: -1 }, { offset: NaN }]) await assert.rejects(exploreGithubIdentities(root, options), code("invalid_options")); + const empty = join(root, "missing"); + await assert.rejects(exploreGithubIdentities(empty), code("incomplete_read")); +})); + +for (const fields of [{ tags: [key, "keep"], external_ref: "jira:ENG-2" }, { tags: [key], external_ref: undefined }]) { + test(`approved detach is audited, scoped and byte-idempotent (${Object.keys(fields).join("+")})`, async () => tracker(async root => { + const owner = await item(root, { github_url: "https://github.com/FIXTURE/REPO/pull/1?view=all", github_link_only: false }); + const detach = await item(root, { ...fields, linear_id: "real-provider-id", release: "2026.1.1" }, "keep provider content"); + const before = snapshot(root); + const proposed = await planGithubIdentityRepair(root, key, owner, detach); + assert.ok(proposed.changes.length); + assert.deepEqual(snapshot(root), before); + await assert.rejects(applyGithubIdentityRepair(root, { ...proposed, author: "fixture", approval: "bad" }), code("invalid_options")); + await assert.rejects(applyGithubIdentityRepair(root, { ...proposed, author: " " }), code("invalid_options")); + await assert.rejects(applyGithubIdentityRepair(root, { ...proposed, author: "fixture", approval: "0".repeat(64) }), code("stale_approval")); + const result = await applyGithubIdentityRepair(root, { ...proposed, author: "fixture" }); + assert.equal(result.changed, true); + const updated = (await get(detach, {}, { pmRoot: root })).item; + assert.equal(updated.linear_id, "real-provider-id"); + assert.equal(updated.release, "2026.1.1"); + assert.equal(updated.body, "keep provider content"); + assert.ok(!updated.tags?.includes(key)); + assert.equal(updated.external_ref, fields.external_ref); + const history = await readHistorySnapshot(getHistoryPath(root, detach), detach); + assert.ok(history.entries.some(entry => entry.author === "fixture" && entry.message?.includes(result.transaction))); + const after = snapshot(root); + assert.equal((await applyGithubIdentityRepair(root, { ...proposed, author: "fixture" })).changed, false); + assert.deepEqual(snapshot(root), after); + assert.equal((await exploreGithubIdentities(root)).collisions, 0); + })); +} + +test("refuse contradictory owners, source metadata, opaque providers and changed approvals before writes", async () => tracker(async root => { + const owner = await item(root, { github_url: url }); + const detach = await item(root, { tags: [key] }); + const proposed = await planGithubIdentityRepair(root, key, owner, detach); + await update(detach, { title: "Edited since preview" }, { pmRoot: root }); + await assert.rejects(applyGithubIdentityRepair(root, { ...proposed, author: "fixture" }), code("stale_approval")); + for (const [ref, rightful, target] of [[key, owner, owner], ["jira:ENG-1", owner, detach], [key, "missing", detach], [key, owner, "missing"]]) { + await assert.rejects(planGithubIdentityRepair(root, ref!, rightful!, target!), code("ambiguous_identity")); + } + const a = await item(root, { github_url: "https://github.com/fixture/repo/issues/2", jira_key: "ENG-1" }); + const b = await item(root, { tags: ["gh:fixture/repo#2"] }); + await assert.rejects(planGithubIdentityRepair(root, "gh:fixture/repo#2", a, b), code("ambiguous_identity")); + const external = await item(root, { external_ref: key }); + await assert.rejects(planGithubIdentityRepair(root, key, owner, external), code("ambiguous_identity")); + const before = snapshot(root); + await item(root, { github_url: url }); + const multiple = snapshot(root); + await assert.rejects(planGithubIdentityRepair(root, key, owner, detach), code("ambiguous_identity")); + assert.deepEqual(snapshot(root), multiple); + assert.notDeepEqual(before, multiple); +})); + +for (const fields of [{ github_url: url }, { github_link_only: false }, { github_number: 1 }, { source_owner: "fixture-provider" }, { tags: [key, "gh:fixture/repo#2"] }]) { + test(`source-owned duplicates require manual review (${Object.keys(fields).join()})`, async () => tracker(async root => { + const owner = await item(root, { description: `GitHub issue ${url}` }); + const detach = await item(root, { tags: [key], ...fields }); + const before = snapshot(root); + await assert.rejects(planGithubIdentityRepair(root, key, owner, detach), code("ambiguous_identity")); + assert.deepEqual(snapshot(root), before); + })); +} + +test("registered command uses compact host TOON/JSON, read-only graph, repair preview and explicit apply", async () => tracker(async root => { + const owner = await item(root, { github_url: url }); + const detach = await item(root, { tags: [key] }); + const harness = await createExtensionTestHarness(extension, { capabilities: ["commands", "schema", "hooks", "preflight", "search", "importers"] }); + const run = async (options: Record) => (await harness.runCommand({ command: "github identity", pmRoot: root, args: [], options })).result; + assert.equal((await run({}) as { scanned: number }).scanned, 2); + const report = await run({ limit: "1", offset: "0", identity: key }) as { collisions: number }; + assert.equal(report.collisions, 1); + const proposed = await run({ identity: key, owner, detach }) as { approval: string }; + assert.match(proposed.approval, /^[a-f0-9]{64}$/); + await assert.rejects(run({ owner }), code("invalid_options")); + await assert.rejects(run({ detach }), code("invalid_options")); + await assert.rejects(run({ approve: "digest" }), code("invalid_options")); + await assert.rejects(run({ identity: key, owner: true, detach }), code("invalid_options")); + await assert.rejects(run({ identity: key, owner, detach: true }), code("invalid_options")); + await assert.rejects(run({ identity: key, owner, detach, approve: true }), code("invalid_options")); + const applied = await withEnv({ PM_AUTHOR: undefined }, () => run({ identity: key, owner, detach, approve: proposed.approval })) as { changed: boolean }; + assert.equal(applied.changed, true); + const packageRoot = join(import.meta.dirname, ".."); + const installed = join(root, "extensions/pm-github"); + mkdirSync(installed, { recursive: true }); + cpSync(join(packageRoot, "dist"), join(installed, "dist"), { recursive: true }); + for (const file of ["manifest.json", "package.json"]) copyFileSync(join(packageRoot, file), join(installed, file)); + symlinkSync(join(packageRoot, "node_modules"), join(installed, "node_modules"), "dir"); + for (const format of ["toon", "json"]) { + const result = spawnSync(process.execPath, [join(packageRoot, "node_modules/@unbrained/pm-cli/dist/cli.js"), "--path", root, ...(format === "json" ? ["--json"] : []), "github", "identity"], { encoding: "utf8", timeout: 60000 }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /collisions/); + } +})); + +test("external reference mutation gaps refuse with a remedy and no source writes", async () => tracker(async root => { + const owner = await item(root, { github_url: url }); + const detach = await item(root, { tags: [key], external_ref: url }); + await assert.rejects(applyGithubIdentityRepair(root, { identity: key, owner, detach: "missing", approval: "a".repeat(64), author: "fixture" }), code("ambiguous_identity")); + const before = snapshot(root); + await assert.rejects(planGithubIdentityRepair(root, key, owner, detach), code("unsupported_repair")); + assert.deepEqual(snapshot(root), before); +})); + +test("recover an interrupted audited detach and refuse an edit made before locked validation", async () => tracker(async root => { + const owner = await item(root, { github_url: url }); + const detach = await item(root, { tags: [key, "retain"] }); + const proposed = await planGithubIdentityRepair(root, key, owner, detach); + await assert.rejects(applyGithubIdentityRepair(root, { ...proposed, author: "fixture", onTransition: context => { + if (context.transition === "step_applied") throw new WorkspaceTransactionInterruptedError(); + } }), WorkspaceTransactionInterruptedError); + const recovered = await applyGithubIdentityRepair(root, { ...proposed, author: "fixture" }); + assert.equal(recovered.changed, false); + const stable = snapshot(root); + await applyGithubIdentityRepair(root, { ...proposed, author: "fixture" }); + assert.deepEqual(snapshot(root), stable); + const current = (await get(detach, {}, { pmRoot: root })).item as ItemMetadata & { body: string }; + await commitImportedItem({ pmRoot: root, id: detach, itemPath: getItemPath(root, "Task", detach), + document: { metadata: { ...current, external_ref: key }, body: current.body! }, settings: await readSettings(root), + author: "fixture", message: "Reintroduce source reference", conflictWarningPrefix: "fixture" }); + await assert.rejects(applyGithubIdentityRepair(root, { ...proposed, author: "fixture" }), code("stale_approval")); + await commitImportedItem({ pmRoot: root, id: detach, itemPath: getItemPath(root, "Task", detach), + document: { metadata: current, body: current.body! }, settings: await readSettings(root), + author: "fixture", message: "Restore synthetic baseline", conflictWarningPrefix: "fixture" }); + await update(detach, { addTags: [key] }, { pmRoot: root }); + await assert.rejects(applyGithubIdentityRepair(root, { ...proposed, author: "fixture" }), code("stale_approval")); + const next = await planGithubIdentityRepair(root, key, owner, detach); + await assert.rejects(applyGithubIdentityRepair(root, { ...next, author: "fixture", onTransition: async context => { + if (context.transition === "prepared") await update(owner, { title: "Concurrent edit" }, { pmRoot: root }); + } }), code("stale_approval")); + assert.ok((await get(detach, {}, { pmRoot: root })).item.tags?.includes(key)); +})); + +test("malformed and mismatched native audit hints cannot authorize a repair", async () => tracker(async root => { + const owner = await item(root, { github_url: url }); + const detach = await item(root, { tags: [key] }); + const proposed = await planGithubIdentityRepair(root, key, owner, detach); + const malformed = ["{", JSON.stringify({ ...proposed, revision: null }), JSON.stringify({ ...proposed, revision: "invalid" }), + JSON.stringify({ ...proposed, changes: [] }), JSON.stringify({ ...proposed, changes: [{ field: "title", after: [] }] }), + JSON.stringify({ ...proposed, changes: [{ field: "tags", after: [] }] }), + JSON.stringify({ ...proposed, changes: [{ field: "tags", before: [key], after: [42] }] }), + JSON.stringify({ ...proposed, changes: [{ field: "tags", before: [42], after: [] }] }), + JSON.stringify({ ...proposed, changes: [{ field: "tags", before: [key], after: null }] })]; + for (const value of malformed) { + await update(detach, { comment: [`GitHub identity repair ${Buffer.from(value).toString("base64url")}`], + message: `bulk-item-transaction github-identity-${proposed.approval} fixture apply` }, { pmRoot: root }); + await assert.rejects(applyGithubIdentityRepair(root, { ...proposed, author: "fixture" }), code("stale_approval")); + } +})); + +test("an audit receipt cannot be replayed for another identity, owner, target or digest", async () => tracker(async root => { + const owner = await item(root, { github_url: url }); + const detach = await item(root, { tags: [key] }); + const proposed = await planGithubIdentityRepair(root, key, owner, detach); + for (const patch of [{ identity: "gh:fixture/repo#2" }, { owner: "another-owner" }, { detach: "another-item" }, { approval: "f".repeat(64) }]) { + const encoded = Buffer.from(JSON.stringify({ ...proposed, ...patch })).toString("base64url"); + await update(detach, { comment: [`GitHub identity repair ${encoded}`], + message: `bulk-item-transaction github-identity-${proposed.approval} fixture apply` }, { pmRoot: root }); + await assert.rejects(applyGithubIdentityRepair(root, { ...proposed, author: "fixture" }), code("stale_approval")); + } +})); + +test("read-only isolation refuses symbolic tracker roots and entries without following them", async () => tracker(async root => { + await item(root, { tags: [key] }); + const linkedRoot = join(root, "../linked-tracker"); + symlinkSync(root, linkedRoot, "dir"); + await assert.rejects(exploreGithubIdentities(linkedRoot), code("incomplete_read")); + symlinkSync(join(root, "settings.json"), join(root, "linked-entry")); + const before = snapshot(root); + await assert.rejects(exploreGithubIdentities(root), code("incomplete_read")); + assert.deepEqual(snapshot(root), before); + unlinkSync(join(root, "linked-entry")); +})); + +test("forged audit plans with nonexistent owners never authorize recovery writes", async () => tracker(async root => { + const detach = await item(root, { tags: ["keep"] }); + const approval = "a".repeat(64); + const forged = { identity: key, owner: "nonexistent-owner", detach, approval, + revision: (await get(detach, {}, { pmRoot: root })).item.updated_at!, + changes: [{ field: "tags", before: [key, "keep"], after: ["unapproved"] }] }; + await update(detach, { comment: [`GitHub identity repair ${Buffer.from(JSON.stringify(forged)).toString("base64url")}`], + message: `bulk-item-transaction github-identity-${approval} fixture apply` }, { pmRoot: root }); + const current = (await get(detach, {}, { pmRoot: root })).item as ItemMetadata & { body: string }; + await commitImportedItem({ pmRoot: root, id: detach, itemPath: getItemPath(root, "Task", detach), + document: { metadata: { ...current, updated_at: forged.revision }, body: current.body }, settings: await readSettings(root), + author: "fixture", message: "Seed forged revision", conflictWarningPrefix: "fixture" }); + const before = snapshot(root); + await assert.rejects(applyGithubIdentityRepair(root, { ...forged, author: "fixture" }), code("stale_approval")); + assert.deepEqual(snapshot(root), before); + assert.deepEqual((await get(detach, {}, { pmRoot: root })).item.tags, ["keep"]); +})); + +test("approved tag detach preserves a stored comma tag byte-identical", async () => tracker(async root => { + const owner = await item(root, { github_url: url, github_number: 1 }); + const detach = await item(root, { tags: [key, "keep,me"] }); + const proposed = await planGithubIdentityRepair(root, key, owner, detach); + assert.deepEqual(proposed.changes[0]!.after, ["keep,me"]); + await applyGithubIdentityRepair(root, { ...proposed, author: "fixture" }); + assert.deepEqual((await get(detach, {}, { pmRoot: root })).item.tags, ["keep,me"]); + const after = snapshot(root); + await applyGithubIdentityRepair(root, { ...proposed, author: "fixture" }); + assert.deepEqual(snapshot(root), after); +})); + +test("an owner with a conflicting github_number cannot authorize detaching another item", async () => tracker(async root => { + const owner = await item(root, { github_url: url, github_number: 2 }); + const detach = await item(root, { tags: [key] }); + const before = snapshot(root); + await assert.rejects(planGithubIdentityRepair(root, key, owner, detach), code("ambiguous_identity")); + await assert.rejects(applyGithubIdentityRepair(root, { identity: key, owner, detach, approval: "a".repeat(64), author: "fixture" }), code("ambiguous_identity")); + assert.deepEqual(snapshot(root), before); +})); + +test("recovery rechecks the approval digest and exact retained tags before SDK replay", async () => tracker(async root => { + const owner = await item(root, { github_url: url }); + const detach = await item(root, { tags: [key, "keep"] }); + const proposed = await planGithubIdentityRepair(root, key, owner, detach); + await applyGithubIdentityRepair(root, { ...proposed, author: "fixture" }); + const forged = { ...proposed, changes: [{ field: "tags", before: [key, "keep"], after: ["unapproved"] }] }; + await update(detach, { comment: [`GitHub identity repair ${Buffer.from(JSON.stringify(forged)).toString("base64url")}`], + message: `bulk-item-transaction github-identity-${proposed.approval} fixture apply` }, { pmRoot: root }); + const before = snapshot(root); + await assert.rejects(applyGithubIdentityRepair(root, { ...proposed, author: "fixture" }), code("stale_approval")); + assert.deepEqual(snapshot(root), before); + await update(detach, { comment: [`GitHub identity repair ${Buffer.from(JSON.stringify(proposed)).toString("base64url")}`], + message: `bulk-item-transaction github-identity-${proposed.approval} fixture apply` }, { pmRoot: root }); + await update(detach, { addTags: ["new-tag"] }, { pmRoot: root }); + const edited = snapshot(root); + await assert.rejects(applyGithubIdentityRepair(root, { ...proposed, author: "fixture" }), code("stale_approval")); + assert.deepEqual(snapshot(root), edited); +})); + +test("an authenticated audit hint without its SDK journal cannot start a new transaction", async () => tracker(async root => { + const owner = await item(root, { github_url: url }); + const detach = await item(root, { tags: [key] }); + const proposed = await planGithubIdentityRepair(root, key, owner, detach); + await applyGithubIdentityRepair(root, { ...proposed, author: "fixture" }); + unlinkSync(join(root, "transactions", "sdk", `github-identity-${proposed.approval}.json`)); + const before = snapshot(root); + await assert.rejects(applyGithubIdentityRepair(root, { ...proposed, author: "fixture" }), code("stale_approval")); + assert.deepEqual(snapshot(root), before); +}));