From 4adf5ad665f8dd8f98d2dabd46f5b4cd8b057cd8 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Thu, 23 Jul 2026 21:03:12 +0300 Subject: [PATCH 1/3] feat: add task-to-PR projection contract --- README.md | 207 +- examples/task-to-pr-projection.valid.json | 622 ++++ package.json | 2 +- src/schemas.ts | 2518 ++++++++++++- tests/task-to-pr-projection.test.ts | 3979 +++++++++++++++++++++ 5 files changed, 7309 insertions(+), 19 deletions(-) create mode 100644 examples/task-to-pr-projection.valid.json create mode 100644 tests/task-to-pr-projection.test.ts diff --git a/README.md b/README.md index 7644c67..8797c6a 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,13 @@ defaults are applied; output aliases such as `EvidenceRef` describe parsed data. traces, or other evidence. - `hasna.work_run.v1`: normalized run receipt for agent, command, workflow, loop, eval, test, deploy, or review work. +- `hasna.task_to_pr_projection.v1`: immutable, provider-neutral identity + projection for one task-to-PR attempt. It binds the Todos root/PR-group/leaf + and replay cursor, the Codewith run/profile route, the Repos + repo/worktree/branch/writer lease, exact-head review and merge-CAS refs, + repair/recovery/cancellation preservation, cleanup, rollback, and optional + OpenLoops invocation context without becoming a receipt, queue, store, or + canonical history. - `hasna.decision_envelope.v1`: decision record with selected/skipped resources, rationale, actor, costs, obligations, redactions, and evidence. - `hasna.cost_estimate.v1`: money and token estimates with provider, model, @@ -382,13 +389,169 @@ Common resource-kind mappings: | `open-economy` | `cost`, `budget`; budget choices are emitted as `DecisionEnvelope` contracts, not resource kinds | | `open-monitor` | `alert`, `incident`, `machine`, `report` | +## Task-to-PR projection + +`TaskToPrProjection` is a strict reference projection, not a `WorkReceipt`, +`PRReceipt`, queue, database, event store, or replacement for any owning +package. `WorkRun` remains the execution receipt. `EvidenceRef` and +`ProofBundle` remain evidence and verification records; `DecisionEnvelope` +remains a decision record; `CapabilityCard` remains an admission snapshot; +`ResourcePointer` remains a generic resource link; and `AgentTrajectory` +remains a bounded trace. The projection only binds their owner-resolved refs. + +Authority is fixed: + +- Todos owns the canonical root request, PR group, leaf task, attempt, + repair state, and append-only lifecycle history. +- Codewith owns durable run admission, opaque profile/route selection, and + runtime state. +- Repos owns repository, worktree, branch, writer lease/fence refs, and cleanup. +- Infinity may execute an admitted attempt but is only an adapter; it does not + become an authority or history store. +- TAI may render the projection read-only; it does not write lifecycle state. +- OpenLoops may contribute one optional invocation ref as orchestration + context. It owns no Codewith goal, Todos task or history, Repos lease, + review, merge guard, or merge outcome. + +Every `TaskToPrRef` has one role, one authority, one typed opaque owner id +(`role:opaque-*`), one lowercase SHA-256 identity digest, and an explicit redaction state. Canonical +objects are dereferenced from their owning packages instead of embedded as +mutable payloads. Sensitive owner ids are role-prefixed opaque refs and reject +account-like, fence-like, URL-like, credential-like, and secret-marker values. +The projection id (`task_to_pr_projection:opaque-*`) and attempt nonce +(`attempt_nonce:opaque-*`) use the same fail-closed rule; untyped ids and values +that hide an account, fence, or GitHub PAT marker behind a delimiter are +rejected. +`TaskToPrEvidenceRef` is deliberately smaller than `EvidencePointer`: it only +carries a typed opaque `evidence:opaque-*` id, immutable digest, and +`partial`/`full` redaction, so a +projection cannot inline a URI, summary, command output, or secret-adjacent +payload. + +| Projection field | Cardinality | Authority and invariant | +| --- | --- | --- | +| `workRunRef` | exactly 1 | Codewith; points to the existing `WorkRun` receipt | +| `rootRequestRef` / `prGroupRef` / `leafTaskRef` | exactly 1 each | Todos; immutable canonical identities | +| `attempt.ref` / `attempt.nonce` | exactly 1 each | Todos attempt identity; every retry uses a fresh nonce | +| admission / worker / runtime refs | exactly 1 each | redacted Codewith owner-record refs for the admitted runtime | +| writer generation / lease / fence refs | exactly 1 each | generation is Todos-bound; lease/fence are redacted Repos refs, never a fence value | +| provider profile / route refs | exactly 1 each | redacted opaque Codewith refs; never provider account or credential data | +| repo / worktree / branch plus base and branch heads | exactly 1 binding | canonical Repos refs with exact Git object ids | +| event stream / replay cursor / sequence / prefix digest | exactly 1 cursor | Todos append-only history is referenced, never copied | +| `handoff` | 0 or 1 current transition | Todos ref binding prior/next attempts, prior/next generations, and the prior WorkRun plus stop/revocation evidence; the full record is immutable under one canonical ref, while a later handoff requires both a fresh canonical role/authority/id and a fresh digest; mutually exclusive with recovery | +| `pullRequestRef` | 0 or 1 | Todos PR-group identity; required by review or merge state | +| exact-head proof | required before review/merge | local head = remote head = provider PR head, with equality and CI proof refs; once established, the complete exact-head fact (including the equality ref and ordered CI refs) is immutable while the branch head is unchanged; every branch-head change requires fresh canonical identities and digests for all equality and CI proof refs | +| `reviews` | 0 or more unique exact-head reviews | review ref, reviewer/run, proof bundle, PR, and immutable head; review-record refs are unique by canonical id and digest; same-head history preserves the prior array as an exact immutable prefix and can only append, while a new branch head requires fresh review, review-run, and proof-bundle identities and digests (the reviewer actor may remain stable) | +| `repair` | exactly 1 state | cumulative Todos cycle `0..2`; never decrements or resets; state/latest refs are disjoint by id and digest, and an advance makes both slots fresh from both prior slots | +| merge guard / outcome | 0 or 1 each | guard binds reviewed head and proof; a guard is immutable under one canonical ref and any changed guard uses a fresh canonical id and digest; a changed expected head also requires a fresh provider-guard receipt id and digest; terminal outcome requires the exact immutable `guardRef` and is CAS-bound | +| recovery / cancellation | recovery is mutually exclusive with handoff and cancellation; 0 or 1 each | preserves required root/group/leaf/repo/worktree/branch/event and present PR refs; recovery binds canonically distinct prior attempt/generation/WorkRun refs, is immutable under one canonical recovery ref, and rotates each successor/current or later recovery ref to both a fresh canonical role/authority/id and fresh digest | +| cleanup eligibility / outcome | 0 or 1 each | Repos-owned; eligibility is immutable under one canonical ref and a changed decision uses a fresh canonical id and digest; both bind the canonical worktree and the outcome binds the eligibility decision | +| rollback plan / outcome | 0 or 1 each | plan targets a commit or branch, remains immutable under one canonical ref, and uses a fresh canonical id and digest when the plan or target changes; immutable outcome binds the exact plan and target | +| `provenanceLedger` | exactly 1 append-only identity ledger | immutable exact-prefix tombstones for projection snapshot id, WorkRun, attempt, nonce, runtime, writer generation/lease/fence, provider profile/route, replay cursor/prefix, repair state/latest repair, handoff, recovery, merge guard, cleanup eligibility, rollback plan, equality/CI/review proof, review record/run, and provider guard receipt identities; active identities must be represented exactly, all ref ids/digests share one global uniqueness domain, and projection-id/nonce/prefix values are independently globally unique | +| OpenLoops invocation | 0 or 1 | optional redacted context only | +| adapter extensions | 0 or more unique mode/schema pairs | `local` or `cloud` ref plus digest only; separately namespaced, unregistered schema ids cannot equal any canonical `SCHEMA_IDS` contract or redefine canonical fields | + +The top-level document deliberately has `createdAt` but no `updatedAt`. +Producers treat a parsed projection as immutable. A changed lifecycle state is +emitted as a new projection while the Todos stream/cursor remains the only +canonical history. `provenanceLedger` is not another event store: it is an +identity-only index over Todos history containing typed immutable projection +ids, owner refs/digests, attempt nonces, replay prefix digests bound to their +sequence, and the Git object id for head-bound refs. Ref equality is exact +across role, authority, id, +digest, and redaction. Its exact-prefix tombstones make pairwise validation +remember all transition-fresh identities across absent phases, preventing +owner, attempt-lineage, replay, repair, and proof ABA without copying owner +payloads or lifecycle events. `validateTaskToPrProjectionTransition` parses both snapshots +before comparing them. It rejects changed canonical identity, replay regression, +sequence advances without a fresh cursor and prefix digest, repair advances +without fresh repair refs, partial attempt/generation/WorkRun rotation, recovery +or handoff that does not bind the immediately prior attempt/generation/WorkRun, +handoff and recovery in the same current snapshot, reused canonical IDs or +digests for advancing replay/repair/attempt/generation/WorkRun refs, reused +attempt-scoped runtime/lease/fence/profile/route refs, mutation, removal, or +handoff/recovery switching while an attempt identity is unchanged, illegal lifecycle +edges, any semantic lifecycle drift without replay-sequence advancement, +provenance-ledger truncation/reordering/mutation (including redaction drift), +inactive identity reactivation, historical projection-id, attempt-nonce, or +replay-prefix reuse, +or appending a tombstone that does not represent a current active fact, +same-ref mutation or half-rotation of handoff, recovery, merge-guard, +cleanup-eligibility, or rollback-plan owner records (a changed owner fact must +rotate both its canonical role/authority/id and digest), +replacement or removal of an established canonical PR ref, mutation or +removal of an established same-head exact-head fact, movement, mutation, or +removal within the immutable same-head review prefix, reuse of any prior equality, CI, or review +proof identity or digest after a branch-head change (including reordered or +recategorized proof refs), reuse of prior review or review-run identities or +digests across heads, active-state re-entry from terminal work states, and mutation or +disappearance of complete terminal merge, cancellation, cleanup, or rollback +facts after their owner outcome exists. +`admitted`, `running`, and `handed_off` snapshots cannot carry direct review +bindings or hidden merge-guard review refs, including on a denied guard. A +`reviewing` to `recovering` transition may retain the exact same-head +`exactHead` and review prefix, but recovery cannot invent them from a pre-review +snapshot or carry merge-guard review refs. Same-head reviews can only be appended after the exact prior array +prefix; reordering or prepending invalidates the transition. This v1 schema has no review +supersession field, so producers must advance the reviewed head before emitting +a replacement review set. An eligible merge guard is authoritative only from +`merge_ready` or in a consistent post-review terminal/failed/blocked/cancelled +history; `reviewing` and `repairing` cannot claim merge eligibility. +Consumers must reject unknown fields rather than retain unvalidated embedded +payloads. + +`canonicalizationVersion: 1` derives `identityDigest` from the ordered, +domain-separated tuple of root-request id/digest, PR-group id/digest, +leaf-task id/digest, repository id/digest, exact base Git object id, and +frozen-scope digest. The PR-group owner digest is an input, never the derived +output, so the binding has no circular dependency. Digests on refs mean +the SHA-256 of the owning package's canonical, redacted record identity bytes; +they must never hash a credential, raw fence value, mutable payload, or +provider account record. + +Exact-head binding is transitive: `remoteBranchRef` equals the canonical +repository branch ref; the local, remote, and provider pull-request heads are +equal and backed by equality plus CI proof refs; equality, every CI proof, and +every review proof obligation are globally unique by canonical ref id and +digest, so one proof cannot discharge two obligations; every `reviews[]` +head equals that canonical branch head; an eligible merge guard references +the exact canonical set of projected approved review refs with no extras or +omissions, every review proof, every CI proof, the equality proof, an opaque +provider guard receipt, and its CAS/expected-head queue mechanism; and a merged +outcome observes the same expected head. Only the non-merged `head_drift` +outcome may carry a mismatched observed head. Worker, reviewer, and +merge-operator identities and run refs are distinct by canonical digest, not +by incompatible role-prefixed ids. Exact-head verification, reviews, guard +evaluation, and merge outcome are chronologically ordered. Cleanup and rollback +are independent post-terminal branches: each is internally chronological and +cannot precede the terminal merge outcome it references, but one does not imply +an order relative to the other unless a runtime intentionally imposes one. + +Sensitive refs (`writer_lease`, `writer_fence`, `provider_profile`, +`provider_route`, `worktree`, merge-provider refs, and adapter extensions) +require `partial` or `full` redaction. Strict objects reject fields such as +provider account ids, credentials, raw fence tokens, or embedded adapter +payloads. Local and cloud adapters serialize the same shared projection and +place provider-specific detail behind separately validated, redacted extension +refs. An extension schema is unregistered and separately namespaced: it cannot +equal any canonical id in `SCHEMA_IDS` (including `hasna.work_run.v1`), use any +`hasna.task_to_pr_projection.*` schema, +encode account/fence/credential markers in its schema id, or shadow a canonical +role. `validateTaskToPrAdapterCoreEquivalence` parses both documents, requires +one or more local-only extensions in its first argument and one or more +cloud-only extensions in its second, removes only those validated +`adapterExtensions`, and requires byte-equivalent local and cloud core +projection data, including the complete cumulative provenance ledger. + ## Package Boundaries `@hasna/contracts` owns schemas, types, validators, examples, and conformance helpers. Owning packages still own storage and behavior. - `open-todos` owns tasks, task plans, locks, comments, and task evidence. -- `open-loops` owns loop and workflow execution. +- `open-loops` owns its own loop and workflow execution. In task-to-PR flows, + its invocation is optional reference-only orchestration context and confers + no authority over Codewith, Todos, Repos, review, or merge state. - `open-events` owns event envelopes, channels, delivery, replay, and notification semantics. - `open-actions` owns executable action manifests. @@ -435,11 +598,12 @@ native domain objects immediately. `ProofBundle`; task execution receipts as `WorkRun`; review gates as `ValidationPlan`; and workflow/run manifest pointers as compact task fields, not embedded handoff artifacts. -- `open-loops`: emit loop/workflow runs as `WorkRun`, audit traces as +- `open-loops`: emit its own loop/workflow runs as `WorkRun`, audit traces as `AgentTrajectory`, logs/artifacts as `EvidenceRef`, and verifier output as - `ProofBundle`. OpenLoops owns `WorkflowInvocation`, admission/work-item - queues, leases, workflow runs, retries, cancellation, worktrees, and run - artifacts. + `ProofBundle`. A task-to-PR adapter may add one redacted + `openLoopsInvocationRef`, but must not own or mirror the task-to-PR queue, + Todos history, Codewith admission/runtime state, Repos leases/worktrees, + review, or merge state. - `open-events`: emit and replay validated event envelopes to channels. OpenEvents delivers notifications only; it does not create workflow invocations, own queue state, or retry agent work. @@ -467,8 +631,8 @@ native domain objects immediately. template copying, setup wizards, source paths, and private metadata inside the scaffold package. - `open-automations`: keep deterministic app/product automation recipes and - connector/action recipes. Any agentic task, PR, review, or evaluation flow - must hand off to OpenLoops rather than creating a second workflow queue. + connector/action recipes. Agentic task, PR, and review flows hand off to the + canonical Todos/Codewith/Repos owners rather than creating a second queue. - `open-reports`: consume `ProofBundle`, `WorkRun`, `ContextPack`, `CostEstimate`, and `EvidenceRef` to render compact Markdown/JSON/HTML proof reports. @@ -479,10 +643,12 @@ native domain objects immediately. ## WorkflowInvocation And App Storage Boundary -The canonical agent-work root is a `WorkflowInvocation`, not a todo task. -Only actionable unfinished work needs a todo. OpenTodos remains the -human-visible intent ledger; OpenLoops owns the durable workflow root, -admission queue, execution lifecycle, and canonical run artifacts. +For task-to-PR work, the canonical root is the Todos root request and PR-group +history referenced by `TaskToPrProjection`; it is not a `WorkflowInvocation`. +Codewith owns durable admission/profile/runtime state and Repos owns +repo/worktree/writer-lease/cleanup state. A `WorkflowInvocation` can still be +useful inside OpenLoops' own workflow domain, but a task-to-PR projection may +only carry it as optional orchestration context. A workflow invocation should carry these fields at the boundary: @@ -499,11 +665,10 @@ A workflow invocation should carry these fields at the boundary: concurrency group - `outputPolicy`: when to write reports and when to create a follow-up task -OpenLoops admission/work items are first-class records with route key, -idempotency key, source/subject refs, project key/group, priority, status, -attempts, next-attempt time, lease expiry, loop/workflow/run ids, and last -reason. Status values should be explicit: `queued`, `deferred`, `admitted`, -`running`, `succeeded`, `failed`, `dead_letter`, or `cancelled`. +OpenLoops admission/work items remain first-class records only for OpenLoops' +own workflow execution. They must not be interpreted as task-to-PR attempts, +writer leases, repair counters, review records, merge guards, or canonical +lifecycle history. Run artifacts live under: @@ -553,7 +718,8 @@ safe auto-route default. `WorkflowInvocation` is documented here as the architecture boundary used by OpenLoops and neighboring packages. It is not yet a wire schema in the current catalog; add a `hasna.workflow_invocation.v1` schema only when at least two -packages need to validate the object directly at a shared boundary. +packages need to validate the object directly at a shared boundary. That +future schema would remain separate from `hasna.task_to_pr_projection.v1`. ## Enforcement Model @@ -593,6 +759,13 @@ During dual-version windows, consumers should inspect the embedded `schema` via `validateEmbeddedContract`, route accepted ids to version-specific adapters, and reject unknown schema ids before writing data or launching work. +`hasna.task_to_pr_projection.v1` is a new strict schema id; it does not widen +`hasna.work_run.v1` or any other existing v1 validator. The package source line +advances to `0.6.0` because registry/consumer evidence already showed `0.5.2` +while the repository still reported `0.5.1`. No package publish is performed +by this source change. Existing v1 inputs continue to parse unchanged, and +producers opt into the new projection explicitly by its embedded schema id. + ## Examples The `examples/` directory contains one valid fixture for every known schema plus diff --git a/examples/task-to-pr-projection.valid.json b/examples/task-to-pr-projection.valid.json new file mode 100644 index 0000000..e209657 --- /dev/null +++ b/examples/task-to-pr-projection.valid.json @@ -0,0 +1,622 @@ +{ + "schema": "hasna.task_to_pr_projection.v1", + "id": "task_to_pr_projection:opaque-example", + "createdAt": "2026-07-23T15:10:00.000Z", + "canonicalizationVersion": 1, + "identityDigest": "efa1cc954b50628d575d7b353262264ad29926c12b8094580ab058bfb081fb4d", + "frozenScopeDigest": "2222222222222222222222222222222222222222222222222222222222222222", + "state": "merged", + "workRunRef": { + "role": "work_run", + "authority": "codewith", + "id": "work_run:opaque-example", + "digest": "0101010101010101010101010101010101010101010101010101010101010101", + "redaction": "none" + }, + "rootRequestRef": { + "role": "root_request", + "authority": "todos", + "id": "root_request:opaque-example", + "digest": "4444444444444444444444444444444444444444444444444444444444444444", + "redaction": "none" + }, + "prGroupRef": { + "role": "pr_group", + "authority": "todos", + "id": "pr_group:opaque-example", + "digest": "5555555555555555555555555555555555555555555555555555555555555555", + "redaction": "none" + }, + "leafTaskRef": { + "role": "leaf_task", + "authority": "todos", + "id": "leaf_task:opaque-example", + "digest": "6666666666666666666666666666666666666666666666666666666666666666", + "redaction": "none" + }, + "attempt": { + "ref": { + "role": "attempt", + "authority": "todos", + "id": "attempt:opaque-example", + "digest": "0202020202020202020202020202020202020202020202020202020202020202", + "redaction": "none" + }, + "nonce": "attempt_nonce:opaque-example-0001", + "admissionRef": { + "role": "admission", + "authority": "codewith", + "id": "admission:opaque-example", + "digest": "1111111111111111111111111111111111111111111111111111111111111111", + "redaction": "partial" + }, + "workerRef": { + "role": "worker", + "authority": "codewith", + "id": "worker:opaque-example", + "digest": "2222222222222222222222222222222222222222222222222222222222222222", + "redaction": "partial" + }, + "runtimeRef": { + "role": "runtime", + "authority": "codewith", + "id": "runtime:opaque-example", + "digest": "0303030303030303030303030303030303030303030303030303030303030303", + "redaction": "partial" + }, + "writerGenerationRef": { + "role": "writer_generation", + "authority": "todos", + "id": "writer_generation:opaque-example", + "digest": "8888888888888888888888888888888888888888888888888888888888888888", + "redaction": "none" + }, + "writerLeaseRef": { + "role": "writer_lease", + "authority": "repos", + "id": "writer_lease:opaque-example", + "digest": "9999999999999999999999999999999999999999999999999999999999999999", + "redaction": "partial" + }, + "writerFenceRef": { + "role": "writer_fence", + "authority": "repos", + "id": "writer_fence:opaque-example", + "digest": "0404040404040404040404040404040404040404040404040404040404040404", + "redaction": "full" + }, + "providerProfileRef": { + "role": "provider_profile", + "authority": "codewith", + "id": "provider_profile:opaque-example", + "digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "redaction": "full" + }, + "providerRouteRef": { + "role": "provider_route", + "authority": "codewith", + "id": "provider_route:opaque-example", + "digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "redaction": "partial" + } + }, + "repository": { + "repoRef": { + "role": "repo", + "authority": "repos", + "id": "repo:opaque-example", + "digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "redaction": "none" + }, + "worktreeRef": { + "role": "worktree", + "authority": "repos", + "id": "worktree:opaque-example", + "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "redaction": "partial" + }, + "branchRef": { + "role": "branch", + "authority": "repos", + "id": "branch:opaque-example", + "digest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "redaction": "none" + }, + "baseHead": { + "algorithm": "sha1", + "value": "1111111111111111111111111111111111111111" + }, + "branchHead": { + "algorithm": "sha1", + "value": "2222222222222222222222222222222222222222" + } + }, + "events": { + "streamRef": { + "role": "event_stream", + "authority": "todos", + "id": "event_stream:opaque-example", + "digest": "1111111111111111111111111111111111111111111111111111111111111111", + "redaction": "none" + }, + "replayCursorRef": { + "role": "replay_cursor", + "authority": "todos", + "id": "replay_cursor:opaque-example", + "digest": "2222222222222222222222222222222222222222222222222222222222222222", + "redaction": "none" + }, + "sequence": 7, + "prefixDigest": "3333333333333333333333333333333333333333333333333333333333333333" + }, + "pullRequestRef": { + "role": "pull_request", + "authority": "todos", + "id": "pull_request:opaque-example", + "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "redaction": "none" + }, + "exactHead": { + "pullRequestRef": { + "role": "pull_request", + "authority": "todos", + "id": "pull_request:opaque-example", + "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "redaction": "none" + }, + "remoteBranchRef": { + "role": "branch", + "authority": "repos", + "id": "branch:opaque-example", + "digest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "redaction": "none" + }, + "localHead": { + "algorithm": "sha1", + "value": "2222222222222222222222222222222222222222" + }, + "remoteHead": { + "algorithm": "sha1", + "value": "2222222222222222222222222222222222222222" + }, + "providerPullRequestHead": { + "algorithm": "sha1", + "value": "2222222222222222222222222222222222222222" + }, + "equalityProofRef": { + "role": "proof_bundle", + "authority": "review", + "id": "proof_bundle:opaque-head-equality", + "digest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "redaction": "none" + }, + "ciProofBundleRefs": [ + { + "role": "proof_bundle", + "authority": "review", + "id": "proof_bundle:opaque-ci", + "digest": "0000000000000000000000000000000000000000000000000000000000000000", + "redaction": "none" + } + ], + "verifiedAt": "2026-07-23T15:10:00.000Z" + }, + "reviews": [ + { + "ref": { + "role": "review", + "authority": "review", + "id": "review:opaque-example", + "digest": "6666666666666666666666666666666666666666666666666666666666666666", + "redaction": "none" + }, + "pullRequestRef": { + "role": "pull_request", + "authority": "todos", + "id": "pull_request:opaque-example", + "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "redaction": "none" + }, + "head": { + "algorithm": "sha1", + "value": "2222222222222222222222222222222222222222" + }, + "reviewerRef": { + "role": "reviewer", + "authority": "review", + "id": "reviewer:opaque-example", + "digest": "4444444444444444444444444444444444444444444444444444444444444444", + "redaction": "none" + }, + "reviewRunRef": { + "role": "review_run", + "authority": "review", + "id": "review_run:opaque-example", + "digest": "5555555555555555555555555555555555555555555555555555555555555555", + "redaction": "none" + }, + "proofBundleRef": { + "role": "proof_bundle", + "authority": "review", + "id": "proof_bundle:opaque-review", + "digest": "1111111111111111111111111111111111111111111111111111111111111111", + "redaction": "none" + }, + "verdict": "approved", + "reviewedAt": "2026-07-23T15:10:00.000Z" + } + ], + "repair": { + "ref": { + "role": "repair_cycle", + "authority": "todos", + "id": "repair_cycle:opaque-zero", + "digest": "4444444444444444444444444444444444444444444444444444444444444444", + "redaction": "none" + }, + "cycle": 0, + "cap": 2, + "exhausted": false + }, + "merge": { + "guard": { + "ref": { + "role": "merge_guard", + "authority": "todos", + "id": "merge_guard:opaque-example", + "digest": "7777777777777777777777777777777777777777777777777777777777777777", + "redaction": "none" + }, + "pullRequestRef": { + "role": "pull_request", + "authority": "todos", + "id": "pull_request:opaque-example", + "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "redaction": "none" + }, + "expectedHead": { + "algorithm": "sha1", + "value": "2222222222222222222222222222222222222222" + }, + "reviewRefs": [ + { + "role": "review", + "authority": "review", + "id": "review:opaque-example", + "digest": "6666666666666666666666666666666666666666666666666666666666666666", + "redaction": "none" + } + ], + "proofBundleRefs": [ + { + "role": "proof_bundle", + "authority": "review", + "id": "proof_bundle:opaque-review", + "digest": "1111111111111111111111111111111111111111111111111111111111111111", + "redaction": "none" + }, + { + "role": "proof_bundle", + "authority": "review", + "id": "proof_bundle:opaque-head-equality", + "digest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "redaction": "none" + }, + { + "role": "proof_bundle", + "authority": "review", + "id": "proof_bundle:opaque-ci", + "digest": "0000000000000000000000000000000000000000000000000000000000000000", + "redaction": "none" + } + ], + "operatorRef": { + "role": "merge_operator", + "authority": "merge_provider", + "id": "merge_operator:opaque-example", + "digest": "8888888888888888888888888888888888888888888888888888888888888888", + "redaction": "partial" + }, + "operatorRunRef": { + "role": "merge_operator_run", + "authority": "merge_provider", + "id": "merge_operator_run:opaque-example", + "digest": "9999999999999999999999999999999999999999999999999999999999999999", + "redaction": "partial" + }, + "providerGuardReceiptRef": { + "role": "merge_guard_receipt", + "authority": "merge_provider", + "id": "merge_guard_receipt:opaque-example", + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "redaction": "full" + }, + "mechanism": "compare_and_swap", + "decision": "eligible", + "evaluatedAt": "2026-07-23T15:10:00.000Z" + }, + "outcome": { + "ref": { + "role": "merge_outcome", + "authority": "merge_provider", + "id": "merge_outcome:opaque-example", + "digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "redaction": "partial" + }, + "guardRef": { + "role": "merge_guard", + "authority": "todos", + "id": "merge_guard:opaque-example", + "digest": "7777777777777777777777777777777777777777777777777777777777777777", + "redaction": "none" + }, + "pullRequestRef": { + "role": "pull_request", + "authority": "todos", + "id": "pull_request:opaque-example", + "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "redaction": "none" + }, + "expectedHead": { + "algorithm": "sha1", + "value": "2222222222222222222222222222222222222222" + }, + "observedHead": { + "algorithm": "sha1", + "value": "2222222222222222222222222222222222222222" + }, + "status": "merged", + "mergeCommitRef": { + "role": "commit", + "authority": "repos", + "id": "commit:opaque-example", + "digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "redaction": "none" + }, + "finishedAt": "2026-07-23T15:10:00.000Z", + "evidenceRefs": [ + { + "id": "evidence:opaque-merge-cas", + "digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "redaction": "full" + } + ] + } + }, + "provenanceLedger": [ + { + "category": "projection_id", + "projectionId": "task_to_pr_projection:opaque-example" + }, + { + "category": "work_run", + "ref": { + "role": "work_run", + "authority": "codewith", + "id": "work_run:opaque-example", + "digest": "0101010101010101010101010101010101010101010101010101010101010101", + "redaction": "none" + } + }, + { + "category": "attempt", + "ref": { + "role": "attempt", + "authority": "todos", + "id": "attempt:opaque-example", + "digest": "0202020202020202020202020202020202020202020202020202020202020202", + "redaction": "none" + } + }, + { + "category": "attempt_nonce", + "nonce": "attempt_nonce:opaque-example-0001" + }, + { + "category": "runtime", + "ref": { + "role": "runtime", + "authority": "codewith", + "id": "runtime:opaque-example", + "digest": "0303030303030303030303030303030303030303030303030303030303030303", + "redaction": "partial" + } + }, + { + "category": "writer_generation", + "ref": { + "role": "writer_generation", + "authority": "todos", + "id": "writer_generation:opaque-example", + "digest": "8888888888888888888888888888888888888888888888888888888888888888", + "redaction": "none" + } + }, + { + "category": "writer_lease", + "ref": { + "role": "writer_lease", + "authority": "repos", + "id": "writer_lease:opaque-example", + "digest": "9999999999999999999999999999999999999999999999999999999999999999", + "redaction": "partial" + } + }, + { + "category": "writer_fence", + "ref": { + "role": "writer_fence", + "authority": "repos", + "id": "writer_fence:opaque-example", + "digest": "0404040404040404040404040404040404040404040404040404040404040404", + "redaction": "full" + } + }, + { + "category": "provider_profile", + "ref": { + "role": "provider_profile", + "authority": "codewith", + "id": "provider_profile:opaque-example", + "digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "redaction": "full" + } + }, + { + "category": "provider_route", + "ref": { + "role": "provider_route", + "authority": "codewith", + "id": "provider_route:opaque-example", + "digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "redaction": "partial" + } + }, + { + "category": "replay_cursor", + "ref": { + "role": "replay_cursor", + "authority": "todos", + "id": "replay_cursor:opaque-example", + "digest": "2222222222222222222222222222222222222222222222222222222222222222", + "redaction": "none" + } + }, + { + "category": "replay_prefix", + "sequence": 7, + "prefixDigest": "3333333333333333333333333333333333333333333333333333333333333333" + }, + { + "category": "repair_state", + "ref": { + "role": "repair_cycle", + "authority": "todos", + "id": "repair_cycle:opaque-zero", + "digest": "4444444444444444444444444444444444444444444444444444444444444444", + "redaction": "none" + } + }, + { + "category": "equality_proof", + "ref": { + "role": "proof_bundle", + "authority": "review", + "id": "proof_bundle:opaque-head-equality", + "digest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "redaction": "none" + }, + "head": { + "algorithm": "sha1", + "value": "2222222222222222222222222222222222222222" + } + }, + { + "category": "ci_proof", + "ref": { + "role": "proof_bundle", + "authority": "review", + "id": "proof_bundle:opaque-ci", + "digest": "0000000000000000000000000000000000000000000000000000000000000000", + "redaction": "none" + }, + "head": { + "algorithm": "sha1", + "value": "2222222222222222222222222222222222222222" + } + }, + { + "category": "review_proof", + "ref": { + "role": "proof_bundle", + "authority": "review", + "id": "proof_bundle:opaque-review", + "digest": "1111111111111111111111111111111111111111111111111111111111111111", + "redaction": "none" + }, + "head": { + "algorithm": "sha1", + "value": "2222222222222222222222222222222222222222" + } + }, + { + "category": "review_record", + "ref": { + "role": "review", + "authority": "review", + "id": "review:opaque-example", + "digest": "6666666666666666666666666666666666666666666666666666666666666666", + "redaction": "none" + }, + "head": { + "algorithm": "sha1", + "value": "2222222222222222222222222222222222222222" + } + }, + { + "category": "review_run", + "ref": { + "role": "review_run", + "authority": "review", + "id": "review_run:opaque-example", + "digest": "5555555555555555555555555555555555555555555555555555555555555555", + "redaction": "none" + }, + "head": { + "algorithm": "sha1", + "value": "2222222222222222222222222222222222222222" + } + }, + { + "category": "merge_guard", + "ref": { + "role": "merge_guard", + "authority": "todos", + "id": "merge_guard:opaque-example", + "digest": "7777777777777777777777777777777777777777777777777777777777777777", + "redaction": "none" + } + }, + { + "category": "provider_guard_receipt", + "ref": { + "role": "merge_guard_receipt", + "authority": "merge_provider", + "id": "merge_guard_receipt:opaque-example", + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "redaction": "full" + }, + "head": { + "algorithm": "sha1", + "value": "2222222222222222222222222222222222222222" + } + } + ], + "adapterExtensions": [ + { + "mode": "local", + "schema": "hasna.task_to_pr_adapter_extension.v1", + "ref": { + "role": "adapter_extension", + "authority": "adapter", + "id": "adapter_extension:opaque-local-example", + "digest": "5555555555555555555555555555555555555555555555555555555555555555", + "redaction": "partial" + }, + "digest": "6666666666666666666666666666666666666666666666666666666666666666" + }, + { + "mode": "cloud", + "schema": "hasna.task_to_pr_adapter_extension.v1", + "ref": { + "role": "adapter_extension", + "authority": "adapter", + "id": "adapter_extension:opaque-cloud-example", + "digest": "7777777777777777777777777777777777777777777777777777777777777777", + "redaction": "full" + }, + "digest": "8888888888888888888888888888888888888888888888888888888888888888" + } + ], + "evidenceRefs": [] +} diff --git a/package.json b/package.json index 23dfeb7..2247617 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hasna/contracts", - "version": "0.5.3", + "version": "0.6.0", "description": "Shared schemas and validators for Hasna open-source agent infrastructure contracts.", "type": "module", "license": "Apache-2.0", diff --git a/src/schemas.ts b/src/schemas.ts index 072347a..51089c9 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1,13 +1,15 @@ +import { createHash } from "node:crypto"; import { z } from "zod"; export const CONTRACTS_PACKAGE_NAME = "@hasna/contracts"; -export const CONTRACTS_PACKAGE_VERSION = "0.5.3"; +export const CONTRACTS_PACKAGE_VERSION = "0.6.0"; export const SCHEMA_IDS = { actorRef: "hasna.actor_ref.v1", resourceRef: "hasna.resource_ref.v1", evidenceRef: "hasna.evidence_ref.v1", workRun: "hasna.work_run.v1", + taskToPrProjection: "hasna.task_to_pr_projection.v1", decisionEnvelope: "hasna.decision_envelope.v1", costEstimate: "hasna.cost_estimate.v1", capabilityCard: "hasna.capability_card.v1", @@ -2030,6 +2032,2512 @@ export const WorkRunSchema = contractBaseSchema(SCHEMA_IDS.workRun) }); export type WorkRun = z.infer; +export const TASK_TO_PR_ROLE_AUTHORITIES = Object.freeze({ + work_run: Object.freeze(["codewith"] as const), + root_request: Object.freeze(["todos"] as const), + pr_group: Object.freeze(["todos"] as const), + leaf_task: Object.freeze(["todos"] as const), + attempt: Object.freeze(["todos"] as const), + writer_generation: Object.freeze(["todos"] as const), + writer_lease: Object.freeze(["repos"] as const), + writer_fence: Object.freeze(["repos"] as const), + provider_profile: Object.freeze(["codewith"] as const), + provider_route: Object.freeze(["codewith"] as const), + admission: Object.freeze(["codewith"] as const), + worker: Object.freeze(["codewith"] as const), + runtime: Object.freeze(["codewith"] as const), + repo: Object.freeze(["repos"] as const), + worktree: Object.freeze(["repos"] as const), + branch: Object.freeze(["repos"] as const), + event_stream: Object.freeze(["todos"] as const), + replay_cursor: Object.freeze(["todos"] as const), + handoff: Object.freeze(["todos"] as const), + pull_request: Object.freeze(["todos"] as const), + commit: Object.freeze(["repos"] as const), + review: Object.freeze(["review"] as const), + reviewer: Object.freeze(["review"] as const), + review_run: Object.freeze(["review"] as const), + proof_bundle: Object.freeze(["review"] as const), + repair_cycle: Object.freeze(["todos"] as const), + merge_guard: Object.freeze(["todos"] as const), + merge_operator: Object.freeze(["merge_provider"] as const), + merge_operator_run: Object.freeze(["merge_provider"] as const), + merge_guard_receipt: Object.freeze(["merge_provider"] as const), + merge_outcome: Object.freeze(["merge_provider"] as const), + recovery: Object.freeze(["todos"] as const), + cancellation: Object.freeze(["todos"] as const), + cleanup_eligibility: Object.freeze(["repos"] as const), + cleanup_outcome: Object.freeze(["repos"] as const), + rollback_plan: Object.freeze(["todos"] as const), + rollback_outcome: Object.freeze(["repos"] as const), + openloops_invocation: Object.freeze(["openloops"] as const), + adapter_extension: Object.freeze(["adapter"] as const) +}); + +export const TaskToPrRefRoleSchema = z.enum([ + "work_run", + "root_request", + "pr_group", + "leaf_task", + "attempt", + "writer_generation", + "writer_lease", + "writer_fence", + "provider_profile", + "provider_route", + "admission", + "worker", + "runtime", + "repo", + "worktree", + "branch", + "event_stream", + "replay_cursor", + "handoff", + "pull_request", + "commit", + "review", + "reviewer", + "review_run", + "proof_bundle", + "repair_cycle", + "merge_guard", + "merge_operator", + "merge_operator_run", + "merge_guard_receipt", + "merge_outcome", + "recovery", + "cancellation", + "cleanup_eligibility", + "cleanup_outcome", + "rollback_plan", + "rollback_outcome", + "openloops_invocation", + "adapter_extension" +]); +export type TaskToPrRefRole = z.infer; + +export const TaskToPrAuthoritySchema = z.enum([ + "todos", + "codewith", + "repos", + "review", + "merge_provider", + "openloops", + "adapter" +]); +export type TaskToPrAuthority = z.infer; + +const LowerSha256DigestSchema = z.string().regex(/^[a-f0-9]{64}$/); +const OpaqueTaskToPrIdSchema = z + .string() + .trim() + .min(3) + .max(256) + .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/, "Reference ids must be opaque identifiers, never accounts, URLs, paths, or queries"); +const ForbiddenOpaqueOwnerDirectPattern = + /(?:@|[/?#=&+]|-----BEGIN|github_pat_|gh[pousr]_|sk-[A-Za-z0-9]|AKIA[0-9A-Z])/i; +const ForbiddenOpaqueOwnerNormalizedMarkerPattern = + /(?:bearer|password|secret|credential|account|fence|token|privatekey|apikey|githubpat)/i; +const OpaqueOwnerSuffixPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; +function containsForbiddenTaskToPrMarker(value: string): boolean { + return ( + ForbiddenOpaqueOwnerDirectPattern.test(value) || + ForbiddenOpaqueOwnerNormalizedMarkerPattern.test(value.replace(/[._:-]+/g, "")) + ); +} +const TaskToPrProjectionIdSchema = OpaqueTaskToPrIdSchema.refine( + (value) => { + const prefix = "task_to_pr_projection:opaque-"; + const suffix = value.startsWith(prefix) ? value.slice(prefix.length) : ""; + return OpaqueOwnerSuffixPattern.test(suffix) && !containsForbiddenTaskToPrMarker(suffix); + }, + "Projection ids must be typed opaque owner-record refs and cannot encode accounts, fences, locators, or credentials" +); +const TaskToPrAttemptNonceSchema = OpaqueTaskToPrIdSchema.refine( + (value) => { + const prefix = "attempt_nonce:opaque-"; + const suffix = value.startsWith(prefix) ? value.slice(prefix.length) : ""; + return OpaqueOwnerSuffixPattern.test(suffix) && !containsForbiddenTaskToPrMarker(suffix); + }, + "Attempt nonces must be typed opaque values and cannot encode accounts, fences, locators, or credentials" +); +const SensitiveTaskToPrRoles = new Set([ + "writer_lease", + "writer_fence", + "provider_profile", + "provider_route", + "admission", + "worker", + "runtime", + "worktree", + "merge_operator", + "merge_operator_run", + "merge_guard_receipt", + "merge_outcome", + "openloops_invocation", + "adapter_extension" +]); +export const TaskToPrRefSchema = z + .object({ + role: TaskToPrRefRoleSchema, + authority: TaskToPrAuthoritySchema, + id: OpaqueTaskToPrIdSchema, + digest: LowerSha256DigestSchema, + redaction: z.enum(["none", "partial", "full"]) + }) + .strict() + .superRefine((value, ctx) => { + const allowedAuthorities = TASK_TO_PR_ROLE_AUTHORITIES[value.role] as readonly TaskToPrAuthority[]; + if (!allowedAuthorities.includes(value.authority)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${value.role} refs must be owned by ${allowedAuthorities.join(" or ")}`, + path: ["authority"] + }); + } + if (SensitiveTaskToPrRoles.has(value.role) && value.redaction === "none") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${value.role} refs must be redacted and cannot carry a raw locator or credential`, + path: ["redaction"] + }); + } + if (SensitiveTaskToPrRoles.has(value.role)) { + const sensitiveSuffix = value.id.startsWith(`${value.role}:opaque-`) + ? value.id.slice(`${value.role}:opaque-`.length) + : ""; + if (!OpaqueOwnerSuffixPattern.test(sensitiveSuffix) || containsForbiddenTaskToPrMarker(sensitiveSuffix)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${value.role} ids must be role-prefixed opaque owner-record refs, never raw account, path, credential, or fence values`, + path: ["id"] + }); + } + } + const expectedPrefix = `${value.role}:opaque-`; + const opaqueSuffix = value.id.startsWith(expectedPrefix) ? value.id.slice(expectedPrefix.length) : value.id; + if ( + !value.id.startsWith(expectedPrefix) || + !OpaqueOwnerSuffixPattern.test(opaqueSuffix) || + containsForbiddenTaskToPrMarker(opaqueSuffix) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Reference ids must be typed opaque owner refs and cannot contain account, fence, credential, URL, or secret markers", + path: ["id"] + }); + } + }); +export type TaskToPrRef = z.infer; + +export const TaskToPrEvidenceRefSchema = z + .object({ + id: OpaqueTaskToPrIdSchema, + digest: LowerSha256DigestSchema, + redaction: z.enum(["partial", "full"]) + }) + .strict() + .superRefine((value, ctx) => { + const prefix = "evidence:opaque-"; + const suffix = value.id.startsWith(prefix) ? value.id.slice(prefix.length) : ""; + if ( + !OpaqueOwnerSuffixPattern.test(suffix) || + containsForbiddenTaskToPrMarker(suffix) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Evidence ids must be typed opaque refs with no account, fence, URL, credential, or secret markers", + path: ["id"] + }); + } + }); +export type TaskToPrEvidenceRef = z.infer; + +function taskToPrRefFor(role: TaskToPrRefRole) { + return TaskToPrRefSchema.refine((value) => value.role === role, { + message: `Reference must use role ${role}`, + path: ["role"] + }); +} + +function sameTaskToPrRef(left: TaskToPrRef, right: TaskToPrRef): boolean { + return ( + left.role === right.role && + left.authority === right.authority && + left.id === right.id && + left.digest === right.digest && + left.redaction === right.redaction + ); +} + +function sameTaskToPrCanonicalRefId(left: TaskToPrRef, right: TaskToPrRef): boolean { + return left.role === right.role && left.authority === right.authority && left.id === right.id; +} + +function requireFreshTaskToPrRef( + prior: TaskToPrRef, + successor: TaskToPrRef, + ctx: z.RefinementCtx, + path: (string | number)[], + label: string +): void { + if (sameTaskToPrCanonicalRefId(prior, successor)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${label} requires a fresh canonical role/authority/id`, + path + }); + } + if (prior.digest === successor.digest) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${label} requires a fresh canonical digest`, + path + }); + } +} + +function taskToPrCanonicalRefKey(ref: TaskToPrRef): string { + return `${ref.role}\u0000${ref.authority}\u0000${ref.id}`; +} + +function sameGitObjectId( + left: z.infer, + right: z.infer +): boolean { + return left.algorithm === right.algorithm && left.value === right.value; +} + +export const TaskToPrGitObjectIdSchema = z + .object({ + algorithm: z.enum(["sha1", "sha256"]), + value: z.string().regex(/^[a-f0-9]+$/) + }) + .strict() + .superRefine((value, ctx) => { + const requiredLength = value.algorithm === "sha1" ? 40 : 64; + if (value.value.length !== requiredLength) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${value.algorithm} object ids must contain exactly ${requiredLength} lowercase hex characters`, + path: ["value"] + }); + } + }); +export type TaskToPrGitObjectId = z.infer; + +export interface TaskToPrBindingInput { + canonicalizationVersion: 1; + rootRequestRef: TaskToPrRef; + prGroupRef: TaskToPrRef; + leafTaskRef: TaskToPrRef; + repoRef: TaskToPrRef; + baseHead: TaskToPrGitObjectId; + frozenScopeDigest: string; +} + +export function deriveTaskToPrIdentityDigest(input: TaskToPrBindingInput): string { + const canonicalBinding = JSON.stringify([ + "hasna.task_to_pr_projection.binding.v1", + input.canonicalizationVersion, + input.rootRequestRef.id, + input.rootRequestRef.digest, + input.prGroupRef.id, + input.prGroupRef.digest, + input.leafTaskRef.id, + input.leafTaskRef.digest, + input.repoRef.id, + input.repoRef.digest, + input.baseHead.algorithm, + input.baseHead.value, + input.frozenScopeDigest + ]); + return createHash("sha256").update(canonicalBinding, "utf8").digest("hex"); +} + +export const TaskToPrAttemptSchema = z + .object({ + ref: taskToPrRefFor("attempt"), + nonce: TaskToPrAttemptNonceSchema, + admissionRef: taskToPrRefFor("admission"), + workerRef: taskToPrRefFor("worker"), + runtimeRef: taskToPrRefFor("runtime"), + writerGenerationRef: taskToPrRefFor("writer_generation"), + writerLeaseRef: taskToPrRefFor("writer_lease"), + writerFenceRef: taskToPrRefFor("writer_fence"), + providerProfileRef: taskToPrRefFor("provider_profile"), + providerRouteRef: taskToPrRefFor("provider_route") + }) + .strict(); +export type TaskToPrAttempt = z.infer; + +export const TaskToPrRepositoryBindingSchema = z + .object({ + repoRef: taskToPrRefFor("repo"), + worktreeRef: taskToPrRefFor("worktree"), + branchRef: taskToPrRefFor("branch"), + baseHead: TaskToPrGitObjectIdSchema, + branchHead: TaskToPrGitObjectIdSchema + }) + .strict(); +export type TaskToPrRepositoryBinding = z.infer; + +export const TaskToPrEventCursorSchema = z + .object({ + streamRef: taskToPrRefFor("event_stream"), + replayCursorRef: taskToPrRefFor("replay_cursor"), + sequence: z.number().int().nonnegative(), + prefixDigest: LowerSha256DigestSchema + }) + .strict(); +export type TaskToPrEventCursor = z.infer; + +export const TaskToPrHandoffSchema = z + .object({ + ref: taskToPrRefFor("handoff"), + previousAttemptRef: taskToPrRefFor("attempt"), + nextAttemptRef: taskToPrRefFor("attempt"), + previousWriterGenerationRef: taskToPrRefFor("writer_generation"), + nextWriterGenerationRef: taskToPrRefFor("writer_generation"), + stoppedWorkRunRef: taskToPrRefFor("work_run"), + stopEvidenceRef: TaskToPrEvidenceRefSchema, + leaseRevocationEvidenceRef: TaskToPrEvidenceRefSchema + }) + .strict() + .superRefine((value, ctx) => { + requireFreshTaskToPrRef( + value.previousAttemptRef, + value.nextAttemptRef, + ctx, + ["nextAttemptRef"], + "Handoff attempt rotation" + ); + requireFreshTaskToPrRef( + value.previousWriterGenerationRef, + value.nextWriterGenerationRef, + ctx, + ["nextWriterGenerationRef"], + "Handoff writer-generation rotation" + ); + }); +export type TaskToPrHandoff = z.infer; + +export const TaskToPrReviewBindingSchema = z + .object({ + ref: taskToPrRefFor("review"), + pullRequestRef: taskToPrRefFor("pull_request"), + head: TaskToPrGitObjectIdSchema, + reviewerRef: taskToPrRefFor("reviewer"), + reviewRunRef: taskToPrRefFor("review_run"), + proofBundleRef: taskToPrRefFor("proof_bundle"), + verdict: z.enum(["approved", "changes_requested", "blocked"]), + reviewedAt: TimestampSchema + }) + .strict(); +export type TaskToPrReviewBinding = z.infer; + +export const TaskToPrExactHeadBindingSchema = z + .object({ + pullRequestRef: taskToPrRefFor("pull_request"), + remoteBranchRef: taskToPrRefFor("branch"), + localHead: TaskToPrGitObjectIdSchema, + remoteHead: TaskToPrGitObjectIdSchema, + providerPullRequestHead: TaskToPrGitObjectIdSchema, + equalityProofRef: taskToPrRefFor("proof_bundle"), + ciProofBundleRefs: z.array(taskToPrRefFor("proof_bundle")).min(1), + verifiedAt: TimestampSchema + }) + .strict() + .superRefine((value, ctx) => { + if ( + !sameGitObjectId(value.localHead, value.remoteHead) || + !sameGitObjectId(value.localHead, value.providerPullRequestHead) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Local, remote, and provider pull-request heads must be exactly equal", + path: ["providerPullRequestHead"] + }); + } + const proofKeys = value.ciProofBundleRefs.map(taskToPrCanonicalRefKey); + if (new Set(proofKeys).size !== proofKeys.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "CI proof bundle refs must have unique canonical identities", + path: ["ciProofBundleRefs"] + }); + } + const proofDigests = value.ciProofBundleRefs.map((ref) => ref.digest); + if (new Set(proofDigests).size !== proofDigests.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "CI proof bundle refs must have unique canonical digests", + path: ["ciProofBundleRefs"] + }); + } + if (value.ciProofBundleRefs.some((ref) => sameTaskToPrCanonicalRefId(ref, value.equalityProofRef))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Head-equality and CI proof refs must have distinct canonical identities", + path: ["ciProofBundleRefs"] + }); + } + if (value.ciProofBundleRefs.some((ref) => ref.digest === value.equalityProofRef.digest)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Head-equality and CI proof refs must have distinct canonical digests", + path: ["ciProofBundleRefs"] + }); + } + }); +export type TaskToPrExactHeadBinding = z.infer; + +export const TaskToPrRepairStateSchema = z + .object({ + ref: taskToPrRefFor("repair_cycle"), + cycle: z.number().int().min(0).max(2), + cap: z.literal(2), + exhausted: z.boolean(), + latestRepairRef: taskToPrRefFor("repair_cycle").optional() + }) + .strict() + .superRefine((value, ctx) => { + if (value.exhausted !== (value.cycle === value.cap)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Repair exhaustion must equal the cumulative cycle cap", + path: ["exhausted"] + }); + } + if (value.cycle === 0 && value.latestRepairRef) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Cycle zero cannot reference a repair", + path: ["latestRepairRef"] + }); + } + if (value.cycle > 0 && !value.latestRepairRef) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Non-zero repair state requires the latest immutable repair ref", + path: ["latestRepairRef"] + }); + } + if (value.latestRepairRef && sameTaskToPrCanonicalRefId(value.ref, value.latestRepairRef)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Repair-state and latest-repair refs must be distinct canonical records", + path: ["latestRepairRef"] + }); + } + if (value.latestRepairRef && value.ref.digest === value.latestRepairRef.digest) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Repair-state and latest-repair refs must have distinct canonical digests", + path: ["latestRepairRef"] + }); + } + }); +export type TaskToPrRepairState = z.infer; + +export const TaskToPrMergeGuardSchema = z + .object({ + ref: taskToPrRefFor("merge_guard"), + pullRequestRef: taskToPrRefFor("pull_request"), + expectedHead: TaskToPrGitObjectIdSchema, + reviewRefs: z.array(taskToPrRefFor("review")).min(1), + proofBundleRefs: z.array(taskToPrRefFor("proof_bundle")).min(1), + operatorRef: taskToPrRefFor("merge_operator"), + operatorRunRef: taskToPrRefFor("merge_operator_run"), + providerGuardReceiptRef: taskToPrRefFor("merge_guard_receipt"), + mechanism: z.enum(["compare_and_swap", "queue_expected_head"]), + decision: z.enum(["eligible", "denied"]), + evaluatedAt: TimestampSchema + }) + .strict() + .superRefine((value, ctx) => { + const uniqueReviews = new Set(value.reviewRefs.map((ref) => ref.id)); + if (uniqueReviews.size !== value.reviewRefs.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merge guard review refs must be unique", + path: ["reviewRefs"] + }); + } + const uniqueProofs = new Set(value.proofBundleRefs.map(taskToPrCanonicalRefKey)); + if (uniqueProofs.size !== value.proofBundleRefs.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merge guard proof refs must have unique canonical identities", + path: ["proofBundleRefs"] + }); + } + const uniqueProofDigests = new Set(value.proofBundleRefs.map((ref) => ref.digest)); + if (uniqueProofDigests.size !== value.proofBundleRefs.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merge guard proof refs must have unique canonical digests", + path: ["proofBundleRefs"] + }); + } + }); +export type TaskToPrMergeGuard = z.infer; + +export const TaskToPrMergeOutcomeSchema = z + .object({ + ref: taskToPrRefFor("merge_outcome"), + guardRef: taskToPrRefFor("merge_guard"), + pullRequestRef: taskToPrRefFor("pull_request"), + expectedHead: TaskToPrGitObjectIdSchema, + observedHead: TaskToPrGitObjectIdSchema, + status: z.enum(["merged", "closed_unmerged", "refused", "head_drift"]), + mergeCommitRef: taskToPrRefFor("commit").optional(), + finishedAt: TimestampSchema, + evidenceRefs: z.array(TaskToPrEvidenceRefSchema).min(1) + }) + .strict() + .superRefine((value, ctx) => { + const headMatches = sameGitObjectId(value.expectedHead, value.observedHead); + if (value.status === "merged") { + if (!headMatches) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merged outcomes require the observed head to equal the expected head", + path: ["observedHead"] + }); + } + if (!value.mergeCommitRef) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merged outcomes require an immutable merge commit ref", + path: ["mergeCommitRef"] + }); + } + } else if (value.mergeCommitRef) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Unmerged outcomes cannot claim a merge commit", + path: ["mergeCommitRef"] + }); + } + if (value.status === "head_drift" && headMatches) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Head-drift outcomes require distinct expected and observed heads", + path: ["observedHead"] + }); + } + if (!headMatches && value.status !== "head_drift") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Only a head_drift outcome may record an observed head that differs from the expected head", + path: ["observedHead"] + }); + } + }); +export type TaskToPrMergeOutcome = z.infer; + +export const TaskToPrMergeStateSchema = z + .object({ + guard: TaskToPrMergeGuardSchema, + outcome: TaskToPrMergeOutcomeSchema.optional() + }) + .strict(); +export type TaskToPrMergeState = z.infer; + +export const TaskToPrRecoverySchema = z + .object({ + ref: taskToPrRefFor("recovery"), + priorAttemptRef: taskToPrRefFor("attempt"), + priorWriterGenerationRef: taskToPrRefFor("writer_generation"), + priorWorkRunRef: taskToPrRefFor("work_run"), + successorAttemptNonce: TaskToPrAttemptNonceSchema, + successorWriterGenerationRef: taskToPrRefFor("writer_generation"), + preservedStateRefs: z.array(TaskToPrRefSchema).min(1), + stopEvidenceRef: TaskToPrEvidenceRefSchema, + leaseRevocationEvidenceRef: TaskToPrEvidenceRefSchema + }) + .strict() + .superRefine((value, ctx) => { + requireFreshTaskToPrRef( + value.priorWriterGenerationRef, + value.successorWriterGenerationRef, + ctx, + ["successorWriterGenerationRef"], + "Recovery writer-generation rotation" + ); + }); +export type TaskToPrRecovery = z.infer; + +export const TaskToPrCancellationSchema = z + .object({ + ref: taskToPrRefFor("cancellation"), + cancelledAttemptRef: taskToPrRefFor("attempt"), + preservedStateRefs: z.array(TaskToPrRefSchema).min(1), + evidenceRefs: z.array(TaskToPrEvidenceRefSchema).min(1) + }) + .strict(); +export type TaskToPrCancellation = z.infer; + +export const TaskToPrCleanupEligibilitySchema = z + .object({ + ref: taskToPrRefFor("cleanup_eligibility"), + status: z.enum(["not_ready", "preserved", "blocked", "eligible"]), + targetWorktreeRef: taskToPrRefFor("worktree"), + eventCursorRef: taskToPrRefFor("replay_cursor"), + evaluatedAt: TimestampSchema, + evidenceRefs: z.array(TaskToPrEvidenceRefSchema).min(1) + }) + .strict(); +export type TaskToPrCleanupEligibility = z.infer; + +export const TaskToPrCleanupOutcomeSchema = z + .object({ + ref: taskToPrRefFor("cleanup_outcome"), + eligibilityRef: taskToPrRefFor("cleanup_eligibility"), + targetWorktreeRef: taskToPrRefFor("worktree"), + status: z.enum(["preserved", "deleted", "failed", "skipped"]), + finishedAt: TimestampSchema, + evidenceRefs: z.array(TaskToPrEvidenceRefSchema).min(1) + }) + .strict(); +export type TaskToPrCleanupOutcome = z.infer; + +export const TaskToPrCleanupStateSchema = z + .object({ + eligibility: TaskToPrCleanupEligibilitySchema, + outcome: TaskToPrCleanupOutcomeSchema.optional() + }) + .strict() + .superRefine((value, ctx) => { + if ( + value.outcome && + !sameTaskToPrRef(value.outcome.eligibilityRef, value.eligibility.ref) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Cleanup outcomes must bind the exact eligibility decision", + path: ["outcome", "eligibilityRef"] + }); + } + if ( + value.outcome && + !sameTaskToPrRef(value.outcome.targetWorktreeRef, value.eligibility.targetWorktreeRef) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Cleanup eligibility and outcome must bind the same target worktree", + path: ["outcome", "targetWorktreeRef"] + }); + } + if (value.outcome?.status === "deleted" && value.eligibility.status !== "eligible") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Deletion requires an eligible cleanup decision", + path: ["outcome", "status"] + }); + } + }); +export type TaskToPrCleanupState = z.infer; + +export const TaskToPrRollbackSchema = z + .object({ + plan: z + .object({ + ref: taskToPrRefFor("rollback_plan"), + targetRef: z.union([taskToPrRefFor("commit"), taskToPrRefFor("branch")]), + createdAt: TimestampSchema + }) + .strict(), + outcome: z + .object({ + ref: taskToPrRefFor("rollback_outcome"), + planRef: taskToPrRefFor("rollback_plan"), + targetRef: z.union([taskToPrRefFor("commit"), taskToPrRefFor("branch")]), + status: z.enum(["not_run", "succeeded", "failed", "cancelled"]), + finishedAt: TimestampSchema, + evidenceRefs: z.array(TaskToPrEvidenceRefSchema).min(1) + }) + .strict() + .optional() + }) + .strict() + .superRefine((value, ctx) => { + if (value.outcome && !sameTaskToPrRef(value.outcome.planRef, value.plan.ref)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Rollback outcomes must bind the exact rollback plan", + path: ["outcome", "planRef"] + }); + } + if (value.outcome && !sameTaskToPrRef(value.outcome.targetRef, value.plan.targetRef)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Rollback outcomes must bind the exact rollback target", + path: ["outcome", "targetRef"] + }); + } + if (value.outcome && Date.parse(value.outcome.finishedAt) < Date.parse(value.plan.createdAt)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Rollback outcomes cannot finish before their plan was created", + path: ["outcome", "finishedAt"] + }); + } + }); +export type TaskToPrRollback = z.infer; + +export type TaskToPrProvenanceEntry = + | { + category: + | "work_run" + | "attempt" + | "runtime" + | "writer_generation" + | "writer_lease" + | "writer_fence" + | "provider_profile" + | "provider_route" + | "replay_cursor" + | "repair_state" + | "latest_repair" + | "handoff" + | "recovery" + | "merge_guard" + | "cleanup_eligibility" + | "rollback_plan"; + ref: TaskToPrRef; + } + | { + category: + | "equality_proof" + | "ci_proof" + | "review_proof" + | "review_record" + | "review_run" + | "provider_guard_receipt"; + ref: TaskToPrRef; + head: TaskToPrGitObjectId; + } + | { + category: "projection_id"; + projectionId: string; + } + | { + category: "attempt_nonce"; + nonce: string; + } + | { + category: "replay_prefix"; + sequence: number; + prefixDigest: string; + }; + +export const TaskToPrProvenanceEntrySchema: z.ZodType = z.discriminatedUnion("category", [ + z + .object({ + category: z.literal("projection_id"), + projectionId: TaskToPrProjectionIdSchema + }) + .strict(), + z + .object({ + category: z.literal("work_run"), + ref: taskToPrRefFor("work_run") + }) + .strict(), + z + .object({ + category: z.literal("attempt"), + ref: taskToPrRefFor("attempt") + }) + .strict(), + z + .object({ + category: z.literal("attempt_nonce"), + nonce: TaskToPrAttemptNonceSchema + }) + .strict(), + z + .object({ + category: z.literal("runtime"), + ref: taskToPrRefFor("runtime") + }) + .strict(), + z + .object({ + category: z.literal("writer_generation"), + ref: taskToPrRefFor("writer_generation") + }) + .strict(), + z + .object({ + category: z.literal("writer_lease"), + ref: taskToPrRefFor("writer_lease") + }) + .strict(), + z + .object({ + category: z.literal("writer_fence"), + ref: taskToPrRefFor("writer_fence") + }) + .strict(), + z + .object({ + category: z.literal("provider_profile"), + ref: taskToPrRefFor("provider_profile") + }) + .strict(), + z + .object({ + category: z.literal("provider_route"), + ref: taskToPrRefFor("provider_route") + }) + .strict(), + z + .object({ + category: z.literal("replay_cursor"), + ref: taskToPrRefFor("replay_cursor") + }) + .strict(), + z + .object({ + category: z.literal("replay_prefix"), + sequence: z.number().int().nonnegative(), + prefixDigest: LowerSha256DigestSchema + }) + .strict(), + z + .object({ + category: z.literal("repair_state"), + ref: taskToPrRefFor("repair_cycle") + }) + .strict(), + z + .object({ + category: z.literal("latest_repair"), + ref: taskToPrRefFor("repair_cycle") + }) + .strict(), + z + .object({ + category: z.literal("handoff"), + ref: taskToPrRefFor("handoff") + }) + .strict(), + z + .object({ + category: z.literal("recovery"), + ref: taskToPrRefFor("recovery") + }) + .strict(), + z + .object({ + category: z.literal("merge_guard"), + ref: taskToPrRefFor("merge_guard") + }) + .strict(), + z + .object({ + category: z.literal("cleanup_eligibility"), + ref: taskToPrRefFor("cleanup_eligibility") + }) + .strict(), + z + .object({ + category: z.literal("rollback_plan"), + ref: taskToPrRefFor("rollback_plan") + }) + .strict(), + z + .object({ + category: z.literal("equality_proof"), + ref: taskToPrRefFor("proof_bundle"), + head: TaskToPrGitObjectIdSchema + }) + .strict(), + z + .object({ + category: z.literal("ci_proof"), + ref: taskToPrRefFor("proof_bundle"), + head: TaskToPrGitObjectIdSchema + }) + .strict(), + z + .object({ + category: z.literal("review_proof"), + ref: taskToPrRefFor("proof_bundle"), + head: TaskToPrGitObjectIdSchema + }) + .strict(), + z + .object({ + category: z.literal("review_record"), + ref: taskToPrRefFor("review"), + head: TaskToPrGitObjectIdSchema + }) + .strict(), + z + .object({ + category: z.literal("review_run"), + ref: taskToPrRefFor("review_run"), + head: TaskToPrGitObjectIdSchema + }) + .strict(), + z + .object({ + category: z.literal("provider_guard_receipt"), + ref: taskToPrRefFor("merge_guard_receipt"), + head: TaskToPrGitObjectIdSchema + }) + .strict() +]); + +type TaskToPrProvenanceProjectionView = { + id: string; + workRunRef: TaskToPrRef; + attempt: TaskToPrAttempt; + events: TaskToPrEventCursor; + repair: TaskToPrRepairState; + handoff?: TaskToPrHandoff | undefined; + recovery?: TaskToPrRecovery | undefined; + exactHead?: TaskToPrExactHeadBinding | undefined; + reviews: TaskToPrReviewBinding[]; + merge?: TaskToPrMergeState | undefined; + cleanup?: TaskToPrCleanupState | undefined; + rollback?: TaskToPrRollback | undefined; +}; + +function taskToPrActiveProvenanceEntries( + projection: TaskToPrProvenanceProjectionView +): TaskToPrProvenanceEntry[] { + return [ + { + category: "projection_id" as const, + projectionId: projection.id + }, + { + category: "work_run" as const, + ref: projection.workRunRef + }, + { + category: "attempt" as const, + ref: projection.attempt.ref + }, + { + category: "attempt_nonce" as const, + nonce: projection.attempt.nonce + }, + { + category: "runtime" as const, + ref: projection.attempt.runtimeRef + }, + { + category: "writer_generation" as const, + ref: projection.attempt.writerGenerationRef + }, + { + category: "writer_lease" as const, + ref: projection.attempt.writerLeaseRef + }, + { + category: "writer_fence" as const, + ref: projection.attempt.writerFenceRef + }, + { + category: "provider_profile" as const, + ref: projection.attempt.providerProfileRef + }, + { + category: "provider_route" as const, + ref: projection.attempt.providerRouteRef + }, + { + category: "replay_cursor" as const, + ref: projection.events.replayCursorRef + }, + { + category: "replay_prefix" as const, + sequence: projection.events.sequence, + prefixDigest: projection.events.prefixDigest + }, + { + category: "repair_state" as const, + ref: projection.repair.ref + }, + ...(projection.repair.latestRepairRef + ? [ + { + category: "latest_repair" as const, + ref: projection.repair.latestRepairRef + } + ] + : []), + ...(projection.handoff + ? [{ category: "handoff" as const, ref: projection.handoff.ref }] + : []), + ...(projection.recovery + ? [{ category: "recovery" as const, ref: projection.recovery.ref }] + : []), + ...(projection.exactHead + ? [ + { + category: "equality_proof" as const, + ref: projection.exactHead.equalityProofRef, + head: projection.exactHead.localHead + }, + ...projection.exactHead.ciProofBundleRefs.map((ref) => ({ + category: "ci_proof" as const, + ref, + head: projection.exactHead!.localHead + })) + ] + : []), + ...projection.reviews.flatMap((review) => [ + { + category: "review_proof" as const, + ref: review.proofBundleRef, + head: review.head + }, + { + category: "review_record" as const, + ref: review.ref, + head: review.head + }, + { + category: "review_run" as const, + ref: review.reviewRunRef, + head: review.head + } + ]), + ...(projection.merge + ? [ + { + category: "merge_guard" as const, + ref: projection.merge.guard.ref + }, + { + category: "provider_guard_receipt" as const, + ref: projection.merge.guard.providerGuardReceiptRef, + head: projection.merge.guard.expectedHead + } + ] + : []), + ...(projection.cleanup + ? [ + { + category: "cleanup_eligibility" as const, + ref: projection.cleanup.eligibility.ref + } + ] + : []), + ...(projection.rollback + ? [ + { + category: "rollback_plan" as const, + ref: projection.rollback.plan.ref + } + ] + : []) + ]; +} + +function sameTaskToPrProvenanceEntry( + left: TaskToPrProvenanceEntry, + right: TaskToPrProvenanceEntry +): boolean { + if (left.category !== right.category) { + return false; + } + if (left.category === "projection_id" && right.category === "projection_id") { + return left.projectionId === right.projectionId; + } + if (left.category === "attempt_nonce" && right.category === "attempt_nonce") { + return left.nonce === right.nonce; + } + if (left.category === "replay_prefix" && right.category === "replay_prefix") { + return left.sequence === right.sequence && left.prefixDigest === right.prefixDigest; + } + if (!("ref" in left) || !("ref" in right)) { + return false; + } + return ( + sameTaskToPrRef(left.ref, right.ref) && + (("head" in left && "head" in right && sameGitObjectId(left.head, right.head)) || + (!("head" in left) && !("head" in right))) + ); +} + +const RegisteredCanonicalSchemaIds = new Set(Object.values(SCHEMA_IDS)); + +export const TaskToPrAdapterExtensionSchema = z + .object({ + mode: z.enum(["local", "cloud"]), + schema: SchemaIdSchema, + ref: taskToPrRefFor("adapter_extension"), + digest: LowerSha256DigestSchema + }) + .strict() + .superRefine((value, ctx) => { + if ( + RegisteredCanonicalSchemaIds.has(value.schema) || + value.schema.startsWith("hasna.task_to_pr_projection.") || + containsForbiddenTaskToPrMarker(value.schema) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Adapter extension schema ids must be separately namespaced and unregistered; they cannot reuse canonical contracts, use the core projection namespace, or encode sensitive owner data", + path: ["schema"] + }); + } + }); +export type TaskToPrAdapterExtension = z.infer; + +export const TaskToPrProjectionStateSchema = z.enum([ + "admitted", + "running", + "handed_off", + "reviewing", + "repairing", + "merge_ready", + "merged", + "closed_unmerged", + "failed", + "blocked", + "cancelled", + "recovering", + "cleanup_complete", + "rolled_back" +]); +export type TaskToPrProjectionState = z.infer; + +const TaskToPrStatesWithoutReviewAuthority = new Set([ + "admitted", + "running", + "handed_off" +]); +const TaskToPrEligibleGuardAuthoritativeStates = new Set([ + "merge_ready", + "merged", + "closed_unmerged", + "failed", + "blocked", + "cancelled", + "cleanup_complete", + "rolled_back" +]); + +export const TaskToPrProjectionSchema = z + .object({ + schema: z.literal(SCHEMA_IDS.taskToPrProjection), + id: TaskToPrProjectionIdSchema, + createdAt: TimestampSchema, + canonicalizationVersion: z.literal(1), + identityDigest: LowerSha256DigestSchema, + frozenScopeDigest: LowerSha256DigestSchema, + state: TaskToPrProjectionStateSchema, + workRunRef: taskToPrRefFor("work_run"), + rootRequestRef: taskToPrRefFor("root_request"), + prGroupRef: taskToPrRefFor("pr_group"), + leafTaskRef: taskToPrRefFor("leaf_task"), + attempt: TaskToPrAttemptSchema, + repository: TaskToPrRepositoryBindingSchema, + events: TaskToPrEventCursorSchema, + openLoopsInvocationRef: taskToPrRefFor("openloops_invocation").optional(), + pullRequestRef: taskToPrRefFor("pull_request").optional(), + exactHead: TaskToPrExactHeadBindingSchema.optional(), + handoff: TaskToPrHandoffSchema.optional(), + reviews: z.array(TaskToPrReviewBindingSchema).default([]), + repair: TaskToPrRepairStateSchema, + merge: TaskToPrMergeStateSchema.optional(), + recovery: TaskToPrRecoverySchema.optional(), + cancellation: TaskToPrCancellationSchema.optional(), + cleanup: TaskToPrCleanupStateSchema.optional(), + rollback: TaskToPrRollbackSchema.optional(), + provenanceLedger: z.array(TaskToPrProvenanceEntrySchema), + adapterExtensions: z.array(TaskToPrAdapterExtensionSchema).default([]), + evidenceRefs: z.array(TaskToPrEvidenceRefSchema).default([]) + }) + .strict() + .superRefine((value, ctx) => { + const derivedIdentityDigest = deriveTaskToPrIdentityDigest({ + canonicalizationVersion: value.canonicalizationVersion, + rootRequestRef: value.rootRequestRef, + prGroupRef: value.prGroupRef, + leafTaskRef: value.leafTaskRef, + repoRef: value.repository.repoRef, + baseHead: value.repository.baseHead, + frozenScopeDigest: value.frozenScopeDigest + }); + if (value.identityDigest !== derivedIdentityDigest) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "identityDigest must equal the canonical v1 root/PR-group/leaf/repo/base/scope binding digest", + path: ["identityDigest"] + }); + } + const provenanceIds = new Set(); + const provenanceDigests = new Set(); + const provenanceProjectionIds = new Set(); + const provenanceAttemptNonces = new Set(); + const provenanceReplayPrefixes = new Set(); + const provenanceReplaySequences = new Set(); + for (const [index, entry] of value.provenanceLedger.entries()) { + if ("ref" in entry) { + if (provenanceIds.has(entry.ref.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Provenance entries cannot reuse a canonical owner id across categories or generations", + path: ["provenanceLedger", index, "ref", "id"] + }); + } + provenanceIds.add(entry.ref.id); + if (provenanceDigests.has(entry.ref.digest)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Provenance entries cannot reuse a canonical digest across categories or generations", + path: ["provenanceLedger", index, "ref", "digest"] + }); + } + provenanceDigests.add(entry.ref.digest); + continue; + } + if (entry.category === "projection_id") { + if (provenanceProjectionIds.has(entry.projectionId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Projection identity provenance tombstones must be globally unique", + path: ["provenanceLedger", index, "projectionId"] + }); + } + provenanceProjectionIds.add(entry.projectionId); + continue; + } + if (entry.category === "attempt_nonce") { + if (provenanceAttemptNonces.has(entry.nonce)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Attempt nonce provenance tombstones must be globally unique", + path: ["provenanceLedger", index, "nonce"] + }); + } + provenanceAttemptNonces.add(entry.nonce); + continue; + } + if (provenanceReplayPrefixes.has(entry.prefixDigest)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Replay prefix provenance tombstones must be globally unique", + path: ["provenanceLedger", index, "prefixDigest"] + }); + } + provenanceReplayPrefixes.add(entry.prefixDigest); + if (provenanceReplaySequences.has(entry.sequence)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Replay prefix provenance entries must bind globally unique replay sequences", + path: ["provenanceLedger", index, "sequence"] + }); + } + provenanceReplaySequences.add(entry.sequence); + } + for (const activeEntry of taskToPrActiveProvenanceEntries(value)) { + if (!value.provenanceLedger.some((entry) => sameTaskToPrProvenanceEntry(entry, activeEntry))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `The active ${activeEntry.category} identity must be represented exactly in the monotonic provenance ledger`, + path: ["provenanceLedger"] + }); + } + } + const requiredCanonicalPreservationRefs = [ + value.rootRequestRef, + value.prGroupRef, + value.leafTaskRef, + value.repository.repoRef, + value.repository.worktreeRef, + value.repository.branchRef, + value.events.streamRef, + ...(value.pullRequestRef ? [value.pullRequestRef] : []) + ]; + const requirePreservedRefs = ( + preservedStateRefs: TaskToPrRef[], + requiredRefs: TaskToPrRef[], + path: (string | number)[], + label: string + ) => { + for (const requiredRef of requiredRefs) { + if (!preservedStateRefs.some((preservedRef) => sameTaskToPrRef(preservedRef, requiredRef))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${label} must preserve ${requiredRef.role}`, + path + }); + } + } + }; + if (value.handoff && !sameTaskToPrRef(value.handoff.nextWriterGenerationRef, value.attempt.writerGenerationRef)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Handoff next generation must be the current attempt writer generation", + path: ["handoff", "nextWriterGenerationRef"] + }); + } + if (value.handoff && !sameTaskToPrRef(value.handoff.nextAttemptRef, value.attempt.ref)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Handoff next attempt must be the current attempt", + path: ["handoff", "nextAttemptRef"] + }); + } + if (value.handoff) { + requireFreshTaskToPrRef( + value.handoff.stoppedWorkRunRef, + value.workRunRef, + ctx, + ["handoff", "stoppedWorkRunRef"], + "Handoff WorkRun rotation" + ); + } + if (value.recovery) { + if (value.recovery.successorAttemptNonce !== value.attempt.nonce) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Recovery successor nonce must equal the current attempt nonce", + path: ["recovery", "successorAttemptNonce"] + }); + } + if (!sameTaskToPrRef(value.recovery.successorWriterGenerationRef, value.attempt.writerGenerationRef)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Recovery successor generation must equal the current writer generation", + path: ["recovery", "successorWriterGenerationRef"] + }); + } + requireFreshTaskToPrRef( + value.recovery.priorAttemptRef, + value.attempt.ref, + ctx, + ["recovery", "priorAttemptRef"], + "Recovery attempt rotation" + ); + requireFreshTaskToPrRef( + value.recovery.priorWorkRunRef, + value.workRunRef, + ctx, + ["recovery", "priorWorkRunRef"], + "Recovery WorkRun rotation" + ); + requirePreservedRefs( + value.recovery.preservedStateRefs, + [value.recovery.priorWorkRunRef, ...requiredCanonicalPreservationRefs], + ["recovery", "preservedStateRefs"], + "Recovery" + ); + } + if (value.cancellation && !sameTaskToPrRef(value.cancellation.cancelledAttemptRef, value.attempt.ref)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Cancellation must bind the current attempt", + path: ["cancellation", "cancelledAttemptRef"] + }); + } + if (value.cancellation) { + requirePreservedRefs( + value.cancellation.preservedStateRefs, + [value.workRunRef, value.attempt.ref, ...requiredCanonicalPreservationRefs], + ["cancellation", "preservedStateRefs"], + "Cancellation" + ); + } + if (value.cancellation && value.recovery) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "A projection cannot be both the cancellation and recovery snapshot", + path: ["recovery"] + }); + } + if (value.handoff && value.recovery) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "A projection cannot be both the handoff and recovery snapshot", + path: ["recovery"] + }); + } + if (value.cleanup && !sameTaskToPrRef(value.cleanup.eligibility.eventCursorRef, value.events.replayCursorRef)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Cleanup eligibility must bind the current canonical replay cursor", + path: ["cleanup", "eligibility", "eventCursorRef"] + }); + } + if ( + value.cleanup && + !sameTaskToPrRef(value.cleanup.eligibility.targetWorktreeRef, value.repository.worktreeRef) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Cleanup eligibility must bind the canonical worktree", + path: ["cleanup", "eligibility", "targetWorktreeRef"] + }); + } + if (value.pullRequestRef) { + if (value.exactHead && !sameTaskToPrRef(value.exactHead.pullRequestRef, value.pullRequestRef)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Exact-head proof must bind the canonical pull request ref", + path: ["exactHead", "pullRequestRef"] + }); + } + for (const [reviewIndex, review] of value.reviews.entries()) { + if (!sameTaskToPrRef(review.pullRequestRef, value.pullRequestRef)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Every review must bind the canonical pull request ref", + path: ["reviews", reviewIndex, "pullRequestRef"] + }); + } + } + if (value.merge && !sameTaskToPrRef(value.merge.guard.pullRequestRef, value.pullRequestRef)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merge guard must bind the canonical pull request ref", + path: ["merge", "guard", "pullRequestRef"] + }); + } + if (value.merge?.outcome && !sameTaskToPrRef(value.merge.outcome.pullRequestRef, value.pullRequestRef)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merge outcome must bind the canonical pull request ref", + path: ["merge", "outcome", "pullRequestRef"] + }); + } + } else if (value.exactHead || value.reviews.length > 0 || value.merge) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Review and merge state require a canonical pull request ref", + path: ["pullRequestRef"] + }); + } + if (value.exactHead && !sameGitObjectId(value.exactHead.localHead, value.repository.branchHead)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Exact local head must equal the canonical branch head", + path: ["exactHead", "localHead"] + }); + } + if (value.exactHead && !sameTaskToPrRef(value.exactHead.remoteBranchRef, value.repository.branchRef)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Exact-head remote branch ref must equal the canonical repository branch ref", + path: ["exactHead", "remoteBranchRef"] + }); + } + if (value.exactHead && Date.parse(value.exactHead.verifiedAt) < Date.parse(value.createdAt)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Exact-head verification cannot precede the projection timestamp", + path: ["exactHead", "verifiedAt"] + }); + } + if (value.reviews.length > 0 && !value.exactHead) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Reviews require local/remote/provider exact-head proof", + path: ["exactHead"] + }); + } + if (value.exactHead) { + const proofObligations: Array<{ ref: TaskToPrRef; path: (string | number)[] }> = [ + { ref: value.exactHead.equalityProofRef, path: ["exactHead", "equalityProofRef"] }, + ...value.exactHead.ciProofBundleRefs.map((ref, index) => ({ + ref, + path: ["exactHead", "ciProofBundleRefs", index] + })), + ...value.reviews.map((review, index) => ({ + ref: review.proofBundleRef, + path: ["reviews", index, "proofBundleRef"] + })) + ]; + const proofObligationKeys = new Set(); + const proofObligationDigests = new Set(); + for (const obligation of proofObligations) { + const key = taskToPrCanonicalRefKey(obligation.ref); + if (proofObligationKeys.has(key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Exact-head equality, CI, and review proof obligations require globally unique canonical identities", + path: obligation.path + }); + } + proofObligationKeys.add(key); + if (proofObligationDigests.has(obligation.ref.digest)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Exact-head equality, CI, and review proof obligations require globally unique canonical digests", + path: obligation.path + }); + } + proofObligationDigests.add(obligation.ref.digest); + } + } + const reviewKeys = new Set(); + const reviewDigests = new Set(); + const reviewerKeys = new Set(); + const reviewerDigests = new Set(); + const reviewRunKeys = new Set(); + const reviewRunDigests = new Set(); + const reviewProofKeys = new Set(); + const reviewProofDigests = new Set(); + for (const [reviewIndex, review] of value.reviews.entries()) { + if (!sameGitObjectId(review.head, value.repository.branchHead)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Review head must equal the exact canonical branch head", + path: ["reviews", reviewIndex, "head"] + }); + } + for (const [key, seen, path] of [ + [review.ref.id, reviewKeys, "ref"], + [review.reviewerRef.id, reviewerKeys, "reviewerRef"], + [review.reviewRunRef.id, reviewRunKeys, "reviewRunRef"] + ] as const) { + if (seen.has(key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Review, reviewer, and review-run refs must each be unique", + path: ["reviews", reviewIndex, path] + }); + } + seen.add(key); + } + if (reviewDigests.has(review.ref.digest)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Review refs must resolve to distinct canonical record digests", + path: ["reviews", reviewIndex, "ref"] + }); + } + reviewDigests.add(review.ref.digest); + if (reviewerDigests.has(review.reviewerRef.digest)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Reviewer refs must resolve to distinct canonical actor digests", + path: ["reviews", reviewIndex, "reviewerRef"] + }); + } + reviewerDigests.add(review.reviewerRef.digest); + if (reviewRunDigests.has(review.reviewRunRef.digest)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Review-run refs must resolve to distinct canonical run digests", + path: ["reviews", reviewIndex, "reviewRunRef"] + }); + } + reviewRunDigests.add(review.reviewRunRef.digest); + const reviewProofKey = taskToPrCanonicalRefKey(review.proofBundleRef); + if (reviewProofKeys.has(reviewProofKey)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Review proof bundles must have unique canonical identities", + path: ["reviews", reviewIndex, "proofBundleRef"] + }); + } + reviewProofKeys.add(reviewProofKey); + if (reviewProofDigests.has(review.proofBundleRef.digest)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Review proof bundles must have unique canonical digests", + path: ["reviews", reviewIndex, "proofBundleRef"] + }); + } + reviewProofDigests.add(review.proofBundleRef.digest); + if (review.reviewerRef.digest === value.attempt.workerRef.digest) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Worker and reviewer identities must resolve to distinct canonical digests", + path: ["reviews", reviewIndex, "reviewerRef"] + }); + } + if (review.reviewRunRef.digest === value.attempt.runtimeRef.digest) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Worker runtime and review run must resolve to distinct canonical digests", + path: ["reviews", reviewIndex, "reviewRunRef"] + }); + } + if ( + value.exactHead && + Date.parse(review.reviewedAt) < Date.parse(value.exactHead.verifiedAt) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Reviews cannot precede exact-head verification", + path: ["reviews", reviewIndex, "reviewedAt"] + }); + } + } + if (value.merge) { + if (!value.exactHead) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merge state requires local/remote/provider exact-head proof", + path: ["exactHead"] + }); + } + if (!sameGitObjectId(value.merge.guard.expectedHead, value.repository.branchHead)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merge guard expected head must equal the exact canonical branch head", + path: ["merge", "guard", "expectedHead"] + }); + } + if (value.exactHead && Date.parse(value.merge.guard.evaluatedAt) < Date.parse(value.exactHead.verifiedAt)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merge guards cannot precede exact-head verification", + path: ["merge", "guard", "evaluatedAt"] + }); + } + if (value.reviews.some((review) => Date.parse(value.merge!.guard.evaluatedAt) < Date.parse(review.reviewedAt))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merge guards cannot precede their bound reviews", + path: ["merge", "guard", "evaluatedAt"] + }); + } + if (value.merge.guard.operatorRef.digest === value.attempt.workerRef.digest) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Worker and merge operator identities must resolve to distinct canonical digests", + path: ["merge", "guard", "operatorRef"] + }); + } + if (value.merge.guard.operatorRunRef.digest === value.attempt.runtimeRef.digest) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Worker runtime and merge-operator run must resolve to distinct canonical digests", + path: ["merge", "guard", "operatorRunRef"] + }); + } + for (const [reviewIndex, review] of value.reviews.entries()) { + if (value.merge.guard.operatorRef.digest === review.reviewerRef.digest) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Reviewer and merge operator identities must resolve to distinct canonical digests", + path: ["reviews", reviewIndex, "reviewerRef"] + }); + } + if (value.merge.guard.operatorRunRef.digest === review.reviewRunRef.digest) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Review and merge-operator runs must resolve to distinct canonical digests", + path: ["reviews", reviewIndex, "reviewRunRef"] + }); + } + } + if (value.merge.guard.decision === "eligible") { + if (value.reviews.length === 0 || value.reviews.some((review) => review.verdict !== "approved")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Eligible merge guards require at least one review and all reviews approved", + path: ["merge", "guard", "decision"] + }); + } + if ( + value.merge.guard.reviewRefs.length !== value.reviews.length || + value.merge.guard.reviewRefs.some( + (reviewRef) => !value.reviews.some((review) => sameTaskToPrRef(reviewRef, review.ref)) + ) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Eligible merge guard review refs must exactly equal the projected approved review refs as a canonical set", + path: ["merge", "guard", "reviewRefs"] + }); + } + for (const review of value.reviews) { + if (!value.merge.guard.proofBundleRefs.some((proofRef) => sameTaskToPrRef(proofRef, review.proofBundleRef))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Eligible merge guards must bind every exact review proof bundle", + path: ["merge", "guard", "proofBundleRefs"] + }); + } + } + if ( + value.exactHead && + !value.merge.guard.proofBundleRefs.some((proofRef) => sameTaskToPrRef(proofRef, value.exactHead!.equalityProofRef)) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Eligible merge guards must bind the exact-head equality proof", + path: ["merge", "guard", "proofBundleRefs"] + }); + } + if ( + value.exactHead && + value.exactHead.ciProofBundleRefs.some( + (ciProofRef) => !value.merge!.guard.proofBundleRefs.some((proofRef) => sameTaskToPrRef(proofRef, ciProofRef)) + ) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Eligible merge guards must bind every exact-head CI proof", + path: ["merge", "guard", "proofBundleRefs"] + }); + } + } + } + if (value.merge?.outcome) { + if (!sameTaskToPrRef(value.merge.outcome.guardRef, value.merge.guard.ref)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merge outcome must bind the exact immutable merge guard", + path: ["merge", "outcome", "guardRef"] + }); + } + if (!sameGitObjectId(value.merge.outcome.expectedHead, value.merge.guard.expectedHead)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merge outcome expected head must equal the guarded expected head", + path: ["merge", "outcome", "expectedHead"] + }); + } + if (value.merge.outcome.status === "merged" && value.merge.guard.decision !== "eligible") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merged outcomes require an eligible merge guard", + path: ["merge", "outcome", "status"] + }); + } + if (Date.parse(value.merge.outcome.finishedAt) < Date.parse(value.merge.guard.evaluatedAt)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merge outcomes cannot precede guard evaluation", + path: ["merge", "outcome", "finishedAt"] + }); + } + } + if (value.cleanup) { + const cleanupFloor = value.merge?.outcome?.finishedAt ?? value.createdAt; + if (Date.parse(value.cleanup.eligibility.evaluatedAt) < Date.parse(cleanupFloor)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Cleanup eligibility cannot precede the terminal merge outcome or projection", + path: ["cleanup", "eligibility", "evaluatedAt"] + }); + } + if ( + value.cleanup.outcome && + Date.parse(value.cleanup.outcome.finishedAt) < Date.parse(value.cleanup.eligibility.evaluatedAt) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Cleanup outcomes cannot precede cleanup eligibility", + path: ["cleanup", "outcome", "finishedAt"] + }); + } + } + if ( + value.rollback?.outcome && + value.merge?.outcome && + Date.parse(value.rollback.outcome.finishedAt) < Date.parse(value.merge.outcome.finishedAt) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Rollback outcomes cannot precede the merge outcome they remediate", + path: ["rollback", "outcome", "finishedAt"] + }); + } + if (value.rollback) { + const rollbackFloor = value.merge?.outcome?.finishedAt ?? value.createdAt; + if (Date.parse(value.rollback.plan.createdAt) < Date.parse(rollbackFloor)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Rollback plans cannot precede the terminal merge outcome or projection", + path: ["rollback", "plan", "createdAt"] + }); + } + } + if (value.state === "handed_off" && !value.handoff) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Handed-off projections require a handoff ref", path: ["handoff"] }); + } + if (TaskToPrStatesWithoutReviewAuthority.has(value.state) && value.reviews.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${value.state} projections cannot carry review bindings before review authority is active`, + path: ["reviews"] + }); + } + if ( + (TaskToPrStatesWithoutReviewAuthority.has(value.state) || value.state === "recovering") && + (value.merge?.guard.reviewRefs.length ?? 0) > 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${value.state} projections cannot hide review bindings in a merge guard before review authority is active`, + path: ["merge", "guard", "reviewRefs"] + }); + } + if ( + value.merge?.guard.decision === "eligible" && + !TaskToPrEligibleGuardAuthoritativeStates.has(value.state) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Eligible merge guards are not authoritative in the ${value.state} phase`, + path: ["merge", "guard", "decision"] + }); + } + if (value.state === "reviewing" && value.reviews.length === 0) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Reviewing projections require review refs", path: ["reviews"] }); + } + if (value.state === "cancelled" && !value.cancellation) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Cancelled projections require preservation state", path: ["cancellation"] }); + } + if (value.cancellation && value.merge?.outcome) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Cancellation cannot coexist with a terminal merge outcome", + path: ["cancellation"] + }); + } + if (value.state === "recovering" && !value.recovery) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Recovering projections require recovery state", path: ["recovery"] }); + } + if (value.state === "repairing" && value.repair.cycle === 0) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Repairing projections require a non-zero repair cycle", path: ["repair", "cycle"] }); + } + if (value.state === "merge_ready" && value.merge?.guard.decision !== "eligible") { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Merge-ready projections require an eligible guard", path: ["merge"] }); + } + if (value.state === "merged" && value.merge?.outcome?.status !== "merged") { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Merged projections require a merged immutable outcome", path: ["merge"] }); + } + if ( + value.state === "closed_unmerged" && + !value.merge?.outcome?.status.match(/^(closed_unmerged|refused|head_drift)$/) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Closed-unmerged projections require a non-merged terminal outcome", + path: ["merge"] + }); + } + if ( + value.state === "cleanup_complete" && + (!value.cleanup?.outcome || !["deleted", "preserved", "skipped"].includes(value.cleanup.outcome.status)) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Cleanup-complete projections require an immutable cleanup outcome", + path: ["cleanup"] + }); + } + if (value.state === "rolled_back" && value.rollback?.outcome?.status !== "succeeded") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Rolled-back projections require a successful rollback outcome", + path: ["rollback"] + }); + } + if ((value.state === "failed" || value.state === "blocked") && value.evidenceRefs.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Failed and blocked projections require redacted evidence refs", + path: ["evidenceRefs"] + }); + } + if ( + ["admitted", "running", "handed_off", "reviewing", "repairing", "merge_ready", "recovering"].includes(value.state) && + (value.merge?.outcome || value.cancellation || value.cleanup?.outcome || value.rollback?.outcome) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Non-terminal projections cannot carry terminal owner outcomes", + path: ["state"] + }); + } + const extensionKeys = new Set(); + for (const [index, extension] of value.adapterExtensions.entries()) { + if (RegisteredCanonicalSchemaIds.has(extension.schema)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Adapter extensions are non-authoritative and cannot reuse any registered canonical contract schema", + path: ["adapterExtensions", index, "schema"] + }); + } + const key = `${extension.mode}:${extension.schema}`; + if (extensionKeys.has(key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Adapter extensions must be unique per local/cloud mode and schema", + path: ["adapterExtensions", index] + }); + } + extensionKeys.add(key); + } + }); +export type TaskToPrProjection = z.infer; + +export interface TaskToPrTransitionIssue { + path: string; + message: string; +} + +export type TaskToPrTransitionResult = + | { success: true; issues: [] } + | { success: false; issues: TaskToPrTransitionIssue[] }; + +type TaskToPrHeadBoundEvidenceCategory = + | "equality_proof" + | "ci_proof" + | "review_proof" + | "review_record" + | "review_run"; + +interface TaskToPrHeadBoundEvidenceBinding { + category: TaskToPrHeadBoundEvidenceCategory; + ref: TaskToPrRef; + path: string; +} + +function taskToPrHeadBoundEvidenceBindings( + projection: TaskToPrProjection +): TaskToPrHeadBoundEvidenceBinding[] { + return [ + ...(projection.exactHead + ? [ + { + category: "equality_proof" as const, + ref: projection.exactHead.equalityProofRef, + path: "exactHead.equalityProofRef" + }, + ...projection.exactHead.ciProofBundleRefs.map((ref, index) => ({ + category: "ci_proof" as const, + ref, + path: `exactHead.ciProofBundleRefs.${index}` + })) + ] + : []), + ...projection.reviews.flatMap((review, index) => [ + { + category: "review_proof" as const, + ref: review.proofBundleRef, + path: `reviews.${index}.proofBundleRef` + }, + { + category: "review_record" as const, + ref: review.ref, + path: `reviews.${index}.ref` + }, + { + category: "review_run" as const, + ref: review.reviewRunRef, + path: `reviews.${index}.reviewRunRef` + } + ]) + ]; +} + +const TASK_TO_PR_CHANGED_HEAD_EVIDENCE_IDENTITY_MESSAGE = + "A changed branch head requires every head-bound evidence ref to use a fresh canonical identity"; +const TASK_TO_PR_CHANGED_HEAD_EVIDENCE_DIGEST_MESSAGE = + "A changed branch head requires every head-bound evidence ref to use a fresh digest"; + +const TASK_TO_PR_LEGAL_STATE_TRANSITIONS: Record = { + admitted: ["admitted", "running", "failed", "blocked", "cancelled", "recovering"], + running: [ + "running", + "handed_off", + "reviewing", + "repairing", + "merge_ready", + "failed", + "blocked", + "cancelled", + "recovering", + "cleanup_complete" + ], + handed_off: ["handed_off", "running", "reviewing", "repairing", "failed", "blocked", "cancelled", "recovering"], + reviewing: ["reviewing", "repairing", "merge_ready", "failed", "blocked", "cancelled", "recovering", "closed_unmerged"], + repairing: ["repairing", "running", "reviewing", "merge_ready", "failed", "blocked", "cancelled", "recovering", "closed_unmerged"], + merge_ready: ["merge_ready", "repairing", "merged", "closed_unmerged", "failed", "blocked", "cancelled", "recovering"], + merged: ["merged", "cleanup_complete", "rolled_back"], + closed_unmerged: ["closed_unmerged", "cleanup_complete"], + failed: ["failed", "recovering", "cleanup_complete", "rolled_back"], + blocked: ["blocked", "recovering", "cancelled", "cleanup_complete"], + cancelled: ["cancelled", "cleanup_complete"], + recovering: ["recovering", "running", "handed_off", "failed", "blocked", "cancelled"], + cleanup_complete: ["cleanup_complete"], + rolled_back: ["rolled_back"] +}; + +function taskToPrParseIssues( + prefix: "previous" | "current" | "local" | "cloud", + issues: z.ZodIssue[] +): TaskToPrTransitionIssue[] { + return issues.map((issue) => ({ + path: [prefix, ...issue.path].join("."), + message: issue.message + })); +} + +export function validateTaskToPrProjectionTransition( + previousInput: unknown, + currentInput: unknown +): TaskToPrTransitionResult { + const issues: TaskToPrTransitionIssue[] = []; + const addIssue = (path: string, message: string) => issues.push({ path, message }); + const parsedPrevious = TaskToPrProjectionSchema.safeParse(previousInput); + const parsedCurrent = TaskToPrProjectionSchema.safeParse(currentInput); + if (!parsedPrevious.success) { + issues.push(...taskToPrParseIssues("previous", parsedPrevious.error.issues)); + } + if (!parsedCurrent.success) { + issues.push(...taskToPrParseIssues("current", parsedCurrent.error.issues)); + } + if (!parsedPrevious.success || !parsedCurrent.success) { + return { success: false, issues }; + } + const previous = parsedPrevious.data; + const current = parsedCurrent.data; + const previousActiveProvenance = taskToPrActiveProvenanceEntries(previous); + const currentActiveProvenance = taskToPrActiveProvenanceEntries(current); + if ( + current.provenanceLedger.length < previous.provenanceLedger.length || + previous.provenanceLedger.some( + (entry, index) => + !current.provenanceLedger[index] || + !sameTaskToPrProvenanceEntry(entry, current.provenanceLedger[index]!) + ) + ) { + addIssue( + "provenanceLedger", + "The provenance ledger is append-only and must retain the previous ledger as an exact immutable prefix" + ); + } + const appendedProvenance = current.provenanceLedger.slice(previous.provenanceLedger.length); + for (const [index, entry] of appendedProvenance.entries()) { + if (!currentActiveProvenance.some((activeEntry) => sameTaskToPrProvenanceEntry(activeEntry, entry))) { + addIssue( + `provenanceLedger.${previous.provenanceLedger.length + index}`, + "A newly appended provenance entry must represent an active identity in the current snapshot" + ); + } + } + for (const activeEntry of currentActiveProvenance) { + const existedBefore = previous.provenanceLedger.some((entry) => + sameTaskToPrProvenanceEntry(entry, activeEntry) + ); + const wasActiveBefore = previousActiveProvenance.some((entry) => + sameTaskToPrProvenanceEntry(entry, activeEntry) + ); + if (existedBefore && !wasActiveBefore) { + addIssue( + "provenanceLedger", + `The ${activeEntry.category} identity cannot reactivate after becoming inactive` + ); + } + } + const validateOwnerRecordTransition = ( + path: string, + previousRecord: { ref: TaskToPrRef } | undefined, + currentRecord: { ref: TaskToPrRef } | undefined + ): void => { + if ( + !previousRecord || + !currentRecord || + JSON.stringify(previousRecord) === JSON.stringify(currentRecord) + ) { + return; + } + if (sameTaskToPrCanonicalRefId(previousRecord.ref, currentRecord.ref)) { + addIssue( + path, + "An owner record must remain exactly immutable while its canonical ref identity is unchanged" + ); + return; + } + if (previousRecord.ref.digest === currentRecord.ref.digest) { + addIssue(path, "A rotated owner record requires both a fresh canonical ref identity and a fresh digest"); + } + }; + + if (previous.id === current.id) { + addIssue("id", "A changed lifecycle snapshot requires a fresh immutable projection id"); + } + if (Date.parse(current.createdAt) < Date.parse(previous.createdAt)) { + addIssue("createdAt", "Projection timestamps cannot move backwards"); + } + const { + id: _previousId, + createdAt: _previousCreatedAt, + events: _previousEvents, + provenanceLedger: _previousProvenanceLedger, + ...previousSemanticState + } = previous; + const { + id: _currentId, + createdAt: _currentCreatedAt, + events: _currentEvents, + provenanceLedger: _currentProvenanceLedger, + ...currentSemanticState + } = current; + if ( + JSON.stringify(previousSemanticState) !== JSON.stringify(currentSemanticState) && + current.events.sequence <= previous.events.sequence + ) { + addIssue("events.sequence", "Semantic lifecycle drift requires replay sequence and cursor advancement"); + } + for (const [path, left, right] of [ + ["identityDigest", previous.identityDigest, current.identityDigest], + ["frozenScopeDigest", previous.frozenScopeDigest, current.frozenScopeDigest], + ["rootRequestRef", JSON.stringify(previous.rootRequestRef), JSON.stringify(current.rootRequestRef)], + ["prGroupRef", JSON.stringify(previous.prGroupRef), JSON.stringify(current.prGroupRef)], + ["leafTaskRef", JSON.stringify(previous.leafTaskRef), JSON.stringify(current.leafTaskRef)], + ["repository.repoRef", JSON.stringify(previous.repository.repoRef), JSON.stringify(current.repository.repoRef)], + ["repository.worktreeRef", JSON.stringify(previous.repository.worktreeRef), JSON.stringify(current.repository.worktreeRef)], + ["repository.branchRef", JSON.stringify(previous.repository.branchRef), JSON.stringify(current.repository.branchRef)], + ["repository.baseHead", JSON.stringify(previous.repository.baseHead), JSON.stringify(current.repository.baseHead)], + ["events.streamRef", JSON.stringify(previous.events.streamRef), JSON.stringify(current.events.streamRef)] + ] as const) { + if (left !== right) { + addIssue(path, "Canonical task-to-PR identity cannot change between projections"); + } + } + + if ( + previous.pullRequestRef && + (!current.pullRequestRef || !sameTaskToPrRef(previous.pullRequestRef, current.pullRequestRef)) + ) { + addIssue("pullRequestRef", "An established canonical pull-request identity cannot change or disappear"); + } + + if (sameGitObjectId(previous.repository.branchHead, current.repository.branchHead)) { + if ( + previous.exactHead && + JSON.stringify(current.exactHead) !== JSON.stringify(previous.exactHead) + ) { + addIssue( + "exactHead", + "An established same-head exact-head fact is immutable and cannot change or disappear" + ); + } + if ( + current.reviews.length < previous.reviews.length || + previous.reviews.some( + (previousReview, index) => + JSON.stringify(current.reviews[index]) !== JSON.stringify(previousReview) + ) + ) { + addIssue( + "reviews", + "Same-head review history is an exact immutable prefix; existing review bindings cannot move, change, or disappear" + ); + } + } else { + const previousHeadBoundEvidence = taskToPrHeadBoundEvidenceBindings(previous); + const currentHeadBoundEvidence = taskToPrHeadBoundEvidenceBindings(current); + const previousHeadBoundEvidenceKeys = new Set( + previousHeadBoundEvidence.map(({ ref }) => taskToPrCanonicalRefKey(ref)) + ); + const previousHeadBoundEvidenceDigests = new Set( + previousHeadBoundEvidence.map(({ ref }) => ref.digest) + ); + for (const { ref, path } of currentHeadBoundEvidence) { + if (previousHeadBoundEvidenceKeys.has(taskToPrCanonicalRefKey(ref))) { + addIssue(path, TASK_TO_PR_CHANGED_HEAD_EVIDENCE_IDENTITY_MESSAGE); + } + if (previousHeadBoundEvidenceDigests.has(ref.digest)) { + addIssue(path, TASK_TO_PR_CHANGED_HEAD_EVIDENCE_DIGEST_MESSAGE); + } + } + } + if (current.state === "recovering" && (current.exactHead || current.reviews.length > 0)) { + if ( + !sameGitObjectId(previous.repository.branchHead, current.repository.branchHead) || + JSON.stringify(previous.exactHead) !== JSON.stringify(current.exactHead) || + JSON.stringify(previous.reviews) !== JSON.stringify(current.reviews) + ) { + addIssue( + "recovery", + "Recovery may retain exact-head and review facts only unchanged from the immediately prior same-head snapshot" + ); + } + } + + if (current.events.sequence < previous.events.sequence) { + addIssue("events.sequence", "Replay sequence cannot decrease"); + } else if (current.events.sequence === previous.events.sequence) { + if ( + current.events.prefixDigest !== previous.events.prefixDigest || + !sameTaskToPrRef(current.events.replayCursorRef, previous.events.replayCursorRef) + ) { + addIssue("events", "An unchanged replay sequence must retain the same cursor and prefix digest"); + } + } else { + if (sameTaskToPrCanonicalRefId(current.events.replayCursorRef, previous.events.replayCursorRef)) { + addIssue("events.replayCursorRef", "An advanced replay sequence requires a fresh canonical replay cursor ref"); + } + if (current.events.replayCursorRef.digest === previous.events.replayCursorRef.digest) { + addIssue("events.replayCursorRef", "An advanced replay sequence requires a fresh replay cursor digest"); + } + if (current.events.prefixDigest === previous.events.prefixDigest) { + addIssue("events.prefixDigest", "An advanced replay sequence requires a fresh prefix digest"); + } + } + + if (current.repair.cycle < previous.repair.cycle || current.repair.cycle > previous.repair.cycle + 1) { + addIssue("repair.cycle", "Repair cycles are cumulative, monotonic, and append at most one cycle per transition"); + } + if (current.repair.cycle === previous.repair.cycle + 1) { + const previousRepairRefs = [ + previous.repair.ref, + ...(previous.repair.latestRepairRef ? [previous.repair.latestRepairRef] : []) + ]; + for (const previousRepairRef of previousRepairRefs) { + if (sameTaskToPrCanonicalRefId(current.repair.ref, previousRepairRef)) { + addIssue("repair.ref", "An advanced repair cycle requires a repair-state ref fresh from both prior repair slots"); + } + if (current.repair.ref.digest === previousRepairRef.digest) { + addIssue("repair.ref", "An advanced repair cycle requires a repair-state digest fresh from both prior repair slots"); + } + } + if (!current.repair.latestRepairRef) { + addIssue("repair.latestRepairRef", "An advanced repair cycle requires a fresh latest-repair ref"); + } else { + for (const previousRepairRef of previousRepairRefs) { + if (sameTaskToPrCanonicalRefId(current.repair.latestRepairRef, previousRepairRef)) { + addIssue( + "repair.latestRepairRef", + "An advanced repair cycle requires a latest-repair ref fresh from both prior repair slots" + ); + } + if (current.repair.latestRepairRef.digest === previousRepairRef.digest) { + addIssue( + "repair.latestRepairRef", + "An advanced repair cycle requires a latest-repair digest fresh from both prior repair slots" + ); + } + } + } + } else if ( + current.repair.cycle === previous.repair.cycle && + JSON.stringify(current.repair) !== JSON.stringify(previous.repair) + ) { + addIssue("repair", "An unchanged repair cycle must retain the same immutable repair refs"); + } + + const attemptChanged = !sameTaskToPrCanonicalRefId(previous.attempt.ref, current.attempt.ref); + const nonceChanged = previous.attempt.nonce !== current.attempt.nonce; + const generationChanged = !sameTaskToPrCanonicalRefId( + previous.attempt.writerGenerationRef, + current.attempt.writerGenerationRef + ); + const workRunChanged = !sameTaskToPrCanonicalRefId(previous.workRunRef, current.workRunRef); + if (new Set([attemptChanged, nonceChanged, generationChanged, workRunChanged]).size !== 1) { + addIssue( + "attempt", + "Attempt ref, nonce, writer generation, and WorkRun must either all remain stable or all advance together" + ); + } + if (attemptChanged && (!current.recovery && !current.handoff)) { + addIssue("attempt", "A fresh attempt requires an explicit recovery or handoff transition ref"); + } + if (attemptChanged && current.recovery && current.handoff) { + addIssue("attempt", "A fresh attempt must use exactly one recovery or handoff transition"); + } + if (!attemptChanged && (current.recovery || current.handoff) && !previous.recovery && !previous.handoff) { + addIssue("attempt", "Recovery and handoff transitions require a fresh attempt"); + } + if (!attemptChanged && JSON.stringify(previous.handoff) !== JSON.stringify(current.handoff)) { + addIssue( + "handoff", + "An unchanged attempt must retain its exact immutable handoff provenance" + ); + } + if (!attemptChanged && JSON.stringify(previous.recovery) !== JSON.stringify(current.recovery)) { + addIssue( + "recovery", + "An unchanged attempt must retain its exact immutable recovery provenance" + ); + } + if (!attemptChanged && JSON.stringify(previous.attempt) !== JSON.stringify(current.attempt)) { + addIssue("attempt", "An unchanged attempt identity cannot mutate attempt-scoped owner refs"); + } + if ( + !generationChanged && + !sameTaskToPrRef(previous.attempt.writerGenerationRef, current.attempt.writerGenerationRef) + ) { + addIssue("attempt.writerGenerationRef", "An unchanged writer-generation identity cannot mutate its digest"); + } + if (!workRunChanged && !sameTaskToPrRef(previous.workRunRef, current.workRunRef)) { + addIssue("workRunRef", "An unchanged WorkRun identity cannot mutate its digest"); + } + + if (attemptChanged) { + if (current.attempt.ref.digest === previous.attempt.ref.digest) { + addIssue("attempt.ref", "A fresh attempt requires a fresh attempt digest"); + } + if (current.attempt.writerGenerationRef.digest === previous.attempt.writerGenerationRef.digest) { + addIssue("attempt.writerGenerationRef", "A fresh attempt requires a fresh writer-generation digest"); + } + if (current.workRunRef.digest === previous.workRunRef.digest) { + addIssue("workRunRef", "A fresh attempt requires a fresh WorkRun digest"); + } + for (const field of [ + "runtimeRef", + "writerLeaseRef", + "writerFenceRef", + "providerProfileRef", + "providerRouteRef" + ] as const) { + if ( + sameTaskToPrCanonicalRefId(previous.attempt[field], current.attempt[field]) || + previous.attempt[field].digest === current.attempt[field].digest + ) { + addIssue(`attempt.${field}`, `A fresh attempt requires a fresh ${field}`); + } + } + if (current.recovery) { + if (!sameTaskToPrRef(current.recovery.priorAttemptRef, previous.attempt.ref)) { + addIssue("recovery.priorAttemptRef", "Recovery must bind the immediately prior attempt"); + } + if (!sameTaskToPrRef(current.recovery.priorWriterGenerationRef, previous.attempt.writerGenerationRef)) { + addIssue("recovery.priorWriterGenerationRef", "Recovery must bind the immediately prior writer generation"); + } + if (!sameTaskToPrRef(current.recovery.priorWorkRunRef, previous.workRunRef)) { + addIssue("recovery.priorWorkRunRef", "Recovery must bind the immediately prior WorkRun"); + } + } + if (current.handoff) { + if (!sameTaskToPrRef(current.handoff.previousAttemptRef, previous.attempt.ref)) { + addIssue("handoff.previousAttemptRef", "Handoff must bind the immediately prior attempt"); + } + if (!sameTaskToPrRef(current.handoff.nextAttemptRef, current.attempt.ref)) { + addIssue("handoff.nextAttemptRef", "Handoff must bind the successor attempt"); + } + if (!sameTaskToPrRef(current.handoff.previousWriterGenerationRef, previous.attempt.writerGenerationRef)) { + addIssue("handoff.previousWriterGenerationRef", "Handoff must bind the immediately prior writer generation"); + } + if (!sameTaskToPrRef(current.handoff.stoppedWorkRunRef, previous.workRunRef)) { + addIssue("handoff.stoppedWorkRunRef", "Handoff must bind the immediately prior WorkRun"); + } + } + } + + if (!TASK_TO_PR_LEGAL_STATE_TRANSITIONS[previous.state].includes(current.state)) { + addIssue("state", `Illegal task-to-PR lifecycle transition from ${previous.state} to ${current.state}`); + } + + validateOwnerRecordTransition("handoff", previous.handoff, current.handoff); + validateOwnerRecordTransition("recovery", previous.recovery, current.recovery); + validateOwnerRecordTransition("merge.guard", previous.merge?.guard, current.merge?.guard); + if ( + previous.merge && + current.merge && + !sameGitObjectId(previous.merge.guard.expectedHead, current.merge.guard.expectedHead) + ) { + if ( + sameTaskToPrCanonicalRefId( + previous.merge.guard.providerGuardReceiptRef, + current.merge.guard.providerGuardReceiptRef + ) + ) { + addIssue( + "merge.guard.providerGuardReceiptRef", + "A changed guarded head requires a fresh provider guard receipt identity" + ); + } + if ( + previous.merge.guard.providerGuardReceiptRef.digest === + current.merge.guard.providerGuardReceiptRef.digest + ) { + addIssue( + "merge.guard.providerGuardReceiptRef", + "A changed guarded head requires a fresh provider guard receipt digest" + ); + } + } + validateOwnerRecordTransition( + "cleanup.eligibility", + previous.cleanup?.eligibility, + current.cleanup?.eligibility + ); + validateOwnerRecordTransition("rollback.plan", previous.rollback?.plan, current.rollback?.plan); + + for (const [path, left, right] of [ + ["merge", previous.merge?.outcome ? previous.merge : undefined, current.merge], + ["cancellation", previous.cancellation, current.cancellation], + ["cleanup", previous.cleanup?.outcome ? previous.cleanup : undefined, current.cleanup], + ["rollback", previous.rollback?.outcome ? previous.rollback : undefined, current.rollback] + ] as const) { + if (left && JSON.stringify(left) !== JSON.stringify(right)) { + addIssue(path, "Complete immutable terminal owner facts cannot change or disappear"); + } + } + + return issues.length === 0 ? { success: true, issues: [] } : { success: false, issues }; +} + +export function validateTaskToPrAdapterCoreEquivalence( + localInput: unknown, + cloudInput: unknown +): TaskToPrTransitionResult { + const issues: TaskToPrTransitionIssue[] = []; + const parsedLocal = TaskToPrProjectionSchema.safeParse(localInput); + const parsedCloud = TaskToPrProjectionSchema.safeParse(cloudInput); + if (!parsedLocal.success) { + issues.push(...taskToPrParseIssues("local", parsedLocal.error.issues)); + } + if (!parsedCloud.success) { + issues.push(...taskToPrParseIssues("cloud", parsedCloud.error.issues)); + } + if (!parsedLocal.success || !parsedCloud.success) { + return { success: false, issues }; + } + if ( + parsedLocal.data.adapterExtensions.length === 0 || + parsedLocal.data.adapterExtensions.some((extension) => extension.mode !== "local") + ) { + issues.push({ + path: "local.adapterExtensions", + message: "The first adapter projection must contain one or more local-only extensions" + }); + } + if ( + parsedCloud.data.adapterExtensions.length === 0 || + parsedCloud.data.adapterExtensions.some((extension) => extension.mode !== "cloud") + ) { + issues.push({ + path: "cloud.adapterExtensions", + message: "The second adapter projection must contain one or more cloud-only extensions" + }); + } + if (issues.length > 0) { + return { success: false, issues }; + } + const { adapterExtensions: _localExtensions, ...localCore } = parsedLocal.data; + const { adapterExtensions: _cloudExtensions, ...cloudCore } = parsedCloud.data; + if (JSON.stringify(localCore) !== JSON.stringify(cloudCore)) { + return { + success: false, + issues: [ + { + path: "core", + message: "Local and cloud adapters must serialize byte-equivalent task-to-PR core projections" + } + ] + }; + } + return { success: true, issues: [] }; +} + export const TrajectoryEventSchema = z .object({ id: z.string().min(1), @@ -2729,6 +5237,11 @@ export const ContractSchemaRegistry = { [SCHEMA_IDS.resourceRef]: ResourceRefSchema, [SCHEMA_IDS.evidenceRef]: EvidenceRefSchema, [SCHEMA_IDS.workRun]: WorkRunSchema, + [SCHEMA_IDS.taskToPrProjection]: TaskToPrProjectionSchema as z.ZodType< + TaskToPrProjection, + z.ZodTypeDef, + TaskToPrProjectionInput + >, [SCHEMA_IDS.decisionEnvelope]: DecisionEnvelopeSchema, [SCHEMA_IDS.costEstimate]: CostEstimateSchema, [SCHEMA_IDS.capabilityCard]: CapabilityCardSchema, @@ -2764,6 +5277,7 @@ export type ContractBySchemaId = { [SCHEMA_IDS.resourceRef]: ResourceRef; [SCHEMA_IDS.evidenceRef]: EvidenceRef; [SCHEMA_IDS.workRun]: WorkRun; + [SCHEMA_IDS.taskToPrProjection]: TaskToPrProjection; [SCHEMA_IDS.decisionEnvelope]: DecisionEnvelope; [SCHEMA_IDS.costEstimate]: CostEstimate; [SCHEMA_IDS.capabilityCard]: CapabilityCard; @@ -2796,6 +5310,7 @@ export type ActorRefInput = z.input; export type ResourceRefInput = z.input; export type EvidenceRefInput = z.input; export type WorkRunInput = z.input; +export type TaskToPrProjectionInput = z.input; export type DecisionEnvelopeInput = z.input; export type CostEstimateInput = z.input; export type CapabilityCardInput = z.input; @@ -2831,6 +5346,7 @@ export type ContractInputBySchemaId = { [SCHEMA_IDS.resourceRef]: ResourceRefInput; [SCHEMA_IDS.evidenceRef]: EvidenceRefInput; [SCHEMA_IDS.workRun]: WorkRunInput; + [SCHEMA_IDS.taskToPrProjection]: TaskToPrProjectionInput; [SCHEMA_IDS.decisionEnvelope]: DecisionEnvelopeInput; [SCHEMA_IDS.costEstimate]: CostEstimateInput; [SCHEMA_IDS.capabilityCard]: CapabilityCardInput; diff --git a/tests/task-to-pr-projection.test.ts b/tests/task-to-pr-projection.test.ts new file mode 100644 index 0000000..13fd54e --- /dev/null +++ b/tests/task-to-pr-projection.test.ts @@ -0,0 +1,3979 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + SCHEMA_IDS, + TASK_TO_PR_ROLE_AUTHORITIES, + type TaskToPrProjection, + type TaskToPrRef, + TaskToPrAdapterExtensionSchema, + TaskToPrHandoffSchema, + TaskToPrProjectionSchema, + TaskToPrRecoverySchema, + TaskToPrRefSchema, + WorkRunSchema, + deriveTaskToPrIdentityDigest, + parseContract, + parseEmbeddedContract, + validateContract, + validateTaskToPrAdapterCoreEquivalence, + validateTaskToPrProjectionTransition +} from "../src"; + +type TaskToPrRole = keyof typeof TASK_TO_PR_ROLE_AUTHORITIES; +type TaskToPrAuthority = (typeof TASK_TO_PR_ROLE_AUTHORITIES)[TaskToPrRole][number]; +type Redaction = "none" | "partial" | "full"; + +const createdAt = "2026-07-23T15:10:00.000Z"; + +function sha(seed: string): string { + return seed.repeat(64); +} + +function ref(role: TaskToPrRole, authority: TaskToPrAuthority, seed: string, redaction: Redaction = "none") { + return { + role, + authority, + id: `${role}:opaque-${seed}`, + digest: createHash("sha256").update(`${role}:${authority}:${seed}`, "utf8").digest("hex"), + redaction + }; +} + +function evidence(id: string, seed: string, redaction: "partial" | "full" = "full") { + return { id: `evidence:opaque-${id}`, digest: sha(seed), redaction }; +} + +function head(seed: string) { + return { algorithm: "sha1" as const, value: seed.repeat(40) }; +} + +function withActiveProvenance>( + projection: T +): Omit & { provenanceLedger: TaskToPrProjection["provenanceLedger"] } { + const active = [ + { category: "projection_id", projectionId: projection.id }, + { category: "work_run", ref: projection.workRunRef }, + { category: "attempt", ref: projection.attempt.ref }, + { category: "attempt_nonce", nonce: projection.attempt.nonce }, + { category: "runtime", ref: projection.attempt.runtimeRef }, + { category: "writer_generation", ref: projection.attempt.writerGenerationRef }, + { category: "writer_lease", ref: projection.attempt.writerLeaseRef }, + { category: "writer_fence", ref: projection.attempt.writerFenceRef }, + { category: "provider_profile", ref: projection.attempt.providerProfileRef }, + { category: "provider_route", ref: projection.attempt.providerRouteRef }, + { category: "replay_cursor", ref: projection.events.replayCursorRef }, + { + category: "replay_prefix", + sequence: projection.events.sequence, + prefixDigest: projection.events.prefixDigest + }, + { category: "repair_state", ref: projection.repair.ref }, + ...(projection.repair.latestRepairRef + ? [{ category: "latest_repair", ref: projection.repair.latestRepairRef }] + : []), + ...(projection.handoff ? [{ category: "handoff", ref: projection.handoff.ref }] : []), + ...(projection.recovery ? [{ category: "recovery", ref: projection.recovery.ref }] : []), + ...(projection.exactHead + ? [ + { + category: "equality_proof", + ref: projection.exactHead.equalityProofRef, + head: projection.exactHead.localHead + }, + ...projection.exactHead.ciProofBundleRefs.map((proofRef: TaskToPrRef) => ({ + category: "ci_proof", + ref: proofRef, + head: projection.exactHead.localHead + })) + ] + : []), + ...((projection.reviews ?? []) as Array<{ + ref: TaskToPrRef; + reviewRunRef: TaskToPrRef; + proofBundleRef: TaskToPrRef; + head: ReturnType; + }>).flatMap((review) => [ + { category: "review_proof", ref: review.proofBundleRef, head: review.head }, + { category: "review_record", ref: review.ref, head: review.head }, + { category: "review_run", ref: review.reviewRunRef, head: review.head } + ]), + ...(projection.merge + ? [ + { category: "merge_guard", ref: projection.merge.guard.ref }, + { + category: "provider_guard_receipt", + ref: projection.merge.guard.providerGuardReceiptRef, + head: projection.merge.guard.expectedHead + } + ] + : []), + ...(projection.cleanup + ? [{ category: "cleanup_eligibility", ref: projection.cleanup.eligibility.ref }] + : []), + ...(projection.rollback + ? [{ category: "rollback_plan", ref: projection.rollback.plan.ref }] + : []) + ]; + const provenanceLedger = [...(projection.provenanceLedger ?? [])]; + for (const entry of active) { + if (!provenanceLedger.some((existing: unknown) => JSON.stringify(existing) === JSON.stringify(entry))) { + provenanceLedger.push(entry); + } + } + return { ...projection, provenanceLedger } as Omit & { + provenanceLedger: TaskToPrProjection["provenanceLedger"]; + }; +} + +function parseProjection>(projection: T): TaskToPrProjection { + return TaskToPrProjectionSchema.parse(withActiveProvenance(projection)); +} + +function validProjection() { + const reviewedHead = head("a"); + const pullRequestRef = ref("pull_request", "todos", "b"); + const currentGeneration = ref("writer_generation", "todos", "c"); + const currentAttempt = ref("attempt", "todos", "d"); + const reviewRef = ref("review", "review", "e"); + const proofBundleRef = ref("proof_bundle", "review", "f"); + const headEqualityProofRef = ref("proof_bundle", "review", "0"); + const ciProofBundleRef = ref("proof_bundle", "review", "1"); + const rootRequestRef = ref("root_request", "todos", "4"); + const prGroupRef = ref("pr_group", "todos", "5"); + const leafTaskRef = ref("leaf_task", "todos", "6"); + const repoRef = ref("repo", "repos", "b"); + const baseHead = head("e"); + const frozenScopeDigest = sha("2"); + const identityDigest = deriveTaskToPrIdentityDigest({ + canonicalizationVersion: 1, + rootRequestRef, + prGroupRef, + leafTaskRef, + repoRef, + baseHead, + frozenScopeDigest + }); + + return withActiveProvenance({ + schema: SCHEMA_IDS.taskToPrProjection, + id: "task_to_pr_projection:opaque-123", + createdAt, + canonicalizationVersion: 1, + identityDigest, + frozenScopeDigest, + state: "cleanup_complete", + workRunRef: ref("work_run", "codewith", "3"), + rootRequestRef, + prGroupRef, + leafTaskRef, + attempt: { + ref: currentAttempt, + nonce: "attempt_nonce:opaque-4fbd95f2-7bb7-4e7e-a7ee-2fa4c9bed5b2", + admissionRef: ref("admission", "codewith", "1", "partial"), + workerRef: ref("worker", "codewith", "2", "partial"), + runtimeRef: ref("runtime", "codewith", "3", "partial"), + writerGenerationRef: currentGeneration, + writerLeaseRef: ref("writer_lease", "repos", "7", "partial"), + writerFenceRef: ref("writer_fence", "repos", "8", "full"), + providerProfileRef: ref("provider_profile", "codewith", "9", "full"), + providerRouteRef: ref("provider_route", "codewith", "a", "partial") + }, + repository: { + repoRef, + worktreeRef: ref("worktree", "repos", "c", "partial"), + branchRef: ref("branch", "repos", "d"), + baseHead, + branchHead: reviewedHead + }, + events: { + streamRef: ref("event_stream", "todos", "e"), + replayCursorRef: ref("replay_cursor", "todos", "f"), + sequence: 42, + prefixDigest: sha("0") + }, + openLoopsInvocationRef: ref("openloops_invocation", "openloops", "1", "partial"), + pullRequestRef, + handoff: { + ref: ref("handoff", "todos", "2"), + previousAttemptRef: ref("attempt", "todos", "1"), + nextAttemptRef: currentAttempt, + previousWriterGenerationRef: ref("writer_generation", "todos", "3"), + nextWriterGenerationRef: currentGeneration, + stoppedWorkRunRef: ref("work_run", "codewith", "4"), + stopEvidenceRef: evidence("ev_prior_worker_stopped", "5"), + leaseRevocationEvidenceRef: evidence("ev_prior_lease_revoked", "6") + }, + exactHead: { + pullRequestRef, + remoteBranchRef: ref("branch", "repos", "d"), + localHead: reviewedHead, + remoteHead: reviewedHead, + providerPullRequestHead: reviewedHead, + equalityProofRef: headEqualityProofRef, + ciProofBundleRefs: [ciProofBundleRef], + verifiedAt: createdAt + }, + reviews: [ + { + ref: reviewRef, + pullRequestRef, + head: reviewedHead, + reviewerRef: ref("reviewer", "review", "7"), + reviewRunRef: ref("review_run", "review", "8"), + proofBundleRef, + verdict: "approved", + reviewedAt: createdAt + } + ], + repair: { + ref: ref("repair_cycle", "todos", "9"), + cycle: 1, + cap: 2, + exhausted: false, + latestRepairRef: ref("repair_cycle", "todos", "a") + }, + merge: { + guard: { + ref: ref("merge_guard", "todos", "b"), + pullRequestRef, + expectedHead: reviewedHead, + reviewRefs: [reviewRef], + proofBundleRefs: [proofBundleRef, headEqualityProofRef, ciProofBundleRef], + operatorRef: ref("merge_operator", "merge_provider", "c", "partial"), + operatorRunRef: ref("merge_operator_run", "merge_provider", "d", "partial"), + providerGuardReceiptRef: ref("merge_guard_receipt", "merge_provider", "c", "full"), + mechanism: "compare_and_swap", + decision: "eligible", + evaluatedAt: createdAt + }, + outcome: { + ref: ref("merge_outcome", "merge_provider", "d", "partial"), + guardRef: ref("merge_guard", "todos", "b"), + pullRequestRef, + expectedHead: reviewedHead, + observedHead: reviewedHead, + status: "merged", + mergeCommitRef: ref("commit", "repos", "e"), + finishedAt: createdAt, + evidenceRefs: [evidence("ev_merge_cas", "f")] + } + }, + cleanup: { + eligibility: { + ref: ref("cleanup_eligibility", "repos", "6"), + status: "eligible", + targetWorktreeRef: ref("worktree", "repos", "c", "partial"), + eventCursorRef: ref("replay_cursor", "todos", "f"), + evaluatedAt: createdAt, + evidenceRefs: [evidence("ev_cleanup_gate", "7")] + }, + outcome: { + ref: ref("cleanup_outcome", "repos", "8"), + eligibilityRef: ref("cleanup_eligibility", "repos", "6"), + targetWorktreeRef: ref("worktree", "repos", "c", "partial"), + status: "deleted", + finishedAt: createdAt, + evidenceRefs: [evidence("ev_cleanup_deleted", "9")] + } + }, + provenanceLedger: [], + adapterExtensions: [ + { + mode: "local", + schema: "hasna.task_to_pr_adapter_extension.v1", + ref: ref("adapter_extension", "adapter", "a", "partial"), + digest: sha("b") + }, + { + mode: "cloud", + schema: "hasna.task_to_pr_adapter_extension.v1", + ref: ref("adapter_extension", "adapter", "c", "full"), + digest: sha("d") + } + ], + evidenceRefs: [evidence("ev_projection", "e")] + }); +} + +function validRecoveryProjection() { + const projection = validProjection(); + return { + ...projection, + handoff: undefined, + recovery: { + ref: ref("recovery", "todos", "7"), + priorAttemptRef: ref("attempt", "todos", "1"), + priorWriterGenerationRef: ref("writer_generation", "todos", "2"), + priorWorkRunRef: ref("work_run", "codewith", "4"), + successorAttemptNonce: projection.attempt.nonce, + successorWriterGenerationRef: projection.attempt.writerGenerationRef, + preservedStateRefs: [ + ref("work_run", "codewith", "4"), + projection.rootRequestRef, + projection.prGroupRef, + projection.leafTaskRef, + projection.repository.repoRef, + projection.repository.worktreeRef, + projection.repository.branchRef, + projection.events.streamRef, + projection.pullRequestRef + ], + stopEvidenceRef: evidence("ev_recovery_stop", "4"), + leaseRevocationEvidenceRef: evidence("ev_recovery_revoke", "5") + }, + provenanceLedger: [ + ...projection.provenanceLedger, + { category: "recovery" as const, ref: ref("recovery", "todos", "7") } + ] + }; +} + +describe("task-to-PR projection v1", () => { + test("parses and round-trips the strict provider-neutral projection", () => { + const input = validProjection(); + const parsed = TaskToPrProjectionSchema.parse(input); + const roundTrip = parseEmbeddedContract(JSON.parse(JSON.stringify(parsed))); + + expect(roundTrip).toEqual(parsed); + expect(parseContract(SCHEMA_IDS.taskToPrProjection, input)).toEqual(parsed); + expect(parsed.workRunRef.role).toBe("work_run"); + expect(parsed.events.sequence).toBe(42); + }); + + test("derives and verifies the non-circular canonical root/PR-group/leaf/repo/base/scope binding", () => { + const projection = validProjection(); + expect( + deriveTaskToPrIdentityDigest({ + canonicalizationVersion: 1, + rootRequestRef: projection.rootRequestRef, + prGroupRef: projection.prGroupRef, + leafTaskRef: projection.leafTaskRef, + repoRef: projection.repository.repoRef, + baseHead: projection.repository.baseHead, + frozenScopeDigest: projection.frozenScopeDigest + }) + ).toBe(projection.identityDigest); + expect(projection.prGroupRef.digest).not.toBe(projection.identityDigest); + + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + identityDigest: sha("0") + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + leafTaskRef: { ...projection.leafTaskRef, id: "leaf_task_changed" } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + prGroupRef: { ...projection.prGroupRef, id: "pr_group:opaque-changed" } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + prGroupRef: { ...projection.prGroupRef, digest: sha("0") } + }).success + ).toBe(false); + }); + + test("enforces the exhaustive role-to-authority matrix", () => { + const expectedRoles = [ + "work_run", + "root_request", + "pr_group", + "leaf_task", + "attempt", + "writer_generation", + "writer_lease", + "writer_fence", + "provider_profile", + "provider_route", + "admission", + "worker", + "runtime", + "repo", + "worktree", + "branch", + "event_stream", + "replay_cursor", + "handoff", + "pull_request", + "commit", + "review", + "reviewer", + "review_run", + "proof_bundle", + "repair_cycle", + "merge_guard", + "merge_operator", + "merge_operator_run", + "merge_guard_receipt", + "merge_outcome", + "recovery", + "cancellation", + "cleanup_eligibility", + "cleanup_outcome", + "rollback_plan", + "rollback_outcome", + "openloops_invocation", + "adapter_extension" + ]; + expect(Object.keys(TASK_TO_PR_ROLE_AUTHORITIES)).toEqual(expectedRoles); + + const redactedRoles = new Set([ + "writer_lease", + "writer_fence", + "provider_profile", + "provider_route", + "admission", + "worker", + "runtime", + "worktree", + "merge_operator", + "merge_operator_run", + "merge_guard_receipt", + "merge_outcome", + "openloops_invocation", + "adapter_extension" + ]); + for (const [role, authorities] of Object.entries(TASK_TO_PR_ROLE_AUTHORITIES)) { + for (const authority of authorities) { + expect( + TaskToPrRefSchema.safeParse(ref(role as TaskToPrRole, authority, "a", redactedRoles.has(role as TaskToPrRole) ? "full" : "none")) + .success + ).toBe(true); + } + const invalidAuthority = (authorities as readonly string[]).includes("todos") ? "codewith" : "todos"; + expect( + TaskToPrRefSchema.safeParse({ + ...ref(role as TaskToPrRole, authorities[0], "b", redactedRoles.has(role as TaskToPrRole) ? "full" : "none"), + authority: invalidAuthority + }).success + ).toBe(false); + } + }); + + test("exposes an immutable role-to-authority matrix without changing validation", () => { + const validRootRef = ref("root_request", "todos", "a"); + expect(TaskToPrRefSchema.safeParse(validRootRef).success).toBe(true); + expect(Object.isFrozen(TASK_TO_PR_ROLE_AUTHORITIES)).toBe(true); + expect(Object.isFrozen(TASK_TO_PR_ROLE_AUTHORITIES.root_request)).toBe(true); + expect(Reflect.set(TASK_TO_PR_ROLE_AUTHORITIES, "root_request", Object.freeze(["codewith"]))).toBe(false); + expect(Reflect.set(TASK_TO_PR_ROLE_AUTHORITIES.root_request, 0, "codewith")).toBe(false); + expect(TASK_TO_PR_ROLE_AUTHORITIES.root_request).toEqual(["todos"]); + expect(TaskToPrRefSchema.safeParse(validRootRef).success).toBe(true); + }); + + test("keeps singular canonical identities required and rejects embedded mutable payloads", () => { + const missingRoot = validProjection() as Record; + delete missingRoot.rootRequestRef; + expect(TaskToPrProjectionSchema.safeParse(missingRoot).success).toBe(false); + + const embeddedTask = validProjection(); + const invalid = { + ...embeddedTask, + leafTaskRef: { + ...embeddedTask.leafTaskRef, + title: "mutable task payload", + status: "in_progress" + } + }; + expect(TaskToPrProjectionSchema.safeParse(invalid).success).toBe(false); + + expect( + TaskToPrProjectionSchema.safeParse({ + ...validProjection(), + rootRequestRefs: [validProjection().rootRequestRef] + }).success + ).toBe(false); + }); + + test("rejects raw account, credential, token, fence, and adapter payload fields", () => { + const projection = validProjection(); + const rawProfile = { + ...projection, + attempt: { + ...projection.attempt, + providerProfileRef: { + ...projection.attempt.providerProfileRef, + accountId: "provider-account-123" + } + } + }; + expect(TaskToPrProjectionSchema.safeParse(rawProfile).success).toBe(false); + + const rawFenceInvalidFixture = { + ...projection, + attempt: { + ...projection.attempt, + fenceToken: "raw-fence-value" + } + }; + expect(TaskToPrProjectionSchema.safeParse(rawFenceInvalidFixture).success).toBe(false); + + const rawExtension = { + ...projection, + adapterExtensions: [ + { + ...projection.adapterExtensions[0], + provider: "github", + credentials: { token: "raw-token" }, + payload: { mutable: true } + } + ] + }; + expect(TaskToPrProjectionSchema.safeParse(rawExtension).success).toBe(false); + + const secretBearingIds = [ + ["provider_profile", "codewith", "provider_profile:opaque-alice@example.com"], + ["provider_route", "codewith", "provider_route:opaque-provider.route?token=value"], + ["writer_fence", "repos", "writer_fence:opaque-github_pat_example"], + ["writer_fence", "repos", "writer_fence:opaque-f.ence447"], + ["writer_lease", "repos", "writer_lease:opaque-raw-fence-value"], + ["runtime", "codewith", "runtime:opaque------BEGIN-PRIVATE-KEY"], + ["merge_operator", "merge_provider", "merge_operator:opaque-Bearer-token"], + ["root_request", "todos", "root_request:opaque-acc.ount013"], + ["branch", "repos", "branch:opaque-raw-fence-447"] + ] as const; + for (const [role, authority, id] of secretBearingIds) { + expect( + TaskToPrRefSchema.safeParse({ + ...ref(role, authority, "a", "full"), + id + }).success + ).toBe(false); + } + const delimiterNormalizedGithubPatRef: TaskToPrRef = { + role: "writer_fence", + authority: "repos", + id: "writer_fence:opaque-github-pat-example", + digest: sha("a"), + redaction: "full" + }; + expect(TaskToPrRefSchema.safeParse(delimiterNormalizedGithubPatRef).success).toBe(false); + + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + evidenceRefs: [ + { + ...evidence("ev_secret", "a"), + uri: "https://user:password@example.com/artifact" + } + ] + }).success + ).toBe(false); + + for (const invalidId of [ + "task_to_pr_projection:account013", + "task_to_pr_projection:raw-fence-447", + "task_to_pr_projection:https-provider", + "task_to_pr_projection:opaque-" + ]) { + expect(TaskToPrProjectionSchema.safeParse({ ...projection, id: invalidId }).success).toBe(false); + } + for (const invalidNonce of [ + "attempt_nonce:account013", + "attempt_nonce:raw-fence-447", + "attempt_nonce:opaque-to.ken447", + "raw-untyped-nonce", + "attempt_nonce:opaque-" + ]) { + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + attempt: { ...projection.attempt, nonce: invalidNonce } + }).success + ).toBe(false); + } + expect( + TaskToPrRefSchema.safeParse({ + ...ref("root_request", "todos", "a"), + id: "root_request:opaque-" + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + adapterExtensions: [ + { + ...projection.adapterExtensions[0], + schema: "hasna.task_to_pr_adapter_acc_ount.v1" + } + ] + }).success + ).toBe(false); + for (const rawEvidenceId of ["evidence:opaque-account013", "evidence:opaque-raw-fence-447", "evidence:untyped"]) { + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + evidenceRefs: [{ ...evidence("ev_safe", "a"), id: rawEvidenceId }] + }).success + ).toBe(false); + } + + expect( + TaskToPrProjectionSchema.safeParse( + withActiveProvenance({ + ...projection, + id: "task_to_pr_projection:opaque-owner-record", + attempt: { + ...projection.attempt, + nonce: "attempt_nonce:opaque-owner-record" + }, + provenanceLedger: projection.provenanceLedger.filter( + (entry) => entry.category !== "attempt_nonce" + ) + }) + ).success + ).toBe(true); + }); + + test("requires redaction for opaque profile, route, lease, fence, worktree, and provider refs", () => { + const sensitiveRoles: TaskToPrRole[] = [ + "writer_lease", + "writer_fence", + "provider_profile", + "provider_route", + "admission", + "worker", + "runtime", + "worktree", + "merge_operator", + "merge_operator_run", + "merge_guard_receipt", + "merge_outcome", + "openloops_invocation", + "adapter_extension" + ]; + for (const role of sensitiveRoles) { + const authority = TASK_TO_PR_ROLE_AUTHORITIES[role][0]; + expect(TaskToPrRefSchema.safeParse(ref(role, authority, "a", "none")).success).toBe(false); + expect(TaskToPrRefSchema.safeParse(ref(role, authority, "a", "partial")).success).toBe(true); + } + }); + + test("binds replay cursors to a non-negative sequence and prefix digest", () => { + const projection = validProjection(); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + events: { ...projection.events, sequence: -1 } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + events: { ...projection.events, prefixDigest: "not-a-digest" } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + events: { + ...projection.events, + replayCursorRef: ref("event_stream", "todos", "a") + } + }).success + ).toBe(false); + }); + + test("prevents review and merge-CAS from floating away from the exact PR head", () => { + const projection = validProjection(); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + exactHead: { + ...projection.exactHead, + remoteBranchRef: ref("branch", "repos", "f") + } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + exactHead: { + ...projection.exactHead, + remoteHead: head("b") + } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + exactHead: { + ...projection.exactHead, + ciProofBundleRefs: [] + } + }).success + ).toBe(false); + const driftedGuard = { + ...projection, + merge: { + ...projection.merge, + guard: { + ...projection.merge.guard, + expectedHead: head("b") + } + } + }; + expect(TaskToPrProjectionSchema.safeParse(driftedGuard).success).toBe(false); + + const driftedMerge = { + ...projection, + merge: { + ...projection.merge, + outcome: { + ...projection.merge.outcome, + observedHead: head("b") + } + } + }; + expect(TaskToPrProjectionSchema.safeParse(driftedMerge).success).toBe(false); + + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + state: "closed_unmerged", + cleanup: undefined, + merge: { + ...projection.merge, + outcome: { + ...projection.merge.outcome, + status: "refused", + observedHead: head("b"), + mergeCommitRef: undefined + } + } + }).success + ).toBe(false); + + const refusedDrift = { + ...projection, + state: "closed_unmerged", + cleanup: undefined, + merge: { + ...projection.merge, + outcome: { + ...projection.merge.outcome, + status: "head_drift", + observedHead: head("b"), + mergeCommitRef: undefined + } + } + }; + expect(TaskToPrProjectionSchema.safeParse(refusedDrift).success).toBe(true); + + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + exactHead: { + ...projection.exactHead, + ciProofBundleRefs: [ + projection.exactHead.ciProofBundleRefs[0], + { + ...projection.exactHead.ciProofBundleRefs[0], + digest: sha("9") + } + ] + } + }).success + ).toBe(false); + + const duplicateCiDigest = { + ...projection.exactHead.ciProofBundleRefs[0], + id: "proof_bundle:opaque-ci-duplicate-digest" + }; + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + exactHead: { + ...projection.exactHead, + ciProofBundleRefs: [...projection.exactHead.ciProofBundleRefs, duplicateCiDigest] + }, + merge: { + ...projection.merge, + guard: { + ...projection.merge.guard, + proofBundleRefs: [...projection.merge.guard.proofBundleRefs, duplicateCiDigest] + } + } + }).success + ).toBe(false); + + const ciWithEqualityDigest = { + ...projection.exactHead.ciProofBundleRefs[0], + digest: projection.exactHead.equalityProofRef.digest + }; + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + exactHead: { + ...projection.exactHead, + ciProofBundleRefs: [ciWithEqualityDigest] + }, + merge: { + ...projection.merge, + guard: { + ...projection.merge.guard, + proofBundleRefs: projection.merge.guard.proofBundleRefs.map((proofRef) => + proofRef.id === projection.exactHead.ciProofBundleRefs[0]!.id ? ciWithEqualityDigest : proofRef + ) + } + } + }).success + ).toBe(false); + }); + + test("keeps equality, CI, and review proof obligations globally distinct by identity and digest", () => { + const projection = validProjection(); + const reviewOnly = parseProjection({ + ...projection, + state: "reviewing", + merge: undefined, + cleanup: undefined + }); + const equalityProof = reviewOnly.exactHead!.equalityProofRef; + const sameIdentityAlias: TaskToPrProjection = { + ...reviewOnly, + reviews: [ + { + ...reviewOnly.reviews[0]!, + proofBundleRef: { + ...reviewOnly.reviews[0]!.proofBundleRef, + id: equalityProof.id + } + } + ] + }; + expect(TaskToPrProjectionSchema.safeParse(sameIdentityAlias).success).toBe(false); + + const sameDigestAlias: TaskToPrProjection = { + ...reviewOnly, + reviews: [ + { + ...reviewOnly.reviews[0]!, + proofBundleRef: { + ...reviewOnly.reviews[0]!.proofBundleRef, + digest: equalityProof.digest + } + } + ] + }; + expect(TaskToPrProjectionSchema.safeParse(sameDigestAlias).success).toBe(false); + + const eligible = parseProjection({ + ...projection, + state: "merge_ready", + merge: { + guard: projection.merge.guard + }, + cleanup: undefined + }); + const ciProof = eligible.exactHead!.ciProofBundleRefs[0]!; + const originalReviewProof = eligible.reviews[0]!.proofBundleRef; + const sharedCiReviewProof: TaskToPrProjection = { + ...eligible, + reviews: [ + { + ...eligible.reviews[0]!, + proofBundleRef: ciProof + } + ], + merge: { + guard: { + ...eligible.merge!.guard, + proofBundleRefs: eligible.merge!.guard.proofBundleRefs.filter( + (proofRef) => proofRef.id !== originalReviewProof.id + ) + } + } + }; + expect(TaskToPrProjectionSchema.safeParse(sharedCiReviewProof).success).toBe(false); + + const reviewProofWithCiDigest = { + ...originalReviewProof, + digest: ciProof.digest + }; + const aliasedEligibleGuard: TaskToPrProjection = { + ...eligible, + reviews: [ + { + ...eligible.reviews[0]!, + proofBundleRef: reviewProofWithCiDigest + } + ], + merge: { + guard: { + ...eligible.merge!.guard, + proofBundleRefs: eligible.merge!.guard.proofBundleRefs.map((proofRef) => + proofRef.id === originalReviewProof.id ? reviewProofWithCiDigest : proofRef + ) + } + } + }; + expect(TaskToPrProjectionSchema.safeParse(aliasedEligibleGuard).success).toBe(false); + }); + + test("supports multiple exact-head reviews and keeps worker, reviewers, and merge operator distinct", () => { + const projection = validProjection(); + const secondReview = { + ...projection.reviews[0]!, + ref: ref("review", "review", "3"), + reviewerRef: ref("reviewer", "review", "5"), + reviewRunRef: ref("review_run", "review", "4"), + proofBundleRef: ref("proof_bundle", "review", "5") + }; + const reviewed = withActiveProvenance({ + ...projection, + reviews: [...projection.reviews, secondReview], + merge: { + ...projection.merge, + guard: { + ...projection.merge.guard, + reviewRefs: [...projection.merge.guard.reviewRefs, secondReview.ref], + proofBundleRefs: [...projection.merge.guard.proofBundleRefs, secondReview.proofBundleRef] + } + } + }); + expect(TaskToPrProjectionSchema.safeParse(reviewed).success).toBe(true); + + expect( + TaskToPrProjectionSchema.safeParse({ + ...reviewed, + merge: { + ...reviewed.merge, + guard: { + ...reviewed.merge.guard, + reviewRefs: [...reviewed.merge.guard.reviewRefs, ref("review", "review", "9")] + } + } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...reviewed, + merge: { + ...reviewed.merge, + guard: { + ...reviewed.merge.guard, + reviewRefs: [reviewed.reviews[0]!.ref] + } + } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...reviewed, + merge: { + ...reviewed.merge, + guard: { + ...reviewed.merge.guard, + reviewRefs: [...reviewed.merge.guard.reviewRefs].reverse() + } + } + }).success + ).toBe(true); + + expect( + TaskToPrProjectionSchema.safeParse({ + ...reviewed, + reviews: [ + reviewed.reviews[0], + { + ...secondReview, + reviewerRef: reviewed.reviews[0]!.reviewerRef + } + ] + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...reviewed, + reviews: [ + reviewed.reviews[0], + { + ...secondReview, + ref: { + ...secondReview.ref, + digest: reviewed.reviews[0]!.ref.digest + } + } + ] + }).success + ).toBe(false); + + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + reviews: [ + { + ...projection.reviews[0]!, + reviewerRef: { ...projection.reviews[0]!.reviewerRef, digest: projection.attempt.workerRef.digest } + } + ] + }).success + ).toBe(false); + + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + reviews: [ + { + ...projection.reviews[0]!, + reviewRunRef: { ...projection.reviews[0]!.reviewRunRef, digest: projection.attempt.runtimeRef.digest } + } + ] + }).success + ).toBe(false); + + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + merge: { + ...projection.merge, + guard: { + ...projection.merge.guard, + operatorRef: { ...projection.merge.guard.operatorRef, digest: projection.reviews[0]!.reviewerRef.digest } + } + } + }).success + ).toBe(false); + + expect( + TaskToPrProjectionSchema.safeParse({ + ...reviewed, + reviews: [ + reviewed.reviews[0], + { + ...secondReview, + reviewerRef: { ...secondReview.reviewerRef, id: reviewed.reviews[0]!.reviewerRef.id } + } + ] + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...reviewed, + reviews: [ + reviewed.reviews[0], + { + ...secondReview, + reviewerRef: { ...secondReview.reviewerRef, digest: reviewed.reviews[0]!.reviewerRef.digest } + } + ] + }).success + ).toBe(false); + + const reviewOnly = { + ...reviewed, + state: "reviewing", + merge: undefined, + cleanup: undefined + }; + expect( + TaskToPrProjectionSchema.safeParse({ + ...reviewOnly, + reviews: [ + reviewOnly.reviews[0], + { + ...reviewOnly.reviews[1], + proofBundleRef: { + ...reviewOnly.reviews[1]!.proofBundleRef, + id: reviewOnly.reviews[0]!.proofBundleRef.id + } + } + ] + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...reviewOnly, + reviews: [ + reviewOnly.reviews[0], + { + ...reviewOnly.reviews[1], + proofBundleRef: { + ...reviewOnly.reviews[1]!.proofBundleRef, + digest: reviewOnly.reviews[0]!.proofBundleRef.digest + } + } + ] + }).success + ).toBe(false); + + const duplicateGuardDigest = { + ...projection.merge.guard.proofBundleRefs[0], + id: "proof_bundle:opaque-guard-duplicate-digest" + }; + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + merge: { + ...projection.merge, + guard: { + ...projection.merge.guard, + proofBundleRefs: [...projection.merge.guard.proofBundleRefs, duplicateGuardDigest] + } + } + }).success + ).toBe(false); + const duplicateGuardCanonicalId = { + ...projection.merge.guard.proofBundleRefs[0], + digest: sha("9") + }; + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + merge: { + ...projection.merge, + guard: { + ...projection.merge.guard, + proofBundleRefs: [...projection.merge.guard.proofBundleRefs, duplicateGuardCanonicalId] + } + } + }).success + ).toBe(false); + }); + + test("enforces exact-head, review, guard, outcome, cleanup, and rollback chronology", () => { + const projection = validProjection(); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + exactHead: { + ...projection.exactHead, + verifiedAt: "2026-07-23T15:09:59.000Z" + } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + reviews: [ + { + ...projection.reviews[0]!, + reviewedAt: "2026-07-23T15:09:59.000Z" + } + ] + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + merge: { + ...projection.merge, + guard: { + ...projection.merge.guard, + evaluatedAt: "2026-07-23T15:09:59.000Z" + } + } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + cleanup: { + ...projection.cleanup, + outcome: { + ...projection.cleanup.outcome, + finishedAt: "2026-07-23T15:09:59.000Z" + } + } + }).success + ).toBe(false); + }); + + test("enforces repair cap and immutable cumulative state", () => { + const projection = validProjection(); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + repair: { ...projection.repair, cycle: 3, exhausted: true } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + repair: { ...projection.repair, cycle: 2, exhausted: false } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + repair: { ...projection.repair, cycle: 0, exhausted: false, latestRepairRef: projection.repair.latestRepairRef } + }).success + ).toBe(false); + }); + + test("validates monotonic replay, repair, attempt, and terminal transitions", () => { + const projection = validProjection(); + const previous = parseProjection({ + ...projection, + id: "task_to_pr_projection:opaque-previous", + createdAt: "2026-07-23T15:09:00.000Z", + state: "running", + pullRequestRef: undefined, + exactHead: undefined, + reviews: [], + handoff: undefined, + merge: undefined, + recovery: undefined, + cleanup: undefined, + repair: { + ref: ref("repair_cycle", "todos", "0"), + cycle: 0, + cap: 2, + exhausted: false + }, + evidenceRefs: [] + }); + const current = parseProjection({ + ...previous, + id: "task_to_pr_projection:opaque-current", + createdAt, + events: { + ...previous.events, + replayCursorRef: ref("replay_cursor", "todos", "a"), + sequence: previous.events.sequence + 1, + prefixDigest: sha("b") + } + }); + expect(validateTaskToPrProjectionTransition(previous, current)).toEqual({ success: true, issues: [] }); + + const replayed = { + ...current, + id: "task_to_pr_projection:opaque-replayed", + events: { ...current.events, sequence: previous.events.sequence - 1 } + }; + expect(validateTaskToPrProjectionTransition(previous, replayed).success).toBe(false); + + const staleCursor = { + ...current, + id: "task_to_pr_projection:opaque-stale-cursor", + events: { + ...current.events, + replayCursorRef: previous.events.replayCursorRef + } + }; + expect(validateTaskToPrProjectionTransition(previous, staleCursor).success).toBe(false); + + const reusedCursorDigest: TaskToPrProjection = { + ...current, + id: "task_to_pr_projection:opaque-reused-cursor-digest", + events: { + ...current.events, + replayCursorRef: { + ...current.events.replayCursorRef, + id: "replay_cursor:opaque-fresh-id-stale-digest", + digest: previous.events.replayCursorRef.digest + } + } + }; + expect(validateTaskToPrProjectionTransition(previous, reusedCursorDigest).success).toBe(false); + + const stalePrefix = { + ...current, + id: "task_to_pr_projection:opaque-stale-prefix", + events: { + ...current.events, + prefixDigest: previous.events.prefixDigest + } + }; + expect(validateTaskToPrProjectionTransition(previous, stalePrefix).success).toBe(false); + + const partialAttemptAdvance = { + ...current, + id: "task_to_pr_projection:opaque-partial-attempt", + attempt: { + ...current.attempt, + nonce: "attempt_nonce:opaque-fresh" + } + }; + expect(validateTaskToPrProjectionTransition(previous, partialAttemptAdvance).success).toBe(false); + expect( + validateTaskToPrProjectionTransition(previous, { + ...current, + id: "task_to_pr_projection:opaque-mutated-attempt-scope", + attempt: { + ...current.attempt, + runtimeRef: ref("runtime", "codewith", "c", "partial") + } + }).success + ).toBe(false); + expect( + validateTaskToPrProjectionTransition(previous, { + ...current, + id: "task_to_pr_projection:opaque-mutated-repair-ref", + repair: { + ...current.repair, + ref: ref("repair_cycle", "todos", "c") + } + }).success + ).toBe(false); + + expect( + validateTaskToPrProjectionTransition(previous, { + ...current, + id: "task_to_pr_projection:opaque-invalid", + unknownMutablePayload: true + }).success + ).toBe(false); + + const mutatedTerminal = { + ...projection, + id: "task_to_pr_projection:opaque-mutated-terminal", + merge: { + ...projection.merge, + outcome: { + ...projection.merge.outcome, + finishedAt: "2026-07-23T15:11:00.000Z" + } + }, + cleanup: { + ...projection.cleanup, + eligibility: { + ...projection.cleanup.eligibility, + evaluatedAt: "2026-07-23T15:11:00.000Z" + }, + outcome: { + ...projection.cleanup.outcome, + finishedAt: "2026-07-23T15:11:00.000Z" + } + } + }; + expect(validateTaskToPrProjectionTransition(TaskToPrProjectionSchema.parse(projection), parseProjection(mutatedTerminal)).success).toBe( + false + ); + }); + + test("requires immutable or fully rotated merge, cleanup, and rollback owner records", () => { + const projection = validProjection(); + const mergePrevious = parseProjection({ + ...projection, + id: "task_to_pr_projection:opaque-guard-owner-previous", + state: "merge_ready", + merge: { + guard: projection.merge.guard + }, + cleanup: undefined + }); + const mergeRotated = TaskToPrProjectionSchema.parse(withActiveProvenance({ + ...mergePrevious, + id: "task_to_pr_projection:opaque-guard-owner-rotated", + events: { + ...mergePrevious.events, + replayCursorRef: ref("replay_cursor", "todos", "2"), + sequence: mergePrevious.events.sequence + 1, + prefixDigest: sha("3") + }, + merge: { + guard: { + ...mergePrevious.merge!.guard, + ref: ref("merge_guard", "todos", "a"), + mechanism: "queue_expected_head" + } + } + })); + + const cleanupPrevious = parseProjection({ + ...projection, + id: "task_to_pr_projection:opaque-cleanup-owner-previous", + state: "merged", + cleanup: { + eligibility: projection.cleanup.eligibility + } + }); + const cleanupCursorRef = ref("replay_cursor", "todos", "4"); + const cleanupRotated = TaskToPrProjectionSchema.parse(withActiveProvenance({ + ...cleanupPrevious, + id: "task_to_pr_projection:opaque-cleanup-owner-rotated", + events: { + ...cleanupPrevious.events, + replayCursorRef: cleanupCursorRef, + sequence: cleanupPrevious.events.sequence + 1, + prefixDigest: sha("5") + }, + cleanup: { + eligibility: { + ...cleanupPrevious.cleanup!.eligibility, + ref: ref("cleanup_eligibility", "repos", "a"), + status: "preserved", + eventCursorRef: cleanupCursorRef + } + } + })); + + const rollbackPrevious = TaskToPrProjectionSchema.parse(withActiveProvenance({ + ...projection, + id: "task_to_pr_projection:opaque-rollback-owner-previous", + state: "merged", + cleanup: undefined, + rollback: { + plan: { + ref: ref("rollback_plan", "todos", "a"), + targetRef: projection.merge.outcome.mergeCommitRef!, + createdAt + } + } + })); + const rollbackRotated = TaskToPrProjectionSchema.parse(withActiveProvenance({ + ...rollbackPrevious, + id: "task_to_pr_projection:opaque-rollback-owner-rotated", + events: { + ...rollbackPrevious.events, + replayCursorRef: ref("replay_cursor", "todos", "6"), + sequence: rollbackPrevious.events.sequence + 1, + prefixDigest: sha("7") + }, + rollback: { + plan: { + ...rollbackPrevious.rollback!.plan, + ref: ref("rollback_plan", "todos", "d"), + targetRef: rollbackPrevious.repository.branchRef + } + } + })); + + const cases = [ + { + name: "merge guard mechanism", + path: "merge.guard", + previous: mergePrevious, + rotated: mergeRotated, + withRef: (candidate: TaskToPrProjection, ownerRef: TaskToPrRef) => ({ + ...candidate, + merge: { + guard: { + ...candidate.merge!.guard, + ref: ownerRef + } + } + }) + }, + { + name: "cleanup eligibility decision", + path: "cleanup.eligibility", + previous: cleanupPrevious, + rotated: cleanupRotated, + withRef: (candidate: TaskToPrProjection, ownerRef: TaskToPrRef) => ({ + ...candidate, + cleanup: { + eligibility: { + ...candidate.cleanup!.eligibility, + ref: ownerRef + } + } + }) + }, + { + name: "rollback target", + path: "rollback.plan", + previous: rollbackPrevious, + rotated: rollbackRotated, + withRef: (candidate: TaskToPrProjection, ownerRef: TaskToPrRef) => ({ + ...candidate, + rollback: { + plan: { + ...candidate.rollback!.plan, + ref: ownerRef + } + } + }) + } + ] as const; + + for (const ownerCase of cases) { + const retained = parseProjection({ + ...ownerCase.previous, + id: `task_to_pr_projection:opaque-${ownerCase.path.replace(".", "-")}-retained` + }); + expect( + validateTaskToPrProjectionTransition(ownerCase.previous, retained), + `${ownerCase.name} permits an exactly retained owner record` + ).toEqual({ success: true, issues: [] }); + expect( + validateTaskToPrProjectionTransition(ownerCase.previous, ownerCase.rotated), + `${ownerCase.name} permits a fully fresh owner-record rotation` + ).toEqual({ success: true, issues: [] }); + + const previousRef = + ownerCase.path === "merge.guard" + ? ownerCase.previous.merge!.guard.ref + : ownerCase.path === "cleanup.eligibility" + ? ownerCase.previous.cleanup!.eligibility.ref + : ownerCase.previous.rollback!.plan.ref; + const rotatedRef = + ownerCase.path === "merge.guard" + ? ownerCase.rotated.merge!.guard.ref + : ownerCase.path === "cleanup.eligibility" + ? ownerCase.rotated.cleanup!.eligibility.ref + : ownerCase.rotated.rollback!.plan.ref; + const invalidRefs = [ + previousRef, + { + ...rotatedRef, + digest: previousRef.digest + }, + { + ...rotatedRef, + id: previousRef.id, + digest: sha("c") + } + ]; + + for (const [index, invalidRef] of invalidRefs.entries()) { + const invalid = ownerCase.withRef( + ownerCase.rotated, + invalidRef + ); + const parsedInvalid = TaskToPrProjectionSchema.safeParse(invalid); + if (!parsedInvalid.success) { + expect(parsedInvalid.success, `${ownerCase.name} stale provenance is rejected statically`).toBe(false); + continue; + } + const result = validateTaskToPrProjectionTransition(ownerCase.previous, invalid); + expect(result.success, `${ownerCase.name} invalid ref rotation ${index}`).toBe(false); + expect( + result.issues.some((issue) => issue.path === ownerCase.path), + `${ownerCase.name} reports its owner-record path` + ).toBe(true); + } + } + }); + + test("binds recovery and handoff transitions to the prior attempt, generation, WorkRun, and fresh attempt-scoped refs", () => { + const base = validProjection(); + const previous = parseProjection({ + ...base, + id: "task_to_pr_projection:opaque-transition-previous", + createdAt: "2026-07-23T15:08:00.000Z", + state: "running", + pullRequestRef: undefined, + exactHead: undefined, + handoff: undefined, + reviews: [], + merge: undefined, + recovery: undefined, + cleanup: undefined, + repair: { + ref: ref("repair_cycle", "todos", "0"), + cycle: 0, + cap: 2, + exhausted: false + }, + provenanceLedger: [], + evidenceRefs: [] + }); + const nextAttempt = { + ...previous.attempt, + ref: ref("attempt", "todos", "a"), + nonce: "attempt_nonce:opaque-next", + runtimeRef: ref("runtime", "codewith", "b", "partial"), + writerGenerationRef: ref("writer_generation", "todos", "5"), + writerLeaseRef: ref("writer_lease", "repos", "d", "partial"), + writerFenceRef: ref("writer_fence", "repos", "e", "full"), + providerProfileRef: ref("provider_profile", "codewith", "f", "full"), + providerRouteRef: ref("provider_route", "codewith", "0", "partial") + }; + const nextWorkRunRef = ref("work_run", "codewith", "1"); + const recovery = { + ref: ref("recovery", "todos", "2"), + priorAttemptRef: previous.attempt.ref, + priorWriterGenerationRef: previous.attempt.writerGenerationRef, + priorWorkRunRef: previous.workRunRef, + successorAttemptNonce: nextAttempt.nonce, + successorWriterGenerationRef: nextAttempt.writerGenerationRef, + preservedStateRefs: [ + previous.workRunRef, + previous.rootRequestRef, + previous.prGroupRef, + previous.leafTaskRef, + previous.repository.repoRef, + previous.repository.worktreeRef, + previous.repository.branchRef, + previous.events.streamRef + ], + stopEvidenceRef: evidence("ev_transition_stop", "2"), + leaseRevocationEvidenceRef: evidence("ev_transition_revoke", "3") + }; + const recovered = TaskToPrProjectionSchema.parse(withActiveProvenance({ + ...previous, + id: "task_to_pr_projection:opaque-transition-current", + createdAt, + state: "recovering", + workRunRef: nextWorkRunRef, + attempt: nextAttempt, + recovery, + events: { + ...previous.events, + replayCursorRef: ref("replay_cursor", "todos", "4"), + sequence: previous.events.sequence + 1, + prefixDigest: sha("5") + } + })); + expect(validateTaskToPrProjectionTransition(previous, recovered)).toEqual({ success: true, issues: [] }); + + const reusedAttemptDigest: TaskToPrProjection = { + ...recovered, + id: "task_to_pr_projection:opaque-reused-attempt-digest", + attempt: { + ...recovered.attempt, + ref: { + ...recovered.attempt.ref, + id: "attempt:opaque-fresh-id-stale-digest", + digest: previous.attempt.ref.digest + } + } + }; + expect(validateTaskToPrProjectionTransition(previous, reusedAttemptDigest).success).toBe(false); + + const reusedGenerationDigest: TaskToPrProjection = { + ...recovered, + id: "task_to_pr_projection:opaque-reused-generation-digest", + attempt: { + ...recovered.attempt, + writerGenerationRef: { + ...recovered.attempt.writerGenerationRef, + id: "writer_generation:opaque-fresh-id-stale-digest", + digest: previous.attempt.writerGenerationRef.digest + } + }, + recovery: { + ...recovered.recovery!, + successorWriterGenerationRef: { + ...recovered.attempt.writerGenerationRef, + id: "writer_generation:opaque-fresh-id-stale-digest", + digest: previous.attempt.writerGenerationRef.digest + } + } + }; + expect(validateTaskToPrProjectionTransition(previous, reusedGenerationDigest).success).toBe(false); + + const reusedWorkRunDigest: TaskToPrProjection = { + ...recovered, + id: "task_to_pr_projection:opaque-reused-work-run-digest", + workRunRef: { + ...recovered.workRunRef, + id: "work_run:opaque-fresh-id-stale-digest", + digest: previous.workRunRef.digest + } + }; + expect(validateTaskToPrProjectionTransition(previous, reusedWorkRunDigest).success).toBe(false); + + expect( + validateTaskToPrProjectionTransition(previous, { + ...recovered, + id: "task_to_pr_projection:opaque-reused-attempt-canonical-id", + attempt: { + ...recovered.attempt, + ref: { + ...recovered.attempt.ref, + id: previous.attempt.ref.id, + digest: sha("6") + } + } + }).success + ).toBe(false); + expect( + validateTaskToPrProjectionTransition(previous, { + ...recovered, + id: "task_to_pr_projection:opaque-reused-generation-canonical-id", + attempt: { + ...recovered.attempt, + writerGenerationRef: { + ...recovered.attempt.writerGenerationRef, + id: previous.attempt.writerGenerationRef.id, + digest: sha("7") + } + }, + recovery: { + ...recovered.recovery, + successorWriterGenerationRef: { + ...recovered.attempt.writerGenerationRef, + id: previous.attempt.writerGenerationRef.id, + digest: sha("7") + } + } + }).success + ).toBe(false); + expect( + validateTaskToPrProjectionTransition(previous, { + ...recovered, + id: "task_to_pr_projection:opaque-reused-work-run-canonical-id", + workRunRef: { + ...recovered.workRunRef, + id: previous.workRunRef.id, + digest: sha("8") + } + }).success + ).toBe(false); + + for (const [field, staleRef] of [ + ["runtimeRef", previous.attempt.runtimeRef], + ["writerLeaseRef", previous.attempt.writerLeaseRef], + ["writerFenceRef", previous.attempt.writerFenceRef], + ["providerProfileRef", previous.attempt.providerProfileRef], + ["providerRouteRef", previous.attempt.providerRouteRef] + ] as const) { + expect( + validateTaskToPrProjectionTransition(previous, { + ...recovered, + id: `task_to_pr_projection:opaque-stale-${field.toLowerCase()}`, + attempt: { ...recovered.attempt, [field]: staleRef } + }).success + ).toBe(false); + } + expect( + validateTaskToPrProjectionTransition(previous, { + ...recovered, + id: "task_to_pr_projection:opaque-reused-runtime-digest", + attempt: { + ...recovered.attempt, + runtimeRef: { + ...recovered.attempt.runtimeRef, + id: "runtime:opaque-fresh-display", + digest: previous.attempt.runtimeRef.digest + } + } + }).success + ).toBe(false); + + expect( + validateTaskToPrProjectionTransition(previous, { + ...recovered, + id: "task_to_pr_projection:opaque-wrong-prior-run", + recovery: { ...recovered.recovery, priorWorkRunRef: nextWorkRunRef } + }).success + ).toBe(false); + + const handedOff = withActiveProvenance({ + ...recovered, + id: "task_to_pr_projection:opaque-handoff-current", + state: "handed_off", + provenanceLedger: previous.provenanceLedger, + recovery: undefined, + handoff: { + ref: ref("handoff", "todos", "6"), + previousAttemptRef: previous.attempt.ref, + nextAttemptRef: nextAttempt.ref, + previousWriterGenerationRef: previous.attempt.writerGenerationRef, + nextWriterGenerationRef: nextAttempt.writerGenerationRef, + stoppedWorkRunRef: previous.workRunRef, + stopEvidenceRef: evidence("ev_handoff_stop", "6"), + leaseRevocationEvidenceRef: evidence("ev_handoff_revoke", "7") + } + }); + expect(validateTaskToPrProjectionTransition(previous, handedOff).success).toBe(true); + expect( + validateTaskToPrProjectionTransition(previous, { + ...handedOff, + id: "task_to_pr_projection:opaque-handoff-wrong-prior", + handoff: { + ...handedOff.handoff, + previousAttemptRef: nextAttempt.ref + } + }).success + ).toBe(false); + + const retainedHandoff = withActiveProvenance({ + ...handedOff, + id: "task_to_pr_projection:opaque-handoff-retained", + events: { + ...handedOff.events, + replayCursorRef: ref("replay_cursor", "todos", "8"), + sequence: handedOff.events.sequence + 1, + prefixDigest: sha("9") + } + }); + expect(validateTaskToPrProjectionTransition(handedOff, retainedHandoff)).toEqual({ + success: true, + issues: [] + }); + + const removedHandoff = withActiveProvenance({ + ...retainedHandoff, + id: "task_to_pr_projection:opaque-handoff-removed", + state: "running" as const, + handoff: undefined + }); + expect(TaskToPrProjectionSchema.safeParse(removedHandoff).success).toBe(true); + expect(validateTaskToPrProjectionTransition(handedOff, removedHandoff).success).toBe(false); + + const mutatedHandoff = withActiveProvenance({ + ...retainedHandoff, + id: "task_to_pr_projection:opaque-handoff-mutated", + handoff: { + ...retainedHandoff.handoff, + stopEvidenceRef: evidence("ev_handoff_stop_mutated", "a") + } + }); + expect(TaskToPrProjectionSchema.safeParse(mutatedHandoff).success).toBe(true); + expect(validateTaskToPrProjectionTransition(handedOff, mutatedHandoff).success).toBe(false); + + const switchedToRecovery = withActiveProvenance({ + ...retainedHandoff, + id: "task_to_pr_projection:opaque-handoff-switched-to-recovery", + state: "recovering" as const, + handoff: undefined, + recovery: { + ...recovery, + successorAttemptNonce: handedOff.attempt.nonce, + successorWriterGenerationRef: handedOff.attempt.writerGenerationRef + } + }); + expect(TaskToPrProjectionSchema.safeParse(switchedToRecovery).success).toBe(true); + expect(validateTaskToPrProjectionTransition(handedOff, switchedToRecovery).success).toBe(false); + + const retainedRecovery = withActiveProvenance({ + ...recovered, + id: "task_to_pr_projection:opaque-recovery-retained", + events: { + ...recovered.events, + replayCursorRef: ref("replay_cursor", "todos", "b"), + sequence: recovered.events.sequence + 1, + prefixDigest: sha("c") + } + }); + expect(validateTaskToPrProjectionTransition(recovered, retainedRecovery)).toEqual({ + success: true, + issues: [] + }); + const mutatedRecovery = withActiveProvenance({ + ...retainedRecovery, + id: "task_to_pr_projection:opaque-recovery-mutated", + recovery: { + ...retainedRecovery.recovery!, + stopEvidenceRef: evidence("ev_recovery_stop_mutated", "d") + } + }); + expect(TaskToPrProjectionSchema.safeParse(mutatedRecovery).success).toBe(true); + expect(validateTaskToPrProjectionTransition(recovered, mutatedRecovery).success).toBe(false); + }); + + test("requires fresh handoff and recovery owner refs across consecutive attempt and WorkRun rotations", () => { + const projection = validProjection(); + const initial = parseProjection({ + ...projection, + id: "task_to_pr_projection:opaque-owner-rotation-initial", + state: "running", + pullRequestRef: undefined, + exactHead: undefined, + handoff: undefined, + reviews: [], + merge: undefined, + recovery: undefined, + cleanup: undefined, + rollback: undefined, + repair: { + ref: ref("repair_cycle", "todos", "0"), + cycle: 0, + cap: 2, + exhausted: false + }, + provenanceLedger: [], + evidenceRefs: [] + }); + const firstSeeds = { + attempt: "a", + runtime: "b", + generation: "5", + lease: "d", + fence: "e", + profile: "f", + route: "0", + workRun: "1", + cursor: "2", + prefix: "3", + owner: "4", + stop: "5", + revoke: "6" + } as const; + const secondSeeds = { + attempt: "b", + runtime: "c", + generation: "6", + lease: "e", + fence: "f", + profile: "0", + route: "1", + workRun: "2", + cursor: "3", + prefix: "4", + owner: "5", + stop: "6", + revoke: "7" + } as const; + + function rotateAttemptOwner( + previous: TaskToPrProjection, + owner: "handoff" | "recovery", + seeds: typeof firstSeeds | typeof secondSeeds, + label: string + ): TaskToPrProjection { + const nextAttempt = { + ...previous.attempt, + ref: ref("attempt", "todos", seeds.attempt), + nonce: `attempt_nonce:opaque-${label}`, + runtimeRef: ref("runtime", "codewith", seeds.runtime, "partial"), + writerGenerationRef: ref("writer_generation", "todos", seeds.generation), + writerLeaseRef: ref("writer_lease", "repos", seeds.lease, "partial"), + writerFenceRef: ref("writer_fence", "repos", seeds.fence, "full"), + providerProfileRef: ref("provider_profile", "codewith", seeds.profile, "full"), + providerRouteRef: ref("provider_route", "codewith", seeds.route, "partial") + }; + const nextWorkRunRef = ref("work_run", "codewith", seeds.workRun); + const transitionRef = ref(owner, "todos", seeds.owner); + const preservedStateRefs = [ + previous.workRunRef, + previous.rootRequestRef, + previous.prGroupRef, + previous.leafTaskRef, + previous.repository.repoRef, + previous.repository.worktreeRef, + previous.repository.branchRef, + previous.events.streamRef + ]; + + return TaskToPrProjectionSchema.parse(withActiveProvenance({ + ...previous, + id: `task_to_pr_projection:opaque-${owner}-${label}`, + state: owner === "handoff" ? "handed_off" : "recovering", + workRunRef: nextWorkRunRef, + attempt: nextAttempt, + events: { + ...previous.events, + replayCursorRef: ref("replay_cursor", "todos", seeds.cursor), + sequence: previous.events.sequence + 1, + prefixDigest: sha(seeds.prefix) + }, + handoff: + owner === "handoff" + ? { + ref: transitionRef, + previousAttemptRef: previous.attempt.ref, + nextAttemptRef: nextAttempt.ref, + previousWriterGenerationRef: previous.attempt.writerGenerationRef, + nextWriterGenerationRef: nextAttempt.writerGenerationRef, + stoppedWorkRunRef: previous.workRunRef, + stopEvidenceRef: evidence(`ev_${owner}_${label}_stop`, seeds.stop), + leaseRevocationEvidenceRef: evidence(`ev_${owner}_${label}_revoke`, seeds.revoke) + } + : undefined, + recovery: + owner === "recovery" + ? { + ref: transitionRef, + priorAttemptRef: previous.attempt.ref, + priorWriterGenerationRef: previous.attempt.writerGenerationRef, + priorWorkRunRef: previous.workRunRef, + successorAttemptNonce: nextAttempt.nonce, + successorWriterGenerationRef: nextAttempt.writerGenerationRef, + preservedStateRefs, + stopEvidenceRef: evidence(`ev_${owner}_${label}_stop`, seeds.stop), + leaseRevocationEvidenceRef: evidence(`ev_${owner}_${label}_revoke`, seeds.revoke) + } + : undefined + })); + } + + for (const owner of ["handoff", "recovery"] as const) { + const first = rotateAttemptOwner(initial, owner, firstSeeds, "first"); + expect(validateTaskToPrProjectionTransition(initial, first), `${owner} establishes the first rotated owner fact`).toEqual({ + success: true, + issues: [] + }); + const rotated = rotateAttemptOwner(first, owner, secondSeeds, "second"); + expect( + validateTaskToPrProjectionTransition(first, rotated), + `${owner} permits a fully fresh owner-record rotation` + ).toEqual({ success: true, issues: [] }); + + const previousRef = owner === "handoff" ? first.handoff!.ref : first.recovery!.ref; + const rotatedRef = owner === "handoff" ? rotated.handoff!.ref : rotated.recovery!.ref; + const invalidRefs = [ + previousRef, + { + ...rotatedRef, + digest: previousRef.digest + }, + { + ...rotatedRef, + id: previousRef.id, + digest: sha("8") + } + ]; + + for (const [index, invalidRef] of invalidRefs.entries()) { + const invalid = { + ...rotated, + [owner]: { + ...(owner === "handoff" ? rotated.handoff! : rotated.recovery!), + ref: invalidRef + } + }; + const parsedInvalid = TaskToPrProjectionSchema.safeParse(invalid); + if (!parsedInvalid.success) { + expect(parsedInvalid.success, `${owner} stale provenance is rejected statically`).toBe(false); + continue; + } + const result = validateTaskToPrProjectionTransition(first, invalid); + expect(result.success, `${owner} invalid ref rotation ${index}`).toBe(false); + expect(result.issues.some((issue) => issue.path === owner), `${owner} reports its owner-record path`).toBe( + true + ); + } + } + }); + + test("rejects A-to-C reuse across every attempt, replay, and repair provenance identity", () => { + const projection = validProjection(); + const initial = parseProjection({ + ...projection, + id: "task_to_pr_projection:opaque-cumulative-a", + state: "running", + workRunRef: ref("work_run", "codewith", "lineage-a"), + attempt: { + ...projection.attempt, + ref: ref("attempt", "todos", "lineage-a"), + nonce: "attempt_nonce:opaque-lineage-a", + runtimeRef: ref("runtime", "codewith", "lineage-a", "partial"), + writerGenerationRef: ref("writer_generation", "todos", "lineage-a"), + writerLeaseRef: ref("writer_lease", "repos", "lineage-a", "partial"), + writerFenceRef: ref("writer_fence", "repos", "lineage-a", "full"), + providerProfileRef: ref("provider_profile", "codewith", "lineage-a", "full"), + providerRouteRef: ref("provider_route", "codewith", "lineage-a", "partial") + }, + events: { + ...projection.events, + replayCursorRef: ref("replay_cursor", "todos", "lineage-a"), + sequence: 0, + prefixDigest: sha("0") + }, + pullRequestRef: undefined, + exactHead: undefined, + handoff: undefined, + reviews: [], + repair: { + ref: ref("repair_cycle", "todos", "lineage-a"), + cycle: 0, + cap: 2, + exhausted: false + }, + merge: undefined, + recovery: undefined, + cancellation: undefined, + cleanup: undefined, + rollback: undefined, + provenanceLedger: [], + evidenceRefs: [] + }); + + function rotateAttempt( + previous: TaskToPrProjection, + label: "b" | "c", + prefixDigest: string + ): TaskToPrProjection { + const nextAttempt = { + ...previous.attempt, + ref: ref("attempt", "todos", `lineage-${label}`), + nonce: `attempt_nonce:opaque-lineage-${label}`, + runtimeRef: ref("runtime", "codewith", `lineage-${label}`, "partial"), + writerGenerationRef: ref("writer_generation", "todos", `lineage-${label}`), + writerLeaseRef: ref("writer_lease", "repos", `lineage-${label}`, "partial"), + writerFenceRef: ref("writer_fence", "repos", `lineage-${label}`, "full"), + providerProfileRef: ref("provider_profile", "codewith", `lineage-${label}`, "full"), + providerRouteRef: ref("provider_route", "codewith", `lineage-${label}`, "partial") + }; + return parseProjection({ + ...previous, + id: `task_to_pr_projection:opaque-cumulative-${label}`, + state: "handed_off", + workRunRef: ref("work_run", "codewith", `lineage-${label}`), + attempt: nextAttempt, + events: { + ...previous.events, + replayCursorRef: ref("replay_cursor", "todos", `lineage-${label}`), + sequence: previous.events.sequence + 1, + prefixDigest + }, + handoff: { + ref: ref("handoff", "todos", `lineage-${label}`), + previousAttemptRef: previous.attempt.ref, + nextAttemptRef: nextAttempt.ref, + previousWriterGenerationRef: previous.attempt.writerGenerationRef, + nextWriterGenerationRef: nextAttempt.writerGenerationRef, + stoppedWorkRunRef: previous.workRunRef, + stopEvidenceRef: evidence(`lineage_${label}_stop`, label === "b" ? "1" : "2"), + leaseRevocationEvidenceRef: evidence( + `lineage_${label}_revoke`, + label === "b" ? "3" : "4" + ) + }, + recovery: undefined, + provenanceLedger: previous.provenanceLedger + }); + } + + const middle = rotateAttempt(initial, "b", sha("1")); + const current = rotateAttempt(middle, "c", sha("2")); + expect(validateTaskToPrProjectionTransition(initial, middle)).toEqual({ + success: true, + issues: [] + }); + expect(validateTaskToPrProjectionTransition(middle, current)).toEqual({ + success: true, + issues: [] + }); + + const projectionIdReuse = structuredClone(current); + projectionIdReuse.id = initial.id; + expect(TaskToPrProjectionSchema.safeParse(projectionIdReuse).success).toBe(true); + expect(validateTaskToPrProjectionTransition(middle, projectionIdReuse).success).toBe(false); + + type RefReuseCase = { + category: + | "work_run" + | "attempt" + | "runtime" + | "writer_generation" + | "writer_lease" + | "writer_fence" + | "provider_profile" + | "provider_route" + | "replay_cursor"; + prior: TaskToPrRef; + read: (value: TaskToPrProjection) => TaskToPrRef; + write: (value: TaskToPrProjection, next: TaskToPrRef) => void; + }; + const refReuseCases: readonly RefReuseCase[] = [ + { + category: "work_run", + prior: initial.workRunRef, + read: (value) => value.workRunRef, + write: (value, next) => { + value.workRunRef = next; + } + }, + { + category: "attempt", + prior: initial.attempt.ref, + read: (value) => value.attempt.ref, + write: (value, next) => { + value.attempt.ref = next; + value.handoff!.nextAttemptRef = next; + } + }, + { + category: "runtime", + prior: initial.attempt.runtimeRef, + read: (value) => value.attempt.runtimeRef, + write: (value, next) => { + value.attempt.runtimeRef = next; + } + }, + { + category: "writer_generation", + prior: initial.attempt.writerGenerationRef, + read: (value) => value.attempt.writerGenerationRef, + write: (value, next) => { + value.attempt.writerGenerationRef = next; + value.handoff!.nextWriterGenerationRef = next; + } + }, + { + category: "writer_lease", + prior: initial.attempt.writerLeaseRef, + read: (value) => value.attempt.writerLeaseRef, + write: (value, next) => { + value.attempt.writerLeaseRef = next; + } + }, + { + category: "writer_fence", + prior: initial.attempt.writerFenceRef, + read: (value) => value.attempt.writerFenceRef, + write: (value, next) => { + value.attempt.writerFenceRef = next; + } + }, + { + category: "provider_profile", + prior: initial.attempt.providerProfileRef, + read: (value) => value.attempt.providerProfileRef, + write: (value, next) => { + value.attempt.providerProfileRef = next; + } + }, + { + category: "provider_route", + prior: initial.attempt.providerRouteRef, + read: (value) => value.attempt.providerRouteRef, + write: (value, next) => { + value.attempt.providerRouteRef = next; + } + }, + { + category: "replay_cursor", + prior: initial.events.replayCursorRef, + read: (value) => value.events.replayCursorRef, + write: (value, next) => { + value.events.replayCursorRef = next; + } + } + ]; + + function latestCategoryIndex( + value: TaskToPrProjection, + category: TaskToPrProjection["provenanceLedger"][number]["category"] + ): number { + for (let index = value.provenanceLedger.length - 1; index >= 0; index -= 1) { + if (value.provenanceLedger[index]!.category === category) { + return index; + } + } + throw new Error(`missing ${category} provenance entry`); + } + + for (const reuseCase of refReuseCases) { + const identityReuse = structuredClone(current); + reuseCase.write(identityReuse, reuseCase.prior); + identityReuse.provenanceLedger.splice( + latestCategoryIndex(identityReuse, reuseCase.category), + 1 + ); + expect(TaskToPrProjectionSchema.safeParse(identityReuse).success, `${reuseCase.category} id fixture`).toBe( + true + ); + expect( + validateTaskToPrProjectionTransition(middle, identityReuse).success, + `${reuseCase.category} A identity cannot reactivate in C` + ).toBe(false); + + const digestReuse = structuredClone(current); + const currentRef = reuseCase.read(digestReuse); + const reusedDigestRef = { ...currentRef, digest: reuseCase.prior.digest }; + reuseCase.write(digestReuse, reusedDigestRef); + digestReuse.provenanceLedger[latestCategoryIndex(digestReuse, reuseCase.category)] = { + category: reuseCase.category, + ref: reusedDigestRef + }; + expect( + TaskToPrProjectionSchema.safeParse(digestReuse).success, + `${reuseCase.category} A digest cannot be reused in C` + ).toBe(false); + } + + const nonceReuse = structuredClone(current); + nonceReuse.attempt.nonce = initial.attempt.nonce; + nonceReuse.provenanceLedger.splice(latestCategoryIndex(nonceReuse, "attempt_nonce"), 1); + expect(TaskToPrProjectionSchema.safeParse(nonceReuse).success).toBe(true); + expect(validateTaskToPrProjectionTransition(middle, nonceReuse).success).toBe(false); + + const duplicateNonceTombstone = structuredClone(current); + duplicateNonceTombstone.attempt.nonce = initial.attempt.nonce; + duplicateNonceTombstone.provenanceLedger[ + latestCategoryIndex(duplicateNonceTombstone, "attempt_nonce") + ] = { + category: "attempt_nonce", + nonce: initial.attempt.nonce + }; + expect(TaskToPrProjectionSchema.safeParse(duplicateNonceTombstone).success).toBe(false); + + const prefixReuse = structuredClone(current); + prefixReuse.events.prefixDigest = initial.events.prefixDigest; + prefixReuse.provenanceLedger[latestCategoryIndex(prefixReuse, "replay_prefix")] = { + category: "replay_prefix", + sequence: prefixReuse.events.sequence, + prefixDigest: initial.events.prefixDigest + }; + expect(TaskToPrProjectionSchema.safeParse(prefixReuse).success).toBe(false); + + const repairMiddle = parseProjection({ + ...initial, + id: "task_to_pr_projection:opaque-cumulative-repair-b", + state: "repairing", + events: { + ...initial.events, + replayCursorRef: ref("replay_cursor", "todos", "repair-b"), + sequence: initial.events.sequence + 1, + prefixDigest: sha("3") + }, + repair: { + ref: ref("repair_cycle", "todos", "repair-b-state"), + cycle: 1, + cap: 2, + exhausted: false, + latestRepairRef: ref("repair_cycle", "todos", "repair-b-latest") + }, + provenanceLedger: initial.provenanceLedger + }); + const repairCurrent = parseProjection({ + ...repairMiddle, + id: "task_to_pr_projection:opaque-cumulative-repair-c", + events: { + ...repairMiddle.events, + replayCursorRef: ref("replay_cursor", "todos", "repair-c"), + sequence: repairMiddle.events.sequence + 1, + prefixDigest: sha("4") + }, + repair: { + ref: ref("repair_cycle", "todos", "repair-c-state"), + cycle: 2, + cap: 2, + exhausted: true, + latestRepairRef: ref("repair_cycle", "todos", "repair-c-latest") + }, + provenanceLedger: repairMiddle.provenanceLedger + }); + expect(validateTaskToPrProjectionTransition(initial, repairMiddle)).toEqual({ + success: true, + issues: [] + }); + expect(validateTaskToPrProjectionTransition(repairMiddle, repairCurrent)).toEqual({ + success: true, + issues: [] + }); + + const repairStateIdentityReuse = structuredClone(repairCurrent); + repairStateIdentityReuse.repair.ref = initial.repair.ref; + repairStateIdentityReuse.provenanceLedger.splice( + latestCategoryIndex(repairStateIdentityReuse, "repair_state"), + 1 + ); + expect(TaskToPrProjectionSchema.safeParse(repairStateIdentityReuse).success).toBe(true); + expect( + validateTaskToPrProjectionTransition(repairMiddle, repairStateIdentityReuse).success + ).toBe(false); + + for (const category of ["repair_state", "latest_repair"] as const) { + const repairDigestReuse = structuredClone(repairCurrent); + const reused = { + ...(category === "repair_state" + ? repairDigestReuse.repair.ref + : repairDigestReuse.repair.latestRepairRef!), + digest: initial.repair.ref.digest + }; + if (category === "repair_state") { + repairDigestReuse.repair.ref = reused; + } else { + repairDigestReuse.repair.latestRepairRef = reused; + } + repairDigestReuse.provenanceLedger[latestCategoryIndex(repairDigestReuse, category)] = { + category, + ref: reused + }; + expect( + TaskToPrProjectionSchema.safeParse(repairDigestReuse).success, + `${category} cannot reuse the A repair digest in cycle 2` + ).toBe(false); + } + + const latestRepairIdentityReuse = structuredClone(repairCurrent); + latestRepairIdentityReuse.repair.latestRepairRef = initial.repair.ref; + latestRepairIdentityReuse.provenanceLedger[ + latestCategoryIndex(latestRepairIdentityReuse, "latest_repair") + ] = { + category: "latest_repair", + ref: initial.repair.ref + }; + expect(TaskToPrProjectionSchema.safeParse(latestRepairIdentityReuse).success).toBe(false); + }); + + test("requires fresh repair refs and enforces the legal state graph without active re-entry", () => { + const base = validProjection(); + const previous = parseProjection({ + ...base, + id: "task_to_pr_projection:opaque-repair-previous", + createdAt: "2026-07-23T15:09:00.000Z", + state: "reviewing", + cleanup: undefined, + merge: undefined, + repair: { + ref: ref("repair_cycle", "todos", "0"), + cycle: 1, + cap: 2, + exhausted: false, + latestRepairRef: ref("repair_cycle", "todos", "5") + } + }); + const repairing = parseProjection({ + ...previous, + id: "task_to_pr_projection:opaque-repair-current", + createdAt, + state: "repairing", + repair: { + ref: ref("repair_cycle", "todos", "1"), + cycle: 2, + cap: 2, + exhausted: true, + latestRepairRef: ref("repair_cycle", "todos", "2") + }, + events: { + ...previous.events, + replayCursorRef: ref("replay_cursor", "todos", "3"), + sequence: previous.events.sequence + 1, + prefixDigest: sha("4") + } + }); + expect(validateTaskToPrProjectionTransition(previous, repairing).success).toBe(true); + + const reusedRepairStateDigest: TaskToPrProjection = { + ...repairing, + id: "task_to_pr_projection:opaque-repair-state-reused-digest", + repair: { + ...repairing.repair, + ref: { + ...repairing.repair.ref, + id: "repair_cycle:opaque-fresh-state-id-stale-digest", + digest: previous.repair.ref.digest + } + } + }; + expect(validateTaskToPrProjectionTransition(previous, reusedRepairStateDigest).success).toBe(false); + + const reusedLatestRepairDigest: TaskToPrProjection = { + ...repairing, + id: "task_to_pr_projection:opaque-latest-repair-reused-digest", + repair: { + ...repairing.repair, + latestRepairRef: { + ...repairing.repair.latestRepairRef!, + id: "repair_cycle:opaque-fresh-latest-id-stale-digest", + digest: previous.repair.latestRepairRef!.digest + } + } + }; + expect(validateTaskToPrProjectionTransition(previous, reusedLatestRepairDigest).success).toBe(false); + + expect( + TaskToPrProjectionSchema.safeParse({ + ...repairing, + repair: { + ...repairing.repair, + latestRepairRef: { + ...repairing.repair.latestRepairRef!, + digest: repairing.repair.ref.digest + } + } + }).success + ).toBe(false); + + const repairStateReusingPriorLatest: TaskToPrProjection = { + ...repairing, + id: "task_to_pr_projection:opaque-repair-state-reuses-prior-latest", + repair: { + ...repairing.repair, + ref: { + ...repairing.repair.ref, + digest: previous.repair.latestRepairRef!.digest + } + } + }; + expect(validateTaskToPrProjectionTransition(previous, repairStateReusingPriorLatest).success).toBe(false); + + const latestRepairReusingPriorState: TaskToPrProjection = { + ...repairing, + id: "task_to_pr_projection:opaque-latest-repair-reuses-prior-state", + repair: { + ...repairing.repair, + latestRepairRef: { + ...repairing.repair.latestRepairRef!, + digest: previous.repair.ref.digest + } + } + }; + expect(validateTaskToPrProjectionTransition(previous, latestRepairReusingPriorState).success).toBe(false); + + expect( + validateTaskToPrProjectionTransition(previous, { + ...repairing, + id: "task_to_pr_projection:opaque-repair-stale-ref", + repair: { + ...repairing.repair, + ref: previous.repair.ref + } + }).success + ).toBe(false); + + expect( + validateTaskToPrProjectionTransition( + parseProjection({ + ...previous, + id: "task_to_pr_projection:opaque-running", + state: "running", + reviews: [], + exactHead: undefined, + pullRequestRef: undefined + }), + { + ...previous, + id: "task_to_pr_projection:opaque-admitted-again", + state: "admitted", + reviews: [], + exactHead: undefined, + pullRequestRef: undefined + } + ).success + ).toBe(false); + expect( + validateTaskToPrProjectionTransition(base, { + ...base, + id: "task_to_pr_projection:opaque-reentered", + state: "running", + merge: undefined, + exactHead: undefined, + reviews: [], + pullRequestRef: undefined, + cleanup: undefined + }).success + ).toBe(false); + }); + + test("enforces bidirectional lifecycle phase requirements", () => { + const projection = validProjection(); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + state: "handed_off", + handoff: undefined, + merge: undefined, + cleanup: undefined + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + state: "reviewing", + reviews: [], + merge: undefined, + cleanup: undefined + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + cleanup: { + ...projection.cleanup, + outcome: { ...projection.cleanup.outcome, status: "failed" } + } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + state: "running" + }).success + ).toBe(false); + + const reviewOnly = parseProjection({ + ...projection, + state: "reviewing", + merge: undefined, + cleanup: undefined + }); + for (const state of ["admitted", "running", "handed_off"] as const) { + expect( + TaskToPrProjectionSchema.safeParse({ + ...reviewOnly, + state, + handoff: state === "handed_off" ? projection.handoff : undefined, + recovery: undefined + }).success + ).toBe(false); + } + + const earlyEligibleGuard = { + ...projection, + state: "reviewing" as const, + merge: { + guard: projection.merge.guard + }, + cleanup: undefined + }; + expect(TaskToPrProjectionSchema.safeParse(earlyEligibleGuard).success).toBe(false); + + const runningPrevious = parseProjection({ + ...reviewOnly, + id: "task_to_pr_projection:opaque-running-before-review", + state: "running", + reviews: [] + }); + const runningWithApprovedReview: TaskToPrProjection = { + ...runningPrevious, + id: "task_to_pr_projection:opaque-running-with-review", + reviews: reviewOnly.reviews + }; + expect(TaskToPrProjectionSchema.safeParse(runningWithApprovedReview).success).toBe(false); + expect(validateTaskToPrProjectionTransition(runningPrevious, runningWithApprovedReview).success).toBe(false); + }); + + test("requires replay advancement for semantic lifecycle drift and preserves canonical pull-request identity", () => { + const projection = validProjection(); + const runningWithPullRequest = parseProjection({ + ...projection, + id: "task_to_pr_projection:opaque-running-with-pr", + state: "running", + exactHead: undefined, + reviews: [], + merge: undefined, + cleanup: undefined + }); + + const phaseDrift = withActiveProvenance({ + ...runningWithPullRequest, + id: "task_to_pr_projection:opaque-blocked-with-stale-cursor", + state: "blocked" + }); + const phaseDriftResult = validateTaskToPrProjectionTransition(runningWithPullRequest, phaseDrift); + expect(phaseDriftResult.success).toBe(false); + expect(phaseDriftResult.issues.some((issue) => issue.path === "events.sequence")).toBe(true); + + const recoveryProjection = parseProjection({ + ...validRecoveryProjection(), + id: "task_to_pr_projection:opaque-recovering-before-identity-drift", + state: "recovering", + pullRequestRef: undefined, + exactHead: undefined, + reviews: [], + merge: undefined, + cleanup: undefined, + recovery: { + ...validRecoveryProjection().recovery, + preservedStateRefs: validRecoveryProjection().recovery.preservedStateRefs.filter( + (preservedRef) => preservedRef.role !== "pull_request" + ) + } + }); + const recoveryIdentityDrift = withActiveProvenance({ + ...recoveryProjection, + id: "task_to_pr_projection:opaque-recovering-after-identity-drift", + recovery: { + ...recoveryProjection.recovery!, + ref: ref("recovery", "todos", "a") + } + }); + const recoveryDriftResult = validateTaskToPrProjectionTransition(recoveryProjection, recoveryIdentityDrift); + expect(recoveryDriftResult.success).toBe(false); + expect(recoveryDriftResult.issues.some((issue) => issue.path === "events.sequence")).toBe(true); + + const advancedEvents = { + ...runningWithPullRequest.events, + replayCursorRef: ref("replay_cursor", "todos", "a"), + sequence: runningWithPullRequest.events.sequence + 1, + prefixDigest: sha("b") + }; + for (const pullRequestRef of [undefined, ref("pull_request", "todos", "c")]) { + const pullRequestDriftResult = validateTaskToPrProjectionTransition( + runningWithPullRequest, + withActiveProvenance({ + ...runningWithPullRequest, + id: `task_to_pr_projection:opaque-pr-identity-drift-${pullRequestRef ? "replaced" : "removed"}`, + events: advancedEvents, + pullRequestRef + }) + ); + expect(pullRequestDriftResult.success).toBe(false); + expect(pullRequestDriftResult.issues.some((issue) => issue.path === "pullRequestRef")).toBe(true); + } + }); + + test("preserves immutable same-head review history and permits append-only review evidence", () => { + const projection = validProjection(); + const adverseReview = { + ...projection.reviews[0], + verdict: "changes_requested" as const + }; + const previous = TaskToPrProjectionSchema.parse(withActiveProvenance({ + ...projection, + id: "task_to_pr_projection:opaque-adverse-review-history", + state: "reviewing", + reviews: [adverseReview], + merge: undefined, + cleanup: undefined + })); + const approvedReview = { + ref: ref("review", "review", "3"), + pullRequestRef: projection.pullRequestRef, + head: projection.repository.branchHead, + reviewerRef: ref("reviewer", "review", "3"), + reviewRunRef: ref("review_run", "review", "4"), + proofBundleRef: ref("proof_bundle", "review", "5"), + verdict: "approved" as const, + reviewedAt: createdAt + }; + const advancedEvents = { + ...previous.events, + replayCursorRef: ref("replay_cursor", "todos", "6"), + sequence: previous.events.sequence + 1, + prefixDigest: sha("7") + }; + + const appendOnly = TaskToPrProjectionSchema.parse(withActiveProvenance({ + ...previous, + id: "task_to_pr_projection:opaque-appended-review-history", + events: advancedEvents, + reviews: [adverseReview, approvedReview] + })); + expect(validateTaskToPrProjectionTransition(previous, appendOnly)).toEqual({ success: true, issues: [] }); + + const reorderedHistory = parseProjection({ + ...appendOnly, + id: "task_to_pr_projection:opaque-reordered-review-history", + events: { + ...appendOnly.events, + replayCursorRef: ref("replay_cursor", "todos", "8"), + sequence: appendOnly.events.sequence + 1, + prefixDigest: sha("9") + }, + reviews: [approvedReview, adverseReview] + }); + expect(validateTaskToPrProjectionTransition(appendOnly, reorderedHistory).success).toBe(false); + + const prependedHistory = parseProjection({ + ...appendOnly, + id: "task_to_pr_projection:opaque-prepended-review-history", + reviews: [approvedReview, adverseReview] + }); + expect(validateTaskToPrProjectionTransition(previous, prependedHistory).success).toBe(false); + + const rewrittenAdverseReview = validateTaskToPrProjectionTransition( + previous, + withActiveProvenance({ + ...previous, + id: "task_to_pr_projection:opaque-rewritten-adverse-review", + events: advancedEvents, + reviews: [{ ...adverseReview, verdict: "approved" }] + }) + ); + expect(rewrittenAdverseReview.success).toBe(false); + expect(rewrittenAdverseReview.issues.some((issue) => issue.path === "reviews")).toBe(true); + + const replacedForEligibility = withActiveProvenance({ + ...appendOnly, + id: "task_to_pr_projection:opaque-replaced-adverse-review", + state: "merge_ready" as const, + reviews: [approvedReview], + merge: { + guard: { + ...projection.merge.guard, + reviewRefs: [approvedReview.ref], + proofBundleRefs: [ + approvedReview.proofBundleRef, + projection.exactHead.equalityProofRef, + ...projection.exactHead.ciProofBundleRefs + ] + } + } + }); + const replacedHistoryResult = validateTaskToPrProjectionTransition(previous, replacedForEligibility); + expect(replacedHistoryResult.success).toBe(false); + expect(replacedHistoryResult.issues.some((issue) => issue.path === "reviews")).toBe(true); + }); + + test("preserves the complete exact-head fact for an unchanged branch head", () => { + const projection = validProjection(); + const secondCiProof = ref("proof_bundle", "review", "3"); + const previous = TaskToPrProjectionSchema.parse(withActiveProvenance({ + ...projection, + id: "task_to_pr_projection:opaque-exact-head-previous", + createdAt: "2026-07-23T15:09:00.000Z", + state: "running", + reviews: [], + merge: undefined, + cleanup: undefined, + exactHead: { + ...projection.exactHead, + ciProofBundleRefs: [...projection.exactHead.ciProofBundleRefs, secondCiProof] + } + })); + const advancedEvents = { + ...previous.events, + replayCursorRef: ref("replay_cursor", "todos", "3"), + sequence: previous.events.sequence + 1, + prefixDigest: sha("4") + }; + const retained = parseProjection({ + ...previous, + id: "task_to_pr_projection:opaque-exact-head-retained", + createdAt, + events: advancedEvents + }); + expect(validateTaskToPrProjectionTransition(previous, retained)).toEqual({ success: true, issues: [] }); + + const removed = parseProjection({ + ...retained, + id: "task_to_pr_projection:opaque-exact-head-removed", + exactHead: undefined + }); + expect(validateTaskToPrProjectionTransition(previous, removed).success).toBe(false); + + const replacedEqualityProof = TaskToPrProjectionSchema.parse(withActiveProvenance({ + ...retained, + id: "task_to_pr_projection:opaque-exact-head-equality-replaced", + exactHead: { + ...retained.exactHead!, + equalityProofRef: ref("proof_bundle", "review", "5") + } + })); + expect(validateTaskToPrProjectionTransition(previous, replacedEqualityProof).success).toBe(false); + + const reorderedCiProofs = parseProjection({ + ...retained, + id: "task_to_pr_projection:opaque-exact-head-ci-reordered", + exactHead: { + ...retained.exactHead!, + ciProofBundleRefs: [...retained.exactHead!.ciProofBundleRefs].reverse() + } + }); + expect(validateTaskToPrProjectionTransition(previous, reorderedCiProofs).success).toBe(false); + }); + + test("requires fresh exact-head proof and review identities after branch-head changes", () => { + const projection = validProjection(); + const previous = parseProjection({ + ...projection, + id: "task_to_pr_projection:opaque-cross-head-previous", + createdAt: "2026-07-23T15:09:00.000Z", + state: "reviewing", + merge: undefined, + cleanup: undefined + }); + const nextHead = head("b"); + const freshCrossHead = TaskToPrProjectionSchema.parse(withActiveProvenance({ + ...previous, + id: "task_to_pr_projection:opaque-cross-head-fresh", + createdAt: "2026-07-23T15:12:00.000Z", + repository: { + ...previous.repository, + branchHead: nextHead + }, + events: { + ...previous.events, + replayCursorRef: ref("replay_cursor", "todos", "a"), + sequence: previous.events.sequence + 1, + prefixDigest: sha("b") + }, + exactHead: { + ...previous.exactHead!, + localHead: nextHead, + remoteHead: nextHead, + providerPullRequestHead: nextHead, + equalityProofRef: ref("proof_bundle", "review", "3"), + ciProofBundleRefs: [ref("proof_bundle", "review", "4")], + verifiedAt: "2026-07-23T15:12:00.000Z" + }, + reviews: previous.reviews.map((review) => ({ + ...review, + ref: ref("review", "review", "7"), + head: nextHead, + reviewRunRef: ref("review_run", "review", "9"), + proofBundleRef: ref("proof_bundle", "review", "5"), + reviewedAt: "2026-07-23T15:12:00.000Z" + })) + })); + + expect(validateTaskToPrProjectionTransition(previous, freshCrossHead)).toEqual({ success: true, issues: [] }); + expect(freshCrossHead.reviews[0]!.reviewerRef).toEqual(previous.reviews[0]!.reviewerRef); + + const reusedEverything: TaskToPrProjection = { + ...freshCrossHead, + id: "task_to_pr_projection:opaque-cross-head-replayed", + exactHead: { + ...freshCrossHead.exactHead!, + equalityProofRef: previous.exactHead!.equalityProofRef, + ciProofBundleRefs: previous.exactHead!.ciProofBundleRefs + }, + reviews: previous.reviews.map((review) => ({ + ...review, + head: nextHead, + reviewedAt: "2026-07-23T15:12:00.000Z" + })) + }; + expect(TaskToPrProjectionSchema.safeParse(reusedEverything).success).toBe(false); + expect(validateTaskToPrProjectionTransition(previous, reusedEverything).success).toBe(false); + + const staleIdentityCases: Array<{ + label: string; + mutate: (candidate: TaskToPrProjection) => void; + }> = [ + { + label: "equality proof canonical id", + mutate: (candidate) => { + candidate.exactHead!.equalityProofRef.id = previous.exactHead!.equalityProofRef.id; + } + }, + { + label: "equality proof digest", + mutate: (candidate) => { + candidate.exactHead!.equalityProofRef.digest = previous.exactHead!.equalityProofRef.digest; + } + }, + { + label: "CI proof canonical id", + mutate: (candidate) => { + candidate.exactHead!.ciProofBundleRefs[0]!.id = previous.exactHead!.ciProofBundleRefs[0]!.id; + } + }, + { + label: "CI proof digest", + mutate: (candidate) => { + candidate.exactHead!.ciProofBundleRefs[0]!.digest = previous.exactHead!.ciProofBundleRefs[0]!.digest; + } + }, + { + label: "review canonical id", + mutate: (candidate) => { + candidate.reviews[0]!.ref.id = previous.reviews[0]!.ref.id; + } + }, + { + label: "review digest", + mutate: (candidate) => { + candidate.reviews[0]!.ref.digest = previous.reviews[0]!.ref.digest; + } + }, + { + label: "review-run canonical id", + mutate: (candidate) => { + candidate.reviews[0]!.reviewRunRef.id = previous.reviews[0]!.reviewRunRef.id; + } + }, + { + label: "review-run digest", + mutate: (candidate) => { + candidate.reviews[0]!.reviewRunRef.digest = previous.reviews[0]!.reviewRunRef.digest; + } + }, + { + label: "review proof canonical id", + mutate: (candidate) => { + candidate.reviews[0]!.proofBundleRef.id = previous.reviews[0]!.proofBundleRef.id; + } + }, + { + label: "review proof digest", + mutate: (candidate) => { + candidate.reviews[0]!.proofBundleRef.digest = previous.reviews[0]!.proofBundleRef.digest; + } + } + ]; + for (const { label, mutate } of staleIdentityCases) { + const candidate = structuredClone(freshCrossHead); + candidate.id = `task_to_pr_projection:opaque-cross-head-stale-${label.replaceAll(" ", "-")}`; + mutate(candidate); + expect(validateTaskToPrProjectionTransition(previous, candidate).success, label).toBe(false); + } + + const reorderedAndExpanded: TaskToPrProjection = { + ...freshCrossHead, + id: "task_to_pr_projection:opaque-cross-head-reordered", + exactHead: { + ...freshCrossHead.exactHead!, + ciProofBundleRefs: [ + ref("proof_bundle", "review", "8"), + previous.exactHead!.equalityProofRef + ] + } + }; + expect(TaskToPrProjectionSchema.safeParse(reorderedAndExpanded).success).toBe(false); + expect(validateTaskToPrProjectionTransition(previous, reorderedAndExpanded).success).toBe(false); + + type HeadBoundEvidenceCategory = { + name: string; + path: string; + read: (value: TaskToPrProjection) => TaskToPrRef; + }; + const headBoundEvidenceCategories: readonly HeadBoundEvidenceCategory[] = [ + { + name: "equality-proof", + path: "exactHead.equalityProofRef", + read: (value) => value.exactHead!.equalityProofRef + }, + { + name: "ci-proof", + path: "exactHead.ciProofBundleRefs.0", + read: (value) => value.exactHead!.ciProofBundleRefs[0]! + }, + { + name: "review-proof", + path: "reviews.0.proofBundleRef", + read: (value) => value.reviews[0]!.proofBundleRef + }, + { + name: "review-record", + path: "reviews.0.ref", + read: (value) => value.reviews[0]!.ref + }, + { + name: "review-run", + path: "reviews.0.reviewRunRef", + read: (value) => value.reviews[0]!.reviewRunRef + } + ]; + const crossHeadCategoryPairs = headBoundEvidenceCategories.flatMap((priorCategory) => + headBoundEvidenceCategories.map((currentCategory) => ({ priorCategory, currentCategory })) + ); + expect(crossHeadCategoryPairs).toHaveLength(25); + + for (const { priorCategory, currentCategory } of crossHeadCategoryPairs) { + const caseLabel = `${priorCategory.name} to ${currentCategory.name}`; + + const reusedIdentity = structuredClone(freshCrossHead); + currentCategory.read(reusedIdentity).id = priorCategory.read(previous).id; + const identitySchemaResult = TaskToPrProjectionSchema.safeParse(reusedIdentity); + const identityTransitionResult = validateTaskToPrProjectionTransition(previous, reusedIdentity); + expect(identityTransitionResult.success, `${caseLabel} canonical identity`).toBe(false); + if (identitySchemaResult.success) { + expect(identityTransitionResult, `${caseLabel} canonical identity`).toMatchObject({ + success: false, + issues: [ + { + path: currentCategory.path, + message: + "A changed branch head requires every head-bound evidence ref to use a fresh canonical identity" + } + ] + }); + } + + const reusedDigest = structuredClone(freshCrossHead); + currentCategory.read(reusedDigest).digest = priorCategory.read(previous).digest; + expect(validateTaskToPrProjectionTransition(previous, reusedDigest).success, `${caseLabel} digest`).toBe(false); + } + }); + + test("keeps owner tombstones monotonic across omission and rejects multi-snapshot ABA reactivation", () => { + const projection = validProjection(); + const present = parseProjection({ + ...projection, + id: "task_to_pr_projection:opaque-owner-aba-present", + state: "merged", + cleanup: { + eligibility: projection.cleanup.eligibility + } + }); + const absentCursor = ref("replay_cursor", "todos", "3"); + const absent = parseProjection({ + ...present, + id: "task_to_pr_projection:opaque-owner-aba-absent", + cleanup: undefined, + events: { + ...present.events, + replayCursorRef: absentCursor, + sequence: present.events.sequence + 1, + prefixDigest: sha("4") + } + }); + expect(validateTaskToPrProjectionTransition(present, absent)).toEqual({ success: true, issues: [] }); + expect(absent.provenanceLedger.slice(0, present.provenanceLedger.length)).toEqual( + present.provenanceLedger + ); + expect(absent.provenanceLedger.slice(present.provenanceLedger.length).map((entry) => entry.category)).toEqual([ + "projection_id", + "replay_cursor", + "replay_prefix" + ]); + + const reactivatedCursor = ref("replay_cursor", "todos", "5"); + const reactivated = parseProjection({ + ...absent, + id: "task_to_pr_projection:opaque-owner-aba-reactivated", + cleanup: { + eligibility: { + ...present.cleanup!.eligibility, + eventCursorRef: reactivatedCursor + } + }, + events: { + ...absent.events, + replayCursorRef: reactivatedCursor, + sequence: absent.events.sequence + 1, + prefixDigest: sha("7") + } + }); + expect(validateTaskToPrProjectionTransition(absent, reactivated).success).toBe(false); + + const freshCursor = ref("replay_cursor", "todos", "9"); + const freshRotation = TaskToPrProjectionSchema.parse( + withActiveProvenance({ + ...absent, + id: "task_to_pr_projection:opaque-owner-aba-fresh", + cleanup: { + eligibility: { + ...present.cleanup!.eligibility, + ref: ref("cleanup_eligibility", "repos", "a"), + status: "preserved", + eventCursorRef: freshCursor + } + }, + events: { + ...absent.events, + replayCursorRef: freshCursor, + sequence: absent.events.sequence + 1, + prefixDigest: sha("d") + } + }) + ); + expect(validateTaskToPrProjectionTransition(absent, freshRotation)).toEqual({ + success: true, + issues: [] + }); + }); + + test("treats ref redaction as exact provenance in ledger prefixes and active bindings", () => { + const previous = parseProjection({ + ...validProjection(), + state: "reviewing", + handoff: undefined, + merge: undefined, + cleanup: undefined + }); + const prefixRedactionMutation = structuredClone(previous); + prefixRedactionMutation.id = "task_to_pr_projection:opaque-prefix-redaction-mutation"; + const handoffIndex = prefixRedactionMutation.provenanceLedger.findIndex( + (entry) => entry.category === "handoff" + ); + const handoffEntry = prefixRedactionMutation.provenanceLedger[handoffIndex]!; + if (!("ref" in handoffEntry)) { + throw new Error("expected a handoff ref provenance entry"); + } + handoffEntry.ref.redaction = "partial"; + prefixRedactionMutation.events = { + ...prefixRedactionMutation.events, + replayCursorRef: ref("replay_cursor", "todos", "b"), + sequence: previous.events.sequence + 1, + prefixDigest: sha("4") + }; + const prefixCandidate = withActiveProvenance(prefixRedactionMutation); + expect(TaskToPrProjectionSchema.safeParse(prefixCandidate).success).toBe(true); + expect(validateTaskToPrProjectionTransition(previous, prefixCandidate)).toMatchObject({ + success: false, + issues: [ + { + path: "provenanceLedger", + message: + "The provenance ledger is append-only and must retain the previous ledger as an exact immutable prefix" + } + ] + }); + + const activeRedactionMismatch = structuredClone(validProjection()); + activeRedactionMismatch.provenanceLedger = activeRedactionMismatch.provenanceLedger.map((entry) => + entry.category === "handoff" + ? { ...entry, ref: { ...entry.ref, redaction: "partial" as const } } + : entry + ); + expect(TaskToPrProjectionSchema.safeParse(activeRedactionMismatch).success).toBe(false); + }); + + test("retains head-evidence tombstones through five evidence-free lifecycle steps and rejects stale reuse", () => { + const projection = validProjection(); + const reviewed = parseProjection({ + ...projection, + id: "task_to_pr_projection:opaque-five-step-reviewed", + state: "reviewing", + merge: undefined, + cleanup: undefined + }); + const repairing = parseProjection({ + ...reviewed, + id: "task_to_pr_projection:opaque-five-step-repairing", + state: "repairing", + events: { + ...reviewed.events, + replayCursorRef: ref("replay_cursor", "todos", "3"), + sequence: reviewed.events.sequence + 1, + prefixDigest: sha("4") + } + }); + expect(validateTaskToPrProjectionTransition(reviewed, repairing)).toEqual({ + success: true, + issues: [] + }); + + const nextHead = head("b"); + const evidenceFree = parseProjection({ + ...repairing, + id: "task_to_pr_projection:opaque-five-step-evidence-free", + state: "running", + repository: { + ...repairing.repository, + branchHead: nextHead + }, + exactHead: undefined, + reviews: [], + events: { + ...repairing.events, + replayCursorRef: ref("replay_cursor", "todos", "5"), + sequence: repairing.events.sequence + 1, + prefixDigest: sha("7") + } + }); + expect(validateTaskToPrProjectionTransition(repairing, evidenceFree)).toEqual({ + success: true, + issues: [] + }); + + const stillEvidenceFree = parseProjection({ + ...evidenceFree, + id: "task_to_pr_projection:opaque-five-step-still-evidence-free", + state: "repairing", + events: { + ...evidenceFree.events, + replayCursorRef: ref("replay_cursor", "todos", "9"), + sequence: evidenceFree.events.sequence + 1, + prefixDigest: sha("a") + } + }); + expect(validateTaskToPrProjectionTransition(evidenceFree, stillEvidenceFree)).toEqual({ + success: true, + issues: [] + }); + + const staleReview = { + ...reviewed.reviews[0]!, + head: nextHead, + reviewedAt: "2026-07-23T15:12:00.000Z" + }; + const staleFifthSnapshot = { + ...stillEvidenceFree, + id: "task_to_pr_projection:opaque-five-step-stale-evidence", + state: "reviewing" as const, + exactHead: { + ...reviewed.exactHead!, + localHead: nextHead, + remoteHead: nextHead, + providerPullRequestHead: nextHead, + verifiedAt: "2026-07-23T15:12:00.000Z" + }, + reviews: [staleReview], + events: { + ...stillEvidenceFree.events, + replayCursorRef: ref("replay_cursor", "todos", "d"), + sequence: stillEvidenceFree.events.sequence + 1, + prefixDigest: sha("3") + } + }; + expect(TaskToPrProjectionSchema.safeParse(staleFifthSnapshot).success).toBe(false); + expect( + validateTaskToPrProjectionTransition(stillEvidenceFree, staleFifthSnapshot).success + ).toBe(false); + }); + + test("allows same-head review provenance to survive recovery but rejects recovery-time invention", () => { + const projection = validProjection(); + const reviewed = parseProjection({ + ...projection, + id: "task_to_pr_projection:opaque-reviewed-before-recovery", + state: "reviewing", + merge: undefined, + cleanup: undefined + }); + + function recoverySnapshot( + previous: TaskToPrProjection, + exactHead: TaskToPrProjection["exactHead"], + reviews: TaskToPrProjection["reviews"], + provenanceLedger: TaskToPrProjection["provenanceLedger"], + id: string + ): TaskToPrProjection { + const nextAttempt = { + ...previous.attempt, + ref: ref("attempt", "todos", "a"), + nonce: "attempt_nonce:opaque-review-recovery", + runtimeRef: ref("runtime", "codewith", "b", "partial"), + writerGenerationRef: ref("writer_generation", "todos", "5"), + writerLeaseRef: ref("writer_lease", "repos", "d", "partial"), + writerFenceRef: ref("writer_fence", "repos", "e", "full"), + providerProfileRef: ref("provider_profile", "codewith", "f", "full"), + providerRouteRef: ref("provider_route", "codewith", "0", "partial") + }; + const recovery = { + ref: ref("recovery", "todos", "7"), + priorAttemptRef: previous.attempt.ref, + priorWriterGenerationRef: previous.attempt.writerGenerationRef, + priorWorkRunRef: previous.workRunRef, + successorAttemptNonce: nextAttempt.nonce, + successorWriterGenerationRef: nextAttempt.writerGenerationRef, + preservedStateRefs: [ + previous.workRunRef, + previous.rootRequestRef, + previous.prGroupRef, + previous.leafTaskRef, + previous.repository.repoRef, + previous.repository.worktreeRef, + previous.repository.branchRef, + previous.events.streamRef, + previous.pullRequestRef! + ], + stopEvidenceRef: evidence("ev_review_recovery_stop", "2"), + leaseRevocationEvidenceRef: evidence("ev_review_recovery_revoke", "3") + }; + return TaskToPrProjectionSchema.parse( + withActiveProvenance({ + ...previous, + id, + state: "recovering", + workRunRef: ref("work_run", "codewith", "1"), + attempt: nextAttempt, + handoff: undefined, + recovery, + exactHead, + reviews, + provenanceLedger, + events: { + ...previous.events, + replayCursorRef: ref("replay_cursor", "todos", "4"), + sequence: previous.events.sequence + 1, + prefixDigest: sha("5") + } + }) + ); + } + + const retained = recoverySnapshot( + reviewed, + reviewed.exactHead, + reviewed.reviews, + reviewed.provenanceLedger, + "task_to_pr_projection:opaque-reviewed-recovery-retained" + ); + expect(validateTaskToPrProjectionTransition(reviewed, retained)).toEqual({ + success: true, + issues: [] + }); + + const preReview = parseProjection({ + ...reviewed, + id: "task_to_pr_projection:opaque-pre-review-recovery", + state: "running", + handoff: undefined, + exactHead: undefined, + reviews: [], + provenanceLedger: [] + }); + const invented = recoverySnapshot( + preReview, + reviewed.exactHead, + reviewed.reviews, + preReview.provenanceLedger, + "task_to_pr_projection:opaque-recovery-invented-reviews" + ); + expect(TaskToPrProjectionSchema.safeParse(invented).success).toBe(true); + expect(validateTaskToPrProjectionTransition(preReview, invented).success).toBe(false); + }); + + test("requires a fresh provider guard receipt whenever the guarded expected head changes", () => { + const projection = validProjection(); + const previous = parseProjection({ + ...projection, + id: "task_to_pr_projection:opaque-guard-receipt-previous", + state: "merge_ready", + merge: { + guard: projection.merge.guard + }, + cleanup: undefined + }); + const nextHead = head("b"); + const nextReview = { + ...previous.reviews[0]!, + ref: ref("review", "review", "7"), + head: nextHead, + reviewRunRef: ref("review_run", "review", "9"), + proofBundleRef: ref("proof_bundle", "review", "5"), + reviewedAt: "2026-07-23T15:12:00.000Z" + }; + const changedHead = TaskToPrProjectionSchema.parse( + withActiveProvenance({ + ...previous, + id: "task_to_pr_projection:opaque-guard-receipt-fresh", + createdAt: "2026-07-23T15:12:00.000Z", + repository: { + ...previous.repository, + branchHead: nextHead + }, + events: { + ...previous.events, + replayCursorRef: ref("replay_cursor", "todos", "a"), + sequence: previous.events.sequence + 1, + prefixDigest: sha("d") + }, + exactHead: { + ...previous.exactHead!, + localHead: nextHead, + remoteHead: nextHead, + providerPullRequestHead: nextHead, + equalityProofRef: ref("proof_bundle", "review", "3"), + ciProofBundleRefs: [ref("proof_bundle", "review", "4")], + verifiedAt: "2026-07-23T15:12:00.000Z" + }, + reviews: [nextReview], + merge: { + guard: { + ...previous.merge!.guard, + ref: ref("merge_guard", "todos", "a"), + expectedHead: nextHead, + reviewRefs: [nextReview.ref], + proofBundleRefs: [ + nextReview.proofBundleRef, + ref("proof_bundle", "review", "3"), + ref("proof_bundle", "review", "4") + ], + providerGuardReceiptRef: ref( + "merge_guard_receipt", + "merge_provider", + "d", + "full" + ), + evaluatedAt: "2026-07-23T15:12:00.000Z" + } + } + }) + ); + expect(validateTaskToPrProjectionTransition(previous, changedHead)).toEqual({ + success: true, + issues: [] + }); + + for (const staleReceipt of [ + previous.merge!.guard.providerGuardReceiptRef, + { + ...changedHead.merge!.guard.providerGuardReceiptRef, + digest: previous.merge!.guard.providerGuardReceiptRef.digest + } + ]) { + const stale = { + ...changedHead, + id: `task_to_pr_projection:opaque-guard-receipt-stale-${staleReceipt.id.endsWith("-example") ? "id" : "digest"}`, + merge: { + guard: { + ...changedHead.merge!.guard, + providerGuardReceiptRef: staleReceipt + } + } + }; + expect(validateTaskToPrProjectionTransition(previous, stale).success).toBe(false); + } + }); + + test("binds every merge outcome to its exact immutable merge guard", () => { + const projection = validProjection(); + const guardBoundProjection = projection; + expect(TaskToPrProjectionSchema.safeParse(guardBoundProjection).success).toBe(true); + const { guardRef: _guardRef, ...outcomeWithoutGuardRef } = guardBoundProjection.merge.outcome; + expect( + TaskToPrProjectionSchema.safeParse({ + ...guardBoundProjection, + merge: { + ...guardBoundProjection.merge, + outcome: outcomeWithoutGuardRef + } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...guardBoundProjection, + merge: { + ...guardBoundProjection.merge, + outcome: { + ...guardBoundProjection.merge.outcome, + guardRef: ref("merge_guard", "todos", "f") + } + } + }).success + ).toBe(false); + }); + + test("rejects hidden review bindings in denied early-phase merge guards", () => { + const projection = validProjection(); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + state: "running", + reviews: [], + merge: { + guard: { + ...projection.merge.guard, + decision: "denied" + } + }, + cleanup: undefined + }).success + ).toBe(false); + }); + + test("preserves handoff, recovery, and cancellation identity without embedded history", () => { + const projection = validProjection(); + const recoveryProjection = validRecoveryProjection(); + expect(TaskToPrProjectionSchema.safeParse(recoveryProjection).success).toBe(true); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + recovery: recoveryProjection.recovery + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + handoff: { + ...projection.handoff, + nextWriterGenerationRef: ref("writer_generation", "todos", "f") + } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...recoveryProjection, + recovery: { + ...recoveryProjection.recovery, + successorAttemptNonce: "attempt_nonce:opaque-different" + } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...recoveryProjection, + recovery: { + ...recoveryProjection.recovery, + preservedStateRefs: recoveryProjection.recovery.preservedStateRefs.filter( + (preservedRef) => preservedRef.id !== recoveryProjection.rootRequestRef.id + ) + } + }).success + ).toBe(false); + + const cancelled = { + ...projection, + state: "cancelled", + recovery: undefined, + exactHead: undefined, + reviews: [], + merge: undefined, + cleanup: undefined, + cancellation: { + ref: ref("cancellation", "todos", "a"), + cancelledAttemptRef: projection.attempt.ref, + preservedStateRefs: [ + projection.workRunRef, + projection.rootRequestRef, + projection.prGroupRef, + projection.leafTaskRef, + projection.attempt.ref, + projection.repository.repoRef, + projection.repository.worktreeRef, + projection.repository.branchRef, + projection.events.streamRef, + projection.pullRequestRef + ], + evidenceRefs: [evidence("ev_cancelled_state", "b")] + } + }; + expect(TaskToPrProjectionSchema.safeParse(cancelled).success).toBe(true); + expect( + TaskToPrProjectionSchema.safeParse({ + ...cancelled, + cancellation: { ...cancelled.cancellation, preservedStateRefs: [] } + }).success + ).toBe(false); + + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + recovery: undefined, + cancellation: { + ref: ref("cancellation", "todos", "d"), + cancelledAttemptRef: projection.attempt.ref, + preservedStateRefs: [ + projection.workRunRef, + projection.rootRequestRef, + projection.prGroupRef, + projection.leafTaskRef, + projection.attempt.ref, + projection.repository.repoRef, + projection.repository.worktreeRef, + projection.repository.branchRef, + projection.events.streamRef, + projection.pullRequestRef + ], + evidenceRefs: [evidence("ev_cancelled_after_merge", "d")] + } + }).success + ).toBe(false); + }); + + test("statically requires fresh canonical ids and digests across handoff and recovery refs", () => { + const projection = validProjection(); + const handoff = projection.handoff; + const recoveryProjection = validRecoveryProjection(); + const recovery = recoveryProjection.recovery; + + expect(TaskToPrHandoffSchema.safeParse(handoff).success).toBe(true); + expect(TaskToPrRecoverySchema.safeParse(recovery).success).toBe(true); + + for (const nextAttemptRef of [ + { ...handoff.nextAttemptRef, id: handoff.previousAttemptRef.id }, + { ...handoff.nextAttemptRef, digest: handoff.previousAttemptRef.digest } + ]) { + expect(TaskToPrHandoffSchema.safeParse({ ...handoff, nextAttemptRef }).success).toBe(false); + } + for (const nextWriterGenerationRef of [ + { ...handoff.nextWriterGenerationRef, id: handoff.previousWriterGenerationRef.id }, + { ...handoff.nextWriterGenerationRef, digest: handoff.previousWriterGenerationRef.digest } + ]) { + expect(TaskToPrHandoffSchema.safeParse({ ...handoff, nextWriterGenerationRef }).success).toBe(false); + } + for (const successorWriterGenerationRef of [ + { ...recovery.successorWriterGenerationRef, id: recovery.priorWriterGenerationRef.id }, + { ...recovery.successorWriterGenerationRef, digest: recovery.priorWriterGenerationRef.digest } + ]) { + expect(TaskToPrRecoverySchema.safeParse({ ...recovery, successorWriterGenerationRef }).success).toBe(false); + } + + const handoffOnly = parseProjection({ + ...projection, + id: "task_to_pr_projection:opaque-handoff-only", + state: "handed_off", + pullRequestRef: undefined, + exactHead: undefined, + reviews: [], + merge: undefined, + cleanup: undefined, + evidenceRefs: [] + }); + const recoveryOnly = parseProjection({ + ...recoveryProjection, + id: "task_to_pr_projection:opaque-recovery-only", + state: "recovering", + pullRequestRef: undefined, + exactHead: undefined, + reviews: [], + merge: undefined, + cleanup: undefined, + recovery: { + ...recovery, + preservedStateRefs: recovery.preservedStateRefs.filter((preservedRef) => preservedRef.role !== "pull_request") + }, + evidenceRefs: [] + }); + + for (const stoppedWorkRunRef of [ + { ...handoffOnly.handoff!.stoppedWorkRunRef, id: handoffOnly.workRunRef.id }, + { ...handoffOnly.handoff!.stoppedWorkRunRef, digest: handoffOnly.workRunRef.digest } + ]) { + expect( + TaskToPrProjectionSchema.safeParse({ + ...handoffOnly, + handoff: { ...handoffOnly.handoff!, stoppedWorkRunRef } + }).success + ).toBe(false); + } + for (const priorAttemptRef of [ + { ...recoveryOnly.recovery!.priorAttemptRef, id: recoveryOnly.attempt.ref.id }, + { ...recoveryOnly.recovery!.priorAttemptRef, digest: recoveryOnly.attempt.ref.digest } + ]) { + expect( + TaskToPrProjectionSchema.safeParse({ + ...recoveryOnly, + recovery: { ...recoveryOnly.recovery!, priorAttemptRef } + }).success + ).toBe(false); + } + for (const priorWorkRunRef of [ + { ...recoveryOnly.recovery!.priorWorkRunRef, id: recoveryOnly.workRunRef.id }, + { ...recoveryOnly.recovery!.priorWorkRunRef, digest: recoveryOnly.workRunRef.digest } + ]) { + expect( + TaskToPrProjectionSchema.safeParse({ + ...recoveryOnly, + recovery: { + ...recoveryOnly.recovery!, + priorWorkRunRef, + preservedStateRefs: recoveryOnly.recovery!.preservedStateRefs.map((preservedRef) => + preservedRef.role === "work_run" ? priorWorkRunRef : preservedRef + ) + } + }).success + ).toBe(false); + } + }); + + test("freezes complete merge, cleanup, and rollback terminal facts across projections", () => { + const projection = TaskToPrProjectionSchema.parse(validProjection()); + expect( + validateTaskToPrProjectionTransition(projection, { + ...projection, + id: "task_to_pr_projection:opaque-guard-mutated-after-outcome", + merge: { + ...projection.merge, + guard: { + ...projection.merge!.guard, + mechanism: "queue_expected_head" + } + } + }).success + ).toBe(false); + expect( + validateTaskToPrProjectionTransition(projection, { + ...projection, + id: "task_to_pr_projection:opaque-cleanup-gate-mutated-after-outcome", + cleanup: { + ...projection.cleanup, + eligibility: { + ...projection.cleanup!.eligibility, + evidenceRefs: [evidence("ev_rewritten_cleanup_gate", "c")] + } + } + }).success + ).toBe(false); + + const rolledBack = TaskToPrProjectionSchema.parse(withActiveProvenance({ + ...projection, + id: "task_to_pr_projection:opaque-rollback-terminal", + state: "rolled_back", + cleanup: undefined, + rollback: { + plan: { + ref: ref("rollback_plan", "todos", "a"), + targetRef: ref("commit", "repos", "c"), + createdAt: "2026-07-23T15:11:00.000Z" + }, + outcome: { + ref: ref("rollback_outcome", "repos", "d"), + planRef: ref("rollback_plan", "todos", "a"), + targetRef: ref("commit", "repos", "c"), + status: "succeeded", + finishedAt: "2026-07-23T15:12:00.000Z", + evidenceRefs: [evidence("ev_rollback_terminal", "e")] + } + } + })); + expect( + validateTaskToPrProjectionTransition(rolledBack, { + ...rolledBack, + id: "task_to_pr_projection:opaque-rollback-plan-mutated-after-outcome", + rollback: { + ...rolledBack.rollback, + plan: { + ...rolledBack.rollback!.plan, + createdAt: "2026-07-23T15:11:30.000Z" + } + } + }).success + ).toBe(false); + }); + + test("gates cleanup outcomes on eligibility and preserves non-deletion outcomes", () => { + const projection = validProjection(); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + cleanup: { + eligibility: { ...projection.cleanup.eligibility, status: "blocked" }, + outcome: projection.cleanup.outcome + } + }).success + ).toBe(false); + + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + state: "blocked", + merge: undefined, + exactHead: undefined, + reviews: [], + cleanup: { + eligibility: { ...projection.cleanup.eligibility, status: "preserved" }, + outcome: { ...projection.cleanup.outcome, status: "preserved" } + } + }).success + ).toBe(true); + + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + cleanup: { + ...projection.cleanup, + eligibility: { + ...projection.cleanup.eligibility, + targetWorktreeRef: ref("worktree", "repos", "f", "partial") + } + } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + cleanup: { + ...projection.cleanup, + outcome: { + ...projection.cleanup.outcome, + eligibilityRef: ref("cleanup_eligibility", "repos", "f") + } + } + }).success + ).toBe(false); + }); + + test("binds a single rollback plan and immutable outcome by refs", () => { + const projection = validProjection(); + const rolledBack = withActiveProvenance({ + ...projection, + state: "rolled_back", + cleanup: undefined, + rollback: { + plan: { + ref: ref("rollback_plan", "todos", "3"), + targetRef: ref("commit", "repos", "b"), + createdAt + }, + outcome: { + ref: ref("rollback_outcome", "repos", "c"), + planRef: ref("rollback_plan", "todos", "3"), + targetRef: ref("commit", "repos", "b"), + status: "succeeded", + finishedAt: createdAt, + evidenceRefs: [evidence("ev_rollback", "d")] + } + } + }); + expect(TaskToPrProjectionSchema.safeParse(rolledBack).success).toBe(true); + expect( + TaskToPrProjectionSchema.safeParse({ + ...rolledBack, + rollback: { outcome: rolledBack.rollback.outcome } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...rolledBack, + rollback: { + ...rolledBack.rollback, + plan: { + ...rolledBack.rollback.plan, + targetRef: ref("worktree", "repos", "f", "partial") + } + } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...rolledBack, + rollback: { + ...rolledBack.rollback, + outcome: { + ...rolledBack.rollback.outcome, + planRef: ref("rollback_plan", "todos", "f") + } + } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...rolledBack, + rollback: { + ...rolledBack.rollback, + plan: { + ...rolledBack.rollback.plan, + createdAt: "2026-07-23T15:09:59.000Z" + } + } + }).success + ).toBe(false); + }); + + test("accepts local and cloud extensions only as redacted referenced digests", () => { + const projection = validProjection(); + expect(TaskToPrProjectionSchema.parse(projection).adapterExtensions.map((extension) => extension.mode)).toEqual(["local", "cloud"]); + expect(TaskToPrAdapterExtensionSchema.safeParse(projection.adapterExtensions[0]).success).toBe(true); + for (const schema of Object.values(SCHEMA_IDS)) { + expect( + TaskToPrAdapterExtensionSchema.safeParse({ + ...projection.adapterExtensions[0], + schema + }).success + ).toBe(false); + } + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + adapterExtensions: [projection.adapterExtensions[0], projection.adapterExtensions[0]] + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + adapterExtensions: [ + { + ...projection.adapterExtensions[0], + schema: "hasna.task_to_pr_projection.extension.v1" + } + ] + }).success + ).toBe(false); + + const { adapterExtensions: _ignored, ...core } = projection; + const local = parseProjection({ ...core, adapterExtensions: [projection.adapterExtensions[0]] }); + const cloud = parseProjection({ ...core, adapterExtensions: [projection.adapterExtensions[1]] }); + expect(validateTaskToPrAdapterCoreEquivalence(local, cloud)).toEqual({ success: true, issues: [] }); + expect(validateTaskToPrAdapterCoreEquivalence(cloud, local).success).toBe(false); + expect(validateTaskToPrAdapterCoreEquivalence({ ...local, adapterExtensions: [] }, cloud).success).toBe(false); + expect(validateTaskToPrAdapterCoreEquivalence(local, { ...cloud, adapterExtensions: [] }).success).toBe(false); + expect( + validateTaskToPrAdapterCoreEquivalence( + { ...local, adapterExtensions: [projection.adapterExtensions[0], projection.adapterExtensions[1]] }, + cloud + ).success + ).toBe(false); + expect( + validateTaskToPrAdapterCoreEquivalence(local, { + ...cloud, + events: { + ...cloud.events, + sequence: cloud.events.sequence + 1, + replayCursorRef: ref("replay_cursor", "todos", "9"), + prefixDigest: sha("8") + } + }).success + ).toBe(false); + expect( + validateTaskToPrAdapterCoreEquivalence(local, { + ...cloud, + provenanceLedger: [ + ...cloud.provenanceLedger, + { + category: "recovery", + ref: ref("recovery", "todos", "adapter-history") + } + ] + }).success + ).toBe(false); + expect(validateTaskToPrAdapterCoreEquivalence(local, { ...cloud, mutableProviderPayload: true }).success).toBe(false); + }); + + test("preserves v1 WorkRun parsing and refuses silent v1 widening", () => { + const legacyWorkRun = { + schema: "hasna.work_run.v1", + id: "run_legacy", + createdAt, + objective: "Legacy compatible run", + status: "running", + actor: { kind: "agent", id: "worker_legacy" } + }; + const parsed = WorkRunSchema.parse(legacyWorkRun); + expect(parsed.schema).toBe("hasna.work_run.v1"); + expect(validateContract(SCHEMA_IDS.workRun, legacyWorkRun).success).toBe(true); + expect( + validateContract(SCHEMA_IDS.workRun, { + ...legacyWorkRun, + taskToPrProjection: validProjection() + }).success + ).toBe(false); + expect(validateContract(SCHEMA_IDS.taskToPrProjection, validProjection()).success).toBe(true); + }); +}); From 271c595c7074e60ec75c9ccc27d562d18f23f00b Mon Sep 17 00:00:00 2001 From: andreihasna Date: Thu, 23 Jul 2026 22:25:21 +0300 Subject: [PATCH 2/3] fix: harden task-to-PR projection lifecycle --- README.md | 198 ++-- examples/task-to-pr-projection.valid.json | 244 +++-- src/schemas.ts | 599 +++++++++--- tests/task-to-pr-projection.test.ts | 1049 +++++++++++++++++---- 4 files changed, 1647 insertions(+), 443 deletions(-) diff --git a/README.md b/README.md index 8797c6a..331b41e 100644 --- a/README.md +++ b/README.md @@ -402,9 +402,10 @@ remains a bounded trace. The projection only binds their owner-resolved refs. Authority is fixed: - Todos owns the canonical root request, PR group, leaf task, attempt, - repair state, and append-only lifecycle history. -- Codewith owns durable run admission, opaque profile/route selection, and - runtime state. + writer generation, terminal disposition, repair state, and append-only + lifecycle history. +- Codewith owns durable run admission, worker actor and assignment, opaque + profile/route selection, and runtime state. - Repos owns repository, worktree, branch, writer lease/fence refs, and cleanup. - Infinity may execute an admitted attempt but is only an adapter; it does not become an authority or history store. @@ -413,18 +414,19 @@ Authority is fixed: context. It owns no Codewith goal, Todos task or history, Repos lease, review, merge guard, or merge outcome. -Every `TaskToPrRef` has one role, one authority, one typed opaque owner id -(`role:opaque-*`), one lowercase SHA-256 identity digest, and an explicit redaction state. Canonical -objects are dereferenced from their owning packages instead of embedded as -mutable payloads. Sensitive owner ids are role-prefixed opaque refs and reject -account-like, fence-like, URL-like, credential-like, and secret-marker values. -The projection id (`task_to_pr_projection:opaque-*`) and attempt nonce -(`attempt_nonce:opaque-*`) use the same fail-closed rule; untyped ids and values -that hide an account, fence, or GitHub PAT marker behind a delimiter are -rejected. +Every `TaskToPrRef` has one role, one allowed authority, one lowercase SHA-256 +owner-record digest, one explicit redaction state, and one structural +nonsemantic id: +`role:authority:opaque-`. This makes owner +resolution schema-driven rather than dependent on a semantic-keyword blacklist. +Canonical objects are dereferenced from their owning packages instead of +embedded as mutable payloads. The projection id +(`task_to_pr_projection:opaque-*`) and attempt nonce +(`attempt_nonce:opaque-*`) each require an independent 128-bit lowercase +hexadecimal surrogate. `TaskToPrEvidenceRef` is deliberately smaller than `EvidencePointer`: it only -carries a typed opaque `evidence:opaque-*` id, immutable digest, and -`partial`/`full` redaction, so a +allows `evidence:opaque-`, the immutable +digest, and `partial`/`full` redaction, so a projection cannot inline a URI, summary, command output, or secret-adjacent payload. @@ -433,111 +435,123 @@ payload. | `workRunRef` | exactly 1 | Codewith; points to the existing `WorkRun` receipt | | `rootRequestRef` / `prGroupRef` / `leafTaskRef` | exactly 1 each | Todos; immutable canonical identities | | `attempt.ref` / `attempt.nonce` | exactly 1 each | Todos attempt identity; every retry uses a fresh nonce | -| admission / worker / runtime refs | exactly 1 each | redacted Codewith owner-record refs for the admitted runtime | +| admission / worker actor / worker assignment / runtime refs | exactly 1 each | distinct redacted Codewith owner records; every successor attempt rotates admission and assignment in addition to the attempt-scoped runtime identity | | writer generation / lease / fence refs | exactly 1 each | generation is Todos-bound; lease/fence are redacted Repos refs, never a fence value | | provider profile / route refs | exactly 1 each | redacted opaque Codewith refs; never provider account or credential data | | repo / worktree / branch plus base and branch heads | exactly 1 binding | canonical Repos refs with exact Git object ids | -| event stream / replay cursor / sequence / prefix digest | exactly 1 cursor | Todos append-only history is referenced, never copied | -| `handoff` | 0 or 1 current transition | Todos ref binding prior/next attempts, prior/next generations, and the prior WorkRun plus stop/revocation evidence; the full record is immutable under one canonical ref, while a later handoff requires both a fresh canonical role/authority/id and a fresh digest; mutually exclusive with recovery | +| event stream / replay cursor / sequence / prefix digest | exactly 1 cursor | Todos append-only history is referenced, never copied; sequences are nonnegative safe integers | +| `handoff` | 0 or 1 current transition | Todos ref binding prior/next attempts, prior/next generations, and the prior WorkRun plus distinct stop and revocation evidence identities/digests; the full record is immutable under one canonical ref, while a later handoff requires both a fresh canonical role/authority/id and a fresh digest; mutually exclusive with recovery | | `pullRequestRef` | 0 or 1 | Todos PR-group identity; required by review or merge state | -| exact-head proof | required before review/merge | local head = remote head = provider PR head, with equality and CI proof refs; once established, the complete exact-head fact (including the equality ref and ordered CI refs) is immutable while the branch head is unchanged; every branch-head change requires fresh canonical identities and digests for all equality and CI proof refs | -| `reviews` | 0 or more unique exact-head reviews | review ref, reviewer/run, proof bundle, PR, and immutable head; review-record refs are unique by canonical id and digest; same-head history preserves the prior array as an exact immutable prefix and can only append, while a new branch head requires fresh review, review-run, and proof-bundle identities and digests (the reviewer actor may remain stable) | -| `repair` | exactly 1 state | cumulative Todos cycle `0..2`; never decrements or resets; state/latest refs are disjoint by id and digest, and an advance makes both slots fresh from both prior slots | -| merge guard / outcome | 0 or 1 each | guard binds reviewed head and proof; a guard is immutable under one canonical ref and any changed guard uses a fresh canonical id and digest; a changed expected head also requires a fresh provider-guard receipt id and digest; terminal outcome requires the exact immutable `guardRef` and is CAS-bound | -| recovery / cancellation | recovery is mutually exclusive with handoff and cancellation; 0 or 1 each | preserves required root/group/leaf/repo/worktree/branch/event and present PR refs; recovery binds canonically distinct prior attempt/generation/WorkRun refs, is immutable under one canonical recovery ref, and rotates each successor/current or later recovery ref to both a fresh canonical role/authority/id and fresh digest | -| cleanup eligibility / outcome | 0 or 1 each | Repos-owned; eligibility is immutable under one canonical ref and a changed decision uses a fresh canonical id and digest; both bind the canonical worktree and the outcome binds the eligibility decision | +| exact-head proof | required before review/merge | exact expected base = provider PR base and local head = remote head = provider PR head, with equality and CI proof refs; same-base/head facts are immutable and every base/head change requires fresh proof identities and digests | +| `reviews` | 0 or more unique exact-base/exact-head reviews | review ref, reviewer/run, proof bundle, PR, immutable base, and immutable head; same-base/head history preserves the prior array as an exact immutable prefix and can only append, while a changed base or head requires fresh review, review-run, and proof-bundle identities and digests | +| `repair` | exactly 1 state | cumulative Todos cycle `0..2`; never decrements or resets; entering `repairing` consumes exactly one new cycle, exhausted work cannot re-enter repair, and repair state freezes after terminal disposition | +| merge guard / outcome | 0 or 1 each | guard binds reviewed base/head and proof; only `merge_ready` may carry `eligible`, terminal outcomes require a `consumed` guard, and failed/blocked/cancelled history may only retain a revoked guard; outcome classifies base drift independently from head drift and binds the exact immutable guard | +| recovery / cancellation | recovery is mutually exclusive with handoff and cancellation; 0 or 1 each | preservation refs are an exact one-per-role set for root/group/leaf/repo/worktree/branch/event and present PR; recovery binds canonically distinct prior attempt/generation/WorkRun refs, distinct stop/revocation evidence, and a fresh successor nonce/generation | +| `terminalDispositionRef` | exactly 1 in terminal states; absent otherwise | durable Todos owner fact for the terminal disposition; immutable after establishment | +| cleanup eligibility / outcome | 0 or 1 each | Repos-owned; eligibility binds the terminal disposition, current replay cursor, exact writer lease, canonical worktree, distinct lease-revocation and consumed-event evidence, and must be `eligible` before deletion; outcome binds the exact eligibility decision | | rollback plan / outcome | 0 or 1 each | plan targets a commit or branch, remains immutable under one canonical ref, and uses a fresh canonical id and digest when the plan or target changes; immutable outcome binds the exact plan and target | -| `provenanceLedger` | exactly 1 append-only identity ledger | immutable exact-prefix tombstones for projection snapshot id, WorkRun, attempt, nonce, runtime, writer generation/lease/fence, provider profile/route, replay cursor/prefix, repair state/latest repair, handoff, recovery, merge guard, cleanup eligibility, rollback plan, equality/CI/review proof, review record/run, and provider guard receipt identities; active identities must be represented exactly, all ref ids/digests share one global uniqueness domain, and projection-id/nonce/prefix values are independently globally unique | +| `provenanceLedger` | exactly 1 append-only identity ledger | immutable exact-prefix tombstones for the stable projection id, WorkRun, attempt, admission, worker actor/assignment, nonce, runtime, writer generation/lease/fence, provider profile/route, replay cursor/prefix, repair state/latest repair, handoff, recovery, merge guard, cleanup eligibility, rollback plan, terminal disposition, and base/head-bound equality/CI/review/provider-receipt identities; active identities must be represented exactly and all ref ids/digests share one global uniqueness domain | | OpenLoops invocation | 0 or 1 | optional redacted context only | -| adapter extensions | 0 or more unique mode/schema pairs | `local` or `cloud` ref plus digest only; separately namespaced, unregistered schema ids cannot equal any canonical `SCHEMA_IDS` contract or redefine canonical fields | +| adapter extensions | 0 or more unique mode/schema pairs | `local` or `cloud` ref plus digest only; every extension schema must use the permanently reserved `hasna.task_to_pr_adapter_extension.*` namespace | The top-level document deliberately has `createdAt` but no `updatedAt`. -Producers treat a parsed projection as immutable. A changed lifecycle state is -emitted as a new projection while the Todos stream/cursor remains the only -canonical history. `provenanceLedger` is not another event store: it is an -identity-only index over Todos history containing typed immutable projection -ids, owner refs/digests, attempt nonces, replay prefix digests bound to their -sequence, and the Git object id for head-bound refs. Ref equality is exact -across role, authority, id, -digest, and redaction. Its exact-prefix tombstones make pairwise validation -remember all transition-fresh identities across absent phases, preventing -owner, attempt-lineage, replay, repair, and proof ABA without copying owner -payloads or lifecycle events. `validateTaskToPrProjectionTransition` parses both snapshots -before comparing them. It rejects changed canonical identity, replay regression, -sequence advances without a fresh cursor and prefix digest, repair advances -without fresh repair refs, partial attempt/generation/WorkRun rotation, recovery -or handoff that does not bind the immediately prior attempt/generation/WorkRun, -handoff and recovery in the same current snapshot, reused canonical IDs or -digests for advancing replay/repair/attempt/generation/WorkRun refs, reused -attempt-scoped runtime/lease/fence/profile/route refs, mutation, removal, or -handoff/recovery switching while an attempt identity is unchanged, illegal lifecycle -edges, any semantic lifecycle drift without replay-sequence advancement, -provenance-ledger truncation/reordering/mutation (including redaction drift), -inactive identity reactivation, historical projection-id, attempt-nonce, or -replay-prefix reuse, -or appending a tombstone that does not represent a current active fact, -same-ref mutation or half-rotation of handoff, recovery, merge-guard, -cleanup-eligibility, or rollback-plan owner records (a changed owner fact must -rotate both its canonical role/authority/id and digest), -replacement or removal of an established canonical PR ref, mutation or -removal of an established same-head exact-head fact, movement, mutation, or -removal within the immutable same-head review prefix, reuse of any prior equality, CI, or review -proof identity or digest after a branch-head change (including reordered or -recategorized proof refs), reuse of prior review or review-run identities or -digests across heads, active-state re-entry from terminal work states, and mutation or -disappearance of complete terminal merge, cancellation, cleanup, or rollback -facts after their owner outcome exists. +Producers emit immutable snapshots under one stable top-level projection id; +the Todos stream/cursor remains the only canonical lifecycle history. Any +semantic change requires replay-sequence advancement with a fresh cursor and +prefix digest. `provenanceLedger` is not another event store: it is an +identity-only exact-prefix index over that history. Head-bound entries carry +both base and head, while admission, worker assignment, terminal disposition, +attempt nonce, and replay-prefix facts remain independently replay-safe. Ref +equality is exact across role, authority, id, digest, and redaction. + +`validateTaskToPrProjectionTransition` parses both snapshots before comparing +them. It rejects top-level id changes, identity-scope mutation, replay +regression, sequence advances without a fresh cursor/prefix, semantic drift +without an event advance, provenance truncation/reordering/mutation, inactive +identity reactivation, illegal lifecycle edges, partial attempt lineage +rotation, and successor attempts that reuse admission, worker assignment, +runtime, lease, fence, profile, or route identities. Handoff/recovery must bind +the immediately prior attempt, generation, and WorkRun; recovery and +cancellation preservation lists must contain exactly the required roles with +no duplicates or extras. Repair entry advances exactly one cycle, cannot occur +after exhaustion, and repair is immutable after terminal disposition. +Same-base/head exact-head and review facts remain immutable prefixes; a base or +head change rotates equality, CI, review, review-run, review-proof, and provider +receipt identities. Terminal disposition plus complete merge, cancellation, +cleanup, and rollback owner facts are immutable once present. `admitted`, `running`, and `handed_off` snapshots cannot carry direct review bindings or hidden merge-guard review refs, including on a denied guard. A `reviewing` to `recovering` transition may retain the exact same-head `exactHead` and review prefix, but recovery cannot invent them from a pre-review snapshot or carry merge-guard review refs. Same-head reviews can only be appended after the exact prior array -prefix; reordering or prepending invalidates the transition. This v1 schema has no review +prefix; reordering or prepending invalidates the transition. This schema has no review supersession field, so producers must advance the reviewed head before emitting -a replacement review set. An eligible merge guard is authoritative only from -`merge_ready` or in a consistent post-review terminal/failed/blocked/cancelled -history; `reviewing` and `repairing` cannot claim merge eligibility. +a replacement review set. `merge_ready` is the only state that can carry an +`eligible` guard. A terminal merge outcome requires the same guard authority +facts in `consumed` state; because guard owner records are immutable, consumption or +revocation uses a fresh guard ref/digest while preserving the eligible guard's +exact PR, base/head, review/proof, operator, provider-receipt, and mechanism +facts. Failed, blocked, and cancelled projections cannot retain eligibility. +Failed and blocked are durable terminal dispositions and do not advertise a +transition back to `recovering`; `merge_ready` must revoke into a supported +non-recovery state before any later recovery attempt. Direct +nonterminal-to-`cleanup_complete` edges are illegal, and a +destructive cleanup decision must prove terminal disposition, writer-lease +revocation, and consumption of the terminal event. Consumers must reject unknown fields rather than retain unvalidated embedded payloads. -`canonicalizationVersion: 1` derives `identityDigest` from the ordered, -domain-separated tuple of root-request id/digest, PR-group id/digest, -leaf-task id/digest, repository id/digest, exact base Git object id, and -frozen-scope digest. The PR-group owner digest is an input, never the derived -output, so the binding has no circular dependency. Digests on refs mean +The exhaustive merge-authority matrix is: + +| Projection state | Allowed merge authority | +| --- | --- | +| admitted, running, handed_off, reviewing, repairing, recovering | absent, denied-without-outcome, or revoked-without-outcome | +| merge_ready | eligible without outcome | +| merged | consumed with `merged` outcome | +| closed_unmerged | consumed with `closed_unmerged`, `refused`, `head_drift`, or `base_drift` outcome | +| failed, blocked, cancelled | absent or revoked-without-outcome | +| cleanup_complete | absent, revoked-without-outcome, or a previously consumed terminal outcome | +| rolled_back | consumed with `merged` outcome | + +`canonicalizationVersion: 2` derives `identityDigest` from the ordered, +domain-separated tuple of role, authority, id, and digest for root request, PR +group, leaf task, repository, worktree, and branch, followed by the exact base +Git object id and frozen-scope digest. `canonicalizationVersion: 1` remains an +explicit legacy compatibility path using the prior root/group/leaf/repository +id/digest tuple and never silently upgrades to v2. The PR-group owner digest is +an input, never the derived output, so the binding has no circular dependency. +Digests on refs mean the SHA-256 of the owning package's canonical, redacted record identity bytes; they must never hash a credential, raw fence value, mutable payload, or provider account record. -Exact-head binding is transitive: `remoteBranchRef` equals the canonical -repository branch ref; the local, remote, and provider pull-request heads are -equal and backed by equality plus CI proof refs; equality, every CI proof, and -every review proof obligation are globally unique by canonical ref id and -digest, so one proof cannot discharge two obligations; every `reviews[]` -head equals that canonical branch head; an eligible merge guard references -the exact canonical set of projected approved review refs with no extras or -omissions, every review proof, every CI proof, the equality proof, an opaque -provider guard receipt, and its CAS/expected-head queue mechanism; and a merged -outcome observes the same expected head. Only the non-merged `head_drift` -outcome may carry a mismatched observed head. Worker, reviewer, and -merge-operator identities and run refs are distinct by canonical digest, not -by incompatible role-prefixed ids. Exact-head verification, reviews, guard -evaluation, and merge outcome are chronologically ordered. Cleanup and rollback -are independent post-terminal branches: each is internally chronological and -cannot precede the terminal merge outcome it references, but one does not imply -an order relative to the other unless a runtime intentionally imposes one. +Exact base/head binding is transitive: `remoteBranchRef` equals the canonical +repository branch ref; `expectedBase`, provider pull-request base, repository +base, every review base, guard base, outcome expected base, and all +base/head-bound provenance entries agree. The local, remote, and provider +pull-request heads are equal and backed by equality plus CI proof refs. +Equality, every CI proof, and every review proof obligation are globally unique +by canonical ref id and digest, so one proof cannot discharge two obligations. +An eligible merge guard references the exact canonical set of approved review +refs with no extras or omissions, every review proof, every CI proof, the +equality proof, an opaque provider receipt, and its CAS/expected-head mechanism. +A merged outcome observes the same base and head. Only `head_drift` may carry a +mismatched observed head and only `base_drift` may carry a mismatched observed +base; each drift outcome keeps the other dimension equal. Exact-head +verification, reviews, guard evaluation, and merge outcome are chronologically +ordered. Sensitive refs (`writer_lease`, `writer_fence`, `provider_profile`, -`provider_route`, `worktree`, merge-provider refs, and adapter extensions) +`provider_route`, `admission`, worker/runtime refs, `worktree`, +merge-provider refs, and adapter extensions) require `partial` or `full` redaction. Strict objects reject fields such as provider account ids, credentials, raw fence tokens, or embedded adapter payloads. Local and cloud adapters serialize the same shared projection and place provider-specific detail behind separately validated, redacted extension -refs. An extension schema is unregistered and separately namespaced: it cannot -equal any canonical id in `SCHEMA_IDS` (including `hasna.work_run.v1`), use any -`hasna.task_to_pr_projection.*` schema, -encode account/fence/credential markers in its schema id, or shadow a canonical -role. `validateTaskToPrAdapterCoreEquivalence` parses both documents, requires +refs. Extension schemas use the permanently reserved +`hasna.task_to_pr_adapter_extension.*` namespace, so adding unrelated canonical +schemas to `SCHEMA_IDS` cannot make previously accepted adapter data ambiguous. +`validateTaskToPrAdapterCoreEquivalence` parses both documents, requires one or more local-only extensions in its first argument and one or more cloud-only extensions in its second, removes only those validated `adapterExtensions`, and requires byte-equivalent local and cloud core diff --git a/examples/task-to-pr-projection.valid.json b/examples/task-to-pr-projection.valid.json index e209657..4177ef6 100644 --- a/examples/task-to-pr-projection.valid.json +++ b/examples/task-to-pr-projection.valid.json @@ -1,36 +1,36 @@ { "schema": "hasna.task_to_pr_projection.v1", - "id": "task_to_pr_projection:opaque-example", + "id": "task_to_pr_projection:opaque-0123456789abcdef0123456789abcdef", "createdAt": "2026-07-23T15:10:00.000Z", - "canonicalizationVersion": 1, - "identityDigest": "efa1cc954b50628d575d7b353262264ad29926c12b8094580ab058bfb081fb4d", + "canonicalizationVersion": 2, + "identityDigest": "2ea71da0896bbae24c42ae106fd2179f46a6c327a7d40cdcd10f63ce57d0a170", "frozenScopeDigest": "2222222222222222222222222222222222222222222222222222222222222222", "state": "merged", "workRunRef": { "role": "work_run", "authority": "codewith", - "id": "work_run:opaque-example", + "id": "work_run:codewith:opaque-01010101010101010101010101010101", "digest": "0101010101010101010101010101010101010101010101010101010101010101", "redaction": "none" }, "rootRequestRef": { "role": "root_request", "authority": "todos", - "id": "root_request:opaque-example", + "id": "root_request:todos:opaque-44444444444444444444444444444444", "digest": "4444444444444444444444444444444444444444444444444444444444444444", "redaction": "none" }, "prGroupRef": { "role": "pr_group", "authority": "todos", - "id": "pr_group:opaque-example", + "id": "pr_group:todos:opaque-55555555555555555555555555555555", "digest": "5555555555555555555555555555555555555555555555555555555555555555", "redaction": "none" }, "leafTaskRef": { "role": "leaf_task", "authority": "todos", - "id": "leaf_task:opaque-example", + "id": "leaf_task:todos:opaque-66666666666666666666666666666666", "digest": "6666666666666666666666666666666666666666666666666666666666666666", "redaction": "none" }, @@ -38,64 +38,71 @@ "ref": { "role": "attempt", "authority": "todos", - "id": "attempt:opaque-example", + "id": "attempt:todos:opaque-02020202020202020202020202020202", "digest": "0202020202020202020202020202020202020202020202020202020202020202", "redaction": "none" }, - "nonce": "attempt_nonce:opaque-example-0001", + "nonce": "attempt_nonce:opaque-11111111111111111111111111111111", "admissionRef": { "role": "admission", "authority": "codewith", - "id": "admission:opaque-example", - "digest": "1111111111111111111111111111111111111111111111111111111111111111", + "id": "admission:codewith:opaque-13131313131313131313131313131313", + "digest": "1313131313131313131313131313131313131313131313131313131313131313", + "redaction": "partial" + }, + "workerActorRef": { + "role": "worker_actor", + "authority": "codewith", + "id": "worker_actor:codewith:opaque-14141414141414141414141414141414", + "digest": "1414141414141414141414141414141414141414141414141414141414141414", "redaction": "partial" }, "workerRef": { "role": "worker", "authority": "codewith", - "id": "worker:opaque-example", - "digest": "2222222222222222222222222222222222222222222222222222222222222222", + "id": "worker:codewith:opaque-15151515151515151515151515151515", + "digest": "1515151515151515151515151515151515151515151515151515151515151515", "redaction": "partial" }, "runtimeRef": { "role": "runtime", "authority": "codewith", - "id": "runtime:opaque-example", + "id": "runtime:codewith:opaque-03030303030303030303030303030303", "digest": "0303030303030303030303030303030303030303030303030303030303030303", "redaction": "partial" }, "writerGenerationRef": { "role": "writer_generation", "authority": "todos", - "id": "writer_generation:opaque-example", + "id": "writer_generation:todos:opaque-88888888888888888888888888888888", "digest": "8888888888888888888888888888888888888888888888888888888888888888", "redaction": "none" }, "writerLeaseRef": { "role": "writer_lease", "authority": "repos", - "id": "writer_lease:opaque-example", + "id": "writer_lease:repos:opaque-99999999999999999999999999999999", "digest": "9999999999999999999999999999999999999999999999999999999999999999", "redaction": "partial" }, "writerFenceRef": { "role": "writer_fence", "authority": "repos", - "id": "writer_fence:opaque-example", + "id": "writer_fence:repos:opaque-04040404040404040404040404040404", "digest": "0404040404040404040404040404040404040404040404040404040404040404", "redaction": "full" }, "providerProfileRef": { "role": "provider_profile", "authority": "codewith", - "id": "provider_profile:opaque-example", + "id": "provider_profile:codewith:opaque-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "redaction": "full" }, "providerRouteRef": { "role": "provider_route", "authority": "codewith", - "id": "provider_route:opaque-example", + "id": "provider_route:codewith:opaque-cccccccccccccccccccccccccccccccc", "digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "redaction": "partial" } @@ -104,21 +111,21 @@ "repoRef": { "role": "repo", "authority": "repos", - "id": "repo:opaque-example", + "id": "repo:repos:opaque-dddddddddddddddddddddddddddddddd", "digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", "redaction": "none" }, "worktreeRef": { "role": "worktree", "authority": "repos", - "id": "worktree:opaque-example", + "id": "worktree:repos:opaque-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "redaction": "partial" }, "branchRef": { "role": "branch", "authority": "repos", - "id": "branch:opaque-example", + "id": "branch:repos:opaque-ffffffffffffffffffffffffffffffff", "digest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "redaction": "none" }, @@ -135,14 +142,14 @@ "streamRef": { "role": "event_stream", "authority": "todos", - "id": "event_stream:opaque-example", + "id": "event_stream:todos:opaque-11111111111111111111111111111111", "digest": "1111111111111111111111111111111111111111111111111111111111111111", "redaction": "none" }, "replayCursorRef": { "role": "replay_cursor", "authority": "todos", - "id": "replay_cursor:opaque-example", + "id": "replay_cursor:todos:opaque-22222222222222222222222222222222", "digest": "2222222222222222222222222222222222222222222222222222222222222222", "redaction": "none" }, @@ -152,7 +159,7 @@ "pullRequestRef": { "role": "pull_request", "authority": "todos", - "id": "pull_request:opaque-example", + "id": "pull_request:todos:opaque-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "redaction": "none" }, @@ -160,17 +167,25 @@ "pullRequestRef": { "role": "pull_request", "authority": "todos", - "id": "pull_request:opaque-example", + "id": "pull_request:todos:opaque-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "redaction": "none" }, "remoteBranchRef": { "role": "branch", "authority": "repos", - "id": "branch:opaque-example", + "id": "branch:repos:opaque-ffffffffffffffffffffffffffffffff", "digest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "redaction": "none" }, + "expectedBase": { + "algorithm": "sha1", + "value": "1111111111111111111111111111111111111111" + }, + "providerPullRequestBase": { + "algorithm": "sha1", + "value": "1111111111111111111111111111111111111111" + }, "localHead": { "algorithm": "sha1", "value": "2222222222222222222222222222222222222222" @@ -186,7 +201,7 @@ "equalityProofRef": { "role": "proof_bundle", "authority": "review", - "id": "proof_bundle:opaque-head-equality", + "id": "proof_bundle:review:opaque-ffffffffffffffffffffffffffffffff", "digest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "redaction": "none" }, @@ -194,7 +209,7 @@ { "role": "proof_bundle", "authority": "review", - "id": "proof_bundle:opaque-ci", + "id": "proof_bundle:review:opaque-00000000000000000000000000000000", "digest": "0000000000000000000000000000000000000000000000000000000000000000", "redaction": "none" } @@ -206,17 +221,21 @@ "ref": { "role": "review", "authority": "review", - "id": "review:opaque-example", + "id": "review:review:opaque-66666666666666666666666666666666", "digest": "6666666666666666666666666666666666666666666666666666666666666666", "redaction": "none" }, "pullRequestRef": { "role": "pull_request", "authority": "todos", - "id": "pull_request:opaque-example", + "id": "pull_request:todos:opaque-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "redaction": "none" }, + "base": { + "algorithm": "sha1", + "value": "1111111111111111111111111111111111111111" + }, "head": { "algorithm": "sha1", "value": "2222222222222222222222222222222222222222" @@ -224,21 +243,21 @@ "reviewerRef": { "role": "reviewer", "authority": "review", - "id": "reviewer:opaque-example", + "id": "reviewer:review:opaque-44444444444444444444444444444444", "digest": "4444444444444444444444444444444444444444444444444444444444444444", "redaction": "none" }, "reviewRunRef": { "role": "review_run", "authority": "review", - "id": "review_run:opaque-example", + "id": "review_run:review:opaque-55555555555555555555555555555555", "digest": "5555555555555555555555555555555555555555555555555555555555555555", "redaction": "none" }, "proofBundleRef": { "role": "proof_bundle", "authority": "review", - "id": "proof_bundle:opaque-review", + "id": "proof_bundle:review:opaque-11111111111111111111111111111111", "digest": "1111111111111111111111111111111111111111111111111111111111111111", "redaction": "none" }, @@ -250,7 +269,7 @@ "ref": { "role": "repair_cycle", "authority": "todos", - "id": "repair_cycle:opaque-zero", + "id": "repair_cycle:todos:opaque-44444444444444444444444444444444", "digest": "4444444444444444444444444444444444444444444444444444444444444444", "redaction": "none" }, @@ -263,17 +282,21 @@ "ref": { "role": "merge_guard", "authority": "todos", - "id": "merge_guard:opaque-example", + "id": "merge_guard:todos:opaque-77777777777777777777777777777777", "digest": "7777777777777777777777777777777777777777777777777777777777777777", "redaction": "none" }, "pullRequestRef": { "role": "pull_request", "authority": "todos", - "id": "pull_request:opaque-example", + "id": "pull_request:todos:opaque-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "redaction": "none" }, + "expectedBase": { + "algorithm": "sha1", + "value": "1111111111111111111111111111111111111111" + }, "expectedHead": { "algorithm": "sha1", "value": "2222222222222222222222222222222222222222" @@ -282,7 +305,7 @@ { "role": "review", "authority": "review", - "id": "review:opaque-example", + "id": "review:review:opaque-66666666666666666666666666666666", "digest": "6666666666666666666666666666666666666666666666666666666666666666", "redaction": "none" } @@ -291,21 +314,21 @@ { "role": "proof_bundle", "authority": "review", - "id": "proof_bundle:opaque-review", + "id": "proof_bundle:review:opaque-11111111111111111111111111111111", "digest": "1111111111111111111111111111111111111111111111111111111111111111", "redaction": "none" }, { "role": "proof_bundle", "authority": "review", - "id": "proof_bundle:opaque-head-equality", + "id": "proof_bundle:review:opaque-ffffffffffffffffffffffffffffffff", "digest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "redaction": "none" }, { "role": "proof_bundle", "authority": "review", - "id": "proof_bundle:opaque-ci", + "id": "proof_bundle:review:opaque-00000000000000000000000000000000", "digest": "0000000000000000000000000000000000000000000000000000000000000000", "redaction": "none" } @@ -313,50 +336,58 @@ "operatorRef": { "role": "merge_operator", "authority": "merge_provider", - "id": "merge_operator:opaque-example", + "id": "merge_operator:merge_provider:opaque-88888888888888888888888888888888", "digest": "8888888888888888888888888888888888888888888888888888888888888888", "redaction": "partial" }, "operatorRunRef": { "role": "merge_operator_run", "authority": "merge_provider", - "id": "merge_operator_run:opaque-example", + "id": "merge_operator_run:merge_provider:opaque-99999999999999999999999999999999", "digest": "9999999999999999999999999999999999999999999999999999999999999999", "redaction": "partial" }, "providerGuardReceiptRef": { "role": "merge_guard_receipt", "authority": "merge_provider", - "id": "merge_guard_receipt:opaque-example", + "id": "merge_guard_receipt:merge_provider:opaque-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "redaction": "full" }, "mechanism": "compare_and_swap", - "decision": "eligible", + "decision": "consumed", "evaluatedAt": "2026-07-23T15:10:00.000Z" }, "outcome": { "ref": { "role": "merge_outcome", "authority": "merge_provider", - "id": "merge_outcome:opaque-example", + "id": "merge_outcome:merge_provider:opaque-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "redaction": "partial" }, "guardRef": { "role": "merge_guard", "authority": "todos", - "id": "merge_guard:opaque-example", + "id": "merge_guard:todos:opaque-77777777777777777777777777777777", "digest": "7777777777777777777777777777777777777777777777777777777777777777", "redaction": "none" }, "pullRequestRef": { "role": "pull_request", "authority": "todos", - "id": "pull_request:opaque-example", + "id": "pull_request:todos:opaque-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "redaction": "none" }, + "expectedBase": { + "algorithm": "sha1", + "value": "1111111111111111111111111111111111111111" + }, + "observedBase": { + "algorithm": "sha1", + "value": "1111111111111111111111111111111111111111" + }, "expectedHead": { "algorithm": "sha1", "value": "2222222222222222222222222222222222222222" @@ -369,31 +400,38 @@ "mergeCommitRef": { "role": "commit", "authority": "repos", - "id": "commit:opaque-example", + "id": "commit:repos:opaque-cccccccccccccccccccccccccccccccc", "digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "redaction": "none" }, "finishedAt": "2026-07-23T15:10:00.000Z", "evidenceRefs": [ { - "id": "evidence:opaque-merge-cas", + "id": "evidence:opaque-dddddddddddddddddddddddddddddddd", "digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", "redaction": "full" } ] } }, + "terminalDispositionRef": { + "role": "terminal_disposition", + "authority": "todos", + "id": "terminal_disposition:todos:opaque-16161616161616161616161616161616", + "digest": "1616161616161616161616161616161616161616161616161616161616161616", + "redaction": "none" + }, "provenanceLedger": [ { "category": "projection_id", - "projectionId": "task_to_pr_projection:opaque-example" + "projectionId": "task_to_pr_projection:opaque-0123456789abcdef0123456789abcdef" }, { "category": "work_run", "ref": { "role": "work_run", "authority": "codewith", - "id": "work_run:opaque-example", + "id": "work_run:codewith:opaque-01010101010101010101010101010101", "digest": "0101010101010101010101010101010101010101010101010101010101010101", "redaction": "none" } @@ -403,21 +441,51 @@ "ref": { "role": "attempt", "authority": "todos", - "id": "attempt:opaque-example", + "id": "attempt:todos:opaque-02020202020202020202020202020202", "digest": "0202020202020202020202020202020202020202020202020202020202020202", "redaction": "none" } }, + { + "category": "admission", + "ref": { + "role": "admission", + "authority": "codewith", + "id": "admission:codewith:opaque-13131313131313131313131313131313", + "digest": "1313131313131313131313131313131313131313131313131313131313131313", + "redaction": "partial" + } + }, + { + "category": "worker_actor", + "ref": { + "role": "worker_actor", + "authority": "codewith", + "id": "worker_actor:codewith:opaque-14141414141414141414141414141414", + "digest": "1414141414141414141414141414141414141414141414141414141414141414", + "redaction": "partial" + } + }, + { + "category": "worker_assignment", + "ref": { + "role": "worker", + "authority": "codewith", + "id": "worker:codewith:opaque-15151515151515151515151515151515", + "digest": "1515151515151515151515151515151515151515151515151515151515151515", + "redaction": "partial" + } + }, { "category": "attempt_nonce", - "nonce": "attempt_nonce:opaque-example-0001" + "nonce": "attempt_nonce:opaque-11111111111111111111111111111111" }, { "category": "runtime", "ref": { "role": "runtime", "authority": "codewith", - "id": "runtime:opaque-example", + "id": "runtime:codewith:opaque-03030303030303030303030303030303", "digest": "0303030303030303030303030303030303030303030303030303030303030303", "redaction": "partial" } @@ -427,7 +495,7 @@ "ref": { "role": "writer_generation", "authority": "todos", - "id": "writer_generation:opaque-example", + "id": "writer_generation:todos:opaque-88888888888888888888888888888888", "digest": "8888888888888888888888888888888888888888888888888888888888888888", "redaction": "none" } @@ -437,7 +505,7 @@ "ref": { "role": "writer_lease", "authority": "repos", - "id": "writer_lease:opaque-example", + "id": "writer_lease:repos:opaque-99999999999999999999999999999999", "digest": "9999999999999999999999999999999999999999999999999999999999999999", "redaction": "partial" } @@ -447,7 +515,7 @@ "ref": { "role": "writer_fence", "authority": "repos", - "id": "writer_fence:opaque-example", + "id": "writer_fence:repos:opaque-04040404040404040404040404040404", "digest": "0404040404040404040404040404040404040404040404040404040404040404", "redaction": "full" } @@ -457,7 +525,7 @@ "ref": { "role": "provider_profile", "authority": "codewith", - "id": "provider_profile:opaque-example", + "id": "provider_profile:codewith:opaque-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "redaction": "full" } @@ -467,7 +535,7 @@ "ref": { "role": "provider_route", "authority": "codewith", - "id": "provider_route:opaque-example", + "id": "provider_route:codewith:opaque-cccccccccccccccccccccccccccccccc", "digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "redaction": "partial" } @@ -477,7 +545,7 @@ "ref": { "role": "replay_cursor", "authority": "todos", - "id": "replay_cursor:opaque-example", + "id": "replay_cursor:todos:opaque-22222222222222222222222222222222", "digest": "2222222222222222222222222222222222222222222222222222222222222222", "redaction": "none" } @@ -492,7 +560,7 @@ "ref": { "role": "repair_cycle", "authority": "todos", - "id": "repair_cycle:opaque-zero", + "id": "repair_cycle:todos:opaque-44444444444444444444444444444444", "digest": "4444444444444444444444444444444444444444444444444444444444444444", "redaction": "none" } @@ -502,10 +570,14 @@ "ref": { "role": "proof_bundle", "authority": "review", - "id": "proof_bundle:opaque-head-equality", + "id": "proof_bundle:review:opaque-ffffffffffffffffffffffffffffffff", "digest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "redaction": "none" }, + "base": { + "algorithm": "sha1", + "value": "1111111111111111111111111111111111111111" + }, "head": { "algorithm": "sha1", "value": "2222222222222222222222222222222222222222" @@ -516,10 +588,14 @@ "ref": { "role": "proof_bundle", "authority": "review", - "id": "proof_bundle:opaque-ci", + "id": "proof_bundle:review:opaque-00000000000000000000000000000000", "digest": "0000000000000000000000000000000000000000000000000000000000000000", "redaction": "none" }, + "base": { + "algorithm": "sha1", + "value": "1111111111111111111111111111111111111111" + }, "head": { "algorithm": "sha1", "value": "2222222222222222222222222222222222222222" @@ -530,10 +606,14 @@ "ref": { "role": "proof_bundle", "authority": "review", - "id": "proof_bundle:opaque-review", + "id": "proof_bundle:review:opaque-11111111111111111111111111111111", "digest": "1111111111111111111111111111111111111111111111111111111111111111", "redaction": "none" }, + "base": { + "algorithm": "sha1", + "value": "1111111111111111111111111111111111111111" + }, "head": { "algorithm": "sha1", "value": "2222222222222222222222222222222222222222" @@ -544,10 +624,14 @@ "ref": { "role": "review", "authority": "review", - "id": "review:opaque-example", + "id": "review:review:opaque-66666666666666666666666666666666", "digest": "6666666666666666666666666666666666666666666666666666666666666666", "redaction": "none" }, + "base": { + "algorithm": "sha1", + "value": "1111111111111111111111111111111111111111" + }, "head": { "algorithm": "sha1", "value": "2222222222222222222222222222222222222222" @@ -558,10 +642,14 @@ "ref": { "role": "review_run", "authority": "review", - "id": "review_run:opaque-example", + "id": "review_run:review:opaque-55555555555555555555555555555555", "digest": "5555555555555555555555555555555555555555555555555555555555555555", "redaction": "none" }, + "base": { + "algorithm": "sha1", + "value": "1111111111111111111111111111111111111111" + }, "head": { "algorithm": "sha1", "value": "2222222222222222222222222222222222222222" @@ -572,7 +660,7 @@ "ref": { "role": "merge_guard", "authority": "todos", - "id": "merge_guard:opaque-example", + "id": "merge_guard:todos:opaque-77777777777777777777777777777777", "digest": "7777777777777777777777777777777777777777777777777777777777777777", "redaction": "none" } @@ -582,14 +670,28 @@ "ref": { "role": "merge_guard_receipt", "authority": "merge_provider", - "id": "merge_guard_receipt:opaque-example", + "id": "merge_guard_receipt:merge_provider:opaque-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "redaction": "full" }, + "base": { + "algorithm": "sha1", + "value": "1111111111111111111111111111111111111111" + }, "head": { "algorithm": "sha1", "value": "2222222222222222222222222222222222222222" } + }, + { + "category": "terminal_disposition", + "ref": { + "role": "terminal_disposition", + "authority": "todos", + "id": "terminal_disposition:todos:opaque-16161616161616161616161616161616", + "digest": "1616161616161616161616161616161616161616161616161616161616161616", + "redaction": "none" + } } ], "adapterExtensions": [ @@ -599,7 +701,7 @@ "ref": { "role": "adapter_extension", "authority": "adapter", - "id": "adapter_extension:opaque-local-example", + "id": "adapter_extension:adapter:opaque-55555555555555555555555555555555", "digest": "5555555555555555555555555555555555555555555555555555555555555555", "redaction": "partial" }, @@ -611,7 +713,7 @@ "ref": { "role": "adapter_extension", "authority": "adapter", - "id": "adapter_extension:opaque-cloud-example", + "id": "adapter_extension:adapter:opaque-77777777777777777777777777777777", "digest": "7777777777777777777777777777777777777777777777777777777777777777", "redaction": "full" }, diff --git a/src/schemas.ts b/src/schemas.ts index 51089c9..455f547 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -2044,6 +2044,7 @@ export const TASK_TO_PR_ROLE_AUTHORITIES = Object.freeze({ provider_profile: Object.freeze(["codewith"] as const), provider_route: Object.freeze(["codewith"] as const), admission: Object.freeze(["codewith"] as const), + worker_actor: Object.freeze(["codewith"] as const), worker: Object.freeze(["codewith"] as const), runtime: Object.freeze(["codewith"] as const), repo: Object.freeze(["repos"] as const), @@ -2070,6 +2071,7 @@ export const TASK_TO_PR_ROLE_AUTHORITIES = Object.freeze({ cleanup_outcome: Object.freeze(["repos"] as const), rollback_plan: Object.freeze(["todos"] as const), rollback_outcome: Object.freeze(["repos"] as const), + terminal_disposition: Object.freeze(["todos"] as const), openloops_invocation: Object.freeze(["openloops"] as const), adapter_extension: Object.freeze(["adapter"] as const) }); @@ -2086,6 +2088,7 @@ export const TaskToPrRefRoleSchema = z.enum([ "provider_profile", "provider_route", "admission", + "worker_actor", "worker", "runtime", "repo", @@ -2112,6 +2115,7 @@ export const TaskToPrRefRoleSchema = z.enum([ "cleanup_outcome", "rollback_plan", "rollback_outcome", + "terminal_disposition", "openloops_invocation", "adapter_extension" ]); @@ -2129,38 +2133,33 @@ export const TaskToPrAuthoritySchema = z.enum([ export type TaskToPrAuthority = z.infer; const LowerSha256DigestSchema = z.string().regex(/^[a-f0-9]{64}$/); -const OpaqueTaskToPrIdSchema = z - .string() - .trim() - .min(3) - .max(256) - .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/, "Reference ids must be opaque identifiers, never accounts, URLs, paths, or queries"); -const ForbiddenOpaqueOwnerDirectPattern = - /(?:@|[/?#=&+]|-----BEGIN|github_pat_|gh[pousr]_|sk-[A-Za-z0-9]|AKIA[0-9A-Z])/i; -const ForbiddenOpaqueOwnerNormalizedMarkerPattern = - /(?:bearer|password|secret|credential|account|fence|token|privatekey|apikey|githubpat)/i; -const OpaqueOwnerSuffixPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; -function containsForbiddenTaskToPrMarker(value: string): boolean { - return ( - ForbiddenOpaqueOwnerDirectPattern.test(value) || - ForbiddenOpaqueOwnerNormalizedMarkerPattern.test(value.replace(/[._:-]+/g, "")) - ); +const OpaqueTaskToPrIdSchema = z.string().trim().min(3).max(256); +const NonsemanticOpaqueSuffixPattern = /^[a-f0-9]{32}$/; +export function deriveTaskToPrRefId( + role: TaskToPrRefRole, + authority: TaskToPrAuthority, + digest: string +): string { + return `${role}:${authority}:opaque-${digest.slice(0, 32)}`; +} +export function deriveTaskToPrEvidenceId(digest: string): string { + return `evidence:opaque-${digest.slice(0, 32)}`; } const TaskToPrProjectionIdSchema = OpaqueTaskToPrIdSchema.refine( (value) => { const prefix = "task_to_pr_projection:opaque-"; const suffix = value.startsWith(prefix) ? value.slice(prefix.length) : ""; - return OpaqueOwnerSuffixPattern.test(suffix) && !containsForbiddenTaskToPrMarker(suffix); + return NonsemanticOpaqueSuffixPattern.test(suffix); }, - "Projection ids must be typed opaque owner-record refs and cannot encode accounts, fences, locators, or credentials" + "Projection ids must use a nonsemantic 128-bit lowercase hexadecimal surrogate" ); const TaskToPrAttemptNonceSchema = OpaqueTaskToPrIdSchema.refine( (value) => { const prefix = "attempt_nonce:opaque-"; const suffix = value.startsWith(prefix) ? value.slice(prefix.length) : ""; - return OpaqueOwnerSuffixPattern.test(suffix) && !containsForbiddenTaskToPrMarker(suffix); + return NonsemanticOpaqueSuffixPattern.test(suffix); }, - "Attempt nonces must be typed opaque values and cannot encode accounts, fences, locators, or credentials" + "Attempt nonces must use a nonsemantic 128-bit lowercase hexadecimal surrogate" ); const SensitiveTaskToPrRoles = new Set([ "writer_lease", @@ -2168,6 +2167,7 @@ const SensitiveTaskToPrRoles = new Set([ "provider_profile", "provider_route", "admission", + "worker_actor", "worker", "runtime", "worktree", @@ -2203,28 +2203,12 @@ export const TaskToPrRefSchema = z path: ["redaction"] }); } - if (SensitiveTaskToPrRoles.has(value.role)) { - const sensitiveSuffix = value.id.startsWith(`${value.role}:opaque-`) - ? value.id.slice(`${value.role}:opaque-`.length) - : ""; - if (!OpaqueOwnerSuffixPattern.test(sensitiveSuffix) || containsForbiddenTaskToPrMarker(sensitiveSuffix)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `${value.role} ids must be role-prefixed opaque owner-record refs, never raw account, path, credential, or fence values`, - path: ["id"] - }); - } - } - const expectedPrefix = `${value.role}:opaque-`; - const opaqueSuffix = value.id.startsWith(expectedPrefix) ? value.id.slice(expectedPrefix.length) : value.id; - if ( - !value.id.startsWith(expectedPrefix) || - !OpaqueOwnerSuffixPattern.test(opaqueSuffix) || - containsForbiddenTaskToPrMarker(opaqueSuffix) - ) { + const expectedId = deriveTaskToPrRefId(value.role, value.authority, value.digest); + if (value.id !== expectedId) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Reference ids must be typed opaque owner refs and cannot contain account, fence, credential, URL, or secret markers", + message: + "Reference ids must be nonsemantic authority-bound surrogates derived from the canonical role, authority, and owner-record digest", path: ["id"] }); } @@ -2239,21 +2223,36 @@ export const TaskToPrEvidenceRefSchema = z }) .strict() .superRefine((value, ctx) => { - const prefix = "evidence:opaque-"; - const suffix = value.id.startsWith(prefix) ? value.id.slice(prefix.length) : ""; - if ( - !OpaqueOwnerSuffixPattern.test(suffix) || - containsForbiddenTaskToPrMarker(suffix) - ) { + if (value.id !== deriveTaskToPrEvidenceId(value.digest)) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Evidence ids must be typed opaque refs with no account, fence, URL, credential, or secret markers", + message: + "Evidence ids must be nonsemantic owner-resolvable surrogates derived from their canonical digest", path: ["id"] }); } }); export type TaskToPrEvidenceRef = z.infer; +function requireDistinctTaskToPrEvidenceRefs( + stopEvidenceRef: TaskToPrEvidenceRef, + leaseRevocationEvidenceRef: TaskToPrEvidenceRef, + ctx: z.RefinementCtx, + path: (string | number)[] +): void { + if ( + stopEvidenceRef.id === leaseRevocationEvidenceRef.id || + stopEvidenceRef.digest === leaseRevocationEvidenceRef.digest + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Stop and lease-revocation facts require distinct evidence identities and digests", + path + }); + } +} + function taskToPrRefFor(role: TaskToPrRefRole) { return TaskToPrRefSchema.refine((value) => value.role === role, { message: `Reference must use role ${role}`, @@ -2309,6 +2308,23 @@ function sameGitObjectId( return left.algorithm === right.algorithm && left.value === right.value; } +function sameTaskToPrMergeGuardLineageFacts( + left: z.infer, + right: z.infer +): boolean { + return ( + sameTaskToPrRef(left.pullRequestRef, right.pullRequestRef) && + sameGitObjectId(left.expectedBase, right.expectedBase) && + sameGitObjectId(left.expectedHead, right.expectedHead) && + JSON.stringify(left.reviewRefs) === JSON.stringify(right.reviewRefs) && + JSON.stringify(left.proofBundleRefs) === JSON.stringify(right.proofBundleRefs) && + sameTaskToPrRef(left.operatorRef, right.operatorRef) && + sameTaskToPrRef(left.operatorRunRef, right.operatorRunRef) && + sameTaskToPrRef(left.providerGuardReceiptRef, right.providerGuardReceiptRef) && + left.mechanism === right.mechanism + ); +} + export const TaskToPrGitObjectIdSchema = z .object({ algorithm: z.enum(["sha1", "sha256"]), @@ -2327,7 +2343,7 @@ export const TaskToPrGitObjectIdSchema = z }); export type TaskToPrGitObjectId = z.infer; -export interface TaskToPrBindingInput { +export interface TaskToPrBindingV1Input { canonicalizationVersion: 1; rootRequestRef: TaskToPrRef; prGroupRef: TaskToPrRef; @@ -2337,18 +2353,44 @@ export interface TaskToPrBindingInput { frozenScopeDigest: string; } +export interface TaskToPrBindingV2Input { + canonicalizationVersion: 2; + rootRequestRef: TaskToPrRef; + prGroupRef: TaskToPrRef; + leafTaskRef: TaskToPrRef; + repoRef: TaskToPrRef; + worktreeRef: TaskToPrRef; + branchRef: TaskToPrRef; + baseHead: TaskToPrGitObjectId; + frozenScopeDigest: string; +} + +export type TaskToPrBindingInput = TaskToPrBindingV1Input | TaskToPrBindingV2Input; + export function deriveTaskToPrIdentityDigest(input: TaskToPrBindingInput): string { + if (input.canonicalizationVersion === 1) { + const legacyCanonicalBinding = JSON.stringify([ + "hasna.task_to_pr_projection.binding.v1", + input.canonicalizationVersion, + input.rootRequestRef.id, + input.rootRequestRef.digest, + input.prGroupRef.id, + input.prGroupRef.digest, + input.leafTaskRef.id, + input.leafTaskRef.digest, + input.repoRef.id, + input.repoRef.digest, + input.baseHead.algorithm, + input.baseHead.value, + input.frozenScopeDigest + ]); + return createHash("sha256").update(legacyCanonicalBinding, "utf8").digest("hex"); + } const canonicalBinding = JSON.stringify([ - "hasna.task_to_pr_projection.binding.v1", + "hasna.task_to_pr_projection.binding.v2", input.canonicalizationVersion, - input.rootRequestRef.id, - input.rootRequestRef.digest, - input.prGroupRef.id, - input.prGroupRef.digest, - input.leafTaskRef.id, - input.leafTaskRef.digest, - input.repoRef.id, - input.repoRef.digest, + ...[input.rootRequestRef, input.prGroupRef, input.leafTaskRef, input.repoRef, input.worktreeRef, input.branchRef] + .flatMap((ref) => [ref.role, ref.authority, ref.id, ref.digest]), input.baseHead.algorithm, input.baseHead.value, input.frozenScopeDigest @@ -2361,6 +2403,7 @@ export const TaskToPrAttemptSchema = z ref: taskToPrRefFor("attempt"), nonce: TaskToPrAttemptNonceSchema, admissionRef: taskToPrRefFor("admission"), + workerActorRef: taskToPrRefFor("worker_actor"), workerRef: taskToPrRefFor("worker"), runtimeRef: taskToPrRefFor("runtime"), writerGenerationRef: taskToPrRefFor("writer_generation"), @@ -2387,7 +2430,7 @@ export const TaskToPrEventCursorSchema = z .object({ streamRef: taskToPrRefFor("event_stream"), replayCursorRef: taskToPrRefFor("replay_cursor"), - sequence: z.number().int().nonnegative(), + sequence: z.number().int().safe().nonnegative(), prefixDigest: LowerSha256DigestSchema }) .strict(); @@ -2420,6 +2463,12 @@ export const TaskToPrHandoffSchema = z ["nextWriterGenerationRef"], "Handoff writer-generation rotation" ); + requireDistinctTaskToPrEvidenceRefs( + value.stopEvidenceRef, + value.leaseRevocationEvidenceRef, + ctx, + ["leaseRevocationEvidenceRef"] + ); }); export type TaskToPrHandoff = z.infer; @@ -2427,6 +2476,7 @@ export const TaskToPrReviewBindingSchema = z .object({ ref: taskToPrRefFor("review"), pullRequestRef: taskToPrRefFor("pull_request"), + base: TaskToPrGitObjectIdSchema, head: TaskToPrGitObjectIdSchema, reviewerRef: taskToPrRefFor("reviewer"), reviewRunRef: taskToPrRefFor("review_run"), @@ -2441,6 +2491,8 @@ export const TaskToPrExactHeadBindingSchema = z .object({ pullRequestRef: taskToPrRefFor("pull_request"), remoteBranchRef: taskToPrRefFor("branch"), + expectedBase: TaskToPrGitObjectIdSchema, + providerPullRequestBase: TaskToPrGitObjectIdSchema, localHead: TaskToPrGitObjectIdSchema, remoteHead: TaskToPrGitObjectIdSchema, providerPullRequestHead: TaskToPrGitObjectIdSchema, @@ -2450,6 +2502,13 @@ export const TaskToPrExactHeadBindingSchema = z }) .strict() .superRefine((value, ctx) => { + if (!sameGitObjectId(value.expectedBase, value.providerPullRequestBase)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Expected and provider-observed pull-request bases must be exactly equal", + path: ["providerPullRequestBase"] + }); + } if ( !sameGitObjectId(value.localHead, value.remoteHead) || !sameGitObjectId(value.localHead, value.providerPullRequestHead) @@ -2545,6 +2604,7 @@ export const TaskToPrMergeGuardSchema = z .object({ ref: taskToPrRefFor("merge_guard"), pullRequestRef: taskToPrRefFor("pull_request"), + expectedBase: TaskToPrGitObjectIdSchema, expectedHead: TaskToPrGitObjectIdSchema, reviewRefs: z.array(taskToPrRefFor("review")).min(1), proofBundleRefs: z.array(taskToPrRefFor("proof_bundle")).min(1), @@ -2552,7 +2612,7 @@ export const TaskToPrMergeGuardSchema = z operatorRunRef: taskToPrRefFor("merge_operator_run"), providerGuardReceiptRef: taskToPrRefFor("merge_guard_receipt"), mechanism: z.enum(["compare_and_swap", "queue_expected_head"]), - decision: z.enum(["eligible", "denied"]), + decision: z.enum(["eligible", "denied", "consumed", "revoked"]), evaluatedAt: TimestampSchema }) .strict() @@ -2589,22 +2649,25 @@ export const TaskToPrMergeOutcomeSchema = z ref: taskToPrRefFor("merge_outcome"), guardRef: taskToPrRefFor("merge_guard"), pullRequestRef: taskToPrRefFor("pull_request"), + expectedBase: TaskToPrGitObjectIdSchema, + observedBase: TaskToPrGitObjectIdSchema, expectedHead: TaskToPrGitObjectIdSchema, observedHead: TaskToPrGitObjectIdSchema, - status: z.enum(["merged", "closed_unmerged", "refused", "head_drift"]), + status: z.enum(["merged", "closed_unmerged", "refused", "head_drift", "base_drift"]), mergeCommitRef: taskToPrRefFor("commit").optional(), finishedAt: TimestampSchema, evidenceRefs: z.array(TaskToPrEvidenceRefSchema).min(1) }) .strict() .superRefine((value, ctx) => { + const baseMatches = sameGitObjectId(value.expectedBase, value.observedBase); const headMatches = sameGitObjectId(value.expectedHead, value.observedHead); if (value.status === "merged") { - if (!headMatches) { + if (!baseMatches || !headMatches) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Merged outcomes require the observed head to equal the expected head", - path: ["observedHead"] + message: "Merged outcomes require observed base and head to equal the guarded values", + path: [!baseMatches ? "observedBase" : "observedHead"] }); } if (!value.mergeCommitRef) { @@ -2628,6 +2691,27 @@ export const TaskToPrMergeOutcomeSchema = z path: ["observedHead"] }); } + if (value.status === "head_drift" && !baseMatches) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Head-drift outcomes cannot also carry an unclassified base drift", + path: ["observedBase"] + }); + } + if (value.status === "base_drift" && baseMatches) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Base-drift outcomes require distinct expected and observed bases", + path: ["observedBase"] + }); + } + if (value.status === "base_drift" && !headMatches) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Base-drift outcomes cannot also carry an unclassified head drift", + path: ["observedHead"] + }); + } if (!headMatches && value.status !== "head_drift") { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -2635,6 +2719,14 @@ export const TaskToPrMergeOutcomeSchema = z path: ["observedHead"] }); } + if (!baseMatches && value.status !== "base_drift") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Only a base_drift outcome may record an observed base that differs from the expected base", + path: ["observedBase"] + }); + } }); export type TaskToPrMergeOutcome = z.infer; @@ -2667,6 +2759,12 @@ export const TaskToPrRecoverySchema = z ["successorWriterGenerationRef"], "Recovery writer-generation rotation" ); + requireDistinctTaskToPrEvidenceRefs( + value.stopEvidenceRef, + value.leaseRevocationEvidenceRef, + ctx, + ["leaseRevocationEvidenceRef"] + ); }); export type TaskToPrRecovery = z.infer; @@ -2686,10 +2784,27 @@ export const TaskToPrCleanupEligibilitySchema = z status: z.enum(["not_ready", "preserved", "blocked", "eligible"]), targetWorktreeRef: taskToPrRefFor("worktree"), eventCursorRef: taskToPrRefFor("replay_cursor"), + terminalDispositionRef: taskToPrRefFor("terminal_disposition"), + writerLeaseRef: taskToPrRefFor("writer_lease"), + leaseRevocationEvidenceRef: TaskToPrEvidenceRefSchema, + consumedEventEvidenceRef: TaskToPrEvidenceRefSchema, evaluatedAt: TimestampSchema, evidenceRefs: z.array(TaskToPrEvidenceRefSchema).min(1) }) - .strict(); + .strict() + .superRefine((value, ctx) => { + if ( + value.leaseRevocationEvidenceRef.id === value.consumedEventEvidenceRef.id || + value.leaseRevocationEvidenceRef.digest === value.consumedEventEvidenceRef.digest + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Cleanup lease-revocation and consumed-event facts require distinct evidence identities and digests", + path: ["consumedEventEvidenceRef"] + }); + } + }); export type TaskToPrCleanupEligibility = z.infer; export const TaskToPrCleanupOutcomeSchema = z @@ -2793,6 +2908,9 @@ export type TaskToPrProvenanceEntry = category: | "work_run" | "attempt" + | "admission" + | "worker_actor" + | "worker_assignment" | "runtime" | "writer_generation" | "writer_lease" @@ -2806,7 +2924,8 @@ export type TaskToPrProvenanceEntry = | "recovery" | "merge_guard" | "cleanup_eligibility" - | "rollback_plan"; + | "rollback_plan" + | "terminal_disposition"; ref: TaskToPrRef; } | { @@ -2818,6 +2937,7 @@ export type TaskToPrProvenanceEntry = | "review_run" | "provider_guard_receipt"; ref: TaskToPrRef; + base: TaskToPrGitObjectId; head: TaskToPrGitObjectId; } | { @@ -2853,6 +2973,24 @@ export const TaskToPrProvenanceEntrySchema: z.ZodType = ref: taskToPrRefFor("attempt") }) .strict(), + z + .object({ + category: z.literal("admission"), + ref: taskToPrRefFor("admission") + }) + .strict(), + z + .object({ + category: z.literal("worker_actor"), + ref: taskToPrRefFor("worker_actor") + }) + .strict(), + z + .object({ + category: z.literal("worker_assignment"), + ref: taskToPrRefFor("worker") + }) + .strict(), z .object({ category: z.literal("attempt_nonce"), @@ -2904,7 +3042,7 @@ export const TaskToPrProvenanceEntrySchema: z.ZodType = z .object({ category: z.literal("replay_prefix"), - sequence: z.number().int().nonnegative(), + sequence: z.number().int().safe().nonnegative(), prefixDigest: LowerSha256DigestSchema }) .strict(), @@ -2950,10 +3088,17 @@ export const TaskToPrProvenanceEntrySchema: z.ZodType = ref: taskToPrRefFor("rollback_plan") }) .strict(), + z + .object({ + category: z.literal("terminal_disposition"), + ref: taskToPrRefFor("terminal_disposition") + }) + .strict(), z .object({ category: z.literal("equality_proof"), ref: taskToPrRefFor("proof_bundle"), + base: TaskToPrGitObjectIdSchema, head: TaskToPrGitObjectIdSchema }) .strict(), @@ -2961,6 +3106,7 @@ export const TaskToPrProvenanceEntrySchema: z.ZodType = .object({ category: z.literal("ci_proof"), ref: taskToPrRefFor("proof_bundle"), + base: TaskToPrGitObjectIdSchema, head: TaskToPrGitObjectIdSchema }) .strict(), @@ -2968,6 +3114,7 @@ export const TaskToPrProvenanceEntrySchema: z.ZodType = .object({ category: z.literal("review_proof"), ref: taskToPrRefFor("proof_bundle"), + base: TaskToPrGitObjectIdSchema, head: TaskToPrGitObjectIdSchema }) .strict(), @@ -2975,6 +3122,7 @@ export const TaskToPrProvenanceEntrySchema: z.ZodType = .object({ category: z.literal("review_record"), ref: taskToPrRefFor("review"), + base: TaskToPrGitObjectIdSchema, head: TaskToPrGitObjectIdSchema }) .strict(), @@ -2982,6 +3130,7 @@ export const TaskToPrProvenanceEntrySchema: z.ZodType = .object({ category: z.literal("review_run"), ref: taskToPrRefFor("review_run"), + base: TaskToPrGitObjectIdSchema, head: TaskToPrGitObjectIdSchema }) .strict(), @@ -2989,6 +3138,7 @@ export const TaskToPrProvenanceEntrySchema: z.ZodType = .object({ category: z.literal("provider_guard_receipt"), ref: taskToPrRefFor("merge_guard_receipt"), + base: TaskToPrGitObjectIdSchema, head: TaskToPrGitObjectIdSchema }) .strict() @@ -3007,6 +3157,7 @@ type TaskToPrProvenanceProjectionView = { merge?: TaskToPrMergeState | undefined; cleanup?: TaskToPrCleanupState | undefined; rollback?: TaskToPrRollback | undefined; + terminalDispositionRef?: TaskToPrRef | undefined; }; function taskToPrActiveProvenanceEntries( @@ -3025,6 +3176,18 @@ function taskToPrActiveProvenanceEntries( category: "attempt" as const, ref: projection.attempt.ref }, + { + category: "admission" as const, + ref: projection.attempt.admissionRef + }, + { + category: "worker_actor" as const, + ref: projection.attempt.workerActorRef + }, + { + category: "worker_assignment" as const, + ref: projection.attempt.workerRef + }, { category: "attempt_nonce" as const, nonce: projection.attempt.nonce @@ -3085,11 +3248,13 @@ function taskToPrActiveProvenanceEntries( { category: "equality_proof" as const, ref: projection.exactHead.equalityProofRef, + base: projection.exactHead.expectedBase, head: projection.exactHead.localHead }, ...projection.exactHead.ciProofBundleRefs.map((ref) => ({ category: "ci_proof" as const, ref, + base: projection.exactHead!.expectedBase, head: projection.exactHead!.localHead })) ] @@ -3098,16 +3263,19 @@ function taskToPrActiveProvenanceEntries( { category: "review_proof" as const, ref: review.proofBundleRef, + base: review.base, head: review.head }, { category: "review_record" as const, ref: review.ref, + base: review.base, head: review.head }, { category: "review_run" as const, ref: review.reviewRunRef, + base: review.base, head: review.head } ]), @@ -3120,6 +3288,7 @@ function taskToPrActiveProvenanceEntries( { category: "provider_guard_receipt" as const, ref: projection.merge.guard.providerGuardReceiptRef, + base: projection.merge.guard.expectedBase, head: projection.merge.guard.expectedHead } ] @@ -3139,6 +3308,14 @@ function taskToPrActiveProvenanceEntries( ref: projection.rollback.plan.ref } ] + : []), + ...(projection.terminalDispositionRef + ? [ + { + category: "terminal_disposition" as const, + ref: projection.terminalDispositionRef + } + ] : []) ]; } @@ -3164,12 +3341,18 @@ function sameTaskToPrProvenanceEntry( } return ( sameTaskToPrRef(left.ref, right.ref) && - (("head" in left && "head" in right && sameGitObjectId(left.head, right.head)) || - (!("head" in left) && !("head" in right))) + (("head" in left && + "head" in right && + "base" in left && + "base" in right && + sameGitObjectId(left.base, right.base) && + sameGitObjectId(left.head, right.head)) || + (!("head" in left) && !("head" in right) && !("base" in left) && !("base" in right))) ); } -const RegisteredCanonicalSchemaIds = new Set(Object.values(SCHEMA_IDS)); +export const TASK_TO_PR_V1_ADAPTER_EXTENSION_SCHEMA_PREFIX = + "hasna.task_to_pr_adapter_extension."; export const TaskToPrAdapterExtensionSchema = z .object({ @@ -3180,15 +3363,11 @@ export const TaskToPrAdapterExtensionSchema = z }) .strict() .superRefine((value, ctx) => { - if ( - RegisteredCanonicalSchemaIds.has(value.schema) || - value.schema.startsWith("hasna.task_to_pr_projection.") || - containsForbiddenTaskToPrMarker(value.schema) - ) { + if (!value.schema.startsWith(TASK_TO_PR_V1_ADAPTER_EXTENSION_SCHEMA_PREFIX)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: - "Adapter extension schema ids must be separately namespaced and unregistered; they cannot reuse canonical contracts, use the core projection namespace, or encode sensitive owner data", + "Adapter extension schema ids must use the permanently reserved task-to-PR adapter-extension namespace", path: ["schema"] }); } @@ -3218,8 +3397,7 @@ const TaskToPrStatesWithoutReviewAuthority = new Set([ "running", "handed_off" ]); -const TaskToPrEligibleGuardAuthoritativeStates = new Set([ - "merge_ready", +const TaskToPrTerminalStates = new Set([ "merged", "closed_unmerged", "failed", @@ -3228,13 +3406,45 @@ const TaskToPrEligibleGuardAuthoritativeStates = new Set +> = { + admitted: new Set(["absent", "denied:none", "revoked:none"]), + running: new Set(["absent", "denied:none", "revoked:none"]), + handed_off: new Set(["absent", "denied:none", "revoked:none"]), + reviewing: new Set(["absent", "denied:none", "revoked:none"]), + repairing: new Set(["absent", "denied:none", "revoked:none"]), + merge_ready: new Set(["eligible:none"]), + merged: new Set(["consumed:merged"]), + closed_unmerged: new Set([ + "consumed:closed_unmerged", + "consumed:refused", + "consumed:head_drift", + "consumed:base_drift" + ]), + failed: new Set(["absent", "revoked:none"]), + blocked: new Set(["absent", "revoked:none"]), + cancelled: new Set(["absent", "revoked:none"]), + recovering: new Set(["absent", "denied:none", "revoked:none"]), + cleanup_complete: new Set([ + "absent", + "revoked:none", + "consumed:merged", + "consumed:closed_unmerged", + "consumed:refused", + "consumed:head_drift", + "consumed:base_drift" + ]), + rolled_back: new Set(["consumed:merged"]) +}; export const TaskToPrProjectionSchema = z .object({ schema: z.literal(SCHEMA_IDS.taskToPrProjection), id: TaskToPrProjectionIdSchema, createdAt: TimestampSchema, - canonicalizationVersion: z.literal(1), + canonicalizationVersion: z.union([z.literal(1), z.literal(2)]), identityDigest: LowerSha256DigestSchema, frozenScopeDigest: LowerSha256DigestSchema, state: TaskToPrProjectionStateSchema, @@ -3256,25 +3466,40 @@ export const TaskToPrProjectionSchema = z cancellation: TaskToPrCancellationSchema.optional(), cleanup: TaskToPrCleanupStateSchema.optional(), rollback: TaskToPrRollbackSchema.optional(), + terminalDispositionRef: taskToPrRefFor("terminal_disposition").optional(), provenanceLedger: z.array(TaskToPrProvenanceEntrySchema), adapterExtensions: z.array(TaskToPrAdapterExtensionSchema).default([]), evidenceRefs: z.array(TaskToPrEvidenceRefSchema).default([]) }) .strict() .superRefine((value, ctx) => { - const derivedIdentityDigest = deriveTaskToPrIdentityDigest({ - canonicalizationVersion: value.canonicalizationVersion, - rootRequestRef: value.rootRequestRef, - prGroupRef: value.prGroupRef, - leafTaskRef: value.leafTaskRef, - repoRef: value.repository.repoRef, - baseHead: value.repository.baseHead, - frozenScopeDigest: value.frozenScopeDigest - }); + const derivedIdentityDigest = + value.canonicalizationVersion === 1 + ? deriveTaskToPrIdentityDigest({ + canonicalizationVersion: 1, + rootRequestRef: value.rootRequestRef, + prGroupRef: value.prGroupRef, + leafTaskRef: value.leafTaskRef, + repoRef: value.repository.repoRef, + baseHead: value.repository.baseHead, + frozenScopeDigest: value.frozenScopeDigest + }) + : deriveTaskToPrIdentityDigest({ + canonicalizationVersion: 2, + rootRequestRef: value.rootRequestRef, + prGroupRef: value.prGroupRef, + leafTaskRef: value.leafTaskRef, + repoRef: value.repository.repoRef, + worktreeRef: value.repository.worktreeRef, + branchRef: value.repository.branchRef, + baseHead: value.repository.baseHead, + frozenScopeDigest: value.frozenScopeDigest + }); if (value.identityDigest !== derivedIdentityDigest) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "identityDigest must equal the canonical v1 root/PR-group/leaf/repo/base/scope binding digest", + message: + "identityDigest must equal the selected v1 compatibility or v2 branch/worktree-bound canonical identity digest", path: ["identityDigest"] }); } @@ -3368,6 +3593,32 @@ export const TaskToPrProjectionSchema = z path: (string | number)[], label: string ) => { + const requiredRoles = new Set(requiredRefs.map((requiredRef) => requiredRef.role)); + const seenRoles = new Set(); + for (const [index, preservedRef] of preservedStateRefs.entries()) { + if (!requiredRoles.has(preservedRef.role)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${label} cannot preserve an unrecognized ${preservedRef.role} role`, + path: [...path, index] + }); + } + if (seenRoles.has(preservedRef.role)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${label} must preserve exactly one canonical ref per role`, + path: [...path, index] + }); + } + seenRoles.add(preservedRef.role); + } + if (preservedStateRefs.length !== requiredRefs.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${label} preservation refs must exactly equal the required canonical role set`, + path + }); + } for (const requiredRef of requiredRefs) { if (!preservedStateRefs.some((preservedRef) => sameTaskToPrRef(preservedRef, requiredRef))) { ctx.addIssue({ @@ -3473,6 +3724,30 @@ export const TaskToPrProjectionSchema = z path: ["cleanup", "eligibility", "eventCursorRef"] }); } + if ( + value.cleanup && + (!value.terminalDispositionRef || + !sameTaskToPrRef( + value.cleanup.eligibility.terminalDispositionRef, + value.terminalDispositionRef + )) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Cleanup eligibility must bind the exact durable terminal owner fact", + path: ["cleanup", "eligibility", "terminalDispositionRef"] + }); + } + if ( + value.cleanup && + !sameTaskToPrRef(value.cleanup.eligibility.writerLeaseRef, value.attempt.writerLeaseRef) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Cleanup eligibility must bind the exact writer lease being revoked", + path: ["cleanup", "eligibility", "writerLeaseRef"] + }); + } if ( value.cleanup && !sameTaskToPrRef(value.cleanup.eligibility.targetWorktreeRef, value.repository.worktreeRef) @@ -3528,6 +3803,13 @@ export const TaskToPrProjectionSchema = z path: ["exactHead", "localHead"] }); } + if (value.exactHead && !sameGitObjectId(value.exactHead.expectedBase, value.repository.baseHead)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Exact-head expected base must equal the canonical repository base", + path: ["exactHead", "expectedBase"] + }); + } if (value.exactHead && !sameTaskToPrRef(value.exactHead.remoteBranchRef, value.repository.branchRef)) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -3592,6 +3874,13 @@ export const TaskToPrProjectionSchema = z const reviewProofKeys = new Set(); const reviewProofDigests = new Set(); for (const [reviewIndex, review] of value.reviews.entries()) { + if (!sameGitObjectId(review.base, value.repository.baseHead)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Review base must equal the exact canonical pull-request base", + path: ["reviews", reviewIndex, "base"] + }); + } if (!sameGitObjectId(review.head, value.repository.branchHead)) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -3687,6 +3976,13 @@ export const TaskToPrProjectionSchema = z path: ["exactHead"] }); } + if (!sameGitObjectId(value.merge.guard.expectedBase, value.repository.baseHead)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merge guard expected base must equal the exact canonical pull-request base", + path: ["merge", "guard", "expectedBase"] + }); + } if (!sameGitObjectId(value.merge.guard.expectedHead, value.repository.branchHead)) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -3738,7 +4034,10 @@ export const TaskToPrProjectionSchema = z }); } } - if (value.merge.guard.decision === "eligible") { + if ( + value.merge.guard.decision === "eligible" || + value.merge.guard.decision === "consumed" + ) { if (value.reviews.length === 0 || value.reviews.some((review) => review.verdict !== "approved")) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -3807,11 +4106,18 @@ export const TaskToPrProjectionSchema = z path: ["merge", "outcome", "expectedHead"] }); } - if (value.merge.outcome.status === "merged" && value.merge.guard.decision !== "eligible") { + if (!sameGitObjectId(value.merge.outcome.expectedBase, value.merge.guard.expectedBase)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merge outcome expected base must equal the guarded expected base", + path: ["merge", "outcome", "expectedBase"] + }); + } + if (value.merge.guard.decision !== "consumed") { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Merged outcomes require an eligible merge guard", - path: ["merge", "outcome", "status"] + message: "Every merge outcome requires an explicitly consumed merge guard", + path: ["merge", "guard", "decision"] }); } if (Date.parse(value.merge.outcome.finishedAt) < Date.parse(value.merge.guard.evaluatedAt)) { @@ -3883,14 +4189,28 @@ export const TaskToPrProjectionSchema = z path: ["merge", "guard", "reviewRefs"] }); } - if ( - value.merge?.guard.decision === "eligible" && - !TaskToPrEligibleGuardAuthoritativeStates.has(value.state) - ) { + const mergeMatrixKey = value.merge + ? `${value.merge.guard.decision}:${value.merge.outcome?.status ?? "none"}` + : "absent"; + if (!TASK_TO_PR_STATE_MERGE_MATRIX[value.state].has(mergeMatrixKey)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `State ${value.state} is incompatible with merge authority ${mergeMatrixKey}`, + path: ["merge"] + }); + } + if (TaskToPrTerminalStates.has(value.state) && !value.terminalDispositionRef) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${value.state} projections require a durable Todos terminal-disposition owner ref`, + path: ["terminalDispositionRef"] + }); + } + if (!TaskToPrTerminalStates.has(value.state) && value.terminalDispositionRef) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `Eligible merge guards are not authoritative in the ${value.state} phase`, - path: ["merge", "guard", "decision"] + message: `${value.state} projections cannot carry a terminal-disposition owner ref`, + path: ["terminalDispositionRef"] }); } if (value.state === "reviewing" && value.reviews.length === 0) { @@ -3920,7 +4240,7 @@ export const TaskToPrProjectionSchema = z } if ( value.state === "closed_unmerged" && - !value.merge?.outcome?.status.match(/^(closed_unmerged|refused|head_drift)$/) + !value.merge?.outcome?.status.match(/^(closed_unmerged|refused|head_drift|base_drift)$/) ) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -3964,13 +4284,6 @@ export const TaskToPrProjectionSchema = z } const extensionKeys = new Set(); for (const [index, extension] of value.adapterExtensions.entries()) { - if (RegisteredCanonicalSchemaIds.has(extension.schema)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Adapter extensions are non-authoritative and cannot reuse any registered canonical contract schema", - path: ["adapterExtensions", index, "schema"] - }); - } const key = `${extension.mode}:${extension.schema}`; if (extensionKeys.has(key)) { ctx.addIssue({ @@ -4060,17 +4373,16 @@ const TASK_TO_PR_LEGAL_STATE_TRANSITIONS: Record>( projection: T ): Omit & { provenanceLedger: TaskToPrProjection["provenanceLedger"] } { + const terminalStates = new Set([ + "merged", + "closed_unmerged", + "failed", + "blocked", + "cancelled", + "cleanup_complete", + "rolled_back" + ]); + const normalized = { + ...projection, + terminalDispositionRef: terminalStates.has(projection.state) + ? projection.terminalDispositionRef + : undefined, + merge: + projection.merge && projection.state === "merge_ready" + ? { + ...projection.merge, + guard: { ...projection.merge.guard, decision: "eligible" } + } + : projection.merge + }; const active = [ - { category: "projection_id", projectionId: projection.id }, - { category: "work_run", ref: projection.workRunRef }, - { category: "attempt", ref: projection.attempt.ref }, - { category: "attempt_nonce", nonce: projection.attempt.nonce }, - { category: "runtime", ref: projection.attempt.runtimeRef }, - { category: "writer_generation", ref: projection.attempt.writerGenerationRef }, - { category: "writer_lease", ref: projection.attempt.writerLeaseRef }, - { category: "writer_fence", ref: projection.attempt.writerFenceRef }, - { category: "provider_profile", ref: projection.attempt.providerProfileRef }, - { category: "provider_route", ref: projection.attempt.providerRouteRef }, - { category: "replay_cursor", ref: projection.events.replayCursorRef }, + { category: "projection_id", projectionId: normalized.id }, + { category: "work_run", ref: normalized.workRunRef }, + { category: "attempt", ref: normalized.attempt.ref }, + { category: "admission", ref: normalized.attempt.admissionRef }, + { category: "worker_actor", ref: normalized.attempt.workerActorRef }, + { category: "worker_assignment", ref: normalized.attempt.workerRef }, + { category: "attempt_nonce", nonce: normalized.attempt.nonce }, + { category: "runtime", ref: normalized.attempt.runtimeRef }, + { category: "writer_generation", ref: normalized.attempt.writerGenerationRef }, + { category: "writer_lease", ref: normalized.attempt.writerLeaseRef }, + { category: "writer_fence", ref: normalized.attempt.writerFenceRef }, + { category: "provider_profile", ref: normalized.attempt.providerProfileRef }, + { category: "provider_route", ref: normalized.attempt.providerRouteRef }, + { category: "replay_cursor", ref: normalized.events.replayCursorRef }, { category: "replay_prefix", - sequence: projection.events.sequence, - prefixDigest: projection.events.prefixDigest + sequence: normalized.events.sequence, + prefixDigest: normalized.events.prefixDigest }, - { category: "repair_state", ref: projection.repair.ref }, - ...(projection.repair.latestRepairRef - ? [{ category: "latest_repair", ref: projection.repair.latestRepairRef }] + { category: "repair_state", ref: normalized.repair.ref }, + ...(normalized.repair.latestRepairRef + ? [{ category: "latest_repair", ref: normalized.repair.latestRepairRef }] : []), - ...(projection.handoff ? [{ category: "handoff", ref: projection.handoff.ref }] : []), - ...(projection.recovery ? [{ category: "recovery", ref: projection.recovery.ref }] : []), - ...(projection.exactHead + ...(normalized.handoff ? [{ category: "handoff", ref: normalized.handoff.ref }] : []), + ...(normalized.recovery ? [{ category: "recovery", ref: normalized.recovery.ref }] : []), + ...(normalized.exactHead ? [ { category: "equality_proof", - ref: projection.exactHead.equalityProofRef, - head: projection.exactHead.localHead + ref: normalized.exactHead.equalityProofRef, + base: normalized.exactHead.expectedBase, + head: normalized.exactHead.localHead }, - ...projection.exactHead.ciProofBundleRefs.map((proofRef: TaskToPrRef) => ({ + ...normalized.exactHead.ciProofBundleRefs.map((proofRef: TaskToPrRef) => ({ category: "ci_proof", ref: proofRef, - head: projection.exactHead.localHead + base: normalized.exactHead.expectedBase, + head: normalized.exactHead.localHead })) ] : []), - ...((projection.reviews ?? []) as Array<{ + ...((normalized.reviews ?? []) as Array<{ ref: TaskToPrRef; reviewRunRef: TaskToPrRef; proofBundleRef: TaskToPrRef; + base: ReturnType; head: ReturnType; }>).flatMap((review) => [ - { category: "review_proof", ref: review.proofBundleRef, head: review.head }, - { category: "review_record", ref: review.ref, head: review.head }, - { category: "review_run", ref: review.reviewRunRef, head: review.head } + { category: "review_proof", ref: review.proofBundleRef, base: review.base, head: review.head }, + { category: "review_record", ref: review.ref, base: review.base, head: review.head }, + { category: "review_run", ref: review.reviewRunRef, base: review.base, head: review.head } ]), - ...(projection.merge + ...(normalized.merge ? [ - { category: "merge_guard", ref: projection.merge.guard.ref }, + { category: "merge_guard", ref: normalized.merge.guard.ref }, { category: "provider_guard_receipt", - ref: projection.merge.guard.providerGuardReceiptRef, - head: projection.merge.guard.expectedHead + ref: normalized.merge.guard.providerGuardReceiptRef, + base: normalized.merge.guard.expectedBase, + head: normalized.merge.guard.expectedHead } ] : []), - ...(projection.cleanup - ? [{ category: "cleanup_eligibility", ref: projection.cleanup.eligibility.ref }] + ...(normalized.cleanup + ? [{ category: "cleanup_eligibility", ref: normalized.cleanup.eligibility.ref }] + : []), + ...(normalized.rollback + ? [{ category: "rollback_plan", ref: normalized.rollback.plan.ref }] : []), - ...(projection.rollback - ? [{ category: "rollback_plan", ref: projection.rollback.plan.ref }] + ...(normalized.terminalDispositionRef + ? [{ category: "terminal_disposition", ref: normalized.terminalDispositionRef }] : []) ]; - const provenanceLedger = [...(projection.provenanceLedger ?? [])]; + const provenanceLedger = [...(normalized.provenanceLedger ?? [])]; for (const entry of active) { if (!provenanceLedger.some((existing: unknown) => JSON.stringify(existing) === JSON.stringify(entry))) { provenanceLedger.push(entry); } } - return { ...projection, provenanceLedger } as Omit & { + return { ...normalized, provenanceLedger } as Omit & { provenanceLedger: TaskToPrProjection["provenanceLedger"]; }; } @@ -142,23 +190,27 @@ function validProjection() { const prGroupRef = ref("pr_group", "todos", "5"); const leafTaskRef = ref("leaf_task", "todos", "6"); const repoRef = ref("repo", "repos", "b"); + const worktreeRef = ref("worktree", "repos", "c", "partial"); + const branchRef = ref("branch", "repos", "d"); const baseHead = head("e"); const frozenScopeDigest = sha("2"); const identityDigest = deriveTaskToPrIdentityDigest({ - canonicalizationVersion: 1, + canonicalizationVersion: 2, rootRequestRef, prGroupRef, leafTaskRef, repoRef, + worktreeRef, + branchRef, baseHead, frozenScopeDigest }); return withActiveProvenance({ schema: SCHEMA_IDS.taskToPrProjection, - id: "task_to_pr_projection:opaque-123", + id: projectionId("valid-projection"), createdAt, - canonicalizationVersion: 1, + canonicalizationVersion: 2, identityDigest, frozenScopeDigest, state: "cleanup_complete", @@ -168,8 +220,9 @@ function validProjection() { leafTaskRef, attempt: { ref: currentAttempt, - nonce: "attempt_nonce:opaque-4fbd95f2-7bb7-4e7e-a7ee-2fa4c9bed5b2", + nonce: attemptNonce("valid-attempt"), admissionRef: ref("admission", "codewith", "1", "partial"), + workerActorRef: ref("worker_actor", "codewith", "stable-worker", "partial"), workerRef: ref("worker", "codewith", "2", "partial"), runtimeRef: ref("runtime", "codewith", "3", "partial"), writerGenerationRef: currentGeneration, @@ -180,8 +233,8 @@ function validProjection() { }, repository: { repoRef, - worktreeRef: ref("worktree", "repos", "c", "partial"), - branchRef: ref("branch", "repos", "d"), + worktreeRef, + branchRef, baseHead, branchHead: reviewedHead }, @@ -206,6 +259,8 @@ function validProjection() { exactHead: { pullRequestRef, remoteBranchRef: ref("branch", "repos", "d"), + expectedBase: baseHead, + providerPullRequestBase: baseHead, localHead: reviewedHead, remoteHead: reviewedHead, providerPullRequestHead: reviewedHead, @@ -217,6 +272,7 @@ function validProjection() { { ref: reviewRef, pullRequestRef, + base: baseHead, head: reviewedHead, reviewerRef: ref("reviewer", "review", "7"), reviewRunRef: ref("review_run", "review", "8"), @@ -236,6 +292,7 @@ function validProjection() { guard: { ref: ref("merge_guard", "todos", "b"), pullRequestRef, + expectedBase: baseHead, expectedHead: reviewedHead, reviewRefs: [reviewRef], proofBundleRefs: [proofBundleRef, headEqualityProofRef, ciProofBundleRef], @@ -243,13 +300,15 @@ function validProjection() { operatorRunRef: ref("merge_operator_run", "merge_provider", "d", "partial"), providerGuardReceiptRef: ref("merge_guard_receipt", "merge_provider", "c", "full"), mechanism: "compare_and_swap", - decision: "eligible", + decision: "consumed", evaluatedAt: createdAt }, outcome: { ref: ref("merge_outcome", "merge_provider", "d", "partial"), guardRef: ref("merge_guard", "todos", "b"), pullRequestRef, + expectedBase: baseHead, + observedBase: baseHead, expectedHead: reviewedHead, observedHead: reviewedHead, status: "merged", @@ -258,12 +317,17 @@ function validProjection() { evidenceRefs: [evidence("ev_merge_cas", "f")] } }, + terminalDispositionRef: ref("terminal_disposition", "todos", "terminal"), cleanup: { eligibility: { ref: ref("cleanup_eligibility", "repos", "6"), status: "eligible", targetWorktreeRef: ref("worktree", "repos", "c", "partial"), eventCursorRef: ref("replay_cursor", "todos", "f"), + terminalDispositionRef: ref("terminal_disposition", "todos", "terminal"), + writerLeaseRef: ref("writer_lease", "repos", "7", "partial"), + leaseRevocationEvidenceRef: evidence("ev_cleanup_lease_revoked", "1"), + consumedEventEvidenceRef: evidence("ev_cleanup_event_consumed", "2"), evaluatedAt: createdAt, evidenceRefs: [evidence("ev_cleanup_gate", "7")] }, @@ -344,11 +408,13 @@ describe("task-to-PR projection v1", () => { const projection = validProjection(); expect( deriveTaskToPrIdentityDigest({ - canonicalizationVersion: 1, + canonicalizationVersion: 2, rootRequestRef: projection.rootRequestRef, prGroupRef: projection.prGroupRef, leafTaskRef: projection.leafTaskRef, repoRef: projection.repository.repoRef, + worktreeRef: projection.repository.worktreeRef, + branchRef: projection.repository.branchRef, baseHead: projection.repository.baseHead, frozenScopeDigest: projection.frozenScopeDigest }) @@ -394,6 +460,7 @@ describe("task-to-PR projection v1", () => { "provider_profile", "provider_route", "admission", + "worker_actor", "worker", "runtime", "repo", @@ -420,6 +487,7 @@ describe("task-to-PR projection v1", () => { "cleanup_outcome", "rollback_plan", "rollback_outcome", + "terminal_disposition", "openloops_invocation", "adapter_extension" ]; @@ -431,6 +499,7 @@ describe("task-to-PR projection v1", () => { "provider_profile", "provider_route", "admission", + "worker_actor", "worker", "runtime", "worktree", @@ -621,10 +690,10 @@ describe("task-to-PR projection v1", () => { TaskToPrProjectionSchema.safeParse( withActiveProvenance({ ...projection, - id: "task_to_pr_projection:opaque-owner-record", + id: projectionId("valid-projection"), attempt: { ...projection.attempt, - nonce: "attempt_nonce:opaque-owner-record" + nonce: attemptNonce("owner-record") }, provenanceLedger: projection.provenanceLedger.filter( (entry) => entry.category !== "attempt_nonce" @@ -1215,7 +1284,7 @@ describe("task-to-PR projection v1", () => { const projection = validProjection(); const previous = parseProjection({ ...projection, - id: "task_to_pr_projection:opaque-previous", + id: projectionId("valid-projection"), createdAt: "2026-07-23T15:09:00.000Z", state: "running", pullRequestRef: undefined, @@ -1235,7 +1304,7 @@ describe("task-to-PR projection v1", () => { }); const current = parseProjection({ ...previous, - id: "task_to_pr_projection:opaque-current", + id: projectionId("valid-projection"), createdAt, events: { ...previous.events, @@ -1248,14 +1317,14 @@ describe("task-to-PR projection v1", () => { const replayed = { ...current, - id: "task_to_pr_projection:opaque-replayed", + id: projectionId("valid-projection"), events: { ...current.events, sequence: previous.events.sequence - 1 } }; expect(validateTaskToPrProjectionTransition(previous, replayed).success).toBe(false); const staleCursor = { ...current, - id: "task_to_pr_projection:opaque-stale-cursor", + id: projectionId("valid-projection"), events: { ...current.events, replayCursorRef: previous.events.replayCursorRef @@ -1265,7 +1334,7 @@ describe("task-to-PR projection v1", () => { const reusedCursorDigest: TaskToPrProjection = { ...current, - id: "task_to_pr_projection:opaque-reused-cursor-digest", + id: projectionId("valid-projection"), events: { ...current.events, replayCursorRef: { @@ -1279,7 +1348,7 @@ describe("task-to-PR projection v1", () => { const stalePrefix = { ...current, - id: "task_to_pr_projection:opaque-stale-prefix", + id: projectionId("valid-projection"), events: { ...current.events, prefixDigest: previous.events.prefixDigest @@ -1289,17 +1358,17 @@ describe("task-to-PR projection v1", () => { const partialAttemptAdvance = { ...current, - id: "task_to_pr_projection:opaque-partial-attempt", + id: projectionId("valid-projection"), attempt: { ...current.attempt, - nonce: "attempt_nonce:opaque-fresh" + nonce: attemptNonce("fresh") } }; expect(validateTaskToPrProjectionTransition(previous, partialAttemptAdvance).success).toBe(false); expect( validateTaskToPrProjectionTransition(previous, { ...current, - id: "task_to_pr_projection:opaque-mutated-attempt-scope", + id: projectionId("valid-projection"), attempt: { ...current.attempt, runtimeRef: ref("runtime", "codewith", "c", "partial") @@ -1309,7 +1378,7 @@ describe("task-to-PR projection v1", () => { expect( validateTaskToPrProjectionTransition(previous, { ...current, - id: "task_to_pr_projection:opaque-mutated-repair-ref", + id: projectionId("valid-projection"), repair: { ...current.repair, ref: ref("repair_cycle", "todos", "c") @@ -1320,14 +1389,14 @@ describe("task-to-PR projection v1", () => { expect( validateTaskToPrProjectionTransition(previous, { ...current, - id: "task_to_pr_projection:opaque-invalid", + id: projectionId("valid-projection"), unknownMutablePayload: true }).success ).toBe(false); const mutatedTerminal = { ...projection, - id: "task_to_pr_projection:opaque-mutated-terminal", + id: projectionId("valid-projection"), merge: { ...projection.merge, outcome: { @@ -1356,7 +1425,7 @@ describe("task-to-PR projection v1", () => { const projection = validProjection(); const mergePrevious = parseProjection({ ...projection, - id: "task_to_pr_projection:opaque-guard-owner-previous", + id: projectionId("valid-projection"), state: "merge_ready", merge: { guard: projection.merge.guard @@ -1365,7 +1434,7 @@ describe("task-to-PR projection v1", () => { }); const mergeRotated = TaskToPrProjectionSchema.parse(withActiveProvenance({ ...mergePrevious, - id: "task_to_pr_projection:opaque-guard-owner-rotated", + id: projectionId("valid-projection"), events: { ...mergePrevious.events, replayCursorRef: ref("replay_cursor", "todos", "2"), @@ -1383,7 +1452,7 @@ describe("task-to-PR projection v1", () => { const cleanupPrevious = parseProjection({ ...projection, - id: "task_to_pr_projection:opaque-cleanup-owner-previous", + id: projectionId("valid-projection"), state: "merged", cleanup: { eligibility: projection.cleanup.eligibility @@ -1392,7 +1461,7 @@ describe("task-to-PR projection v1", () => { const cleanupCursorRef = ref("replay_cursor", "todos", "4"); const cleanupRotated = TaskToPrProjectionSchema.parse(withActiveProvenance({ ...cleanupPrevious, - id: "task_to_pr_projection:opaque-cleanup-owner-rotated", + id: projectionId("valid-projection"), events: { ...cleanupPrevious.events, replayCursorRef: cleanupCursorRef, @@ -1411,7 +1480,7 @@ describe("task-to-PR projection v1", () => { const rollbackPrevious = TaskToPrProjectionSchema.parse(withActiveProvenance({ ...projection, - id: "task_to_pr_projection:opaque-rollback-owner-previous", + id: projectionId("valid-projection"), state: "merged", cleanup: undefined, rollback: { @@ -1424,7 +1493,7 @@ describe("task-to-PR projection v1", () => { })); const rollbackRotated = TaskToPrProjectionSchema.parse(withActiveProvenance({ ...rollbackPrevious, - id: "task_to_pr_projection:opaque-rollback-owner-rotated", + id: projectionId("valid-projection"), events: { ...rollbackPrevious.events, replayCursorRef: ref("replay_cursor", "todos", "6"), @@ -1491,7 +1560,7 @@ describe("task-to-PR projection v1", () => { for (const ownerCase of cases) { const retained = parseProjection({ ...ownerCase.previous, - id: `task_to_pr_projection:opaque-${ownerCase.path.replace(".", "-")}-retained` + id: projectionId("valid-projection"), }); expect( validateTaskToPrProjectionTransition(ownerCase.previous, retained), @@ -1551,7 +1620,7 @@ describe("task-to-PR projection v1", () => { const base = validProjection(); const previous = parseProjection({ ...base, - id: "task_to_pr_projection:opaque-transition-previous", + id: projectionId("valid-projection"), createdAt: "2026-07-23T15:08:00.000Z", state: "running", pullRequestRef: undefined, @@ -1573,7 +1642,9 @@ describe("task-to-PR projection v1", () => { const nextAttempt = { ...previous.attempt, ref: ref("attempt", "todos", "a"), - nonce: "attempt_nonce:opaque-next", + nonce: attemptNonce("next"), + admissionRef: ref("admission", "codewith", "next", "partial"), + workerRef: ref("worker", "codewith", "next", "partial"), runtimeRef: ref("runtime", "codewith", "b", "partial"), writerGenerationRef: ref("writer_generation", "todos", "5"), writerLeaseRef: ref("writer_lease", "repos", "d", "partial"), @@ -1604,7 +1675,7 @@ describe("task-to-PR projection v1", () => { }; const recovered = TaskToPrProjectionSchema.parse(withActiveProvenance({ ...previous, - id: "task_to_pr_projection:opaque-transition-current", + id: projectionId("valid-projection"), createdAt, state: "recovering", workRunRef: nextWorkRunRef, @@ -1621,7 +1692,7 @@ describe("task-to-PR projection v1", () => { const reusedAttemptDigest: TaskToPrProjection = { ...recovered, - id: "task_to_pr_projection:opaque-reused-attempt-digest", + id: projectionId("valid-projection"), attempt: { ...recovered.attempt, ref: { @@ -1635,7 +1706,7 @@ describe("task-to-PR projection v1", () => { const reusedGenerationDigest: TaskToPrProjection = { ...recovered, - id: "task_to_pr_projection:opaque-reused-generation-digest", + id: projectionId("valid-projection"), attempt: { ...recovered.attempt, writerGenerationRef: { @@ -1657,7 +1728,7 @@ describe("task-to-PR projection v1", () => { const reusedWorkRunDigest: TaskToPrProjection = { ...recovered, - id: "task_to_pr_projection:opaque-reused-work-run-digest", + id: projectionId("valid-projection"), workRunRef: { ...recovered.workRunRef, id: "work_run:opaque-fresh-id-stale-digest", @@ -1669,7 +1740,7 @@ describe("task-to-PR projection v1", () => { expect( validateTaskToPrProjectionTransition(previous, { ...recovered, - id: "task_to_pr_projection:opaque-reused-attempt-canonical-id", + id: projectionId("valid-projection"), attempt: { ...recovered.attempt, ref: { @@ -1683,7 +1754,7 @@ describe("task-to-PR projection v1", () => { expect( validateTaskToPrProjectionTransition(previous, { ...recovered, - id: "task_to_pr_projection:opaque-reused-generation-canonical-id", + id: projectionId("valid-projection"), attempt: { ...recovered.attempt, writerGenerationRef: { @@ -1705,7 +1776,7 @@ describe("task-to-PR projection v1", () => { expect( validateTaskToPrProjectionTransition(previous, { ...recovered, - id: "task_to_pr_projection:opaque-reused-work-run-canonical-id", + id: projectionId("valid-projection"), workRunRef: { ...recovered.workRunRef, id: previous.workRunRef.id, @@ -1724,7 +1795,7 @@ describe("task-to-PR projection v1", () => { expect( validateTaskToPrProjectionTransition(previous, { ...recovered, - id: `task_to_pr_projection:opaque-stale-${field.toLowerCase()}`, + id: projectionId("valid-projection"), attempt: { ...recovered.attempt, [field]: staleRef } }).success ).toBe(false); @@ -1732,7 +1803,7 @@ describe("task-to-PR projection v1", () => { expect( validateTaskToPrProjectionTransition(previous, { ...recovered, - id: "task_to_pr_projection:opaque-reused-runtime-digest", + id: projectionId("valid-projection"), attempt: { ...recovered.attempt, runtimeRef: { @@ -1747,14 +1818,14 @@ describe("task-to-PR projection v1", () => { expect( validateTaskToPrProjectionTransition(previous, { ...recovered, - id: "task_to_pr_projection:opaque-wrong-prior-run", + id: projectionId("valid-projection"), recovery: { ...recovered.recovery, priorWorkRunRef: nextWorkRunRef } }).success ).toBe(false); const handedOff = withActiveProvenance({ ...recovered, - id: "task_to_pr_projection:opaque-handoff-current", + id: projectionId("valid-projection"), state: "handed_off", provenanceLedger: previous.provenanceLedger, recovery: undefined, @@ -1773,7 +1844,7 @@ describe("task-to-PR projection v1", () => { expect( validateTaskToPrProjectionTransition(previous, { ...handedOff, - id: "task_to_pr_projection:opaque-handoff-wrong-prior", + id: projectionId("valid-projection"), handoff: { ...handedOff.handoff, previousAttemptRef: nextAttempt.ref @@ -1783,7 +1854,7 @@ describe("task-to-PR projection v1", () => { const retainedHandoff = withActiveProvenance({ ...handedOff, - id: "task_to_pr_projection:opaque-handoff-retained", + id: projectionId("valid-projection"), events: { ...handedOff.events, replayCursorRef: ref("replay_cursor", "todos", "8"), @@ -1798,7 +1869,7 @@ describe("task-to-PR projection v1", () => { const removedHandoff = withActiveProvenance({ ...retainedHandoff, - id: "task_to_pr_projection:opaque-handoff-removed", + id: projectionId("valid-projection"), state: "running" as const, handoff: undefined }); @@ -1807,7 +1878,7 @@ describe("task-to-PR projection v1", () => { const mutatedHandoff = withActiveProvenance({ ...retainedHandoff, - id: "task_to_pr_projection:opaque-handoff-mutated", + id: projectionId("valid-projection"), handoff: { ...retainedHandoff.handoff, stopEvidenceRef: evidence("ev_handoff_stop_mutated", "a") @@ -1818,7 +1889,7 @@ describe("task-to-PR projection v1", () => { const switchedToRecovery = withActiveProvenance({ ...retainedHandoff, - id: "task_to_pr_projection:opaque-handoff-switched-to-recovery", + id: projectionId("valid-projection"), state: "recovering" as const, handoff: undefined, recovery: { @@ -1832,7 +1903,7 @@ describe("task-to-PR projection v1", () => { const retainedRecovery = withActiveProvenance({ ...recovered, - id: "task_to_pr_projection:opaque-recovery-retained", + id: projectionId("valid-projection"), events: { ...recovered.events, replayCursorRef: ref("replay_cursor", "todos", "b"), @@ -1846,7 +1917,7 @@ describe("task-to-PR projection v1", () => { }); const mutatedRecovery = withActiveProvenance({ ...retainedRecovery, - id: "task_to_pr_projection:opaque-recovery-mutated", + id: projectionId("valid-projection"), recovery: { ...retainedRecovery.recovery!, stopEvidenceRef: evidence("ev_recovery_stop_mutated", "d") @@ -1860,7 +1931,7 @@ describe("task-to-PR projection v1", () => { const projection = validProjection(); const initial = parseProjection({ ...projection, - id: "task_to_pr_projection:opaque-owner-rotation-initial", + id: projectionId("valid-projection"), state: "running", pullRequestRef: undefined, exactHead: undefined, @@ -1919,7 +1990,9 @@ describe("task-to-PR projection v1", () => { const nextAttempt = { ...previous.attempt, ref: ref("attempt", "todos", seeds.attempt), - nonce: `attempt_nonce:opaque-${label}`, + nonce: attemptNonce(`${label}`), + admissionRef: ref("admission", "codewith", `${label}-admission`, "partial"), + workerRef: ref("worker", "codewith", `${label}-worker`, "partial"), runtimeRef: ref("runtime", "codewith", seeds.runtime, "partial"), writerGenerationRef: ref("writer_generation", "todos", seeds.generation), writerLeaseRef: ref("writer_lease", "repos", seeds.lease, "partial"), @@ -1942,7 +2015,7 @@ describe("task-to-PR projection v1", () => { return TaskToPrProjectionSchema.parse(withActiveProvenance({ ...previous, - id: `task_to_pr_projection:opaque-${owner}-${label}`, + id: projectionId("valid-projection"), state: owner === "handoff" ? "handed_off" : "recovering", workRunRef: nextWorkRunRef, attempt: nextAttempt, @@ -2035,13 +2108,13 @@ describe("task-to-PR projection v1", () => { const projection = validProjection(); const initial = parseProjection({ ...projection, - id: "task_to_pr_projection:opaque-cumulative-a", + id: projectionId("valid-projection"), state: "running", workRunRef: ref("work_run", "codewith", "lineage-a"), attempt: { ...projection.attempt, ref: ref("attempt", "todos", "lineage-a"), - nonce: "attempt_nonce:opaque-lineage-a", + nonce: attemptNonce("lineage-a"), runtimeRef: ref("runtime", "codewith", "lineage-a", "partial"), writerGenerationRef: ref("writer_generation", "todos", "lineage-a"), writerLeaseRef: ref("writer_lease", "repos", "lineage-a", "partial"), @@ -2082,7 +2155,9 @@ describe("task-to-PR projection v1", () => { const nextAttempt = { ...previous.attempt, ref: ref("attempt", "todos", `lineage-${label}`), - nonce: `attempt_nonce:opaque-lineage-${label}`, + nonce: attemptNonce(`lineage-${label}`), + admissionRef: ref("admission", "codewith", `lineage-${label}`, "partial"), + workerRef: ref("worker", "codewith", `lineage-${label}`, "partial"), runtimeRef: ref("runtime", "codewith", `lineage-${label}`, "partial"), writerGenerationRef: ref("writer_generation", "todos", `lineage-${label}`), writerLeaseRef: ref("writer_lease", "repos", `lineage-${label}`, "partial"), @@ -2092,7 +2167,7 @@ describe("task-to-PR projection v1", () => { }; return parseProjection({ ...previous, - id: `task_to_pr_projection:opaque-cumulative-${label}`, + id: projectionId("valid-projection"), state: "handed_off", workRunRef: ref("work_run", "codewith", `lineage-${label}`), attempt: nextAttempt, @@ -2131,10 +2206,8 @@ describe("task-to-PR projection v1", () => { issues: [] }); - const projectionIdReuse = structuredClone(current); - projectionIdReuse.id = initial.id; - expect(TaskToPrProjectionSchema.safeParse(projectionIdReuse).success).toBe(true); - expect(validateTaskToPrProjectionTransition(middle, projectionIdReuse).success).toBe(false); + expect(initial.id).toBe(middle.id); + expect(middle.id).toBe(current.id); type RefReuseCase = { category: @@ -2296,7 +2369,7 @@ describe("task-to-PR projection v1", () => { const repairMiddle = parseProjection({ ...initial, - id: "task_to_pr_projection:opaque-cumulative-repair-b", + id: projectionId("valid-projection"), state: "repairing", events: { ...initial.events, @@ -2315,7 +2388,7 @@ describe("task-to-PR projection v1", () => { }); const repairCurrent = parseProjection({ ...repairMiddle, - id: "task_to_pr_projection:opaque-cumulative-repair-c", + id: projectionId("valid-projection"), events: { ...repairMiddle.events, replayCursorRef: ref("replay_cursor", "todos", "repair-c"), @@ -2389,7 +2462,7 @@ describe("task-to-PR projection v1", () => { const base = validProjection(); const previous = parseProjection({ ...base, - id: "task_to_pr_projection:opaque-repair-previous", + id: projectionId("valid-projection"), createdAt: "2026-07-23T15:09:00.000Z", state: "reviewing", cleanup: undefined, @@ -2404,7 +2477,7 @@ describe("task-to-PR projection v1", () => { }); const repairing = parseProjection({ ...previous, - id: "task_to_pr_projection:opaque-repair-current", + id: projectionId("valid-projection"), createdAt, state: "repairing", repair: { @@ -2425,7 +2498,7 @@ describe("task-to-PR projection v1", () => { const reusedRepairStateDigest: TaskToPrProjection = { ...repairing, - id: "task_to_pr_projection:opaque-repair-state-reused-digest", + id: projectionId("valid-projection"), repair: { ...repairing.repair, ref: { @@ -2439,7 +2512,7 @@ describe("task-to-PR projection v1", () => { const reusedLatestRepairDigest: TaskToPrProjection = { ...repairing, - id: "task_to_pr_projection:opaque-latest-repair-reused-digest", + id: projectionId("valid-projection"), repair: { ...repairing.repair, latestRepairRef: { @@ -2466,7 +2539,7 @@ describe("task-to-PR projection v1", () => { const repairStateReusingPriorLatest: TaskToPrProjection = { ...repairing, - id: "task_to_pr_projection:opaque-repair-state-reuses-prior-latest", + id: projectionId("valid-projection"), repair: { ...repairing.repair, ref: { @@ -2479,7 +2552,7 @@ describe("task-to-PR projection v1", () => { const latestRepairReusingPriorState: TaskToPrProjection = { ...repairing, - id: "task_to_pr_projection:opaque-latest-repair-reuses-prior-state", + id: projectionId("valid-projection"), repair: { ...repairing.repair, latestRepairRef: { @@ -2493,7 +2566,7 @@ describe("task-to-PR projection v1", () => { expect( validateTaskToPrProjectionTransition(previous, { ...repairing, - id: "task_to_pr_projection:opaque-repair-stale-ref", + id: projectionId("valid-projection"), repair: { ...repairing.repair, ref: previous.repair.ref @@ -2505,7 +2578,7 @@ describe("task-to-PR projection v1", () => { validateTaskToPrProjectionTransition( parseProjection({ ...previous, - id: "task_to_pr_projection:opaque-running", + id: projectionId("valid-projection"), state: "running", reviews: [], exactHead: undefined, @@ -2513,7 +2586,7 @@ describe("task-to-PR projection v1", () => { }), { ...previous, - id: "task_to_pr_projection:opaque-admitted-again", + id: projectionId("valid-projection"), state: "admitted", reviews: [], exactHead: undefined, @@ -2524,7 +2597,7 @@ describe("task-to-PR projection v1", () => { expect( validateTaskToPrProjectionTransition(base, { ...base, - id: "task_to_pr_projection:opaque-reentered", + id: projectionId("valid-projection"), state: "running", merge: undefined, exactHead: undefined, @@ -2600,13 +2673,13 @@ describe("task-to-PR projection v1", () => { const runningPrevious = parseProjection({ ...reviewOnly, - id: "task_to_pr_projection:opaque-running-before-review", + id: projectionId("valid-projection"), state: "running", reviews: [] }); const runningWithApprovedReview: TaskToPrProjection = { ...runningPrevious, - id: "task_to_pr_projection:opaque-running-with-review", + id: projectionId("valid-projection"), reviews: reviewOnly.reviews }; expect(TaskToPrProjectionSchema.safeParse(runningWithApprovedReview).success).toBe(false); @@ -2617,7 +2690,7 @@ describe("task-to-PR projection v1", () => { const projection = validProjection(); const runningWithPullRequest = parseProjection({ ...projection, - id: "task_to_pr_projection:opaque-running-with-pr", + id: projectionId("valid-projection"), state: "running", exactHead: undefined, reviews: [], @@ -2627,8 +2700,10 @@ describe("task-to-PR projection v1", () => { const phaseDrift = withActiveProvenance({ ...runningWithPullRequest, - id: "task_to_pr_projection:opaque-blocked-with-stale-cursor", - state: "blocked" + id: projectionId("valid-projection"), + state: "blocked", + terminalDispositionRef: ref("terminal_disposition", "todos", "blocked-stale-cursor"), + evidenceRefs: [evidence("blocked_stale_cursor", "1")] }); const phaseDriftResult = validateTaskToPrProjectionTransition(runningWithPullRequest, phaseDrift); expect(phaseDriftResult.success).toBe(false); @@ -2636,7 +2711,7 @@ describe("task-to-PR projection v1", () => { const recoveryProjection = parseProjection({ ...validRecoveryProjection(), - id: "task_to_pr_projection:opaque-recovering-before-identity-drift", + id: projectionId("valid-projection"), state: "recovering", pullRequestRef: undefined, exactHead: undefined, @@ -2652,7 +2727,7 @@ describe("task-to-PR projection v1", () => { }); const recoveryIdentityDrift = withActiveProvenance({ ...recoveryProjection, - id: "task_to_pr_projection:opaque-recovering-after-identity-drift", + id: projectionId("valid-projection"), recovery: { ...recoveryProjection.recovery!, ref: ref("recovery", "todos", "a") @@ -2673,7 +2748,7 @@ describe("task-to-PR projection v1", () => { runningWithPullRequest, withActiveProvenance({ ...runningWithPullRequest, - id: `task_to_pr_projection:opaque-pr-identity-drift-${pullRequestRef ? "replaced" : "removed"}`, + id: projectionId("valid-projection"), events: advancedEvents, pullRequestRef }) @@ -2691,7 +2766,7 @@ describe("task-to-PR projection v1", () => { }; const previous = TaskToPrProjectionSchema.parse(withActiveProvenance({ ...projection, - id: "task_to_pr_projection:opaque-adverse-review-history", + id: projectionId("valid-projection"), state: "reviewing", reviews: [adverseReview], merge: undefined, @@ -2700,6 +2775,7 @@ describe("task-to-PR projection v1", () => { const approvedReview = { ref: ref("review", "review", "3"), pullRequestRef: projection.pullRequestRef, + base: projection.repository.baseHead, head: projection.repository.branchHead, reviewerRef: ref("reviewer", "review", "3"), reviewRunRef: ref("review_run", "review", "4"), @@ -2716,7 +2792,7 @@ describe("task-to-PR projection v1", () => { const appendOnly = TaskToPrProjectionSchema.parse(withActiveProvenance({ ...previous, - id: "task_to_pr_projection:opaque-appended-review-history", + id: projectionId("valid-projection"), events: advancedEvents, reviews: [adverseReview, approvedReview] })); @@ -2724,7 +2800,7 @@ describe("task-to-PR projection v1", () => { const reorderedHistory = parseProjection({ ...appendOnly, - id: "task_to_pr_projection:opaque-reordered-review-history", + id: projectionId("valid-projection"), events: { ...appendOnly.events, replayCursorRef: ref("replay_cursor", "todos", "8"), @@ -2737,7 +2813,7 @@ describe("task-to-PR projection v1", () => { const prependedHistory = parseProjection({ ...appendOnly, - id: "task_to_pr_projection:opaque-prepended-review-history", + id: projectionId("valid-projection"), reviews: [approvedReview, adverseReview] }); expect(validateTaskToPrProjectionTransition(previous, prependedHistory).success).toBe(false); @@ -2746,7 +2822,7 @@ describe("task-to-PR projection v1", () => { previous, withActiveProvenance({ ...previous, - id: "task_to_pr_projection:opaque-rewritten-adverse-review", + id: projectionId("valid-projection"), events: advancedEvents, reviews: [{ ...adverseReview, verdict: "approved" }] }) @@ -2756,7 +2832,7 @@ describe("task-to-PR projection v1", () => { const replacedForEligibility = withActiveProvenance({ ...appendOnly, - id: "task_to_pr_projection:opaque-replaced-adverse-review", + id: projectionId("valid-projection"), state: "merge_ready" as const, reviews: [approvedReview], merge: { @@ -2781,7 +2857,7 @@ describe("task-to-PR projection v1", () => { const secondCiProof = ref("proof_bundle", "review", "3"); const previous = TaskToPrProjectionSchema.parse(withActiveProvenance({ ...projection, - id: "task_to_pr_projection:opaque-exact-head-previous", + id: projectionId("valid-projection"), createdAt: "2026-07-23T15:09:00.000Z", state: "running", reviews: [], @@ -2800,7 +2876,7 @@ describe("task-to-PR projection v1", () => { }; const retained = parseProjection({ ...previous, - id: "task_to_pr_projection:opaque-exact-head-retained", + id: projectionId("valid-projection"), createdAt, events: advancedEvents }); @@ -2808,14 +2884,14 @@ describe("task-to-PR projection v1", () => { const removed = parseProjection({ ...retained, - id: "task_to_pr_projection:opaque-exact-head-removed", + id: projectionId("valid-projection"), exactHead: undefined }); expect(validateTaskToPrProjectionTransition(previous, removed).success).toBe(false); const replacedEqualityProof = TaskToPrProjectionSchema.parse(withActiveProvenance({ ...retained, - id: "task_to_pr_projection:opaque-exact-head-equality-replaced", + id: projectionId("valid-projection"), exactHead: { ...retained.exactHead!, equalityProofRef: ref("proof_bundle", "review", "5") @@ -2825,7 +2901,7 @@ describe("task-to-PR projection v1", () => { const reorderedCiProofs = parseProjection({ ...retained, - id: "task_to_pr_projection:opaque-exact-head-ci-reordered", + id: projectionId("valid-projection"), exactHead: { ...retained.exactHead!, ciProofBundleRefs: [...retained.exactHead!.ciProofBundleRefs].reverse() @@ -2838,7 +2914,7 @@ describe("task-to-PR projection v1", () => { const projection = validProjection(); const previous = parseProjection({ ...projection, - id: "task_to_pr_projection:opaque-cross-head-previous", + id: projectionId("valid-projection"), createdAt: "2026-07-23T15:09:00.000Z", state: "reviewing", merge: undefined, @@ -2847,7 +2923,7 @@ describe("task-to-PR projection v1", () => { const nextHead = head("b"); const freshCrossHead = TaskToPrProjectionSchema.parse(withActiveProvenance({ ...previous, - id: "task_to_pr_projection:opaque-cross-head-fresh", + id: projectionId("valid-projection"), createdAt: "2026-07-23T15:12:00.000Z", repository: { ...previous.repository, @@ -2883,7 +2959,7 @@ describe("task-to-PR projection v1", () => { const reusedEverything: TaskToPrProjection = { ...freshCrossHead, - id: "task_to_pr_projection:opaque-cross-head-replayed", + id: projectionId("valid-projection"), exactHead: { ...freshCrossHead.exactHead!, equalityProofRef: previous.exactHead!.equalityProofRef, @@ -2965,14 +3041,14 @@ describe("task-to-PR projection v1", () => { ]; for (const { label, mutate } of staleIdentityCases) { const candidate = structuredClone(freshCrossHead); - candidate.id = `task_to_pr_projection:opaque-cross-head-stale-${label.replaceAll(" ", "-")}`; + candidate.id = previous.id; mutate(candidate); expect(validateTaskToPrProjectionTransition(previous, candidate).success, label).toBe(false); } const reorderedAndExpanded: TaskToPrProjection = { ...freshCrossHead, - id: "task_to_pr_projection:opaque-cross-head-reordered", + id: projectionId("valid-projection"), exactHead: { ...freshCrossHead.exactHead!, ciProofBundleRefs: [ @@ -3052,7 +3128,7 @@ describe("task-to-PR projection v1", () => { const projection = validProjection(); const present = parseProjection({ ...projection, - id: "task_to_pr_projection:opaque-owner-aba-present", + id: projectionId("valid-projection"), state: "merged", cleanup: { eligibility: projection.cleanup.eligibility @@ -3061,7 +3137,7 @@ describe("task-to-PR projection v1", () => { const absentCursor = ref("replay_cursor", "todos", "3"); const absent = parseProjection({ ...present, - id: "task_to_pr_projection:opaque-owner-aba-absent", + id: projectionId("valid-projection"), cleanup: undefined, events: { ...present.events, @@ -3075,7 +3151,6 @@ describe("task-to-PR projection v1", () => { present.provenanceLedger ); expect(absent.provenanceLedger.slice(present.provenanceLedger.length).map((entry) => entry.category)).toEqual([ - "projection_id", "replay_cursor", "replay_prefix" ]); @@ -3083,7 +3158,7 @@ describe("task-to-PR projection v1", () => { const reactivatedCursor = ref("replay_cursor", "todos", "5"); const reactivated = parseProjection({ ...absent, - id: "task_to_pr_projection:opaque-owner-aba-reactivated", + id: projectionId("valid-projection"), cleanup: { eligibility: { ...present.cleanup!.eligibility, @@ -3103,7 +3178,7 @@ describe("task-to-PR projection v1", () => { const freshRotation = TaskToPrProjectionSchema.parse( withActiveProvenance({ ...absent, - id: "task_to_pr_projection:opaque-owner-aba-fresh", + id: projectionId("valid-projection"), cleanup: { eligibility: { ...present.cleanup!.eligibility, @@ -3135,7 +3210,7 @@ describe("task-to-PR projection v1", () => { cleanup: undefined }); const prefixRedactionMutation = structuredClone(previous); - prefixRedactionMutation.id = "task_to_pr_projection:opaque-prefix-redaction-mutation"; + prefixRedactionMutation.id = previous.id; const handoffIndex = prefixRedactionMutation.provenanceLedger.findIndex( (entry) => entry.category === "handoff" ); @@ -3152,7 +3227,14 @@ describe("task-to-PR projection v1", () => { }; const prefixCandidate = withActiveProvenance(prefixRedactionMutation); expect(TaskToPrProjectionSchema.safeParse(prefixCandidate).success).toBe(true); - expect(validateTaskToPrProjectionTransition(previous, prefixCandidate)).toMatchObject({ + const prefixCandidateWithStableId = { + ...prefixCandidate, + id: previous.id, + provenanceLedger: prefixCandidate.provenanceLedger.filter( + (entry) => entry.category !== "projection_id" || entry.projectionId === previous.id + ) + }; + expect(validateTaskToPrProjectionTransition(previous, prefixCandidateWithStableId)).toMatchObject({ success: false, issues: [ { @@ -3176,15 +3258,22 @@ describe("task-to-PR projection v1", () => { const projection = validProjection(); const reviewed = parseProjection({ ...projection, - id: "task_to_pr_projection:opaque-five-step-reviewed", + id: projectionId("valid-projection"), state: "reviewing", merge: undefined, cleanup: undefined }); const repairing = parseProjection({ ...reviewed, - id: "task_to_pr_projection:opaque-five-step-repairing", + id: projectionId("valid-projection"), state: "repairing", + repair: { + ref: ref("repair_cycle", "todos", "five-step-state"), + cycle: reviewed.repair.cycle + 1, + cap: 2, + exhausted: true, + latestRepairRef: ref("repair_cycle", "todos", "five-step-latest") + }, events: { ...reviewed.events, replayCursorRef: ref("replay_cursor", "todos", "3"), @@ -3200,7 +3289,7 @@ describe("task-to-PR projection v1", () => { const nextHead = head("b"); const evidenceFree = parseProjection({ ...repairing, - id: "task_to_pr_projection:opaque-five-step-evidence-free", + id: projectionId("valid-projection"), state: "running", repository: { ...repairing.repository, @@ -3222,8 +3311,8 @@ describe("task-to-PR projection v1", () => { const stillEvidenceFree = parseProjection({ ...evidenceFree, - id: "task_to_pr_projection:opaque-five-step-still-evidence-free", - state: "repairing", + id: projectionId("valid-projection"), + state: "running", events: { ...evidenceFree.events, replayCursorRef: ref("replay_cursor", "todos", "9"), @@ -3243,7 +3332,7 @@ describe("task-to-PR projection v1", () => { }; const staleFifthSnapshot = { ...stillEvidenceFree, - id: "task_to_pr_projection:opaque-five-step-stale-evidence", + id: projectionId("valid-projection"), state: "reviewing" as const, exactHead: { ...reviewed.exactHead!, @@ -3270,7 +3359,7 @@ describe("task-to-PR projection v1", () => { const projection = validProjection(); const reviewed = parseProjection({ ...projection, - id: "task_to_pr_projection:opaque-reviewed-before-recovery", + id: projectionId("valid-projection"), state: "reviewing", merge: undefined, cleanup: undefined @@ -3286,7 +3375,9 @@ describe("task-to-PR projection v1", () => { const nextAttempt = { ...previous.attempt, ref: ref("attempt", "todos", "a"), - nonce: "attempt_nonce:opaque-review-recovery", + nonce: attemptNonce("review-recovery"), + admissionRef: ref("admission", "codewith", "review-recovery", "partial"), + workerRef: ref("worker", "codewith", "review-recovery", "partial"), runtimeRef: ref("runtime", "codewith", "b", "partial"), writerGenerationRef: ref("writer_generation", "todos", "5"), writerLeaseRef: ref("writer_lease", "repos", "d", "partial"), @@ -3342,7 +3433,7 @@ describe("task-to-PR projection v1", () => { reviewed.exactHead, reviewed.reviews, reviewed.provenanceLedger, - "task_to_pr_projection:opaque-reviewed-recovery-retained" + reviewed.id ); expect(validateTaskToPrProjectionTransition(reviewed, retained)).toEqual({ success: true, @@ -3351,7 +3442,7 @@ describe("task-to-PR projection v1", () => { const preReview = parseProjection({ ...reviewed, - id: "task_to_pr_projection:opaque-pre-review-recovery", + id: projectionId("valid-projection"), state: "running", handoff: undefined, exactHead: undefined, @@ -3363,7 +3454,7 @@ describe("task-to-PR projection v1", () => { reviewed.exactHead, reviewed.reviews, preReview.provenanceLedger, - "task_to_pr_projection:opaque-recovery-invented-reviews" + preReview.id ); expect(TaskToPrProjectionSchema.safeParse(invented).success).toBe(true); expect(validateTaskToPrProjectionTransition(preReview, invented).success).toBe(false); @@ -3373,7 +3464,7 @@ describe("task-to-PR projection v1", () => { const projection = validProjection(); const previous = parseProjection({ ...projection, - id: "task_to_pr_projection:opaque-guard-receipt-previous", + id: projectionId("valid-projection"), state: "merge_ready", merge: { guard: projection.merge.guard @@ -3392,7 +3483,7 @@ describe("task-to-PR projection v1", () => { const changedHead = TaskToPrProjectionSchema.parse( withActiveProvenance({ ...previous, - id: "task_to_pr_projection:opaque-guard-receipt-fresh", + id: projectionId("valid-projection"), createdAt: "2026-07-23T15:12:00.000Z", repository: { ...previous.repository, @@ -3450,7 +3541,7 @@ describe("task-to-PR projection v1", () => { ]) { const stale = { ...changedHead, - id: `task_to_pr_projection:opaque-guard-receipt-stale-${staleReceipt.id.endsWith("-example") ? "id" : "digest"}`, + id: projectionId("valid-projection"), merge: { guard: { ...changedHead.merge!.guard, @@ -3532,7 +3623,7 @@ describe("task-to-PR projection v1", () => { ...recoveryProjection, recovery: { ...recoveryProjection.recovery, - successorAttemptNonce: "attempt_nonce:opaque-different" + successorAttemptNonce: attemptNonce("different") } }).success ).toBe(false); @@ -3637,7 +3728,7 @@ describe("task-to-PR projection v1", () => { const handoffOnly = parseProjection({ ...projection, - id: "task_to_pr_projection:opaque-handoff-only", + id: projectionId("valid-projection"), state: "handed_off", pullRequestRef: undefined, exactHead: undefined, @@ -3648,7 +3739,7 @@ describe("task-to-PR projection v1", () => { }); const recoveryOnly = parseProjection({ ...recoveryProjection, - id: "task_to_pr_projection:opaque-recovery-only", + id: projectionId("valid-projection"), state: "recovering", pullRequestRef: undefined, exactHead: undefined, @@ -3708,7 +3799,7 @@ describe("task-to-PR projection v1", () => { expect( validateTaskToPrProjectionTransition(projection, { ...projection, - id: "task_to_pr_projection:opaque-guard-mutated-after-outcome", + id: projectionId("valid-projection"), merge: { ...projection.merge, guard: { @@ -3721,7 +3812,7 @@ describe("task-to-PR projection v1", () => { expect( validateTaskToPrProjectionTransition(projection, { ...projection, - id: "task_to_pr_projection:opaque-cleanup-gate-mutated-after-outcome", + id: projectionId("valid-projection"), cleanup: { ...projection.cleanup, eligibility: { @@ -3734,7 +3825,7 @@ describe("task-to-PR projection v1", () => { const rolledBack = TaskToPrProjectionSchema.parse(withActiveProvenance({ ...projection, - id: "task_to_pr_projection:opaque-rollback-terminal", + id: projectionId("valid-projection"), state: "rolled_back", cleanup: undefined, rollback: { @@ -3756,7 +3847,7 @@ describe("task-to-PR projection v1", () => { expect( validateTaskToPrProjectionTransition(rolledBack, { ...rolledBack, - id: "task_to_pr_projection:opaque-rollback-plan-mutated-after-outcome", + id: projectionId("valid-projection"), rollback: { ...rolledBack.rollback, plan: { @@ -3956,6 +4047,636 @@ describe("task-to-PR projection v1", () => { expect(validateTaskToPrAdapterCoreEquivalence(local, { ...cloud, mutableProviderPayload: true }).success).toBe(false); }); + test("binds the expected and provider-observed PR base through exact-head, review, guard, and outcome facts", () => { + const projection = validProjection(); + expect("expectedBase" in projection.exactHead).toBe(true); + expect("providerPullRequestBase" in projection.exactHead).toBe(true); + expect("base" in projection.reviews[0]!).toBe(true); + expect("expectedBase" in projection.merge.guard).toBe(true); + expect("expectedBase" in projection.merge.outcome).toBe(true); + expect("observedBase" in projection.merge.outcome).toBe(true); + const otherBase = head("c"); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + exactHead: { + ...projection.exactHead, + providerPullRequestBase: otherBase + } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + reviews: projection.reviews.map((review, index) => + index === 0 ? { ...review, base: otherBase } : review + ) + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + merge: { + ...projection.merge, + guard: { ...projection.merge.guard, expectedBase: otherBase } + } + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + merge: { + ...projection.merge, + outcome: { ...projection.merge.outcome, observedBase: otherBase } + } + }).success + ).toBe(false); + }); + + test("keeps one canonical projection id and rejects identity-only snapshots without owner-event advancement", () => { + const projection = validProjection(); + const previous = parseProjection({ + ...projection, + id: projectionId("valid-projection"), + state: "running", + pullRequestRef: undefined, + exactHead: undefined, + handoff: undefined, + reviews: [], + merge: undefined, + cleanup: undefined, + repair: { + ref: ref("repair_cycle", "todos", "identity-stable"), + cycle: 0, + cap: 2, + exhausted: false + }, + provenanceLedger: [], + evidenceRefs: [] + }); + const identityOnly = parseProjection({ + ...previous, + id: projectionId("identity-rotated"), + }); + expect(validateTaskToPrProjectionTransition(previous, identityOnly).success).toBe(false); + + const eventAdvanced = parseProjection({ + ...previous, + id: projectionId("identity-rotated-with-event"), + events: { + ...previous.events, + replayCursorRef: ref("replay_cursor", "todos", "identity-advanced"), + sequence: previous.events.sequence + 1, + prefixDigest: sha("6") + } + }); + expect(validateTaskToPrProjectionTransition(previous, eventAdvanced).success).toBe(false); + }); + + test("enforces the bidirectional state, merge-guard, and merge-outcome matrix", () => { + const projection = validProjection(); + const cancellation = { + ref: ref("cancellation", "todos", "matrix-cancel"), + cancelledAttemptRef: projection.attempt.ref, + preservedStateRefs: [ + projection.workRunRef, + projection.rootRequestRef, + projection.prGroupRef, + projection.leafTaskRef, + projection.attempt.ref, + projection.repository.repoRef, + projection.repository.worktreeRef, + projection.repository.branchRef, + projection.events.streamRef, + projection.pullRequestRef + ], + evidenceRefs: [evidence("matrix_cancel", "7")] + }; + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + state: "cancelled", + merge: { guard: projection.merge.guard }, + cleanup: undefined, + cancellation + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + state: "failed", + cleanup: undefined, + evidenceRefs: [evidence("matrix_failed", "8")] + }).success + ).toBe(false); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + state: "merge_ready", + merge: { + ...projection.merge, + outcome: projection.merge.outcome + }, + cleanup: undefined + }).success + ).toBe(false); + }); + + test("preserves eligible merge-guard lineage through revocation or consumption", () => { + const terminalProjection = validProjection(); + const previous = parseProjection({ + ...terminalProjection, + state: "merge_ready", + terminalDispositionRef: undefined, + cleanup: undefined, + merge: { + guard: { + ...terminalProjection.merge.guard, + decision: "eligible" + } + }, + provenanceLedger: [] + }); + const nextEvents = (seed: string) => ({ + ...previous.events, + replayCursorRef: ref("replay_cursor", "todos", `guard-lineage-${seed}`), + sequence: previous.events.sequence + 1, + prefixDigest: createHash("sha256").update(`guard-lineage-${seed}`, "utf8").digest("hex") + }); + const terminalDispositionRef = ref("terminal_disposition", "todos", "guard-lineage-terminal"); + const consumedGuard = { + ...previous.merge!.guard, + ref: ref("merge_guard", "todos", "guard-lineage-consumed"), + decision: "consumed" as const + }; + const merged = parseProjection({ + ...previous, + state: "merged", + events: nextEvents("consumed"), + terminalDispositionRef, + merge: { + guard: consumedGuard, + outcome: { + ...terminalProjection.merge.outcome, + guardRef: consumedGuard.ref + } + }, + provenanceLedger: previous.provenanceLedger + }); + expect(validateTaskToPrProjectionTransition(previous, merged)).toEqual({ + success: true, + issues: [] + }); + + const unrelatedGuard = { + ...consumedGuard, + ref: ref("merge_guard", "todos", "guard-lineage-unrelated"), + operatorRef: ref("merge_operator", "merge_provider", "guard-lineage-unrelated", "partial") + }; + const unrelatedMerged = parseProjection({ + ...merged, + events: nextEvents("unrelated"), + merge: { + guard: unrelatedGuard, + outcome: { + ...merged.merge!.outcome!, + guardRef: unrelatedGuard.ref + } + }, + provenanceLedger: previous.provenanceLedger + }); + expect(validateTaskToPrProjectionTransition(previous, unrelatedMerged).success).toBe(false); + + const failedWithoutRevocation = parseProjection({ + ...previous, + state: "failed", + events: nextEvents("dropped"), + terminalDispositionRef, + merge: undefined, + provenanceLedger: previous.provenanceLedger, + evidenceRefs: [evidence("guard_lineage_dropped", "4")] + }); + expect(validateTaskToPrProjectionTransition(previous, failedWithoutRevocation).success).toBe(false); + + const revokedGuard = { + ...previous.merge!.guard, + ref: ref("merge_guard", "todos", "guard-lineage-revoked"), + decision: "revoked" as const + }; + const failedWithRevocation = parseProjection({ + ...previous, + state: "failed", + events: nextEvents("revoked"), + terminalDispositionRef, + merge: { guard: revokedGuard }, + provenanceLedger: previous.provenanceLedger, + evidenceRefs: [evidence("guard_lineage_revoked", "5")] + }); + expect(validateTaskToPrProjectionTransition(previous, failedWithRevocation)).toEqual({ + success: true, + issues: [] + }); + }); + + test("treats failed and blocked dispositions as terminal rather than advertising impossible recovery edges", () => { + const terminalProjection = validProjection(); + const failed = parseProjection({ + ...terminalProjection, + state: "failed", + pullRequestRef: undefined, + exactHead: undefined, + handoff: undefined, + reviews: [], + merge: undefined, + cleanup: undefined, + repair: { + ref: ref("repair_cycle", "todos", "terminal-recovery"), + cycle: 0, + cap: 2, + exhausted: false + }, + terminalDispositionRef: ref("terminal_disposition", "todos", "terminal-recovery"), + provenanceLedger: [], + evidenceRefs: [evidence("terminal_recovery_failed", "6")] + }); + const nextAttempt = { + ...failed.attempt, + ref: ref("attempt", "todos", "terminal-recovery-next"), + nonce: attemptNonce("terminal-recovery-next"), + admissionRef: ref("admission", "codewith", "terminal-recovery-next", "partial"), + workerRef: ref("worker", "codewith", "terminal-recovery-next", "partial"), + runtimeRef: ref("runtime", "codewith", "terminal-recovery-next", "partial"), + writerGenerationRef: ref("writer_generation", "todos", "terminal-recovery-next"), + writerLeaseRef: ref("writer_lease", "repos", "terminal-recovery-next", "partial"), + writerFenceRef: ref("writer_fence", "repos", "terminal-recovery-next", "full"), + providerProfileRef: ref("provider_profile", "codewith", "terminal-recovery-next", "full"), + providerRouteRef: ref("provider_route", "codewith", "terminal-recovery-next", "partial") + }; + const recovering = parseProjection({ + ...failed, + state: "recovering", + workRunRef: ref("work_run", "codewith", "terminal-recovery-next"), + attempt: nextAttempt, + events: { + ...failed.events, + replayCursorRef: ref("replay_cursor", "todos", "terminal-recovery-next"), + sequence: failed.events.sequence + 1, + prefixDigest: createHash("sha256").update("terminal-recovery-next", "utf8").digest("hex") + }, + recovery: { + ref: ref("recovery", "todos", "terminal-recovery-next"), + priorAttemptRef: failed.attempt.ref, + priorWriterGenerationRef: failed.attempt.writerGenerationRef, + priorWorkRunRef: failed.workRunRef, + successorAttemptNonce: nextAttempt.nonce, + successorWriterGenerationRef: nextAttempt.writerGenerationRef, + preservedStateRefs: [ + failed.workRunRef, + failed.rootRequestRef, + failed.prGroupRef, + failed.leafTaskRef, + failed.repository.repoRef, + failed.repository.worktreeRef, + failed.repository.branchRef, + failed.events.streamRef + ], + stopEvidenceRef: evidence("terminal_recovery_stop", "7"), + leaseRevocationEvidenceRef: evidence("terminal_recovery_revoke", "8") + }, + terminalDispositionRef: undefined, + provenanceLedger: failed.provenanceLedger, + evidenceRefs: [] + }); + const result = validateTaskToPrProjectionTransition(failed, recovering); + expect(result.success).toBe(false); + expect(result.issues.some((issue) => issue.path === "state")).toBe(true); + }); + + test("requires terminal owner, lease-revocation, and consumed-event facts before deleted cleanup", () => { + const projection = validProjection(); + const previous = parseProjection({ + ...projection, + id: projectionId("valid-projection"), + state: "running", + pullRequestRef: undefined, + exactHead: undefined, + handoff: undefined, + reviews: [], + merge: undefined, + cleanup: undefined, + repair: { + ref: ref("repair_cycle", "todos", "cleanup-running"), + cycle: 0, + cap: 2, + exhausted: false + }, + provenanceLedger: [], + evidenceRefs: [] + }); + const deleted = withActiveProvenance({ + ...previous, + id: projectionId("valid-projection"), + state: "cleanup_complete", + events: { + ...previous.events, + replayCursorRef: ref("replay_cursor", "todos", "cleanup-consumed"), + sequence: previous.events.sequence + 1, + prefixDigest: sha("9") + }, + cleanup: { + eligibility: { + ref: ref("cleanup_eligibility", "repos", "cleanup-gate"), + status: "eligible", + targetWorktreeRef: previous.repository.worktreeRef, + eventCursorRef: ref("replay_cursor", "todos", "cleanup-consumed"), + evaluatedAt: createdAt, + evidenceRefs: [evidence("cleanup_gate_without_terminal", "a")] + }, + outcome: { + ref: ref("cleanup_outcome", "repos", "cleanup-deleted"), + eligibilityRef: ref("cleanup_eligibility", "repos", "cleanup-gate"), + targetWorktreeRef: previous.repository.worktreeRef, + status: "deleted", + finishedAt: createdAt, + evidenceRefs: [evidence("cleanup_deleted_without_terminal", "b")] + } + } + }); + expect(TaskToPrProjectionSchema.safeParse(deleted).success).toBe(false); + }); + + test("consumes exactly one repair cycle on entry and freezes repair state after terminal disposition", () => { + const projection = validProjection(); + const exhausted = parseProjection({ + ...projection, + id: projectionId("valid-projection"), + state: "running", + pullRequestRef: undefined, + exactHead: undefined, + handoff: undefined, + reviews: [], + merge: undefined, + cleanup: undefined, + repair: { + ref: ref("repair_cycle", "todos", "repair-exhausted-state"), + cycle: 2, + cap: 2, + exhausted: true, + latestRepairRef: ref("repair_cycle", "todos", "repair-exhausted-latest") + }, + provenanceLedger: [], + evidenceRefs: [] + }); + const reentered = parseProjection({ + ...exhausted, + id: projectionId("valid-projection"), + state: "repairing", + events: { + ...exhausted.events, + replayCursorRef: ref("replay_cursor", "todos", "repair-reentry"), + sequence: exhausted.events.sequence + 1, + prefixDigest: sha("a") + } + }); + expect(validateTaskToPrProjectionTransition(exhausted, reentered).success).toBe(false); + + const terminal = parseProjection({ + ...projection, + id: projectionId("valid-projection"), + state: "merged", + cleanup: undefined + }); + const terminalRepairMutation = parseProjection({ + ...terminal, + id: projectionId("valid-projection"), + events: { + ...terminal.events, + replayCursorRef: ref("replay_cursor", "todos", "repair-terminal"), + sequence: terminal.events.sequence + 1, + prefixDigest: sha("b") + }, + repair: { + ref: ref("repair_cycle", "todos", "repair-terminal-state"), + cycle: terminal.repair.cycle + 1, + cap: 2, + exhausted: true, + latestRepairRef: ref("repair_cycle", "todos", "repair-terminal-latest") + } + }); + expect(validateTaskToPrProjectionTransition(terminal, terminalRepairMutation).success).toBe(false); + }); + + test("requires fresh admission and worker-assignment refs on every successor attempt", () => { + const projection = validProjection(); + const previous = parseProjection({ + ...projection, + id: projectionId("valid-projection"), + state: "running", + pullRequestRef: undefined, + exactHead: undefined, + handoff: undefined, + reviews: [], + merge: undefined, + cleanup: undefined, + repair: { + ref: ref("repair_cycle", "todos", "attempt-previous"), + cycle: 0, + cap: 2, + exhausted: false + }, + provenanceLedger: [], + evidenceRefs: [] + }); + const nextAttempt = { + ...previous.attempt, + ref: ref("attempt", "todos", "attempt-next"), + nonce: attemptNonce("attempt-next"), + runtimeRef: ref("runtime", "codewith", "attempt-next", "partial"), + writerGenerationRef: ref("writer_generation", "todos", "attempt-next"), + writerLeaseRef: ref("writer_lease", "repos", "attempt-next", "partial"), + writerFenceRef: ref("writer_fence", "repos", "attempt-next", "full"), + providerProfileRef: ref("provider_profile", "codewith", "attempt-next", "full"), + providerRouteRef: ref("provider_route", "codewith", "attempt-next", "partial") + }; + const successor = parseProjection({ + ...previous, + id: projectionId("valid-projection"), + state: "recovering", + workRunRef: ref("work_run", "codewith", "attempt-next"), + attempt: nextAttempt, + events: { + ...previous.events, + replayCursorRef: ref("replay_cursor", "todos", "attempt-next"), + sequence: previous.events.sequence + 1, + prefixDigest: sha("c") + }, + recovery: { + ref: ref("recovery", "todos", "attempt-next"), + priorAttemptRef: previous.attempt.ref, + priorWriterGenerationRef: previous.attempt.writerGenerationRef, + priorWorkRunRef: previous.workRunRef, + successorAttemptNonce: nextAttempt.nonce, + successorWriterGenerationRef: nextAttempt.writerGenerationRef, + preservedStateRefs: [ + previous.workRunRef, + previous.rootRequestRef, + previous.prGroupRef, + previous.leafTaskRef, + previous.repository.repoRef, + previous.repository.worktreeRef, + previous.repository.branchRef, + previous.events.streamRef + ], + stopEvidenceRef: evidence("attempt_next_stopped", "d"), + leaseRevocationEvidenceRef: evidence("attempt_next_revoked", "e") + } + }); + expect(validateTaskToPrProjectionTransition(previous, successor).success).toBe(false); + }); + + test("versions canonical identity and binds branch plus worktree while retaining the v1 derivation path", () => { + const projection = validProjection(); + const original = deriveTaskToPrIdentityDigest({ + canonicalizationVersion: 2, + rootRequestRef: projection.rootRequestRef, + prGroupRef: projection.prGroupRef, + leafTaskRef: projection.leafTaskRef, + repoRef: projection.repository.repoRef, + worktreeRef: projection.repository.worktreeRef, + branchRef: projection.repository.branchRef, + baseHead: projection.repository.baseHead, + frozenScopeDigest: projection.frozenScopeDigest + }); + const aliased = deriveTaskToPrIdentityDigest({ + canonicalizationVersion: 2, + rootRequestRef: projection.rootRequestRef, + prGroupRef: projection.prGroupRef, + leafTaskRef: projection.leafTaskRef, + repoRef: projection.repository.repoRef, + worktreeRef: ref("worktree", "repos", "identity-alias", "partial"), + branchRef: ref("branch", "repos", "identity-alias"), + baseHead: projection.repository.baseHead, + frozenScopeDigest: projection.frozenScopeDigest + }); + expect(aliased).not.toBe(original); + + const legacyIdentityDigest = deriveTaskToPrIdentityDigest({ + canonicalizationVersion: 1, + rootRequestRef: projection.rootRequestRef, + prGroupRef: projection.prGroupRef, + leafTaskRef: projection.leafTaskRef, + repoRef: projection.repository.repoRef, + baseHead: projection.repository.baseHead, + frozenScopeDigest: projection.frozenScopeDigest + }); + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + canonicalizationVersion: 1, + identityDigest: legacyIdentityDigest + }).success + ).toBe(true); + }); + + test("reserves the v1 adapter-extension namespace independently of unrelated schema registration", () => { + const projection = validProjection(); + expect( + TaskToPrAdapterExtensionSchema.safeParse({ + ...projection.adapterExtensions[0], + schema: "hasna.unrelated_future_contract.v1" + }).success + ).toBe(false); + expect(TaskToPrAdapterExtensionSchema.safeParse(projection.adapterExtensions[0]).success).toBe(true); + }); + + test("rejects duplicate and conflicting same-role recovery or cancellation preservation refs", () => { + const recoveryProjection = validRecoveryProjection(); + expect( + TaskToPrProjectionSchema.safeParse({ + ...recoveryProjection, + recovery: { + ...recoveryProjection.recovery, + preservedStateRefs: [ + ...recoveryProjection.recovery.preservedStateRefs, + ref("root_request", "todos", "conflicting-root") + ] + } + }).success + ).toBe(false); + + const projection = validProjection(); + const cancellation = { + ref: ref("cancellation", "todos", "duplicate-preservation"), + cancelledAttemptRef: projection.attempt.ref, + preservedStateRefs: [ + projection.workRunRef, + projection.rootRequestRef, + projection.rootRequestRef, + projection.prGroupRef, + projection.leafTaskRef, + projection.attempt.ref, + projection.repository.repoRef, + projection.repository.worktreeRef, + projection.repository.branchRef, + projection.events.streamRef, + projection.pullRequestRef + ], + evidenceRefs: [evidence("duplicate_preservation", "f")] + }; + expect( + TaskToPrProjectionSchema.safeParse({ + ...projection, + state: "cancelled", + merge: undefined, + exactHead: undefined, + reviews: [], + cleanup: undefined, + cancellation + }).success + ).toBe(false); + }); + + test("requires distinct stop and lease-revocation evidence identities", () => { + const projection = validProjection(); + expect( + TaskToPrHandoffSchema.safeParse({ + ...projection.handoff, + leaseRevocationEvidenceRef: projection.handoff.stopEvidenceRef + }).success + ).toBe(false); + const recoveryProjection = validRecoveryProjection(); + expect( + TaskToPrRecoverySchema.safeParse({ + ...recoveryProjection.recovery, + leaseRevocationEvidenceRef: recoveryProjection.recovery.stopEvidenceRef + }).success + ).toBe(false); + }); + + test("rejects event cursor sequences beyond Number.MAX_SAFE_INTEGER", () => { + const projection = validProjection(); + expect( + TaskToPrProjectionSchema.safeParse( + withActiveProvenance({ + ...projection, + events: { + ...projection.events, + sequence: Number.MAX_SAFE_INTEGER + 1 + } + }) + ).success + ).toBe(false); + }); + + test("requires structurally nonsemantic owner-resolvable reference ids", () => { + expect( + TaskToPrRefSchema.safeParse({ + ...ref("root_request", "todos", "semantic"), + id: "root_request:opaque-project-alpha-customer-42" + }).success + ).toBe(false); + }); + test("preserves v1 WorkRun parsing and refuses silent v1 widening", () => { const legacyWorkRun = { schema: "hasna.work_run.v1", From d161b0e9ee5ecac4a26929c846da2a8caa545641 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Fri, 24 Jul 2026 10:56:12 +0300 Subject: [PATCH 3/3] fix: bind admission to writer generation --- README.md | 8 +++-- examples/task-to-pr-projection.valid.json | 7 ++++ src/schemas.ts | 12 +++++++ tests/task-to-pr-projection.test.ts | 40 +++++++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 331b41e..6d54d94 100644 --- a/README.md +++ b/README.md @@ -435,7 +435,7 @@ payload. | `workRunRef` | exactly 1 | Codewith; points to the existing `WorkRun` receipt | | `rootRequestRef` / `prGroupRef` / `leafTaskRef` | exactly 1 each | Todos; immutable canonical identities | | `attempt.ref` / `attempt.nonce` | exactly 1 each | Todos attempt identity; every retry uses a fresh nonce | -| admission / worker actor / worker assignment / runtime refs | exactly 1 each | distinct redacted Codewith owner records; every successor attempt rotates admission and assignment in addition to the attempt-scoped runtime identity | +| admission / worker actor / worker assignment / runtime refs | exactly 1 each | distinct redacted Codewith owner records; admission carries an exact writer-generation binding, and every successor attempt rotates admission and assignment in addition to the attempt-scoped runtime identity | | writer generation / lease / fence refs | exactly 1 each | generation is Todos-bound; lease/fence are redacted Repos refs, never a fence value | | provider profile / route refs | exactly 1 each | redacted opaque Codewith refs; never provider account or credential data | | repo / worktree / branch plus base and branch heads | exactly 1 binding | canonical Repos refs with exact Git object ids | @@ -487,7 +487,11 @@ snapshot or carry merge-guard review refs. Same-head reviews can only be appende prefix; reordering or prepending invalidates the transition. This schema has no review supersession field, so producers must advance the reviewed head before emitting a replacement review set. `merge_ready` is the only state that can carry an -`eligible` guard. A terminal merge outcome requires the same guard authority +`eligible` guard. Eligible and consumed guards require +`attempt.admissionWriterGenerationRef` to exactly equal the current +`attempt.writerGenerationRef`, so a stale admission cannot survive writer +generation rollover into merge readiness or a terminal merge outcome. A +terminal merge outcome requires the same guard authority facts in `consumed` state; because guard owner records are immutable, consumption or revocation uses a fresh guard ref/digest while preserving the eligible guard's exact PR, base/head, review/proof, operator, provider-receipt, and mechanism diff --git a/examples/task-to-pr-projection.valid.json b/examples/task-to-pr-projection.valid.json index 4177ef6..18911d1 100644 --- a/examples/task-to-pr-projection.valid.json +++ b/examples/task-to-pr-projection.valid.json @@ -50,6 +50,13 @@ "digest": "1313131313131313131313131313131313131313131313131313131313131313", "redaction": "partial" }, + "admissionWriterGenerationRef": { + "role": "writer_generation", + "authority": "todos", + "id": "writer_generation:todos:opaque-88888888888888888888888888888888", + "digest": "8888888888888888888888888888888888888888888888888888888888888888", + "redaction": "none" + }, "workerActorRef": { "role": "worker_actor", "authority": "codewith", diff --git a/src/schemas.ts b/src/schemas.ts index 455f547..5308d67 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -2403,6 +2403,7 @@ export const TaskToPrAttemptSchema = z ref: taskToPrRefFor("attempt"), nonce: TaskToPrAttemptNonceSchema, admissionRef: taskToPrRefFor("admission"), + admissionWriterGenerationRef: taskToPrRefFor("writer_generation"), workerActorRef: taskToPrRefFor("worker_actor"), workerRef: taskToPrRefFor("worker"), runtimeRef: taskToPrRefFor("runtime"), @@ -4232,6 +4233,17 @@ export const TaskToPrProjectionSchema = z if (value.state === "repairing" && value.repair.cycle === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Repairing projections require a non-zero repair cycle", path: ["repair", "cycle"] }); } + if ( + value.merge && + (value.merge.guard.decision === "eligible" || value.merge.guard.decision === "consumed") && + !sameTaskToPrRef(value.attempt.admissionWriterGenerationRef, value.attempt.writerGenerationRef) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merge eligibility requires admission from the current writer generation", + path: ["attempt", "admissionWriterGenerationRef"] + }); + } if (value.state === "merge_ready" && value.merge?.guard.decision !== "eligible") { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Merge-ready projections require an eligible guard", path: ["merge"] }); } diff --git a/tests/task-to-pr-projection.test.ts b/tests/task-to-pr-projection.test.ts index ae27248..5264e92 100644 --- a/tests/task-to-pr-projection.test.ts +++ b/tests/task-to-pr-projection.test.ts @@ -222,6 +222,7 @@ function validProjection() { ref: currentAttempt, nonce: attemptNonce("valid-attempt"), admissionRef: ref("admission", "codewith", "1", "partial"), + admissionWriterGenerationRef: currentGeneration, workerActorRef: ref("worker_actor", "codewith", "stable-worker", "partial"), workerRef: ref("worker", "codewith", "2", "partial"), runtimeRef: ref("runtime", "codewith", "3", "partial"), @@ -1644,6 +1645,7 @@ describe("task-to-PR projection v1", () => { ref: ref("attempt", "todos", "a"), nonce: attemptNonce("next"), admissionRef: ref("admission", "codewith", "next", "partial"), + admissionWriterGenerationRef: ref("writer_generation", "todos", "5"), workerRef: ref("worker", "codewith", "next", "partial"), runtimeRef: ref("runtime", "codewith", "b", "partial"), writerGenerationRef: ref("writer_generation", "todos", "5"), @@ -1992,6 +1994,7 @@ describe("task-to-PR projection v1", () => { ref: ref("attempt", "todos", seeds.attempt), nonce: attemptNonce(`${label}`), admissionRef: ref("admission", "codewith", `${label}-admission`, "partial"), + admissionWriterGenerationRef: ref("writer_generation", "todos", seeds.generation), workerRef: ref("worker", "codewith", `${label}-worker`, "partial"), runtimeRef: ref("runtime", "codewith", seeds.runtime, "partial"), writerGenerationRef: ref("writer_generation", "todos", seeds.generation), @@ -2157,6 +2160,7 @@ describe("task-to-PR projection v1", () => { ref: ref("attempt", "todos", `lineage-${label}`), nonce: attemptNonce(`lineage-${label}`), admissionRef: ref("admission", "codewith", `lineage-${label}`, "partial"), + admissionWriterGenerationRef: ref("writer_generation", "todos", `lineage-${label}`), workerRef: ref("worker", "codewith", `lineage-${label}`, "partial"), runtimeRef: ref("runtime", "codewith", `lineage-${label}`, "partial"), writerGenerationRef: ref("writer_generation", "todos", `lineage-${label}`), @@ -3377,6 +3381,7 @@ describe("task-to-PR projection v1", () => { ref: ref("attempt", "todos", "a"), nonce: attemptNonce("review-recovery"), admissionRef: ref("admission", "codewith", "review-recovery", "partial"), + admissionWriterGenerationRef: ref("writer_generation", "todos", "5"), workerRef: ref("worker", "codewith", "review-recovery", "partial"), runtimeRef: ref("runtime", "codewith", "b", "partial"), writerGenerationRef: ref("writer_generation", "todos", "5"), @@ -4182,6 +4187,40 @@ describe("task-to-PR projection v1", () => { ).toBe(false); }); + test("rejects merge readiness and consumed outcomes when admission predates the writer generation", () => { + const projection = validProjection(); + const staleAdmissionAfterRollover = withActiveProvenance({ + ...projection, + state: "merge_ready" as const, + attempt: { + ...projection.attempt, + writerGenerationRef: ref("writer_generation", "todos", "rolled-over") + }, + handoff: undefined, + merge: { + guard: { + ...projection.merge.guard, + decision: "eligible" as const + } + }, + terminalDispositionRef: undefined, + cleanup: undefined, + provenanceLedger: [] + }); + + expect(TaskToPrProjectionSchema.safeParse(staleAdmissionAfterRollover).success).toBe(false); + + const staleAdmissionAtConsumedOutcome = withActiveProvenance({ + ...projection, + attempt: { + ...projection.attempt, + writerGenerationRef: ref("writer_generation", "todos", "consumed-rollover") + }, + provenanceLedger: [] + }); + expect(TaskToPrProjectionSchema.safeParse(staleAdmissionAtConsumedOutcome).success).toBe(false); + }); + test("preserves eligible merge-guard lineage through revocation or consumption", () => { const terminalProjection = validProjection(); const previous = parseProjection({ @@ -4304,6 +4343,7 @@ describe("task-to-PR projection v1", () => { ref: ref("attempt", "todos", "terminal-recovery-next"), nonce: attemptNonce("terminal-recovery-next"), admissionRef: ref("admission", "codewith", "terminal-recovery-next", "partial"), + admissionWriterGenerationRef: ref("writer_generation", "todos", "terminal-recovery-next"), workerRef: ref("worker", "codewith", "terminal-recovery-next", "partial"), runtimeRef: ref("runtime", "codewith", "terminal-recovery-next", "partial"), writerGenerationRef: ref("writer_generation", "todos", "terminal-recovery-next"),