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
Draft
feat(gateway): v3 evidence-signing Step-6 — async split + final-package (audited; gate CLOSED; HOLD for auth-P0)#270LamaSu wants to merge 66 commits into
LamaSu wants to merge 66 commits into
Conversation
…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.
…eEvidenceTime + skew flag (§8.5-4)
…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
…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>
@
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-checkpointGatewayReceipts, with the transaction-bound checkpoint-acceptance guard (session-open ∧ session.jobId==jobId ∧ job-collectable ∧ terminalopen→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/gateway→ 167 files, 2743 passed / 6 skipped / 0 failed@pcc/store→ 17 files, 227 passed / 0 failedBoundary status: settlement gate CLOSED; SEAM-2 not opened; no O5 / contract redeploy;
achievedTier/Step-10 not in scope here.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