Skip to content

feat(spec): EvidenceBlockV1 v2 producer (evidenceBlockHash for FinalMilestonePackageV2) - #361

Draft
LamaSu wants to merge 37 commits into
masterfrom
feat/evidence-block-v2
Draft

LamaSu wants to merge 37 commits into
masterfrom
feat/evidence-block-v2

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Why

Must-close 8 needs evidence.evidenceBlockHash to mint a FinalMilestonePackageV2 (#358), and nothing in public PCC produced it. The encoding existed only as the evidence lane's mirror script on #270, which is not merged. This ports it to @pcc/spec.

evidenceBlockHash = keccak256(abi.encode(
  bytes32 keccak256("PCC:vnext:evidence-block:v2"), uint16 2,
  unitContextDigest, kernelSignedEventsRoot, sessionKeyAuthDigest,
  attestationSetRoot, workProductRoot, programHash))

New functions:

  • computeEvidenceBlockHash
  • computeUnitContextDigest, which refuses a context whose milestoneIndex or stepId doesn't derive its settlementUnitId
  • computeSettlementUnitId, the escrow's frozen derivation
  • computeSessionKeyAuthDigest
  • computeAttestationSetRoot, which binds role, quorum and job
  • taggedDigestToBytes32

It reuses computeWorkProductHash and computeVerificationProgramHash.

Byte-exact

These reproduce the pinned goldens:

  • the domain 0xf15817db…;
  • the escrow's settlement unit id 0x4453a3d2…, the same one the integrated settlement vector uses;
  • the mirror's evidenceBlockHash 0x4605a6e9…, from the mirror's six roots.

Finding in the evidence lane's own golden

The mirror derived kernelSignedEventsRoot over 0x-prefixed event hashes. The kernel signs hashBundle over sha256:-prefixed ones. So the mirror's root (0xcc6a7e95…) is not the signed bundleHash (0x24bb6410…), and a block built that way could never match a genuinely signed bundle.

This producer takes the root from the signed bundleHash. For the same golden inputs, the block is 0x854079f7…, and both facts are pinned in tests. The oracle has been asked which derivation it reconstructs.

Input forms

Every hex value must be 0x + lowercase hex of its exact width. An EIP-55 escrow address must be lowercased by the caller; this is finding F3 from #358, decided here. Integers may be a bigint, a safe integer or a decimal string without leading zeros.

Tests

  • 15 new; spec 812/812; tsc clean.
  • Eight mutants, each turning a test red: unit coherence, hex case, address case, role order, job binding, quorum binding, version, leading-zero decimals.

Base: master.

🤖 Generated with Claude Code

https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS

LamaSu and others added 14 commits September 24, 2026 09:44
…nalMilestonePackageV2 carries

Item 8 cannot mint a package without evidence.evidenceBlockHash, and no
public code produced it: the encoding existed only as the evidence lane's
mirror script on #270 (not merged). This ports it to @pcc/spec:
computeEvidenceBlockHash over the six roots, plus computeUnitContextDigest,
computeSettlementUnitId (the escrow's frozen derivation),
computeSessionKeyAuthDigest, computeAttestationSetRoot and
taggedDigestToBytes32. It reuses computeWorkProductHash and
computeVerificationProgramHash for the other two roots.

Byte-exact against the pinned goldens: domain 0xf15817db..., the escrow's
settlement unit id 0x4453a3d2... (also the integrated settlement vector's),
and the mirror's evidenceBlockHash 0x4605a6e9... from the mirror's roots.

Finding in the evidence lane's own golden: the mirror derived
kernelSignedEventsRoot over "0x"-prefixed event hashes, but the kernel signs
hashBundle over "sha256:"-prefixed ones, so the mirror's root (0xcc6a7e95...)
is not the signed bundleHash (0x24bb6410...), and a block built that way
could never match a genuinely signed bundle. The producer takes the root from
the signed bundleHash; for the golden inputs the block is 0x854079f7....

A context whose milestoneIndex or stepId does not derive its settlementUnitId
is refused, and hex inputs must be 0x + lowercase hex of exact width.

Tests: 15 new; spec 812/812; tsc clean. Eight mutants (unit coherence, hex
case, address case, role order, job binding, quorum binding, version,
leading-zero decimals) each turn a test red.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
…rted from #270)

R19 group B: the one #270 mirror whose home is this PR. Two schema-valid
events (ISO-8601 timestamps) go through the production hashEvent and
hashBundle, then taggedDigestToBytes32, and reproduce the golden that
#270's kernel-signed-events-root-golden-vector.cjs pinned for the
oracle's EvidenceBlockV2 builder:
0x4e0af964e4e066717998ed7a49bf7c874023bd402b825da22b4dabd70fb6f9fe,
with both per-event hashes pinned too.

Properties pinned alongside it:
- the root does not depend on event order;
- an event's id and hash are outside its preimage;
- the root moves for a 0x-tagged inner preimage, a simulated source, and
  pass:true in place of pass:1.

The existing fixture keeps the v2 mirror's inputs, whose Unix-second
timestamps are not schema-valid. Its block golden 0x854079f7 is
unchanged.

spec 816/816, tsc clean. Mutants (hashBundle without the sort, id or
hash inside the event preimage) are each killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
A unit's evidence is one kernel-signed bundle that holds every
outcome-bearing event, so the events root it commits leaves nothing out.
Same rule as the LO-EV-9 header (bus #3543).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…fied bundle (E7 F1)

hashBundle trusts each carried event.hash, so two bundles with different
payloads and the same carried hashes shared one root, and nothing refused
an empty bundle or a bundleHash that did not match its events.

Add computeKernelSignedEventsRoot(bundle): snapshot the events once (a JSON
round trip of each event), recompute every event hash with hashEvent and
require it to equal the carried one, recompute hashBundle over the snapshot
and require it to equal bundle.bundleHash, refuse an empty list, and return
the bytes32 root. Every failure is an EvidenceBlockInputError naming the field.

The module header now states the boundary this function does not cover (kernel
signature and session-key delegation: the LO-EV-1 verifier #338; one finalized
bundle per settlement unit: a stateful record at the gateway/VCR boundary;
parallel, partial, superseded or unfinalized bundles: the oracle), and
computeEvidenceBlockHash is documented as the low-level mirror-exact function
whose roots must come from the verified derivations.

The pinned goldens are unchanged: the suite still reproduces 0x4605a6e9...,
0x854079f7... and the production-form root 0x4e0af964... byte for byte.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…re hashing (E7 F2)

The only attestation validation was the format of each hash, so a quorum
such as {roleId "", minPositive 2, total 1, minScore NaN, hashes [H, H]}, an
empty role set, an empty role, and two roles with one roleId all hashed.
Duplicates stayed cryptographically bound, so a downstream evaluator that
counts entries could have its quorum inflated.

computeAttestationRoleDigest and computeAttestationSetRoot now validate each
role and copy it into frozen plain data (every field and array element read
once), then hash only that copy. Rules, from the E7 design and the #270
mirror inventory:
- job and roleId: 1-128 printable ASCII characters, no whitespace
- the set has at least one role (the mirror pins no empty set) and roleIds
  are distinct
- a role has at least one attestation hash
- minPositive and total are safe integers, 1 <= minPositive <= total
- minScore is a safe integer in [0, 100] (golden 80, scores 92 and 88; the
  production type is int 0..100)
- attestation hashes are 0x + 64 lowercase hex, distinct, and no more than
  total (golden: 2 of 3)
Negative zero is refused for every integer. Duplicates are refused, never
silently de-duplicated. Every refusal is an EvidenceBlockInputError naming the
field.

The pinned goldens are unchanged: the golden role set still hashes to the
same root and the block goldens 0x4605a6e9... and 0x854079f7... still hold.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…rization (E7 F3)

Validation and hashing read the same live objects more than once, so a getter
or Proxy could pass validation with one answer and be committed with another:
attestationHashes was read for validation and again for hashing, and the
session authorization went live into canonicalize, which reads every property
in filter and again in map. The authorization the consumer then evaluated could
differ from the one hashed.

Roles: computeAttestationRoleDigest and computeAttestationSetRoot hash only the
frozen snapshot taken by the F2 validation, in which every field and every array
element is read once. A getter's second answer is never consulted. This commit
adds the tests that pin that behaviour (getters on every field, on an array
element, and a Proxy over the roles array).

Session authorization: add sessionKeyAuthSnapshot(auth) returning { value,
digest }. value is a deep-frozen plain copy of exactly the fields
SessionKeyAuthorization declares (nested scope and arrays included), read once
through property descriptors so an accessor is never invoked. An unknown own
key, a symbol key, an accessor, a non-enumerable field, a non-plain object or
any Proxy is refused. digest is sha256(canonicalize(value)) over the frozen
copy. computeSessionKeyAuthDigest(auth) returns snapshot.digest. Consumers
evaluate value, never the object they passed in. A compile-time guard fails the
build if SessionKeyAuthorization gains a field this snapshot does not list.
The name snapshotSessionKeyAuthorization is not used (it belongs to #438).

The pinned goldens are unchanged; the golden authorization digests exactly as
sha256(canonicalize(auth)) as the #270 mirror defines it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
… has one digest (E7 F4)

SessionKeyAuthorization.publicKey permits an optional 0x prefix and the gateway
intake accepts uppercase, and the digest hashed the raw spelling, so one Ed25519
key had several digests (0x vs bare, upper vs lower).

sessionKeyAuthSnapshot now pins publicKey to 64 and parentSignature to 128
lowercase hex characters with no 0x prefix, by REJECTION, never normalization.
That is the golden's form and the only form every in-repo producer emits
(kernel-sdk job-handler.ts:357,361 and gateway identity-session.ts:119,123, both
via a lowercase bare toHex), so no producer is refused and neither the oracle
mirror nor the goldens change. parentSignature is pinned the same way as an
in-spirit extension of the design (same defect class, same producer forms); it
is one line to drop. Cross-form vectors: 0x-prefixed, uppercase, mixed-case,
padded and wrong-length spellings are refused; the producers' exact hex is
accepted.

NOT done (STOP condition from the design): the scope arrays allowedActions and
contractIds are not required to be strictly ascending and duplicate-free. The
design asks for that only if every producer already complies, and
gateway/src/routes/identity-session.ts:99-124 does not: it returns the caller's
arrays verbatim through SessionKeyService.issueSessionKey
(verifier/src/workflow/ephemeral-identity.ts:156-160), by design (the parent
signature covers a sorted copy; ephemeral-identity.test.ts:1184-1232). The module
header states the arrays are committed in the order given, and a test.todo marks
the open item.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
Number.isSafeInteger(-0) is true and BigInt(-0) is 0n, so -0 and 0 reached the
same ABI word although the module pins one spelling of each input. uintWord now
refuses a number for which Object.is(value, -0) holds, with a field-named
EvidenceBlockInputError. Zero stays valid as 0, 0n and "0", and still yields
the same word.

The pinned goldens are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…ot in the verdict)

DROPPABLE: this commit is independent of F1-F5 and is not in the E7 design.

computeUnitContextDigest read each context field to check that settlementUnitId
derives from them and then read them all again to hash, the same live-object
double read as F3. A getter could answer the check coherently and be committed
with different values. The function now copies each field once and uses the
copies for both the derivation check and the hash. A non-object context is now
an EvidenceBlockInputError instead of a TypeError. Reproduced first against
2fa5af8: every field was read twice and the digest differed from the coherent
context's.

The pinned goldens are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…header (E7 F5)

The header still described integers as safe integers without saying that -0 is
refused since F5. Also wraps one over-long declaration. No behaviour change.

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

A mutation pass over the new rules left three mutants alive and exposed guards
with no direct test:
- accessor refusal: the tests asserted only the field name, but an accepted
  accessor is refused downstream under the same field for required keys, so the
  mutant passed. They now assert the message, and a new case covers an accessor
  on the optional derivationPath, which a lenient reader would have dropped as
  "absent" without any refusal.
- required fields: the missing-field test now asserts "is required".
- array length: a Proxy over an array passes Array.isArray and NaN < 1 is
  false, so without the explicit safe-integer check an array with a lying
  length is read as empty and an EMPTY bundle, role set or hash list would be
  accepted. New tests cover bundle events, roles and attestationHashes with
  length NaN, -1, 1.5, "2", undefined and Infinity.

No source change; the pinned goldens are untouched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
, so the event snapshot reuses it

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
… return the snapshot with the root (E7b)

E7b (HIGH, cross-family verdict on #361 at 53e347d): snapshotEvent used a
JSON.stringify/parse round trip, so computeKernelSignedEventsRoot verified and
committed a projection of the evidence, not the evidence. Reproduced first at
53e347d with a scratch test (8 of 8 cases failed): a {value: NaN} event with the
carried hash and bundleHash of a {value: null} event was accepted with the same
root; so were a top-level toJSON() returning the hashed event, an accessor (its
getter ran), a class-instance payload, a non-enumerable or prototype toJSON,
Infinity, an undefined array element and a hole.

- snapshotEvent takes canonicalSnapshot (#359, merged in 456f076): own property
  descriptors only, so no getter or toJSON runs; it refuses accessors,
  non-enumerable and symbol keys, NaN and Infinity, bigint, functions, undefined
  array elements and holes, cycles and non-plain objects. A NonCanonicalValueError
  becomes an EvidenceBlockInputError whose field is the event index plus the member
  path (events[1].payload.value); the reason is bounded and has no control
  characters.
- The snapshot's own value is what hashEvent checks and what is returned,
  deep-frozen.
- computeKernelSignedEventsRoot returns { root, events } (KernelSignedEventsSnapshot).
  events is exactly the data whose hashes were recomputed and checked; consumers
  evaluate it, never the object they passed in. The return type changes; the only
  callers at this head are the tests, updated here.
- An undefined OBJECT member stays omitted, as canonicalize omits it everywhere
  (the gateway's carrier events leave optional fields undefined and hash them that
  way); it is absent from the hash and from the returned events alike. Refusing it
  would reject honest bundles and need a second walk over the input.
- Existing tests follow the new return and the accessor case, which is now refused
  at events[0].hash with the getter never invoked. The pinned goldens are untouched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…mitted and returned as one snapshot (E7b)

Fifteen tests for the E7b fix, one per case of the verdict plus the contract of the
returned value:
- (a) the reviewer's repro: a {value: NaN} event with the same carried hash and
  bundleHash as a {value: null} event is refused, naming events[0].payload.value;
  Infinity, an undefined array element, a hole, a bigint, a function, a symbol,
  an unsafe integer and a symbol key are refused at their member before any hash is
  compared; a hole or undefined entry in the events array is refused at its index.
- (b) a toJSON on the event (own, non-enumerable, on the prototype) or on a payload
  is refused and never called.
- (c) an accessor on a payload member, an array element or source is refused and its
  getter never runs.
- (d) a class instance, Date, Map, Set, RegExp, typed array, Error and an object with
  a substituted prototype are refused, wherever they appear.
- (e) an undefined array element is refused. An undefined OBJECT member is omitted
  from the hash and from the returned events alike, as canonicalize omits it
  everywhere; pinned so a change of that policy is noticed.
- (f) { root, events }: events equal the submitted events in the order given, are
  copies, are frozen at every depth, do not change when the input is mutated
  afterwards, still re-verify against every carried hash and the root, and have no
  prototype. Frozen, structuredClone and null-prototype spellings of the same data
  give the same root and events.
- (g) a Proxy event is read once through its reflection traps (no [[Get]]), so a
  trap that answers differently on a second read cannot split the hashed value from
  the returned one.
- A refusal's field and message carry no control characters and are bounded, so a
  hostile key cannot inject log lines.

Reproduced first at 53e347d: the scratch version of cases (a) to (f) failed 8 of 8.
The pinned goldens are untouched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
LamaSu and others added 4 commits October 1, 2026 02:55
… the E7b mutation run

The mutation run over the E7b fix left one mutant alive: dropping the Array.isArray
check in snapshotEvent. An array snapshots as valid JSON, so without the check it
reaches the hash comparison and is refused at events[0].hash instead of at
events[0] as "expected an event object", and no test pinned which. The non-object
table now includes [] and [1, 2].

No source change; the pinned goldens are untouched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…order (E7 F4, gateway #4670)

The F4 residual was blocked because SessionKeyService.issueSessionKey passed the
caller's scope arrays through in the caller's order, so one set of permissions could
have several digests and a producer existed that would not comply. The gateway
agreed (bus #4670) to build scope.allowedActions and scope.contractIds as
[...new Set(xs)].sort() before signing, so every producer emits the canonical form.

sessionKeyAuthSnapshot now pins both arrays by rejection: each must be an array of
strings in STRICTLY ascending UTF-16 code-unit order (each element < the next with
plain JS string comparison, so no duplicates). An unsorted or duplicated array is
refused at the first element that breaks the order, with the field path
(sessionKeyAuthorization.scope.contractIds[1]); nothing is sorted or de-duplicated.
An empty array is allowed. Each array is still read once, through descriptors, and
the order is judged on the copy that is committed. readStringArray becomes
readCanonicalStringSet.

The F4 it.todo is replaced by tests: unsorted refused, duplicated refused (adjacent
or not), sorted and de-duplicated accepted and committed as given, empty accepted,
the order is code-unit order (not locale, not code point: "Z" before "a", an astral
character before U+FF5E), and of the 192 arrays over {a, b, c} that hold all three
permissions exactly one (the canonical form) is accepted, so two inputs differing only
in order or duplicates can no longer both produce a digest. One existing test pushed
"added-after-hashing" after "unit-golden"; it now pushes a value that sorts after, so
it still tests that later changes to the original do not reach the snapshot.

The module header's F4 note now says the arrays are pinned and producers emit the
canonical form. The pinned goldens are untouched: their scope arrays have one element.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…ect member is omitted (E7b)

Comments and one assertion only, no behaviour change.

acd5b7f gave, in its message and in the module header, a reason that is wrong: that
the gateway's carrier events leave optional fields undefined and that refusing an
undefined object member would therefore reject honest bundles. Checked afterwards
against the producers: carrier.ts takes those fields from TrackerWebhookEvent, which
types them string | null, and lob.ts includes trackingNumber only when present "so
canonical hashing omits it rather than hashing null". No producer in this tree leaves
an undefined member, so that reason is withdrawn. (acd5b7f is unpushed history and is
not rewritten; this commit is the correction.)

The behaviour stands on these grounds instead:
- canonicalize, and so hashEvent and verifyEventHash, omit an undefined OBJECT member
  everywhere in the repo, and #359 pins that (canonical.test.ts:240-241);
- JSON transport drops it too, so the oracle's reconstruction sees the same event;
- it is absent from the hashed text and from the returned events alike, so what a
  consumer evaluates is what was hashed;
- refusing it would take a second read of the input, against the lane's decision to
  reuse canonicalSnapshot and not write another walker, and would make this step
  stricter than verifyEventHash, which accepts the same event.

The (e) test now also asserts that verifyEventHash accepts the event with the undefined
member, which is the premise of the last point. If the lane wants such an event refused,
the single-read way is a strict option in canonicalSnapshot (#359); this test would then
flip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
… state the Proxy read-once guarantee (E7b)

- printable() now cuts the text to its limit BEFORE it scrubs control characters, so a
  very long hostile key cannot make the scrub itself expensive. The limit is applied
  once, so there is a single cap.
- computeKernelSignedEventsRoot's doc states in one place that a Proxy is not refused
  but read once, through its reflection traps, so what is hashed is what is returned
  and evaluated, however the traps answer a later read.
- A new test pins that a value nested 100,000 levels deep, as a payload member or as
  the event itself, is refused as an EvidenceBlockInputError at events[0], never a
  RangeError.

No other behaviour change; the pinned goldens are untouched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
@LamaSu
LamaSu changed the base branch from master to fix/spec-canonicalize-refuse-non-json October 1, 2026 10:05
LamaSu and others added 10 commits October 1, 2026 03:35
…ore reading it, use captured intrinsics (E7c)

E7c (HIGH, cross-family verdict on 55721c3): computeKernelSignedEventsRoot
read bundle.events and bundle.bundleHash with a [[Get]] (so a caller getter
ran) and accepted Proxy events (so their traps ran inside canonicalSnapshot).
That code can replace Object.freeze with the identity function before
deepFreeze() and restore it with queueMicrotask, so the returned events stay
mutable and the events a consumer evaluates can differ from the events that
were committed. Reproduced at 55721c3 for all six shapes: events getter,
events-array Proxy, Proxy nested in a payload, Proxy bundle, bundleHash
getter, and a caller that replaces Object.freeze across the call.

Design: refuse every code-running input BEFORE touching it, and use only
captured intrinsics afterwards.
- Capture Object.freeze, Array.isArray, Reflect.ownKeys,
  Reflect.getOwnPropertyDescriptor, Reflect.getPrototypeOf,
  Number.isSafeInteger and util.types.isProxy when the module loads. They
  replace every use of the globals after input is touched: deepFreeze, the
  result freezes, and the session and role snapshots (key enumeration,
  freezes, array and integer checks).
- The bundle: refuse a Proxy; read events and bundleHash only from their own
  descriptors. An accessor ("a getter on the bundle runs code"), a missing
  property and an inherited one are refused.
- The events array: refuse a Proxy (checked before Array.isArray, which throws
  on a revoked one); read length and each index from own data descriptors, so
  a hole or an accessor element is refused.
- assertNoCodeRunningInput runs on each event before canonicalSnapshot:
  iterative, captured functions only, a WeakSet skips a node already walked.
  It refuses a Proxy at any depth and an accessor anywhere, and refuses (as
  canonicalize does) a node whose prototype is not plain before enumerating
  it, so a large typed array or Buffer stays an O(1) refusal instead of an
  ownKeys over every element. JSON-type rules stay in canonicalSnapshot, which
  now runs on a graph proven free of Proxies and accessors.
- The events array is frozen before hashBundle receives it.

Also found while capturing the intrinsics: isPlainObject asked an object's
prototype for its own prototype, which runs a Proxy prototype's getPrototypeOf
trap in sessionKeyAuthSnapshot. A Proxy prototype, and a revoked Proxy, are
now refused unasked. Roles are unchanged apart from the captured intrinsics.

Tests. Three existing tests pinned the behavior this removes and now expect
REFUSAL, with no trap or getter allowed to run:
- "reads the bundle's events and bundleHash exactly once each" expressly
  accepted and invoked bundle getters (the reviewer's evidence-block.test.ts
  465-479); it is now "refuses a getter on the bundle ... and never runs it",
  and the reviewer's exact reproduction (a) sits beside it;
- "reads each element of the events array once" accepted a Proxy events array;
- "(g) reads a Proxy event once, through its reflection traps" accepted a
  Proxy event.
New: (b) events-array Proxy, (c) a Proxy at every depth and as a prototype,
(d) bundle Proxy, (e) bundleHash getter, (f) Object.freeze replaced across the
whole call (events and session snapshot) still returns deeply frozen data,
plus an accessor-before-JSON-defect precedence pin, a 50,000-deep Proxy, the
non-plain-before-enumeration cases, and a spy test showing the module calls
none of the replaceable intrinsics at call time. canonical.ts and
types/evidence.ts are untouched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…ype-chain Proxies, found by the E7c mutation run

The mutation run over the E7c fix killed every required mutant (bundle read
with [[Get]], isProxy pre-walk dropped, global Object.freeze in deepFreeze,
accessor refusal dropped in the pre-walk) and one survived: judging a
descriptor as data with `"value" in descriptor` instead of by the fields it
owns. That reads an inherited `value` from a polluted Object.prototype, so an
accessor on the bundle or in an event would be taken for a data property
holding the polluter's value. A new test pollutes Object.prototype.value for
the synchronous part of the call and requires both an accessor on the bundle
and an accessor in an event to be refused unrun; the survivor now fails it.

Also pinned: a Proxy that sits in the prototype chain of the bundle, of the
events array or of an event is never asked anything (identity comparison of
the prototype only, no trap), whether the call is accepted or refused.

Test-only: no production change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…pe the test Proxy helper (E7c)

Comments and test typing only; no behavior change.

The header and the capture block said the module captures "everything it calls
on caller data". That overstates it: the roles, the session fields and the unit
context still use Set, Map, Object.is and BigInt, which see only primitives
after the objects have been inspected. What is captured is what inspects and
freezes caller-supplied objects (Object.freeze, Array.isArray, Reflect.ownKeys,
Reflect.getOwnPropertyDescriptor, Reflect.getPrototypeOf, Number.isSafeInteger,
util.types.isProxy), and that is what the text now says.

recordingHandler in the test file is generic over the Proxy target, so the file
type-checks under the temporary tests tsconfig (the package tsconfig excludes
__tests__): no error in evidence-block.test.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…y point, read fields from own descriptors, encode without mutable globals (E7c round 2)

Round 1 closed the events bundle. The other entry points still read their
input with a [[Get]] and looked Buffer.from, Number, BigInt, regular
expressions, Array.prototype.sort and Hash.prototype.update up at call time.
Reproduced at ad2359e: a unit, unit-context, role, role-set or roots getter
that swaps Buffer.from or Array.prototype.sort runs, the call succeeds and the
digest changes (u1, r1, r2, b1); a context getter makes a forged
settlementUnitId derive (u2b); a Proxy at any of the six inputs runs its traps
(p1-p6); an accessor inherited from a polluted Object.prototype is read for a
missing field (h1, h2). The session snapshot already refused (controls s1-s3).

Every exported function that takes an object or an array now runs the SAME
guard first, assertNoCodeRunningInput (a Proxy at any depth, an accessor
anywhere, an object that is not plain), through one helper, admit():
computeSettlementUnitId, computeUnitContextDigest,
computeAttestationRoleDigest, computeAttestationSetRoot,
computeEvidenceBlockHash, sessionKeyAuthSnapshot (and so
computeSessionKeyAuthDigest). Each field is then read from its own data
descriptor (fieldOf, ownDataValue), never with a [[Get]], so no getter runs and
a field the object does not own is missing whatever Object.prototype says.
A non-object input is a typed refusal (it was a TypeError for a unit and for
the roots). "Plain" is now one predicate for every input: prototype null or a
prototype-less object that is no Proxy (any realm), so a class instance, Map,
typed array, Error or a Proxy prototype is refused before it is enumerated;
canonicalize stays the strict judge of an event.

After the guard only captured references and pure loops are used: Number,
BigInt, the Uint8Array constructor, Hash.prototype.update and digest, Set and
String.prototype.charCodeAt and slice are captured at load; hex, bytes, ABI
words and the field checks are char-code and byte loops (no Buffer, no regular
expression, no Uint8Array.from or .set, no Object.is, no Map); sorting is a
heapsort over a copy and arrays are built with Reflect.defineProperty, so no
Array.prototype method is looked up. @noble/hashes and node:crypto internals
are not capturable and are not claimed.

Tests. Eight existing tests pinned the behavior this removes and now expect
REFUSAL, with no getter or trap allowed to run: the five "roles are read once"
tests (getter answers, accessor element, scalar getters, Proxy roles array),
"every field is read once" for the unit context, and the session accessor test
(its message is now the guard's); one F2 test sees a Set as attestationHashes
refused as non-plain instead of "expected an array". New, for each of the seven
entry points: honest input accepted (plain or null-prototype), a Proxy as the
whole input (live and revoked) and at every member, a getter at every member,
a getter that swaps Buffer.from, Array.prototype.sort and Object.freeze (none is
touched), a missing field never supplied by a polluted Object.prototype, an
unrelated member ignored but a Proxy or getter there refused, a class instance,
Map or Proxy prototype refused; plus a test that every digest is unchanged
while about 130 Buffer, Array, Set, Map, RegExp, String, Object, Reflect, JSON,
Function.prototype and Hash methods throw when called and Number and BigInt
cannot be called, a 2,800-case comparison of every validator against the
regular expression it replaced, and a shuffled-input test of the new sort. The
golden literals are untouched and byte-identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…un (decimal strings, the events call, an accessor under a polluted value)

The round-2 mutation run killed every guard mutant (one per entry point) and
every shared-piece mutant, and left one survivor in the pure encoding layer:
isDecimal judged by a regular expression instead of a char-code loop. The
replaced-globals test only passed bigint and number chain ids, so isDecimal
(string spellings only) was never called inside its window. It now also
passes decimal-string unit and context spellings, and a second window covers
the synchronous part of computeKernelSignedEventsRoot (admission, the walk, the
snapshot and its freezes) with Array, Set, Map, RegExp, String, Object, Reflect,
JSON and Hash methods throwing, which also kills an events path that goes back
to events.push.

Also pinned, per entry point: an accessor member is still refused while
Object.prototype carries a `value` (judging a descriptor with `"value" in
descriptor` instead of by the fields it owns would read the polluter's value);
this kills that mutant at all seven entry points, where only the round-1
bundle test did before.

Equivalent, left alone: reading an array's `length` with a [[Get]] instead of
from its descriptor (E2). A real array's length is an own non-configurable data
property, and the only exotic array, a Proxy, is refused before that line.

Test-only: no production change. 179 tests in the file, 1162 in the package.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
… static node:util import, and fail closed without it

build-dashboard has failed on this PR since 53e347d: apps/dashboard bundles
@pcc/spec's evidence barrel with Vite, the dashboard aliases node:crypto to a
browser shim but has nothing for node:util, and evidence-block.ts imported
{ types } from "node:util" (the only spec module that does) to get
util.types.isProxy:

  packages/spec/dist/evidence/evidence-block.js (143:9): "types" is not
  exported by "__vite-browser-external"

Reproduced locally at 814f694 (spec, contract-builder and ui built with tsc,
then the dashboard's own tsc -b && vite build): same error, same line.

The static import is gone. The module now takes isProxy ONCE, when it loads,
from process.getBuiltinModule("node:util").types.isProxy (Node >= 20.16 and
>= 22.3), with the other captured intrinsics, so a util.types.isProxy that is
replaced after load still changes nothing (the two existing tests that replace
it pass unchanged). If the runtime cannot give it (an older Node, a browser, a
getBuiltinModule that is missing, throws, or answers with something that is not
a function) the module still LOADS: nothing is checked at import time. Every
Proxy test in the module goes through one function, isProxy(), which THROWS
EvidenceBlockInputError (field "runtime"):

  evidence-block needs Node's util.types.isProxy (Node >= 20.16 / 22.3) to
  refuse code-running input; it is unavailable in this runtime

An unknown is never "no": there is no fallback to skipping the Proxy check, and
the throw comes before any caller object is read, so no trap runs. All eight
entry points that take an object or an array (the seven synchronous ones, and
computeKernelSignedEventsRoot, which rejects) refuse; taggedDigestToBytes32 and
the exported constants take no object and work everywhere. node:crypto is
untouched (the dashboard aliases it); the dashboard config and every other
package are untouched; there is no new export.

Tests (evidence-block.test.ts, +5: 184 in the file, 1167 in the package). Each
loads a FRESH copy of the real module while process.getBuiltinModule is absent
or wrong in each of six shapes and node:util throws if anything imports it, so
there is no production override or seam. They assert: the import succeeds and
the constants and taggedDigestToBytes32 work; every one of the eight entry
points refuses an honest input with the typed error; a Proxy input is refused
the same way with no trap run; the table covers every function the module
exports; and, as a control, a host that does give the function gets the same
module accepting honest input, refusing a Proxy as a Proxy, and asked for
node:util exactly once, at load. The existing Proxy-refusal tests pass
unchanged on Node 22.

Verified: the dashboard build now exits 0 (3890 modules). The evidence-block
bundle, built with the dashboard's node:crypto alias, loads in a bare VM context
with no process, Buffer or require; its constants match the goldens and every
guarded entry point refuses with the typed error without running a trap.
tsc --noEmit is clean and the goldens check passes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…bservable assertion, not a vacuous one

The control test in 9e370bb ended with expect(asked).toEqual(["node:util"]),
meant to pin that the module takes util.types.isProxy from the host once, when
it loads, and never again. It could not fail: loadWith has put the real
process.getBuiltinModule back by the time the module is used, so the recorder
was no longer installed and nothing was ever recorded at call time. The
mutation run found it: a module that resolves the host function at CALL time
(M5) was killed by the two older "replaced util.types.isProxy" tests and by
two of the new ones, but not by this test.

The recorder is now installed again while the module is used, and the test
asserts that the host was asked exactly once during the load (empty after
that, across the honest calls, the checks against the other copy of the
module, and the Proxy calls). Under M5 it now fails with the host asked 99
times; on the real module it passes. Test-only: no production change. 184
tests in the file, 1167 in the package.

Mutants run against the committed source for this change, each restored with
git checkout:
  M1 reinstate the static named import of node:util: the dashboard build fails
     ("types" is not exported by "__vite-browser-external"), and all five new
     tests fail (the node:util mock throws on import); the 179 older tests pass.
  M2 remove the fail-closed throw (return false): the two refusal tests fail
     (an honest input returns the golden digest); the 179 older tests pass.
  M3 drop the typeof-function check on the host value: killed by the refusal test.
  M4 rethrow from the catch around getBuiltinModule: killed by the load and
     refusal tests.
  M7 a throwing getBuiltinModule falls back to a no-op Proxy test: killed by the
     refusal test.
  M5 resolve the host function at call time: five tests, two of them old.
  M8 add an exported function that the table does not list: killed by the
     coverage test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…AttestationSetRoot to ratified D4 keccak/abi formula

#361 implemented an older sha256/JCS role-tree formula bound to the job, and refused an
empty role set and an empty role. The ratified D4 formula (oracle #1030, #1289) is
keccak/abi and bound to the funded program instead, and its empty case is defined
(oracle #4836): a role with no attestations yet and a funded program naming no roles
are both valid.

- AttestationQuorumRole gains `signers: { kind: "registry"; registryId; snapshotHash }`
  (D4: registry-snapshot role policy, not inline signers).
- computeAttestationRoleDigest/computeAttestationSetRoot now take `fundedProgramHash:
  Bytes32Hex` instead of a free-form `job: string`, and compute
  keccak256(abi.encode(ROLE_DOMAIN, K(roleId), roleSignersDigest, uint32 minPositive,
  uint32 total, uint32 minScore, fundedProgramHash, sortedAttestationHashes)) and
  keccak256(abi.encode(ATTSET_DOMAIN, fundedProgramHash, sortedRoleDigests))
  respectively, byte-exact against the evidence lane's golden
  attestation-set-root-golden-vector.cjs (#270 @ c56bc14).
- Kept every existing refusal (duplicate roleIds, duplicate attestation hashes within a
  role, invalid quorum, a malformed fundedProgramHash/snapshotHash, a non-token
  registryId) plus a non-"registry" signers kind and uint32 range on minPositive/total.
  Dropped only the empty-set and empty-role refusals.
- Added `keccakAbiWithTrailingArray`, matching Solidity's abi.encode layout for a
  parameter list whose only dynamic type is a trailing bytes32[] (offset, then length,
  then elements), built from the module's existing captured-intrinsic helpers
  (appendTo, uintWord, keccakWords): no Array.prototype method is looked up.
- Captured `TextEncoder`'s constructor and `.encode` at module load (keccakUtf8 is now
  called per attestation call, not only once for the three existing domain constants,
  so its UTF-8 encoding needed the same discipline as the hash methods already have).
- No code-running-input or captured-intrinsics regression: the module's admit/
  assertNoCodeRunningInput guard, own-data-descriptor reads and captured intrinsics are
  unchanged in discipline, just extended to the new `signers` field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…shared block golden, extend realm-patch coverage

Realigns the test file with #361's D4 realignment (see the paired fix(spec) commit):

- The top-level `roles`/`attJob` fixture IS now the evidence lane's pinned golden
  (attestation-set-root-golden-vector.cjs on #270 @ c56bc14): A1/A2 and
  fundedProgramHash copied verbatim from its printed output, roleId "inspector" with a
  registry-snapshot signers policy.
- New "E7 D4" describe block pins: the inspector roleDigest and attestationSetRoot
  goldens byte-exact; the two empty-set goldens (oracle #4836); an empty role (no
  attestations yet) is valid; negatives (3)-(9) from the spec (snapshotHash,
  minPositive, roleId, duplicate roleId/hash, non-registry kind, uint32 overflow).
- The shared EvidenceBlockV2 block golden (goldenRoots(), 0x4605a6e9.../0x854079f7...)
  now pins the OLD pre-D4 sample's attestationSetRoot as an opaque
  PRE_D4_SAMPLE_ATTESTATION_SET_ROOT constant (recorded before any change:
  0x606f17fcfd5dabd1746cd8ec406636b3d1bae2e80f310bd6dee221a756c024b2) instead of
  calling computeAttestationSetRoot, so it does not move.
- E7 F2 rewritten for D4: signers added to every role fixture; dropped the two
  empty-set/empty-role refusal assertions (now valid, per brief); added refusals for
  signers.kind, signers.registryId, signers.snapshotHash and a malformed
  fundedProgramHash in place of the old job-token checks; added uint32-overflow cases
  for minPositive/total.
- E7 F3, the E7c round-2 generic entry table, the "judges every spelling" fuzz table,
  the non-integer-array-length block and the host-realm module-reload table all extend
  to the new `signers` field and the renamed `attFundedProgramHash` parameter.
- Fixed two now-stale realm-patch assertions exposed by the D4 switch to keccak
  (@noble/hashes calls Number.isSafeInteger internally, same documented caveat as the
  unit-context checks; TextEncoder.prototype.encode is now called per attestation call,
  not only at module load for the three pre-existing domain constants).

Suite: 1176/1176 passing (45 files), tsc --noEmit clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
#359 merged master in (6f513fe; A05e CONFIRMED). The only conflict here
was packages/spec/src/evidence/index.ts. Both exports are kept: master's
signing-preimage.js, then #361's evidence-block.js.

Spec: 54 files, 1472 tests pass; tsc --noEmit is clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
LamaSu and others added 9 commits October 3, 2026 11:27
…fied D3 two-value keccak/abi formula

computeSessionKeyAuthDigest was the OLD mirror form, 0x + sha256(canonicalize(snapshot of
SessionKeyAuthorization)). Replace it with the RATIFIED D3 form the oracle's production
EvidenceBlockV2 builder uses (oracle #1030, Evidence Commitment Profile v1 S3), byte-exact with
the evidence lane's golden sessionkey-grant-golden-vector.cjs on #270:

  sessionKeyGrantHash  = keccak256(GRANT_DOMAIN || canonicalSessionKeyBytes(sessionKey))
  sessionKeyAuthDigest = keccak256(abi.encode(AUTH_DOMAIN, sessionKeyGrantHash,
                           bytes parentSignature, bytes32 parentPublicKeyRaw32, uint8 scheme,
                           uint32 keyVersion))

canonicalSessionKeyBytes is utf8(JSON.stringify(body)), body in the EXPLICIT field order the
golden's production re-derivation (ephemeral-identity.ts) uses: sessionId, parentAgentId,
publicKey, issuedAt, expiresAt, scope{allowedActions, contractIds, maxSignatures},
[derivationPath]. parentSignature is EXCLUDED from the grant (it is the signature over that body)
and BOUND into the auth digest instead, alongside the parent's raw32 public key, the scheme and
the key version -- none of which live on SessionKeyAuthorization itself, so the smallest honest
API adds a separate `context` argument (SessionKeyAuthDigestContext: parentPublicKey, keyVersion,
scheme) rather than widening the authorization type.

New exports:
  - SessionKeyAuthDigestContext, SessionKeyScheme
  - computeSessionKeyGrantHash(auth): the grant hash alone, independently useful since a verifier
    checks the parentSignature against it before it ever sees a context.
  - computeSessionKeyAuthDigest(auth, context): now two-argument; auth is admitted and snapshotted
    exactly as before (sessionKeyAuthSnapshot is unchanged -- its own `.digest` stays the
    superseded mirror form); context is admitted the same way every object input is (E7c).

New module-private helpers, all pure loops/captured intrinsics (no Array.prototype method, no
Buffer, no ambient lookup at call time), matching the module's existing discipline:
  - `jsonStringify` captured at load (alongside TextEncoderConstructor/textEncoderEncode/UINT32_MAX,
    moved earlier in the file so the new session code can reference them in reading order).
  - sessionKeyGrantBody / sessionKeyGrantHashOf / keccakDomainPrefixedBytes: the grant hash is
    keccak256(domainWord || data) over a VARIABLE-length byte string with no padding -- literal
    concatenation, not abi.encode (keccakWords/keccakAbiWithTrailingArray only ever concatenate
    whole 32-byte words).
  - paddedDataWords / keccakAbiSessionKeyAuth: abi.encode(bytes32, bytes32, bytes, bytes32, uint8,
    uint32) for the one dynamic `bytes` parameter (parentSignature) this formula ever encodes --
    the six-slot head holds an offset word at slot 2, the tail holds the length then the
    32-byte-padded data.

The superseded mirror-form fixtures (old sessionKeyAuthDigest 0x73b60d4d..., block goldens
0x4605a6e9.../0x854079f7...) are preserved via an opaque constant in the test file, unaffected by
this change (test commit follows).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…oduction-aggregate acceptance test, update every call site for the new context argument

- "E7 D3": the evidence lane's own golden sample (sessionkey-grant-golden-vector.cjs on #270
  @ 973fdeb), copied verbatim: sessionKeyGrantHash 0x9b0a9a62..., sessionKeyAuthDigest
  0xaccbbe5a... (keyVersion 1, scheme ed25519). Negatives: a mutated parentSignature changes the
  auth digest but not the grant hash (excluded from the grant, bound in the auth); keyVersion 2
  changes the digest; a mutated session body changes the grant (and so the auth digest). Malformed
  context: a non-raw32 parentPublicKey, a keyVersion outside uint32, an unknown scheme, a
  non-object context, and a Proxy/accessor inside it -- all refused with EvidenceBlockInputError,
  the accessor case never invoking the getter.

- New top-level describe: the six production roots (unitContextDigest, kernelSignedEventsRoot via
  computeKernelSignedEventsRoot over the production ISO-8601 event set, sessionKeyAuthDigest via
  the D3 golden sample, attestationSetRoot via D4, workProductRoot, programHash -- #361's own
  functions wherever #361 computes them) give computeEvidenceBlockHash(...) ===
  0xcb30733c2904e714a4ef89a387b75bdcfa07aff5a4ea7482b55404a1e24e396c, the oracle's pinned
  production aggregate (bus #1069, #5772).

- goldenRoots() now feeds sessionKeyAuthDigest from a new opaque constant,
  PRE_D3_SAMPLE_SESSION_KEY_AUTH_DIGEST = the exact pre-change computeSessionKeyAuthDigest(sessionKeyAuth)
  output (0x73b60d4d..., recorded via a scratch vitest file against the pre-change code, run once,
  then deleted, not committed), so the two superseded mirror-form block goldens (0x4605a6e9... /
  0x854079f7...) are unchanged and still pass -- same method D4 used for
  PRE_D4_SAMPLE_ATTESTATION_SET_ROOT.

- New top-level fixture `sessionAuthContext` (a fixed, arbitrary SessionKeyAuthDigestContext) pairs
  with the existing `sessionKeyAuth` sample everywhere it is reused: the E7c round-2 entry table,
  the globals-saboteur "derives every digest" test, the per-character regex-fuzz table, the
  session-authorization and E7 F4 describe blocks, and the host-realm module-reload table (which
  also gains a `computeSessionKeyGrantHash` entry, required by its own exhaustive
  "covers every function the module exports" assertion).

- Digest-VALUE assertions that compared computeSessionKeyAuthDigest to sha(canonicalize(...)) (the
  old mirror form) are repointed: sessionKeyAuthSnapshot's OWN `.digest` field is unchanged and
  still asserted equal to sha(canonicalize(...)) wherever that was the thing under test; every
  computeSessionKeyAuthDigest assertion now either compares against a golden value pinned from the
  D3 vector, or proves internal consistency (same content via two separate objects gives the same
  digest; a mutated/rotated input gives a different one) -- never a parallel hand-rolled
  reimplementation of the D3 formula.

Suite: 198/198 passing in evidence-block.test.ts (193 pre-change + 5 new); 1477/1477 across the
whole spec package. tsc --noEmit: exit 0, zero diagnostics (the package tsconfig excludes
src/__tests__/**, so this does not typecheck the test file; vitest's esbuild transform runs it
untyped, which is why every call site still needed fixing by hand).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…ter load changes a session-key hash (E7f MEDIUM)

E7f (astra on 2550254, SHIP-WITH-FIXES) found that the captured JSON.stringify
still asks every object and array of the D3 grant body for `toJSON`, through the
prototype chain.

Reproduced, by a different route than the review's:
- The review's own repro (an own toJSON on Object.prototype or Array.prototype)
  does NOT change a hash at 2550254. canonicalize's polluted-prototype guard
  (util/canonical.ts, from #359) refuses the session snapshot first, and the
  hook never runs.
- That guard reads only those two prototypes' own properties. A toJSON on an
  object inserted between Array.prototype and Object.prototype passes it. At
  2550254 the hook runs (4 calls), the grant hash moves from 0x9b0a9a62... to
  0xad76b713..., and the auth digest from 0xaccbbe5a... to 0x904a9e10....

Fix:
- sessionKeyGrantBody builds the body and its scope with no prototype.
- Both scope arrays are copied into arrays with no prototype
  (prototypeFreeArray: elements are defined, never assigned).
- JSON.stringify's toJSON lookup now ends at each value itself.
- The bytes are unchanged: the D3 goldens and the production aggregate
  0xcb30733c... still pass.

New test: three installs after load, each as a method and as a counting getter,
over every synchronous entry point:
- an own toJSON on Object.prototype or Array.prototype;
- a toJSON on an object inserted under Array.prototype;
- an own toJSON on String.prototype or Number.prototype.
The hook is never looked up or run. The session-key entry points are refused in
the first case (the guard) and unchanged in the other two.

spec: 54 files, 1478 tests; tsc clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…ath, so no inherited getter supplies one (E7g MEDIUM)

E7g (astra on 605eb92, NOT CONFIRMED) found one more route for E7f's property.
The snapshot leaves an absent derivationPath out, and it has Object.prototype.
sessionKeyGrantBody read value.derivationPath with an ordinary [[Get]], so an
absent field walked to Object.prototype, where a getter placed after load could
supply a path that the grant then committed.

Reproduced at 605eb92 with the review's own steps: the fixture has no own
derivationPath, and Object.prototype.derivationPath is a getter returning
"m/injected". Results:
- the getter ran 4 times;
- the grant hash moved from 0x9b0a9a62... to 0xa92a2f16...;
- the auth digest moved from 0xaccbbe5a... to 0x60264b41....

Fix: sessionKeyGrantBody takes derivationPath through ownDataValue (own
descriptors only). It is present only as the validated string; ABSENT leaves
the key out. Every other field it reads is always present on the snapshot.

Tests:
- the review's reproduction, one to one: the getter never runs, and both hashes
  are unchanged;
- the general property: an Object.prototype accessor (logging get and set) for
  EVERY key the module uses. Those are every own key of every input, the
  descriptor keys, the result keys and "length". Every synchronous entry point
  runs. At 605eb92 the log is exactly "get derivationPath" x4, so no other key
  reaches a prototype. Now the log is empty, and every answer is unchanged.

The E7f inputs and runner are hoisted to the describe block and shared.

spec: 54 files, 1480 tests; tsc clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
… end pre-stage; index.ts keeps both exports

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
….ts keeps every export, plus the explicit Bytes32Hex re-export (TS2308 with #496)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…ch was first fast-forwarded to its E7i-confirmed end pre-stage (0259682)

Clean merge, no conflicts: #361's own files (evidence-block.ts, its test) and every module they import are byte-identical to 0259682; evidence/index.ts takes master's kernel-pull-capture export beside #361's evidence-block export.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116fiVRS9gDE1HrCECyjVR9
…use keyVersion 0 (oracle D3 review F1, F3)

The Opus 5.5 review of oracle D3 (pcc-oracle #23 @2cb5095f) found two LOWs that are partly evidence's:

- F1: no vector computed outside the oracle pinned the ORDER of the uint8 scheme slot and the uint32
  keyVersion slot. At keyVersion 1 both slots hold 1, so #361's own goldens had the same blind spot: an
  encoder that swapped the two slots passed all 201 tests. The new test pins keyVersion 2
  (0x82c74db8...026f) and 2^32-1 (0x7afa19ea...2a4a). Both values were computed outside this module by two
  encoders that share no code: ethers 6.16.0's AbiCoder (the D3 golden generator's authV2) and a pure-Python
  Keccak-256 over a hand-written 288-byte layout (bus #7155). Negative control: swapping the two words in
  computeSessionKeyAuthDigest fails only this test; the received value is F1's predicted 0xefbcc851...8b2c.
- F3: keyVersion >= 1 as a SHARED rule. computeSessionKeyAuthDigest now refuses 0 (context.keyVersion),
  matching the oracle's step-c range [1, 2^32-1] and evidence's ruling that versions start at 1 (bus #6776).
  Evidence Commitment Profile v1 section 3 carries the amendment. Negative control: a minimum of 0 fails only
  the refusal test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116fiVRS9gDE1HrCECyjVR9
@LamaSu
LamaSu changed the base branch from fix/spec-canonicalize-refuse-non-json to master October 7, 2026 00:18

This branch had an error being deployed

1 failed deployment
trusted-checks — df0a3965 Deployed Oct 7, 2026 by LamaSu via post-verdicts #204
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