Skip to content

feat(gateway): v3 evidence-signing Step-6 — async split + final-package (audited; gate CLOSED; HOLD for auth-P0) - #270

Draft
LamaSu wants to merge 66 commits into
masterfrom
feat/v3-evidence-signing
Draft

feat(gateway): v3 evidence-signing Step-6 — async split + final-package (audited; gate CLOSED; HOLD for auth-P0)#270
LamaSu wants to merge 66 commits into
masterfrom
feat/v3-evidence-signing

Conversation

@LamaSu

@LamaSu LamaSu commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Step-6 of the frozen §8.5 evidence-signing ladder — async split + final-package

Reconciled onto current master (merge 27148f1f; 61 evidence-signing commits + master, 0 behind).

What this is: beginJob / submitCheckpoint / finalizeMilestone + FinalMilestonePackage (claim-free, content-addressed) + per-checkpoint GatewayReceipts, with the transaction-bound checkpoint-acceptance guard (session-open ∧ session.jobId==jobId ∧ job-collectable ∧ terminal open→terminal_* CAS, all atomic with the receipt+body insert).

Verification (lane-isolated clean checkout at the reconciled tip 27148f1f):

  • pnpm install --frozen-lockfile → 0 · pnpm build → 0 (53 tasks)
  • @pcc/gateway167 files, 2743 passed / 6 skipped / 0 failed
  • @pcc/store17 files, 227 passed / 0 failed
  • (The prior "9 pre-existing failures" were shared-Spark-directory contamination, not branch defects — see the audit package.)

Boundary status: settlement gate CLOSED; SEAM-2 not opened; no O5 / contract redeploy; achievedTier/Step-10 not in scope here.

⚠️ DO NOT MERGE YET — held as DRAFT. Per owner directive, the auth-P0 fix (fix/audit-p0: /api/auth/provision fails-open, mints scopes:['*']) must be reviewed, merged, deployed, and verified live first — it is upstream of the entire money path. This PR merges only after that is confirmed, because merge-to-master triggers a staging deploy.

Audit trail: pcc-audit-package/PCC-v3-round7-reaudit-for-chatgpt.md (7 rounds + residual-P1 + guard hardening) · audit/CLEAN-isolated-fullsuite-3ae90404.txt.

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com

LamaSu and others added 30 commits July 13, 2026 01:50
…edTier>=1 (§8.5-1)

resolveSettlementEvidence: with the gate OPEN, a missing/failed device bundle at
purchased tier >= 1 now returns source:"hold" instead of silently downgrading onto
the gateway placeholder (§7.3-3, §8.4-C). Tier 0 keeps the buyer-approved
gateway-fallback; the gate-CLOSED default path is byte-identical (tier-agnostic).
Adds requestedTier to SettlementEvidenceInput and "hold" to the decision.source union.
…estedTier (§8.5-1)

Passes requestedTier=jobAssuranceTier into resolveSettlementEvidence and, when it
returns source:"hold", short-circuits BEFORE any anchor/store/oracle/EAS/settle work:
records job status 'settlement_hold', emits telemetry, and returns the same response
shape as the settled path with status:"hold" and null anchor fields. Money holds in
escrow (no release, no refund). Inert today (gate closed by default); Mode-C/D dispute
wiring is a later owner-gated step.
…+ requestedTier (§8.5-1)

Adds 5 cases: (a) open+verify-fail+tier>=1 -> hold; (b) open+verify-fail+tier0 ->
gateway-fallback; (c) open+no-bundle+tier>=1 -> hold; (d) CLOSED+tier>=1 ->
gateway-fallback/gate-closed (default money path unchanged); (e) open+verify-ok+tier>=1
-> device. Threads requestedTier through the 5 pre-existing resolveSettlementEvidence
call sites (tier 0 on the fallback-expecting cases, preserving their outcomes).
…lement, keyed to effectiveEvidenceTime (§8.5-2)

Shared in-memory session-revocation store (sessionId -> revokedAt, Unix seconds)
replaces the module-private Set in identity-session.ts so the settlement verify
path can consult time-keyed revocation state. verifyDeviceSignedEvidence /
resolveSettlementEvidence gain optional revocations + effectiveEvidenceTime; the
session-key branch disqualifies a session iff revokedAt < effectiveEvidenceTime
(strict, per frozen §7.3-4 / §8.4-A-6 'before' / 'prior evidence stands'), passed
as revokedSessionIds to the existing verifier (no verifier change). paid-job-flow
reads the store + a PROVISIONAL submission-time evidence value inside the (closed
by default) SEAM-2 gate, so the live money path is unchanged. effectiveEvidenceTime
is provisional pending §8.5 step 4.
…ent revocation cases (§8.5-2)

New session-revocation-store.test.ts (time-keyed records, earliest-wins, Set/list
read shapes, seconds unit). device-evidence-settlement.test.ts gains 10 cases: the
session-key branch fails 'session_revoked' when revoked before effectiveEvidenceTime,
stands when revoked at/after (strict '<' boundary tested), a different session or no
revocations is unaffected, and the direct registered-signer path ignores revocation;
resolveSettlementEvidence maps these to hold (tier>=1) / device (revoked after) /
gateway-fallback (tier0) / gate-closed. Spark: 148 files, 2361 passed, tsc clean.
…ate + maxSignatures enforcement (§8.5-3)

Add checkSequence(entry, priorState) implementing §8.4-A step 5 exactly:
seq===lastSeq+1 (seq_gap_or_replay) ∧ seq<=maxSignatures (max_signatures_exceeded)
∧ prevHash===lastHash (chain_broken) ∧ !seen.has(hash) (duplicate_hash). This is
the first enforcement of maxSignatures, which SessionScope carried but no code
checked. genesisSequenceState() pins the first accepted checkpoint to seq===1 ∧
prevHash===null. Pure/stateless (advancement lives in the gateway store); additive,
does not touch SessionKey shapes or verifySessionSignedEvent. Exported from @pcc/verifier.
… maxSignatures + genesis (§8.5-3)

12 cases: genesis (seq1/prevHash=null accept; seq0/seq2/non-null-prev reject),
threaded 1->2->3 chain, replay + gap (seq_gap_or_replay), chain_broken,
duplicate_hash at fresh seq, maxSignatures inclusive-ceiling accept + exceed reject.
…cceptance store (§8.5-3)

SessionSequenceStore.accept(sessionId, entry): synchronous read->checkSequence->advance
with NO await between = the single-writer critical section (§8.3 'acceptance serialized
per session', single-process). Advances state only on accept; a rejected submission never
creates/poisons session state. Enforces maxSignatures via the verifier predicate. In-memory
single-process; durable/multi-instance is the Gate-5 (§8.6) concern, mirrors SessionRevocationStore.
Not yet wired into settlement (step 6). Exports sessionSequenceStore singleton.
…l. single-writer advance (§8.5-3)

11 cases: valid chain via accept(); single-writer (accept(seq=1) then accept(seq=1)
rejected, tip unchanged); gap; maxSignatures exceed; chain_broken; duplicate_hash;
genesis rejections don't create state; per-session isolation; reset/clear; singleton.
…ew flag + harmonized revocation fixtures (§8.5-4)
…e on delegated path (§8.5-4)

Audit round-2 fix #3. verifyDeviceSignedEvidence no longer defaults
effectiveEvidenceTime to Date.now() on the session-key branch: a missing time
on the delegated live path now returns { ok:false,
reason:"missing-effective-evidence-time" } (fail-closed) instead of fabricating
settlement wall-clock (a since-expired/revoked delegation could otherwise verify
as if fresh). The `?? now` fallback is retained ONLY for the non-delegated
direct-Ed25519 path, where nothing is time-judged. Two delegated-path unit
fixtures now supply the required time; +2 guard tests. 51/51 green on Spark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
… silent placeholder downgrade (§8.5-1)

Audit round-2 fix #1. resolveSettlementEvidence previously returned the gateway
fallback for a CLOSED gate regardless of tier, letting a PURCHASED tier >= 1 job
settle on the zero-address "gateway-auto-sign" placeholder while the required
verifier is unavailable (fail-open wrt purchased assurance). Correct: tier 0 keeps
the buyer-approved fallback; tier >= 1 HOLDS (source:"hold",
reason:"required-verifier-unavailable") — fail-closed, funds stay in escrow.

Tests (RULE 18 — rewrite to the frozen design):
- device-evidence-settlement.test.ts: the two gate-closed+tier-2 tests that
  asserted gateway-auto-sign now assert HOLD; added tier-0 closed-gate coverage;
  the revocation-inert closed-gate test now asserts HOLD (its reason proves
  revocation was never consulted); re-scoped the "regardless of tier" wiring tests
  as CRYPTO-ROUTING (signature valid != requested assurance satisfied; achieved-tier
  derivation is §8.5-10).
- completion-real-tier.test.ts (ONE FILE BEYOND the audit's named 2, flagged):
  tier-2/tier-1 live completions now assert the fail-closed HOLD (was: settle on
  placeholder); tier-0 still settles.

Full gateway suite 149/149 files green on Spark (2390 pass, 6 skip). The live hold
performed is minimal (status + response); full hold persistence/resolution is
DEFERRED (§8.5, "before hold path is operational").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
… persisted gateway receipt time (§7.1/§8.5-4)

Audit round-2 fix #2. /complete previously used the /complete handler wall-clock
(Math.floor(new Date(now))) as effectiveEvidenceTime even though it RETRIEVES a
device bundle captured EARLIER via operator-relay — swapping settlement-now for a
slightly-earlier settlement-stage clock, not the time the gateway actually received
the evidence.

- @pcc/store: new nullable evidence_bundles.gateway_received_at column (schema +
  idempotent safeAddColumn migration; additive, legacy rows fall back to created_at).
- operator-relay POST /api/operator/evidence: persist gatewayReceivedAt = the gateway
  receipt time WHEN device evidence enters (the key ingestion point).
- paid-job-flow /complete: effectiveEvidenceTime now derives from the retrieved row's
  gatewayReceivedAt ?? createdAt (gateway-insert time for legacy rows) — never the
  /complete time, never the device's advisory createdAt, never settlement time. Passed
  only when a device row was retrieved (undefined otherwise; inert on the closed path).
  The settlement-anchor insert also records gatewayReceivedAt.

Tests: operator-relay asserts the receipt time is persisted at ingestion. No
regression — store 192/192, full gateway suite 149/149 files (2386 pass, 6 skip)
green on Spark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
…eved-tier and §8.5 hold resolution

Audit round-2 DEFER hygiene (do NOT build; leave TODOs referencing the frozen spec):
- device-evidence-settlement.ts: TODO(§8.5-10) at the device-anchor return — a verified
  signature routes to the device anchor regardless of ACHIEVED tier ("signature valid !=
  requested assurance satisfied"); claims-based achievedTier >= requestedTier gating is a
  mandatory-before-the-gate-opens step, not built in this steps-1-4 slice.
- paid-job-flow.ts: TODO(§8.5) in the HOLD branch — persist a SettlementHold record + wire
  buyer resolution (supplement/accept/dispute); today's hold is minimal (status + response).

Comment-only. Full gateway suite 149/149 green on Spark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
…routes in production

SEPARATE pre-existing security issue (NOT part of the v3 evidence slice; greenlit by
audit round-2). POST /api/identity/session derives the "principal" Ed25519 keypair
DETERMINISTICALLY from principalAgentId, so anyone who knows an agent id can mint that
agent's session keys — acceptable only for dev/test. Added a startup guard: the route
plugin throws when NODE_ENV=production, refusing to register the mock (forgeable) auth in
a production boot rather than exposing it. Real principal-key custody (identity wallet)
must be wired before this route may ship.

No test boots the full server under NODE_ENV=production, so the guard is inert in tests —
full gateway suite 149/149 green on Spark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
…po (§8.5 step 5, additive)

Per-checkpoint transactional GatewayReceipt persistence (frozen §8.3 object
model). New gateway_receipts table (CREATE TABLE IF NOT EXISTS, idempotent),
drizzle schema, IGatewayReceiptRepository + repo, registered in IRepositories.
Mirrors the invocation_receipts precedent end-to-end. Additive + non-breaking:
no route reads/writes it until step 6 wires the checkpoint-submission path.

@pcc/store build exit 0; store suite 198 passed (incl. 7 new gateway-receipts
tests: round-trip, per-job/per-session order, checkpoint provenance, last-
accepted snapshot, migration creates table+indexes).
…point_hash) + align forward-ref comments (ChatGPT step-5 audit)

DB now enforces the §8.3 one-accept-per-(session,seq) invariant directly (was
reliant on the deterministic receiptId PK alone) and adds UNIQUE(session_id,
checkpoint_hash) matching the documented per-session hash-uniqueness. Fixes a
findByJob fixture that collided on (sess-1,seq 1); adds 2 uniqueness tests.
Comments now state the transactional store module is unit-proven but NOT yet
wired (step 6 wires it). @pcc/store suite 200 passed on Spark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
…igner + transactional invariant (§8.5-5), supervisor-verified

Per-checkpoint transactional GatewayReceipt: read priorState -> checkSequence ->
persist-in-txn -> advance-after-commit -> construct-from-persisted (receipt
exposed only after the row commits). Dedicated Ed25519 receipt key, separate
from the secp256k1 EVM settlement key. Additive: not wired into any live route
(step 6). @pcc/gateway suite 2391 passed / 6 skipped on Spark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
…ables)

Adds evidence_sessions, milestone_packages, checkpoint_bodies to @pcc/store,
each fully wired through all five points (schema + schema/index, interface +
interfaces/index export block + IRepositories member, repo + repositories/index
import/export/factory, migrate.ts raw SQL, index.ts public barrel). Mirrors the
step-5 gateway_receipts template byte-for-byte in structure.

- evidence_sessions: open execution-authority window per (job, milestone);
  UNIQUE(job_id, milestone_index) fail-closes the one-session rule (spec §2.1-4).
- milestone_packages: persisted claim-free FinalMilestonePackage + PackageReceipt
  (§8.4-B); UNIQUE(job_id, milestone_index) makes re-finalize idempotent (§2.3-6).
- checkpoint_bodies: persisted checkpoint content for payload-commitment verify
  at finalize (§8.4-B-3); sibling to gateway_receipts to keep that table pure.

Entirely additive: only appends CREATE TABLE blocks + barrel entries; no existing
table/row/route touched. 15 new colocated vitest round-trip/finder/UNIQUE tests.
Verified on Spark: @pcc/store build clean (tsc); test 215 passed (215), 0 failed
(200 baseline preserved). Domain times are Unix seconds (integer); row metadata
createdAt/openedAt ISO text; JSON columns via text(col,{mode:"json"}).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
… tests

Deterministic Merkle root over checkpoint hashes in GIVEN seq order (never sorted; order is chain meaning). Leaves hex-decoded to raw 32B (sha256: prefix stripped, malformed/empty throw = fail closed); parent=sha256(left||right); odd node promoted UNCHANGED (no dup, CVE-2012-2459-immune); single leaf = leaf; returns sha256:<lower-hex>. Recomputed by oracle (step 10) + anchored on-chain (O5) so byte-exact. Exported from workflow/index.ts + src/index.ts. 12 tests: 1/2/3/5-leaf vectors computed DIRECTLY via node:crypto, prefix/case-normalization, order-sensitivity, empty/malformed throw. Verifier build clean, 609 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
SessionSequenceStore.rehydrate(sessionId,{lastSeq,lastHash,seen}): first-touch-wins, no-op if live memory present (memory >= durable since it advances only post-commit). gateway-receipt-store.record() gains a synchronous step 0 before the priorState read: when memory has no session but durable rows exist, recover the tip via lastAcceptedForSession + rebuild seen from findBySession. Post-restart a replayed seq-1 now rejects seq_gap_or_replay instead of exploding on the deterministic PK; a legit mid-session seq re-joins. Idempotent re-issue (rule 4) deferred to the Wave-3 route (record() stays strict). 5 restart-sim tests over a real @pcc/store; completed test (e)'s throwingRepo mock (now reads tip before insert). Gateway 2396 passed | 6 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
…erms + one-open-session)

computeWindow (pure): terms -> env -> hard default (3600/86400), deadline clamped
>= execution window. open(): deterministic sessionId evs-<job>-<mi>; opened /
idempotent-on-same-delegation / conflict (session_already_open|session_finalized),
UNIQUE(job,mi) as the fail-closed backstop. Service returns a discriminated union
with stable reason codes (Wave-3 route maps variant->HTTP). Additive; no route wired.

Verified on Spark: @pcc/gateway BUILD_EXIT=0; 2405 passed | 6 skipped (baseline 2396 + 9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
…ize, claim-free package)

finalize() implements §8.4-B in order, each clause fail-closed, in one synchronous
critical section mirroring gateway-receipt-store: session load (open|finalized) ->
idempotent-if-package-exists -> deadline (B-4) -> rebuild accepted chain from the
DURABLE gateway_receipts only (B-1, +completeness guard vs the repo fetch cap) ->
payload-commitment check (B-3) -> evidenceRoot=merkleRoot (B-2) -> assemble CLAIM-FREE
FinalMilestonePackage (no assuranceTier/oracleVerified/success) -> packageHash=
canonicalSha256 -> sign PackageReceipt -> [TXN: package insert + session->finalized,
atomic] -> construct-from-persisted, signature exposed only post-commit. Reuses
GatewayReceiptSigner (shared time authority, §8.3) + merkleRoot. Discriminated union
with stable reason codes (session_not_found/evidence_deadline_passed/no_checkpoints/
payload_commitment_mismatch); route owns HTTP + the job-status flip.

Cross-wave contract stated in code: eventsRoot/packageHash = sha256:+hex(sha256(utf8(
canonicalize(x)))) — same node:crypto idiom as paid-job-flow gatewayBundleHash.

Verified on Spark: @pcc/gateway BUILD_EXIT=0; 2413 passed | 6 skipped (baseline 2396 + 17).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
R-14a: assert body/column integrity on every full-row read. A persisted
GatewayReceipt is a signed artifact; if the JSON body diverges from the
duplicated scalar columns on any of the 10 load-bearing fields it is
corruption and must never be served. assertRowIntegrity fails closed
(throws naming the diverging field / non-object body) and runs in
findById, findByJob, findBySession, findByCheckpointHash, findAllBySession.
insert is not asserted (write path / construct-from-persisted); the
lastAcceptedForSession projection has no body to reconcile.

R-14c: add findAllBySession — the FULL session chain, seq-ascending, no
limit. Protocol chain reconstruction / evidenceRoot / recovery MUST use it,
never the capped findBySession (a >MAX_LIMIT chain silently truncates to a
forked root).

R-14b (DB half): CHECK(seq>=1), CHECK(session_state_version=seq),
CHECK(accepted_at>0) on the gateway_receipts CREATE TABLE. Documented
caveat: CREATE TABLE IF NOT EXISTS applies CHECK only to a fresh table
(no retro-alter); acceptable while step 5 is route-inert and test DBs are
fresh :memory:. Schema comment mirrors the rules (raw SQL authoritative).

Verified on Spark: @pcc/store build exit 0; @pcc/store 226 passed (215
baseline + 11 new); @pcc/gateway 2413 passed | 6 skipped (no regression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
… + fingerprint keyId + pubkey/seed assert

- getDefaultGatewayReceiptSigner THROWS on missing/malformed key when NODE_ENV=production
  (a settlement-time-authority key must never silently become ephemeral across restarts).
- keyId is now a fingerprint of the public key (gw-rcpt-<8hex>), not the constant 'gw-rcpt-1'
  — two different keys can never share one gatewayKeyId.
- constructor asserts pubkey <=> seed (derive from seed, compare) — reject a mis-paired key.
- ephemeral fallback kept for non-production only (zero-config dev/test).
- test-only __resetDefaultGatewayReceiptSigner() to exercise NODE_ENV/key states.
- New test file: 8 cases (prod missing/malformed throw; prod valid + 0x; fingerprint uniqueness;
  dev ephemeral; cache). Verified on Spark (GW_BUILD_EXIT=0; all green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
…14b input validation in record()

H1 — the durable gateway_receipts row at (sessionId, seq) is the source of truth; the
in-memory chain is only a cache. Before checkSequence, resolve the deterministic id:
- committed row + SAME (jobId, checkpointHash, prevCheckpointHash) -> new 'idempotent'
  variant (response-lost-after-commit; returns the committed receipt, inserts NO 2nd row).
- committed row + ANY of those differ -> new 'conflict':'equivocation' variant (fork
  attempt at a committed height; the committed row is never overwritten).
- no row -> fall through to the existing checkSequence path, unchanged.

R-14b — defensive input validation at the top of record(): seq int>=1, maxSignatures
int>=0, effectiveEvidenceTime finite>0, non-empty jobId/sessionId/checkpointHash. A
contract violation returns 'errored' (never a sequence reject); nothing persisted.

Intended test deltas (RULE 18, called out in-file):
- Wave-1b restart 'replayed seq-1' now expects 'idempotent' (exact-retry of committed h1).
- Main test (b) 'replay seq 2 with h2x' now expects 'conflict':'equivocation'
  (different-hash-same-seq; the audit-specified H1 outcome, a 2nd existing site the task's
  'only one change' note under-counted).
- Test (e) throwing-repo stub gains findById:()=>undefined (test-double completeness for
  the new probe; not an expectation change).
Verified on Spark (GW_BUILD_EXIT=0; H1+R-14b tests green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
… findAllBySession

finalize() rebuilt the accepted chain via findBySession(sessionId, CHAIN_FETCH_LIMIT=1000)
— the CAPPED finder. A >1000-checkpoint session silently truncated the chain, forking the
money-path evidenceRoot (or tripping the completeness guard to 'errored', leaving a valid
long session un-finalizable). Switch to findAllBySession (uncapped, integrity-asserted,
W2.5a). The contiguity/completeness guard stays as defense-in-depth (now it can only trip
on genuine durable-chain corruption -> 'errored', never a partial root). Removed the now-
unused CHAIN_FETCH_LIMIT const.

Test: seed 1001 accepted checkpoints via record() (valid sha256 leaves), finalize, assert
evidenceRoot === merkleRoot(all 1001 hashes) and != merkleRoot(first 1000). Verified on
Spark (GW_BUILD_EXIT=0; R-14c green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
…oints/finalize)

§8.5 step-6 async split. New Fastify plugin routes/evidence-async.ts composes the
existing services (EvidenceSessionStore.open, GatewayReceiptStore.record,
MilestonePackageStore.finalize) + the verifier composition (verifySessionSignedEvent
mirroring verifyDeviceSignedEvidence). HTTP-mapping only; no new store logic.

- begin: job-collectable + delegation-vs-registered-signer (parent-sig, minus a bundle
  signature) + window-from-terms subset + open (idempotent/conflict).
- checkpoints: effectiveEvidenceTime fixed at handler entry (§7.1); server-computed
  checkpointHash; full session-sig+delegation+expiry+scope+revocation verify; skew flag
  (never gates); record() result mapped; checkpoint body persisted on accept.
- finalize: no session sig / no live key (§8.1-#1); MilestonePackageStore.finalize
  mapped; job → evidence_finalized. Settlement stays behind /complete (§2.4).

Registered in server.ts beside paidJobFlowRoutes. Cross-wave canonical checkpoint
content = {sessionId,seq,createdAt,prevCheckpointHash,eventsRoot,checkpointType}
(prevCheckpointHash explicit null at genesis; canonicalize sorts keys).

Spark: @pcc/gateway build EXIT=0; 2460 passed | 6 skipped (baseline 2441 + 19 new, 0 failed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
… (§8.5 step 6, §2.4)

PART 1 (paid-job-flow.ts): /complete gains ONE additive branch — when a finalized
FinalMilestonePackage exists for (job, milestone 0), settlement anchors on the
package's content hash (packageHash) instead of the synthesized gateway envelope.
Swaps ONLY the fallback anchor hash fed to resolveSettlementEvidence; the SEAM-2
decision, HOLD gate, tier logic, oracle, EAS and escrow are untouched. No package
(every legacy job) => anchorFallbackHash === gatewayBundleHash => byte-identical.

PART 2 (settlement.ts): the existing GET /api/evidence/:jobId hash-form branch now
resolves a finalized package FIRST (findByPackageHash) and serves canonicalize(pkg.body)
as raw bytes, so the oracle's hash-derived fetch-and-rehash works with zero oracle
change. Chosen over a distinct /packages/ path because the external oracle derives
the fetch path from the hash and cannot be handed a custom URL (spec section 3: "via
the existing evidence-serving path"). No route collision.

@pcc/store: + IMilestonePackageRepository.findByPackageHash (mirror findByHash form
normalization; sha256:/0x/bare).

Tests (evidence-async-settlement.test.ts, real Ed25519): legacy zero-diff, full async
happy path (anchors on packageHash, claim-free served evidence), long-job (execution
window elapses before finalize+/complete — still settles; the section 8.1-#1 TTL
regression), serve-bytes (rehash === packageHash; unknown -> 404).

Spark: GW_BUILD_EXIT=0, STORE_BUILD_EXIT=0. @pcc/gateway 2464 passed | 6 skipped
(2460 baseline + 4 new), 0 failed. @pcc/store 226 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
LamaSu and others added 30 commits July 13, 2026 23:27
…i-griefing)

Revelation is now a SEPARATE verified step (POST .../checkpoints/:seq/reveal — verify
events hash to the receipted eventsRoot, then persist payload idempotently). finalize
takes NO caller payloads: it deterministically includes EVERY durably-revealed payload
(checkpoint_bodies.payload) in seq order via the uncapped findAllBySession. Closes the
griefing vector where a permissionless keeper could finalize with an empty/curated payload
set to exclude the device's measurements. Adds setPayload + findAllBySession to the
checkpoint-bodies repo. Direct-in-main-loop fix (subagents weekly-walled).
@pcc/store 226, @pcc/gateway 2493 pass | 6 skip (+4 net), 0 fail on Spark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
…t, not finalize

The SDK's finalize now POSTs each kept payload to .../checkpoints/:seq/reveal (the gateway
verifies each against its receipted eventsRoot), THEN finalizes with no payloads — matching
the S6-5 server contract (1e3c82e). Reveal is idempotent. Makes the client consistent with
the real gateway (previously it sent payloads to finalize, which the S6-5 server now ignores).
FakeGateway mirrors the real reveal + payload-free finalize. @pcc/kernel-sdk 23 pass, tsc clean on Spark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
…equivocation fork)

The SDK retains the EXACT pending checkpoint (content/createdAt/signature/hash) per seq and
reuses it verbatim on any retry, so a lost-response retry never regenerates createdAt (which
would fork the hash → equivocation). On a network/timeout error the outcome is UNCERTAIN:
the client re-reads the durable tip via begin, then either (a) tip == its checkpoint → fetches
the committed receipt via the new GET .../sessions/:sessionId/receipts/:seq (no re-POST), or
(b) tip still at prev → resubmits the exact same bytes once. Adds that read-only receipt route
(gateway) + FakeGateway recovery support. Tests: after-commit recovery via GET (no fork),
before-commit exact-bytes resubmit (same createdAt), + a gateway-level GET-route test.
@pcc/gateway 2494 pass | 6 skip, @pcc/kernel-sdk 25 pass on Spark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
…y from the evidence session id

The evidence sessionId (evs-<job>-<mi>, the PK) and the delegation's cryptographic session-key
id were both called 'sessionId' in the proof chain (the schema comment even mislabeled the PK as
'the delegation's session key id'). Adds a distinct, additive delegation_session_id column on
evidence_sessions, populated at begin from the auth, so the evidence-chain <-> authorization link
is explicit + queryable for the step-10 oracle (session-key / revocation / chain-continuity).
Corrects the schema comment. @pcc/store 226, @pcc/gateway 2495 pass | 6 skip (+1), 0 fail on Spark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFMcxqkRpv1TUuNj6s3bvk
…b / keyId / R-09 [UNVERIFIED: Spark down]

Round-5 audit (ChatGPT "owner-gated call decisions") post-fix source review found 4 of the
8 round-4 fixes were point-fixes leaking at lifecycle edges + new findings. Batch A = the
non-finalize surgical fixes:

- Milestone binding (P0): begin+finalize reject milestoneIndex !== 0 (unsupported_milestone_
  index). Blocks arbitrary-milestone -> whole-job evidence_finalized -> /complete legacy
  fallback. Multi-milestone binding into the signed delegation is deferred.
- S6-3b (P1): begin requires >=1 terminal action (execution_completed|fault_report) in
  allowedActions -> no unfinalizable delegation can occupy the unique session slot.
- S6-4b (P1): recoverUncertain tries the read-only receipt GET BEFORE begin(), so a committed
  receipt is retrievable AFTER delegation expiry (the long-job boundary); begin's post-S6-3
  expiry rejection no longer blocks historical-proof retrieval. + tryFetchReceipt helper.
- gatewayKeyId (P1): full sha256(pubkey) fingerprint (256-bit) not 8-hex/32-bit — safe for an
  independent oracle key registry to treat as unique identity.
- R-09 (P0 immediate guard): settlement_hold added to the /complete NOT-IN exclusion AND the
  begin EVIDENCE_UNCOLLECTABLE set (kept mirrored). Full hold state-machine is a separate pass.

Tests reconciled to the new behavior (honest RULE-18 reclassifications, never weakened): signer
keyId now full-sha256; non-zero milestone -> 422; the phase-action-binding test gets a
fault_report terminal so it can begin; + new S6-3b / milestone / keyId assertions.

NOT YET VERIFIED — tablet has no node_modules; Spark blocked on Tailscale SSH re-auth. Verify
pending: @pcc/gateway + @pcc/kernel-sdk full suites. Gate stays CLOSED (SEAM-2 off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e + S6-2b terminal state + terminal-integrity

S6-5b (ratified: payloads out). The FinalMilestonePackage is now claim-free AND payload-free: it binds
ONLY commitments (accepted chain, receipts, evidenceRoot, terminalCheckpointHash, delegationSessionId).
Revealed payloads live OUTSIDE the immutable package (content-addressed by checkpoint hash, fetched
independently). This KILLS the finalize-before-reveal griefing race: the package never carries payloads,
so a permissionless keeper cannot curate/exclude them, finalize may run the instant the terminal
checkpoint is accepted, and packageHash is identical whether payloads were revealed before finalize or
never. (The reveal endpoint still persists payloads to checkpoint_bodies; content-addressing them by
checkpointHash + the GET endpoint is Batch B2.)

Also in the finalize critical section:
- S6-2b (P0/P1): scan the WHOLE durable chain and reject `post_terminal_checkpoint` if any NON-last
  checkpoint is a terminal type. Closes `fault_report -> execution_completed -> success` (the finalizer
  trusted only the last type). New tests: fault-then-completion + completion-then-completion both reject.
- Terminal integrity (Med): the terminal body must be receipt-bound — `body.checkpointHash ==
  lastReceipt.checkpointHash`, not merely self-consistent — so a consistently-altered body can't
  classify the terminal type for a different receipt commitment.
- Package now binds `terminalCheckpointHash` (= last receipt's hash) and `delegationSessionId` (S6-8).

Tests reconciled to payloads-out (honest RULE-18, not weakened): the 3 S6-5 "payload rides in the
package" tests -> payloads-out proofs (no payloads; griefing gone); the finalize payload-hash
defense-in-depth test removed (finalize no longer reads payloads — the reveal endpoint owns that check);
kernel-sdk FakeGateway finalize response made payloads-out to MIRROR the real server (fake-masks-reality
guard). seedChain gains a per-checkpoint type override for the S6-2b cases.

VERIFIED on Spark: @pcc/kernel-sdk 25/25; the 5 gateway evidence files 77/77 (milestone-package,
evidence-async, evidence-session-store, signer, evidence-async-settlement) — 0 failed.

Batch A (5626e33) also VERIFIED on Spark: @pcc/store 226, @pcc/verifier 609, @pcc/kernel-sdk 25, gateway
evidence tests green. The 9 gateway reds (mcp-apps _meta.ui.resourceUri / job-status-ownership / a
fork-test timeout) are PRE-EXISTING — baseline 36a6ecc returns Exit 1 on the same files; unrelated to
evidence-signing (harness.exe env drift explains the prior "0 failed").

Gate stays CLOSED (SEAM-2 off; achievedTier = step 10). Not merged, not deployed, testnet only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… session disables the legacy /complete fallback)

Round-5 re-audit (ChatGPT "owner-gated call decisions") correctly found that entering the Step-6 async
evidence flow was NOT authoritative: `/complete` chose its settlement anchor solely on whether a finalized
FinalMilestonePackage exists (paid-job-flow.ts ~L1092) and never checked for an OPEN evidence session. So a
job could begin async evidence, skip the terminal checkpoint + finalization, and still settle on the
gateway-synthesized LEGACY envelope — bypassing the terminal-completion requirement, the package receipt, and
the accepted checkpoint chain.

Fix: before the atomic completion claim, if an evidence session for (job, milestone 0) exists but no finalized
package does, return 409 `evidence_not_finalized`. Once a job has EVER opened an async session, the legacy
fallback is permanently unreachable for it — it must finalize the package first. Placed BEFORE the claim so a
blocked job is never stranded in `completing`. Legacy jobs that never opened a session are unaffected (no
session ⇒ guard inert ⇒ legacy anchor path byte-identical).

Test: begin without finalize → /complete → 409; job not claimed/settled; then finalize → /complete → settles
(the guard blocks ONLY the bypass, not the legitimate package path).

VERIFIED on Spark: the /complete-flow cluster (evidence-async-settlement, completion-real-tier,
completion-resume-settlement, capture-flow, negotiation-settlement-correctness, operator-relay,
device-evidence-settlement) — 7 files / 106 tests, 0 failed.

This is round-6 correction-order item 1. Remaining (per the re-audit): lifecycle-status checks on
checkpoint/finalize + atomic terminal-state transition (P1-2), whole-chain body↔receipt (P1-3), SDK recovery
robustness (P1-4), field reconciliation + acceptedEnvelopeHash (M2), settlement_hold tests (M1); spec:
Tier-0 v2-rail attestation gate (P0-2), B2 as a full checkpoint-artifact API (P1-1), reveal deadlines (P1-5).
Gate stays CLOSED; branch undeployed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…erminal transition + job-status gates)

Round-5 re-audit P1-2: terminal state was only DETECTED at finalize (the S6-2b scan), not enforced at
acceptance — so a stray/malicious post-terminal checkpoint could poison an otherwise-valid completed chain
(the chain then permanently fails finalization). And checkpoint/finalize did not check the JOB lifecycle.

Fixes (correction-order item 1):
- ATOMIC terminal transition: GatewayReceiptStore.record() gains an optional `evidenceSessions` dep and,
  when a terminal checkpoint (execution_completed -> terminal_success / fault_report -> terminal_fault) is
  accepted, sets the session status IN THE SAME transaction as the receipt+body insert. No await between
  the inserts and the transition => genuinely atomic (a crash cannot leave a receipted terminal with an
  `open` session). A subsequent checkpoint then hits the existing `session_not_open` gate — the transition
  PREVENTS poisoning that the finalizer scan (retained as defense-in-depth) can only detect after the fact.
- Job-lifecycle gates (P1-2a): the checkpoint route rejects a checkpoint on a settled/completed/held/
  cancelled/failed/completing/evidence_finalized job (`job_not_evidence_collectable`), even if the session
  is somehow still open. The finalize route rejects a FRESH finalize on such a job while preserving
  idempotency (a job with an existing package still returns it).
- finalize accepts the new terminal_success/terminal_fault session states (open still accepted for test
  seeders that don't transition; finalized for idempotent re-finalize); the terminal-TYPE check still
  classifies success vs fault. Seeders that omit the optional dep skip the transition (sessions stay open).

Tests: terminal_success transition + post-terminal 409; terminal_fault transition + 409; out-of-band
terminal job -> checkpoint 409 job_not_evidence_collectable.

VERIFIED on Spark: 11 files / 209 tests, 0 failed (evidence-async 42, milestone-package, evidence-session-
store, evidence-async-settlement, gateway-receipt-store, + the completion/settlement cluster). Gate CLOSED.

Next (round-6 item 2): whole-chain body<->receipt integrity in finalize.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n finalize

Round-5 re-audit P1-3: the package is a settlement ROOT, but finalize verified only the LAST checkpoint
body against its receipt. A missing / altered / spliced NON-terminal body could evade detection or make
the package impossible for an independent oracle to replay.

Fix (correction-order item 2): a single whole-chain loop replaces the last-only terminal-integrity check
and the type-only S6-2b scan. For EVERY accepted checkpoint, in seq order, fail closed unless:
  - exactly one body per receipt (count check — catches a missing / extra / duplicate body, anywhere);
  - the body recomputes to its OWN checkpointHash over the 6 canonical keys AND that hash equals the
    RECEIPTED hash (receipt-bound — a consistently-altered body can't classify for a different receipt);
  - seq is contiguous (i+1), the receipt belongs to THIS job, and prevCheckpointHash chains to the
    previous receipt's hash (no reorder / splice / graft);
  - no NON-last body is a terminal type (S6-2b, now part of the same loop).
The last-body terminal-completion-type guard follows (success vs terminal_fault vs missing).

Tests: a tampered NON-terminal body -> errored (the terminal body intact — the old last-only check would
have missed it); the existing "missing terminal body" test now asserts the whole-chain COUNT-mismatch
message (a more general catch that also covers a missing middle body).

VERIFIED on Spark: milestone-package-store 17/17; the finalize + /complete surface green (evidence-async,
evidence-async-settlement, completion-real-tier, completion-resume-settlement, capture-flow,
negotiation-settlement-correctness, device-evidence-settlement) — 0 failed. Gate CLOSED.

Next (round-6 item 3): SDK recovery — 404-vs-transient, finalizePackage without begin, post-expiry tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…TONIC (no settlement rerun)

Round-6 re-audit P0: the finalize route's status reconcile unconditionally set `evidence_finalized` on BOTH
the finalized and idempotent branches. The item-1 job-lifecycle check exempts jobs that already have a
package, so a settled / settlement_hold / completing / evidence_submitted job that gets a repeat idempotent
finalize reached the store, got an `idempotent` result, and had its status ROLLED BACK to `evidence_finalized`
— which /complete allows, so settlement could re-run:

  finalize -> /complete -> settled  ->  POST /finalize again -> idempotent -> status rewound to
  evidence_finalized -> PUT /complete again -> re-claims -> settlement pipeline runs a SECOND time.

The same rollback defeated the R-09 settlement_hold guard and the resume-settlement-only recovery for
evidence_submitted, and a concurrent finalize during /complete could overwrite `completing` mid-settlement.

Fix: both reconcile sites use an ATOMIC status-GUARDED update (mirrors the /complete claim) — set
evidence_finalized ONLY WHERE status NOT IN ('completing','evidence_submitted','settled','completed',
'cancelled','failed','settlement_hold'). Job lifecycle is now monotonic: a later state is never rolled back;
only a genuine pre-settlement/crash-recovery job (e.g. `executing`) is reconciled. Added `and`, `sql` imports.

Test: settled job + repeat idempotent finalize -> 200 idempotent, status STAYS settled, second /complete -> 409.
The S6-7 crash-recovery test (reconcile from `executing`) still passes — that path is still allowed.

VERIFIED on Spark: 8 files / 149 tests, 0 failed (evidence-async 42 incl. S6-7, evidence-async-settlement incl.
the new P0 test, + completion-real-tier / completion-resume-settlement / capture-flow / negotiation-settlement /
device-evidence-settlement / operator-relay). Gate CLOSED.

Round-6 re-audit remaining (boundary, still open — NOT closed): P1 terminal exact-retry idempotency (a
regression from item-1's transition), P1 whole-chain receipt-field binding completion, P1 mandatory terminal-
state dep, low-level settlement-API scope, item-2 evidence re-run. Then items 3-6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The checkpoint route blanket-rejected any non-open session (409 session_not_open)
and any lifecycle-terminal job BEFORE reaching GatewayReceiptStore.record()'s
DB-authoritative idempotency. Since item-1 flips the session to
terminal_success/terminal_fault on an accepted terminal checkpoint, an EXACT
retransmission of that terminal checkpoint (a lost-response retry) 409'd instead
of returning the committed receipt.

Fix: resolve any committed receipt at (sessionId, seq) BEFORE the session-status
and job-lifecycle gates. An existing receipt routes to record()'s exact-retry
(200 idempotent) / equivocation (409 equivocation) path with NO lifecycle
mutation; only a genuinely new acceptance (no committed row at this seq) is
lifecycle-gated, so new post-terminal checkpoints still 409 session_not_open. The
submitted checkpoint is fully signature/delegation-verified either way.

Tests: terminal exact retry -> 200 idempotent (one receipt row, session
unchanged); different content at the committed terminal seq -> 409 equivocation.
Verified on Spark: evidence-async.test.ts 44/44 passed (VITEST_EXIT=0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nalize

Make "a success package can only be minted from a terminalized session" a
SERVICE property, not one enforced only by the live route composition or a
test-only bypass. Two coupled changes:

1. GatewayReceiptStore.evidenceSessions is now REQUIRED (was optional). The
   atomic terminal-state transition on an accepted terminal checkpoint
   (round-6 P1-2) therefore always fires — a caller can no longer omit it. The
   begin-route construction is updated; receipt-store unit tests that do not
   exercise the lifecycle pass a no-op { setStatus() {} } (behaviourally
   identical to the prior transition-skipped path).

2. MilestonePackageStore.finalize requires a terminal session state for a NEW
   finalization: the idempotent package lookup runs FIRST (a settled/held/
   finalized job still returns its package), then terminal_success -> proceed;
   terminal_fault -> return the fault, never a success package; open / any
   other state -> new reason terminal_state_missing (route maps -> 422). The
   old gate accepted an `open` session (a seeder bypass). The downstream
   terminal-completion scan stays as defense-in-depth for a corrupt terminal
   session whose durable last body is not a completion.

Seeders reproduce the LIVE terminal transition (seedChain wires
evidenceSessions), so tests drive terminal_success exactly as production does.
Honest reclassifications where a seeded terminal checkpoint now transitions the
session: assertions previously "open" after seeding become terminal_success /
terminal_fault; finalize on an open / non-terminal-ending chain now rejects
terminal_state_missing (was no_checkpoints / terminal_checkpoint_missing — both
retained as defense-in-depth). Same security properties, enforced earlier and
at the session level.

Verified on Spark: gateway-receipt-store + milestone-package-store +
evidence-async + evidence-async-settlement = 102/102 passed (VITEST_EXIT=0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…binding

The finalize whole-chain loop verified body/receipt counts, seq contiguity,
receipt.jobId, body recompute, body==receipt checkpointHash, and body-prev vs
the previous receipt's hash. It did NOT bind the body's OWN jobId, the receipt's
own previousAcceptedHash, or the session ids — so a receipt could CLAIM a
different prior accepted hash while the body chain stayed internally continuous
and finalize would still succeed (the per-row assertRowIntegrity is body==columns
only, never a cross-receipt check).

Add per-iteration bindings (every field exists on the rows — no claim narrowing):
- body.jobId === jobId (LOAD-BEARING: bodies are fetched by sessionId, not jobId,
  so the existing receipt.jobId check does not cover them);
- receipt.previousAcceptedHash === expectedPrev AND body-prev === receipt-prev
  (LOAD-BEARING, the most material omission: catches a receipt claiming a wrong
  prior hash while the body chain looks internally continuous);
- body.sessionId / receipt.sessionId === sessionId (defense-in-depth: both chains
  are already fetched by findAllBySession(sessionId); kept explicit so a future
  query change cannot silently drop the invariant).

Tests: a non-terminal body with a wrong jobId -> errored; a receipt made
internally consistent (column==body so assertRowIntegrity passes) but chain-wrong
on previousAcceptedHash -> errored. Verified on Spark: milestone-package-store +
evidence-async + evidence-async-settlement = 69/69 passed (VITEST_EXIT=0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…it APIs

POST /api/settlement/release and /api/settlement/submit accepted caller-supplied
settlement operations / oracle attestations and did NOT consult Step-6 evidence
session/package state. They sit behind apiGate (a valid /api/* key), but ANY
provisioned agent (scopes ["*"]) could invoke these low-level primitives — the
round-6 re-audit flagged that "sticky entry on every settlement path" overclaimed
coverage (the P0-1 sticky guard only protects PUT /api/jobs/:id/complete).

ENFORCE (not document) a privileged caller: both routes now require the internal
SETTLEMENT_ADMIN_TOKEN (presented as X-Settlement-Admin-Token), mirroring the
adminOk pattern in routes/waitlist.ts + routes/feedback.ts. Fails CLOSED — an
unset token rejects everything (a money-path-safe default). This does NOT disable
automated settlement: the keeper releases via the escrow ACTIVITY directly, not
this manual HTTP route (which the SettlementFacade supersedes).

Tests (proving an ordinary caller is rejected): release without the token -> 403;
release with a wrong token -> 403; submit without the token -> 403 (gated before
the batch-enabled check). The 3 existing release tests present the token and are
otherwise unchanged. Verified on Spark: settlement + evidence-async-settlement +
settlement-telemetry (5 files) = 114/114 passed (VITEST_EXIT=0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…handoff)

ai/research/ is gitignored (worktree specs); force-add the two round-6 boundary
docs so the branch carries the durable development handoff for a fresh agent
pulling feat/v3-evidence-signing from lamasu. Secret-checked (no keys, tokens,
private keys, credentials, or host IPs). The frozen auditor-facing copies also
travel in pcc-audit-package/audit/.

- v3-round6-handoff-items3-6.md: the §A/§B pickup handoff (A1-A5 done this round)
- v3-round6-REAUDIT-chatgpt.md: the authoritative round-6 re-audit transcript

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…art-safe finalize

checkpoint-client recovery collapsed a clean 404 and a transient network error both
to null, and finalize hard-required begin() this process. Three fixes:

(a) tryFetchReceipt returns a discriminated {found|absent|uncertain}: a clean 404
    receipt_not_found is ABSENT (genuinely not committed); a network error / 5xx /
    status 0 is UNCERTAIN — never conflated with absent.
(b) recoverUncertain RETRIES the read-only receipt GET on `uncertain` (bounded)
    BEFORE the authority-dependent begin()/resubmit. Failure mode closed: the final
    checkpoint commits just before delegation expiry, its response is lost, the
    recovery GET hits ONE transient error -> a single-shot lookup reported "absent",
    begin (post-expiry) rejected, and the committed receipt was unrecoverable. A
    still-uncertain result after retries surfaces uncertain_commit rather than
    authoring new work under a possibly-committed state.
(c) finalize() no longer throws begin_not_called: the evidence sessionId is
    deterministic (evs-<jobId>-<milestoneIndex>), so it is derived when begin() has
    not run this process -> finalize works after a device restart (payloads-out means
    the restarted client needs no payloads; they are revealed independently).

Tests (FakeGateway extended faithfully with failReceiptGetOnce + rejectBeginExpired):
a transient recovery-GET failure still recovers (retry, not false absent); recovery
uses the read-only GET even when begin would 422; finalize succeeds after a restart
with no begin(). Verified on Spark: @pcc/kernel-sdk 28/28 passed (VITEST_EXIT=0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y regression tests

The settlement_hold guard existed (the /complete atomic claim excludes it; the
checkpoint/finalize routes treat it as evidence-uncollectable) but had no direct
test (round-5 re-audit M1). Add 3 tests to evidence-async-settlement.test.ts:
- a settlement_hold job -> /complete -> 409 (never re-claimed);
- concurrent /complete on a held job -> BOTH 409 (the hold guard is race-safe);
- an open-session job that goes to settlement_hold -> a new checkpoint AND finalize
  are rejected job_not_evidence_collectable (the item-1 lifecycle gates).

Seeds the settlement_hold state directly (as existing tests seed statuses) -- inducing
the fail-then-hold path needs a specific mocked-settlement failure, orthogonal to the
guard under test. Verified on Spark: evidence-async-settlement 9/9 passed (VITEST_EXIT=0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… names with the step-10 spec

Rename the FinalMilestonePackage body fields to match the step-10 spec:
- sessionId    -> evidenceSessionId (the deterministic evs-<jobId>-<milestoneIndex>;
                  distinct from the package's delegationSessionId)
- receiptIds   -> gatewayReceiptIds

acceptedEnvelopeHash is DEFERRED (owner directive) — added only once its authoritative
input + ownership are settled, never hashed from a partial/reconstructed envelope.

The rename changes packageHash = canonicalSha256(pkg) (new field names), which is
correct: the package is content-addressed, so its hash SHOULD reflect its shape. Every
consumer round-trips the stored body — idempotentFromRow, the finalized return, and the
GET /api/evidence/:hash serve (canonicalize(row.body)) — so the rename is transparent
through storage and serving (no migration: the body is a JSON blob; the scalar sessionId
DB column is unchanged). PackageReceipt.sessionId is kept as-is.

Verified on Spark: milestone-package-store + evidence-async + evidence-async-settlement
+ device-evidence-settlement = 125/125 passed (VITEST_EXIT=0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cceptedAt, not retry time

A1 moved the committed-receipt lookup ahead of the lifecycle gates, but the handler
still verified EVERY checkpoint (including an existing-receipt replay) against
effectiveEvidenceTime = nowSeconds() (retry time). So an exact retransmission of a
validly-receipted checkpoint after the delegation expired — or after the session was
revoked post-acceptance — failed verification (403), even though the checkpoint was
valid when the gateway receipted it. Per the frozen model (§8.4-A) a committed
checkpoint's validity is fixed at receipt time; later aggregation needs no live key.

Fix: for an existing committedReceipt, verify at verificationTime =
committedReceipt.acceptedAt (the trusted receipt time); a genuinely NEW acceptance
keeps request-entry time. verifyCheckpoint uses this single time for BOTH the
delegation window and the revocation cutoff, so the replay now evaluates exactly as it
did when accepted. Equivocation + invalid-signature are still caught (record()
classifies exact-match vs conflict; a bad signature fails regardless of time).

Tests: exact terminal replay after expiry -> 200 idempotent; after post-acceptance
revocation -> 200 idempotent; a NEW checkpoint first submitted after expiry -> 403;
different content at the committed seq after expiry -> 409 equivocation (not masked as
403). Verified on Spark: evidence-async.test.ts 48/48 (VITEST_EXIT=0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed, rollback-coupled invariant

A3 made evidenceSessions a REQUIRED GatewayReceiptStore dep, but the contract was
{ setStatus(): unknown } and record() IGNORED the return — so a no-op stub satisfied
the type and a terminal receipt + body could commit with NO durable terminal write.
The invariant was still enforced by the caller passing a real repo, not by the service.

Fix: the dep return is now { status } | undefined (the real repo already returns the
updated row), and inside record()'s transaction the terminal transition is VERIFIED — if
the returned row is missing or does not report the terminal status, we throw, and drizzle
rolls back the receipt AND body (no split-brain: a receipted terminal checkpoint can never
commit with a non-terminal / missing session state). The invariant is now a SERVICE
guarantee, not an assumption about one caller's composition.

Tests: the 8 receipt-store constructions use a STATEFUL session fake (records + returns the
transition), not a no-op. New rollback tests: setStatus returns no row / throws / reports
the wrong status -> errored, receipt+body rolled back, no advance; a working repo ->
receipt+body commit AND the transition is recorded. Verified on Spark: gateway-receipt-store
+ milestone-package-store + evidence-async + evidence-async-settlement = 115/115 (VITEST_EXIT=0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…one the handoff asked for)

The earlier B4 tests seeded settlement_hold directly and exercised exclusion of a stable
held state; the handoff wanted concurrency ACROSS the transition into hold. Add it: a
claimable tier-1 job (which, with the settlement gate closed, deterministically resolves
to a HOLD — required-verifier-unavailable) receives two concurrent /complete requests.
The atomic completion claim (synchronous before any settlement await) admits exactly one
winner, which settles into settlement_hold (200, status "hold"); the loser loses the claim
-> 409; a subsequent /complete -> 409. No second settlement runs. Verified on Spark:
evidence-async-settlement 10/10 (VITEST_EXIT=0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e, positive /submit test

POST /api/settlement/flush (manual epoch settlement) is another caller-triggered
settlement endpoint but was ungated. Extend the privileged-caller gate to /flush (same
SETTLEMENT_ADMIN_TOKEN as /submit + /release). Also replace the plain string token
comparison with a constant-time compare (timingSafeEqual) to remove a timing side-channel.

Tests: /flush without the token -> 403; /submit WITH the correct token passes the gate
(reaches the batch-disabled 503, not 403). Verified on Spark: settlement 34/34 (VITEST_EXIT=0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Test-completeness for the A2 whole-chain bindings (the code already enforced them).
Adds adversarial finalize tests directly corrupting the durable chain: a MIDDLE
checkpoint body deleted -> errored (body/receipt count mismatch); a MIDDLE gateway
receipt deleted -> errored (accepted chain incomplete); a body RE-HOMED to another
session -> errored. Verified on Spark: milestone-package-store 22/22 (VITEST_EXIT=0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eceipt transaction

Round-7 residual-P1 (ChatGPT re-audit): move the evidence-session lifecycle
authority check for a NEW checkpoint acceptance INSIDE the same better-sqlite3
transaction that inserts the GatewayReceipt + checkpoint body, via a
CheckpointAcceptanceGuard service invariant.

- gateway-receipt-store: record() now calls acceptanceGuard.claimForCheckpoint()
  inside the txn; a receipt+body cannot commit unless the session is open, the
  job is evidence-collectable, and (for a terminal checkpoint) an open->terminal_*
  compare-and-set wins. Guard rejection or throw rolls the whole txn back.
- evidence-sessions repo: add transitionIfOpen() CAS (WHERE status='open'), the
  atomic primitive that makes a post-terminal acceptance and a concurrent second
  terminal both lose the race.
- evidence-async route: wire makeEvidenceAcceptanceGuard(sessions, jobs,
  EVIDENCE_UNCOLLECTABLE_STATUSES) into begin + checkpoint.

Honest framing: verified NOT exploitable in the current synchronous single-process
model (the checkpoint route handler has no await from session-read to record()
commit), so this is transaction-hardening / boundary-closure, not a live-bug fix.
It makes the acceptance precondition a service invariant rather than a route
assumption, and closes the multi-writer boundary (frozen-spec Gate-5).

Verified on DGX Spark: @pcc/store build clean; @pcc/gateway
gateway-receipt-store(42) + milestone-package-store(22) + evidence-async(48) +
evidence-async-settlement(10) = 122 passed; @pcc/store evidence-sessions(7) passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… acceptance guard

Re-audit follow-up to 18e3932: makeEvidenceAcceptanceGuard had two gaps for a NEW
checkpoint acceptance —
  1. a MISSING job was not rejected (`if (job && uncollectable)` let job===undefined
     fall through to accept); and
  2. it verified the session was `open` but not that the session actually belongs to
     the supplied jobId (a session minted for job A could authorize a checkpoint on job B).

Both now fail closed:
  - session must exist, be `open`, AND session.jobId === jobId;
  - job must EXIST and be evidence-collectable.
The sessions.findById dep shape is widened to expose { status, jobId }; the production
repo already returns the full row.

Tests: the makeEvidenceAcceptanceGuard logic tests gain jobId in their session fakes,
plus two new cases — missing job -> reject, session-belongs-to-another-job -> reject.

Verified on DGX Spark (affected suites, freshly synced): @pcc/gateway gateway-receipt-store(44)
+ evidence-async(48) + evidence-async-settlement(10) + milestone-package-store(22) = 124 passed,
exit 0. No evidence-signing route regression from the stricter guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…den vectors

Independent ethers v6 mirror reproduces all 6 escrow-published golden values
(vnext-golden-vectors-v1.md) over the same canonical inputs: settlementUnitId,
feeScheduleHash, payoutConfigHash, claimId(PRINCIPAL/FEE), O5Verdict 384-byte
keccak — all byte-exact, plus field-mutation rejection. Cross-implementation M2
proof (Solidity VNextSettlementLib == ethers). Coord bulletin #422.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
evidence-lane: re-pin gate-1 ethers mirror to O5=448 (escrow #453 golden)

Escrow published the 448 O5Verdict golden (rev-3): 12 fields unchanged +
field13 uint64 oracleAuthEpoch + field14 bytes32 compositionRoot. Extend the
independent ethers mirror to the 14-field/448 layout and re-verify byte-exact:
  o5Verdict keccak 0x8f3ae745..edf7 (448 bytes) MATCH
  5 existing primitives (settlementUnitId/feeScheduleHash/payoutConfigHash/
  claimId x2) UNCHANGED + still byte-exact.
Add O5 field-mutation tests proving the two new words BIND (oracleAuthEpoch
7->8 and compositionRoot nonzero-vs-0 each change the keccak). 3-way M2 parity
now byte-exact at 448 (evidence == oracle #454 == escrow #453).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
evidence-lane: termsHash golden + ethers mirror (CanonicalJobTermsV1 v2)

Independent ethers mirror for the v-next termsHash canonical encoding, cross-family
ratified (escrow #491, oracle #492/#495, sol r1/r2). Computes:
  TERMS_DOMAIN   0xaf4cf568..c141  ("PCC:vnext:job-terms:v1")
  milestonesRoot 0x814381ab..0125
  termsHash      0xf9e67009..9637
over canonical inputs with a deliberately UNSORTED milestone pair (proves explicit
milestoneIndex + pin-to-funded-order). 7 negative-parity mutations all change the hash.
termsHash is PURE (no chain/factory), abi.encode only, drops inert bondAmount/challengeWindow.
Golden doc: ~/.claude/shared/vnext-termshash-golden-v1.md (+ the termsHash<->UnitConfig[]
equality predicate the oracle pre-sign gate verifies).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
evidence-lane: termsHash golden v2.1 — deadline PER-MILESTONE (escrow #499 + oracle #500)

reclaimAt is per-unit VARIABLE on-chain (no uniformity constraint; staggered
milestone deadlines are supported), so deadline moves INTO CanonicalMilestoneTermsV1
{milestoneIndex, stepId, amount, deadline} to mirror UnitConfig 1:1. Supersedes the
v2 (job-level deadline) golden in #502. New values:
  milestonesRoot 0xf7147855..9388
  termsHash      0x2cb7a79e..4fe6
Staggered per-milestone deadlines in the golden prove the per-milestone binding;
7 negative-parity mutations all change the hash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant