Skip to content

feat(evidence): LO-EV-9 bind device evidence to the accepted job and kernel before settlement - #341

Merged
LamaSu merged 15 commits into
masterfrom
feat/evidence-subject-binding
Oct 3, 2026
Merged

LamaSu merged 15 commits into
masterfrom
feat/evidence-subject-binding

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Update 2026-09-24 @65485764: review rounds and the oracle's findings

Commits since the first cut (4132c1ed):

  • R1, 2a8f574e: /resume-settlement settles only the pinned, re-verified anchor.

    • A device anchor needs the binding plus the registered-key signature.
    • A gateway anchor must match the envelope hash.
    • /complete pins the device row itself.
  • Merges of feat(spec): LO-EV-1 canonical signing byte contract + cross-language goldens #338: da1268f0 brought in R20; 146df5da brought in R20 round 2.

  • Evaluate only what you hashed, 98352619 (coord-watch's cross-cutting rule):

    • the binding reads jobId, kernelId and outputHash only as own properties of the canonical snapshot of what was hashed, and returns those snapshots;
    • the gateway anchors on the snapshots, never the caller's objects;
    • a non-enumerable, inherited or polluted jobId, and a getter answering A to the hash and B to the evaluator, cannot bind;
    • an event canonicalize refuses is malformed-event, and the binding never throws.
  • Milestone binding, d3309de8 (oracle audit #3006: milestone 3's evidence settled milestone 4):

    • the optional subject fields settlementUnitId and challengeNonce (0x hex32) must be committed by the events;
    • kernel-sdk commits them from the job request.
  • Per-event binding, 0a4836a6 (oracle #3101, which /settle already enforces):

    • every event must commit payload.jobId, and the unit fields when named;
    • kernel-sdk now names the job on every event.
  • Incumbents conform, 65485764:

    • EvidenceEmitter stamps the PCC jobId (and the unit and nonce when registered) on every event, and refuses a conflicting value.
    • The kernel IPP adapter's printer-local number is now ippJobId, since payload.jobId is reserved for the PCC job.
    • The digital kernels name the job on every event.
    • A real mock IPP print bundle and a procurement bundle now bind. Kernel 868/868; 6 mutants killed.

Tests:

  • subject-binding 36/36
  • kernel-sdk 38/38
  • spec 860/860
  • gateway 3013 passed / 6 skipped; gateway and kernel-sdk tsc clean
  • mutants killed across the rounds: 6 (R1), 4 (snapshots), 6 (unit), 4 (per-event)

Review: coord-watch's Gate B slot takes this head (the range is 897c7bae..65485764).


What this closes (technical pack §3, must-close 9)

A signature over a bundle digest proves who signed which digest. It does not say which job, node or output the digest is evidence for. The bundle-level jobId / kernelId are not inputs to hashBundle, and /complete never reopened the digest. So once the SEAM-2 gate opens, three replays settle money:

Replay Why the signature check alone accepts it
Job A's genuine device bundle, relayed again under job B (same kernel, direct-signed) Same key, same digest. The route's jobId is an unsigned label.
Job A's bundle under job B, delegated session whose scope lists both jobs contractIds.includes(B) passes.
A log-chain entryHash + its per-entry signature, presented as a bundle The node key signs entry hashes too, and they are tagged digests (LO-EV-1 only stops non-digest messages such as the registration challenge).

Each negative test first shows the signature leg accepting the replay, then shows settlement refusing it.

The contract: pcc.evidence.subject-binding.v1

verifyEvidenceSubjectBinding({ bundleHash, events, subject }) in @pcc/spec fails closed unless:

  1. the subject names a job and a kernel;
  2. bundleHash is a canonical tagged digest (LO-EV-1);
  3. every event is well-formed and reproduces its own hash (hashEvent);
  4. the events reproduce bundleHash (hashBundle);
  5. at least one event commits payload.jobId, and every payload.jobId equals the subject job;
  6. every source.kernelId, and every payload.kernelId, equals the kernel that accepted the job;
  7. when the subject names an output, payload.outputHash is committed and matches.

These are fields kernel-sdk already hashes, so its bundles conform unchanged. The positive control uses the real createKernelHandler.

Gateway wiring (gate still closed by default)

  • resolveSettlementEvidence:
    • Requires a subject and the stored events for any device slot.
    • Checks the binding before the signature.
    • Takes the delegation scope from subject.jobId.
    • Accepts deviceBundles (every candidate). The first one that passes both checks anchors settlement. If none passes, the result falls back and reports the first failure.
  • /complete:
    • Loads each device-signed row's events with findEventsByBundle.
    • Sets subject = { jobId, kernelId: job.kernelId }. This is the job record's kernel, not an id supplied by the relay.
    • Tries every such row, so a replayed row stored first cannot hide the genuine bundle.
  • The closed-gate path is byte-for-byte unchanged.

Dependency

Relayed device rows carry events only once the operator relay stores them (#335, LO-GW-4b). Until then, a relayed row has no events and falls back to the gateway anchor. That fails closed. This PR does not touch the relay route (#335 owns it).

Producer requirements this sets

Tests

  • spec: 843/843 (22 new).
  • gateway full suite: 2996 passed / 6 skipped / 0 failed across 188 files. That includes 8 new resolver cases and 3 new /complete route cases with the gate forced open. The route cases cover:
    • job A→B replay refused, while job A still anchors on its own bundle (positive control);
    • a kernel-nyc bundle refused for a job kernel-sf accepted;
    • a replayed row stored first does not hide the genuine one.
  • gateway tsc --noEmit: clean.

Mutation checks: each leg was removed in turn, and every mutant turned at least one test red.

Mutant spec gateway
no job check 3 red 6 red
no per-event recompute 1 red 1 red
no source.kernelId check 1 red 0 red
no kernel checks at all 3 red 1 red
resolver skips binding 0 red 10 red
/complete tries first row only 0 red 1 red
no bundle recompute 3 red 0 red

Not in this PR

  • Output binding at /complete: the gateway holds no digest of the delivered output there. The spec supports subject.outputHash for consumers that do hold one.
  • stepId: not bound. kernel-sdk's payload.stepId names manifest workflow steps, a different namespace from job.stepId.

Stacked on #338 (LO-EV-1).

Update, 2026-10-03: round 4 (answering astra's E11 DO-NOT-SHIP on 51dbabd), head f249eb4

Every finding was reproduced at 51dbabd before any change (3 of 3 failing tests), and so were sensors' five realm recipes (bus #5381).

  • F1 (HIGH, unit/challenge replay at /complete and /resume-settlement): fixed.
    • The settlement unit and challenge must match exactly, in both directions.
    • A subject that names none refuses evidence that commits either (unit-not-in-subject, challenge-not-in-subject).
    • The legacy routes, which name only job + kernel and drive milestone 0, therefore cannot settle unit-scoped evidence.
    • Seam and route tests cover it. /resume-settlement answers 409 for a unit-scoped row pinned before the fix.
  • F2 (HIGH, an event.hash getter or Proxy): fixed.
    • Every field is read once, through its own descriptor, and source/payload are read through plainDataCopy.
    • A Proxy or accessor is refused before it runs, and all checks and hashes read only the copies.
  • F3 (MEDIUM, never throws): fixed. The body is wrapped, and every input resolves to a refusal.
  • Realm (sensors #5381): fixed.
  • New reason codes: unit-not-in-subject, challenge-not-in-subject and unsupported-runtime. SUBJECT_UNIT_FIELD_PATTERN is no longer exported (it had no users).
  • Verification:
    • spec 1206, kernel 869, kernel-sdk 39;
    • gateway 3733 of 3748: 2 load flakes (completion-real-tier, settlement-crank-wiring) that pass alone 3 of 3 and run with the gate closed;
    • tsc clean;
    • mutants: 6 of 7 killed, 1 equivalent.
  • Pack: E11b-binding-341-r4-f249eb48.md (PASTE ORDER 510), a full delta round.

🤖 Generated with Claude Code

https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS

…kernel before settlement

A signature over a bundle digest proves who signed which digest, not which
job, node or output the digest is evidence for. The bundle-level jobId and
kernelId are not inputs to hashBundle, and /complete never reopened the
digest, so with the SEAM-2 gate open:

- a genuine device bundle for job A, relayed again under job B, settled job B
  (same kernel, direct-signed);
- a delegation scoped to several jobs let job A's bundle settle job B;
- any other tagged digest the node key signs (a log-chain entryHash) passed
  the signature check as a bundle digest.

@pcc/spec gains verifyEvidenceSubjectBinding (pcc.evidence.subject-binding.v1).
It fails closed unless every event reproduces its own hash, the events
reproduce the signed bundleHash, at least one event commits payload.jobId and
every payload.jobId is the subject job, every source.kernelId and
payload.kernelId is the kernel that accepted the job, and (when the subject
names one) payload.outputHash matches. These are the fields kernel-sdk
already hashes.

resolveSettlementEvidence now requires a subject and the stored events for a
device slot, checks the binding before the signature, and takes the delegation
scope from the subject's job. /complete loads each device-signed row's events
and tries every such row, so a replayed row stored first cannot hide the
genuine one. The gate stays closed by default; the closed path is unchanged.

Relayed rows only carry events once the operator relay stores them (#335,
LO-GW-4b). Until then a relayed device row has no events and falls back to
the gateway anchor.

Tests: spec 843/843 (22 new), gateway full suite 2996 passed / 6 skipped
(8 new resolver cases, 3 new /complete route cases with the gate forced open).
Each binding leg was removed in turn and a test failed every time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
LamaSu and others added 5 commits September 24, 2026 13:58
# Conflicts:
#	packages/gateway/src/__tests__/device-evidence-settlement.test.ts
…re-verified anchor

Cross-family review of #341 (r1-341-binding-astra, DO-NOT-SHIP): the binding
held in /complete, but /resume-settlement took the LAST evidence row for the
job and drove the chain and the oracle with its digest, verifying nothing.
Any row relayed after completion became the settlement evidence.

- /resume-settlement settles only the evidence /complete pinned
  (job.evidenceBundleId) and re-verifies it before any settlement step
  (verifyPinnedSettlementEvidence): the row must belong to this job and its
  kernel; a device anchor must pass the subject binding and the registered-key
  signature again; a gateway anchor's bundleHash must recompute from its
  stored envelope (the bytes GET /api/evidence/:hash serves). Otherwise the
  claim is released and the route refuses with the reason. The chain receives
  the pinned digest as bytes32, as /complete's V3 path already does.
- /complete no longer restates a device digest over the gateway's own events:
  the gateway record is stored under its own envelope hash, and when a
  verified device bundle anchors settlement the job pins that device row
  (its events open to its digest; its delegation is kept). The archive and the
  response report the anchor.
- Recovery test fixtures pinned fake hashes ("sha256:trapped-evidence", a bare
  0x); they now pin genuine gateway anchors.

Merged #338 @897c7bae (R20 fixes) into this branch first.

Tests: gateway full suite 3010 passed / 6 skipped / 0 failed (188 files; 12
new here); gateway tsc clean. Six mutants (latest row again, device re-check
skipped, gateway recompute skipped, /complete restating the digest, job check
removed, refusal ignored) each turn a test red.

Still open from the review, with owners (not claimed here): delivered-output
and assigned-device binding need authoritative execution state (gateway /
composition); an acceptance nonce per execution (gateway); a persisted
settlement intent for idempotent retries (escrow / gateway); comparing the
pinned digest with evidence already on-chain (escrow read).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
…anchor carries the canonical snapshots

Coord-watch's cross-cutting rule, from two independent verdicts: an unsigned
payload.jobId hidden as a non-enumerable property passed the job binding,
because hashing skipped it but the evaluator read it.

verifyEvidenceSubjectBinding now:
- hashes canonicalize({type, timestamp, source, payload}) itself;
- reads jobId, kernelId and outputHash from the parsed canonical text, as own
  properties only, never from the live object;
- returns those snapshots (ok: true, events).
A non-enumerable, inherited or Object.prototype-polluted jobId, and a getter
that answers the hash with job A and the evaluator with job B, can no longer
bind a subject. Any event canonicalize cannot hash (a cycle today; more once
#359's strict tree lands) is malformed-event: the function still never
throws.

The gateway's resolveSettlementEvidence anchors on the binding's snapshots
instead of the caller's objects, so what is archived and evaluated
downstream is exactly what was hashed. The R1 pinned-evidence check uses the
same anchor verifier.

Tests:
- subject-binding 28/28: 5 new hashed-only cases plus snapshot return
- device-evidence-settlement 47/47: the anchor is the snapshot, independent
  of later changes to the caller's copy
- spec 852/852
- gateway 3012 passed / 6 skipped, tsc clean; the one timeout in
  completion-real-tier.test.ts under load passes 3/3 alone, three times
- 4 mutants killed: live objects evaluated, inherited reads,
  canonicalize throw escaping, resolver returning caller objects

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
…once, so one milestone's evidence cannot settle another

The oracle's audit probe found that evidence signed for milestone 3 of a job
settled milestone 4 of the same job. Kernel-signed events committed only
payload.jobId; the unit and the challenge lived in the evidence block, which
the producer assembles and the kernel never signs.

- EvidenceSubject gains optional settlementUnitId and challengeNonce, each
  0x + 64 lowercase hex and byte-equal to the settlement package's
  unitBinding.settlementUnitId and challengeBinding.nonce. They come from the
  unit record, never from the evidence.
- When the subject names one, some event must commit it and every event
  that carries it must agree, the same rule as jobId and outputHash. The new
  reasons are unit-not-committed, unit-mismatch, challenge-not-committed and
  challenge-mismatch. A subject that names no unit behaves as before.
- kernel-sdk's handler accepts settlementUnitId and challengeNonce on the job
  request (refusing either if malformed, 400) and commits them in the signed
  execution_started and execution_completed payloads. Jobs without them keep
  their bytes.
- The gateway must forward both on dispatch, and pcc-node must commit them
  (adk). The oracle mirrors the check at /settle. Until then a unit-bound
  subject fails closed.

Tests:
- subject-binding 34/34 (6 new)
- kernel-sdk 38/38 (3 new; the handler's evidence binds its unit and refuses
  another)
- spec 858/858
- gateway 3013 passed / 6 skipped; gateway and kernel-sdk tsc clean
- 6 mutants killed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
LamaSu added a commit that referenced this pull request Sep 24, 2026
…binding) into #358

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
…ernel-sdk names the job on every event

The oracle enforces per-event job binding at /settle: every event's
payload.jobId must equal the settling job. kernel-sdk's
gcode_hash_verified and workflow_step_completed carried no jobId, so every
kernel-sdk bundle authenticated and then failed the job binding. The kernel
signs event by event, and a session delegated for several jobs could not
attribute a jobless event, so the producer moves (oracle #3101; my answer
#3137). LO-EV-9 moves with it, so public and private binding agree.

- LO-EV-9 rule 5 changes from "some event commits payload.jobId" to "EVERY
  event commits payload.jobId equal to the subject" (job-not-committed now
  names the event).
- settlementUnitId and challengeNonce, when the subject names them, likewise
  scope EVERY event. outputHash stays "some event", since it lives on the
  completion.
- kernel-sdk puts jobId, and the unit fields when given, on every event: the
  input commitment, every step completion, started and completed.
- Other producers (pcc-node for adk; sensors' adapters) must follow. Until
  they do, their bundles fall back and fail closed.

Tests:
- subject-binding 36/36 (new: only some events naming the job; a unit
  carried by only some events)
- kernel-sdk 38/38 (every event, step completions included, names the job
  and unit)
- spec 860/860
- gateway 3013 passed / 6 skipped; gateway and kernel-sdk tsc clean
- 4 mutants killed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
LamaSu added a commit that referenced this pull request Sep 24, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
…el bundles bind (LO-EV-9 per-event)

Charter item 3, incumbents conform. After per-event job binding
(0a4836a), no bundle from packages/kernel could bind:
- EvidenceEmitter.addEvent knew the job but never committed it.
- The digital kernels' input and step events had no jobId.
- The IPP adapter put the PRINTER's own job number in payload.jobId, the
  field LO-EV-9 reserves for the PCC job. That evidence could never bind,
  even under the old "every present jobId must match" rule.

- EvidenceEmitter commits payload.jobId on every event before hashing. When
  registerStep is given a settlement unit (0x hex32 unitId + nonce), it
  commits both on every event too. An adapter may pre-fill those fields, but
  a different value is refused, never overwritten.
- IPP adapter: the printer-local number is now payload.ippJobId, at every
  evidence emit site (mock and real). printer-job reads printerJobId from it.
  Status responses are unchanged.
- Accounting and procurement-RFQ kernels thread jobId into every step event
  and the input commitment.

Tests:
- evidence-emitter-binding 4/4: stamping, the unit, refusing another job or
  a malformed unit, and a real mock IPP print whose bundle binds
  {jobId, kernelId}
- procurement 16/16: the bundle binds
- kernel 868/868; tsc clean
- spec 860; gateway 3013 passed / 6 skipped
- 6 mutants killed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
Two gaps in the per-event unit binding, found while reviewing pcc-node's
matching change (#420), where the same probes pass:

- EvidenceEmitter kept a settlementUnitId or challengeNonce that an
  adapter pre-filled on a step registered without a unit, so a signed
  event could commit a unit the kernel was never given. Those fields are
  reserved for the binding; a unit-less step now refuses them.
- kernel-sdk's job handler accepted a settlementUnitId without its
  challengeNonce, or the reverse. The oracle requires both on every
  event, so half a binding can never settle; it is now refused with 400
  before anything executes.

kernel-sdk 39/39, kernel 869/869, tsc clean for both. Four mutants (each
guard dropped, the pair check narrowed to one direction, the nonce left
unreserved) are each killed by one test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
LamaSu added a commit that referenced this pull request Sep 28, 2026
…vidence's failure and contradiction rules

Coord-watch's cross-cutting rule (#2961), carried to #363 by evidence (#3079):
evaluate only what you hashed. LO-EV-9 (#341 @799cea1d) now returns the
verified canonical snapshots of each bundle's events. Admission reads those
snapshots for every check (dedupe, simulation, levels, device, version,
window, the inspection verdict), never the caller's objects, whose getters or
non-enumerable fields could answer differently from the hashed bytes.

inspectionFailed and the contradiction rule now come from evidence-level.ts
(#345 @cb81284f): inspectionFailed replaces my private copy (same rule), and
deriveContradictions is the one public contradiction rule the oracle signs
rejects on, so admission and the oracle cannot drift. A failure with no
completion is still a device failure under onDeviceFailure.

Stack refreshed: #338 @92b4302b (R20 round 2), #341 @799cea1d, #345
@cb81284f, #336 @06a5a49b.

Tests: a payload.passed getter that answers false while binding hashes it and
true afterwards is rejected (with the old live-object read put back, that
test fails); contradictions are named by kind. 42 admission tests; spec
979/979; tsc clean; test files type-check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Sep 29, 2026
…ompleted commands are timed by the run's end

Sensors' review of #417 (bus #3461, returns/pcc-sensors-review-417.md):

- F1: every event now has an id, <opentronsRunId>:<commandId>, and a
  source.deviceType of "instrument", the value the kernel's Opentrons adapter
  reports (packages/kernel/src/opentrons/adapter.ts, on master and on #426).
  EvidenceEventSchema.safeParse passes on every event; on fbdab3d it failed
  with "id: Required" and "source.deviceType: Required".
- F2, option (a): an entry is timed by the command's completedAt. A command
  that never completed is timed by run_ended_at, the run's own completedAt.
  run_ended_at is required in that case and may not be earlier than any
  completion, so capturedAt never runs backwards for that entry. createdAt
  stays in rawContent. Times are parsed as ISO-8601 with a zone, and a
  malformed time fails closed.
- The TS goldens are recomputed with packages/spec's hashEvent and hashBundle.
  deviceType is part of the hashed source; id is not hashed, as in TS.

Verified: 34 command-trace tests and 23 log-capture parity tests pass. With
the TS spec (run through tsx), every event passes EvidenceEventSchema, Python
and TS agree on hashEvent and hashBundle, and verifyEvidenceSubjectBinding
(#341) returns ok. Against fbdab3d, 8 of the tests fail, each on the
property it names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Sep 29, 2026
… the enclosing bundle with subject binding
LamaSu added a commit that referenced this pull request Sep 29, 2026
…; profile checks run on a one-pass copy

Round 2 answer to astra's DO-NOT-SHIP on 2df436b (pack 40; triage bus #3687).
#336 is now stacked on #341 (LO-EV-9, 799cea1) and #338 round 3 (6cea24d).

#52 (40-1..40-3): an entry's signature covers {capturedAt, rawContent,
source}, not its predecessor, position or run, so entries alone could not show
a chain was the kernel's. makeExecutionLogVerifier now takes the kernel-signed
BUNDLE as its instance and the subject from ctx.subject (the job record). It:
- verifies the bundle signature: the registered signer (deps.expectedSigner),
  ed25519, a 64-byte value, and deps.verifyBundleSignature;
- runs LO-EV-9 subject binding;
- extracts exactly one linear chain from GENESIS from the bound events (one
  payload.entries carrier, or one log_hash_chain_entry per entry, linked);
- pins every entry signature to the same signer and algorithm;
- requires capturedAt to be non-decreasing (evidence ruling, bus #3553);
- runs verifyLogChain.
Truncation, reordering with rewritten links, cross-run splicing, forks, gaps
and a second log are rejected. The factory throws on an invalid minEntries or
a missing expectedSigner. The entry-hash formula is unchanged. The oracle has
been told of the contract change (bus #3691).

Profile (40-5..40-8): calibration procedureId and validityWindowSeconds are
invalid unless calibration.required; sparse arrays fail; profileGoverns never
throws and returns the deep-frozen snapshot it validated and digested (plus a
code). Every check runs on util/plain-data.ts plainDataCopy, a one-pass copy
that reads each property exactly once and refuses anything JSON cannot carry
(canonicalize reads each property twice). There is a real key-reordering test.

Vector (40-9, 40-10): the emitter exports buildLose3Envelope, and a test
regenerates the fixture byte-for-byte. negatives.failureBearingBundle is the
positive bundle plus execution_failed, hashed and signed; its integrity
verifies, so a consumer refuses it by outcome policy (#363). The positive
bundle is unchanged (b80f569c...).

Tests: spec 43 files, 968 passed; tsc clean; the test files type-check.
Mutation check: 23 single-rule mutations of the new code, all caught.
LamaSu added a commit that referenced this pull request Oct 1, 2026
…the current subject binding

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 feat/evidence-loev1-byte-contract to master October 3, 2026 02:13
@LamaSu LamaSu closed this Oct 3, 2026
@LamaSu LamaSu reopened this Oct 3, 2026
…ets master

Brings in #459 (the relay binds a signed document to its job and kernel) and the
rest of master, so the subject binding is tested against current code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…LO-EV-9

#459's test (adk #4322, rule 5) presented a session bundle signed for job A as
job B with no events or subject. Merged with #341 (LO-EV-9), the slot is refused
earlier as missing-subject, so the test no longer exercised rule 5 (found by
#341's first full CI on master: build-and-test, 1 failure).

The test now binds the events to job B, the job being settled, so the subject
binding passes, and signs them with a session key whose delegation names only
job A. The scope check refuses it: contract_not_allowed. The whole-bundle replay
(job A's bundle for job B) stays covered by the kernel-sdk subject test.

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

LamaSu commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Merge order (steward, bus #4856): this PR (#341) merges first, then #336 (MeasurementProfileV1, sensors), which carries this branch's commits and is now retargeted to master.

🤖 Generated with Claude Code

LamaSu added a commit that referenced this pull request Oct 3, 2026
…k #4322 test under subject binding (#336 CI build-and-test failed at d382255)
LamaSu added a commit that referenced this pull request Oct 3, 2026
…pack 162 (HIGH 2, MEDIUM 3, HIGH 4), with the #341 merge (b4f1bd2) it carries
LamaSu added a commit that referenced this pull request Oct 3, 2026
…ra pack 162 (HIGH 2, MEDIUM 3, HIGH 4), with the #341 merge it carries
LamaSu and others added 4 commits October 3, 2026 09:52
… canonicalize (verbatim from 8dc6ef2)

E11 (astra, DO-NOT-SHIP on #341 @51dbabd2) found that the subject binding
reads caller objects live: an event.hash getter or Proxy answers the checks
one way and the bundle hash another (F2), and a throwing getter or Proxy
makes the verifier throw (F3). Sensors closed the same class on #336 with
one plain-data copy at the boundary and intrinsics captured at load (astra
packs 158-171, 171 SHIP). The steward suggested reusing it (#5232), and
sensors reproduced realm-mutation forgeries against subject-binding.ts
itself (#5381).

These six files are byte-identical to #336 @8dc6ef2b, so #336's merge-up
of #341 stays clean:
- packages/spec/src/util/primordials.ts (new);
- packages/spec/src/util/plain-data.ts (new): plainDataCopy, and isProxy
  from a static node:util import;
- packages/spec/src/util/canonical.ts: canonicalize calls only captured
  intrinsics, with byte-identical output for JSON data;
- apps/dashboard/src/lib/node-util-shim.ts (new) and the vite alias, so
  the dashboard build resolves node:util;
- packages/spec/src/__tests__/plain-data-prototype.test.ts (new).

The next commit routes verifyEvidenceSubjectBinding through them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…throws, and binds unit and challenge both ways (E11 F1-F3)

astra's E11 on #341 @51dbabd2 was DO-NOT-SHIP. All three findings were
reproduced at 51dbabd before any change:
/mnt/sparkbulk/tmp/evidence-341-e11-repro-51dbabd2.txt has 3 of 3 failing.

F1 (HIGH). A subject that names no settlement unit accepted evidence that
commits one. The legacy /complete and /resume-settlement build exactly that
subject ({jobId, kernelId}) and drive milestone 0, so evidence signed for
U3/N3 could anchor them. Unit and challenge are now matched exactly in both
directions:
- named by the subject, every event commits the value;
- not named, no event may commit one (unit-not-in-subject,
  challenge-not-in-subject).
A consumer that cannot name the unit it settles therefore cannot accept
unit-scoped evidence. The output stays one-way: it is content, not scope.

F2 (HIGH). event.hash was read three times, so a getter or Proxy could
answer the checks with B's hash and the bundle with A's.

F3 (MEDIUM). Field reads sat outside the try, so a throwing getter or Proxy
rejected the promise.

For F2 and F3, every field is read once:
- the input's three fields, the subject's five and each event's six through
  own data descriptors;
- source and payload through #336's plainDataCopy.
A Proxy, an accessor, a hole or non-JSON data is refused without being run.
All checks and hashes then read the copies only, and the whole body is
wrapped, so every input resolves to a refusal.

Realm mutation (sensors' residual on this file, bus #5381). The checks call
only intrinsics captured at load:
- primordials;
- canonicalize;
- node:crypto's SHA-256, captured and synchronous, so nothing is awaited
  inside.
There is no sort, iterator protocol, RegExp or JSON.parse. Results and copies
have null prototypes and are frozen, so Object.prototype.then cannot rewrite
an answer.

Tests:
- subject-binding.test.ts: the F1 unit test flips, plus 4 more F1 cases and
  6 F2/F3 cases: astra's getter and Proxy reproductions, nested Proxy and
  accessor, throwing getters at every read, never-throws inputs, frozen
  null-prototype results.
- subject-binding-realm.test.ts (new): 33 after-load patches x 9 cases leave
  every answer unchanged, and a source scan finds no ambient method,
  iterator, RegExp or await.
- The scratch reproduction file is deleted.

spec: 50 files, 1206 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
… patch held until the answer settles

Each recipe forged an ok out of 51dbabd's binding leg. They were reproduced
before the fix (/mnt/sparkbulk/tmp/evidence-341-realm-repro-51dbabd2.txt):
- a targeted Array.prototype.sort answers job A's signed hashes for
  re-hashed job B events;
- JSON.parse answers a snapshot that commits job B;
- an Object.prototype.then getter forges the refusal into ok;
- SubtleCrypto.prototype.digest hashes B's content as A's;
- a replaced array iterator skips the unit and challenge checks.

At this head all five refuse. Run against 51dbabd's subject-binding.ts and
canonical.ts, all five fail
(/mnt/sparkbulk/tmp/evidence-341-r4-recipes-vs-51dbabd2.txt). The patches
are targeted, so vitest's own use of each intrinsic keeps working while the
patch is held.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
… anchor /complete or /resume-settlement

/complete and /resume-settlement verify device evidence against
{ jobId, kernelId } and drive milestone 0 of the job's per-job escrow. A
bundle whose events commit settlement unit U3 and challenge N3 used to
anchor them (astra E11 F1).

LO-EV-9 now matches unit and challenge exactly in both directions, so these
routes refuse such evidence and settle on the gateway fallback. Recovery
refuses a unit-scoped row pinned before the fix.

device-evidence-settlement.test.ts:
- resolveSettlementEvidence on the exact /complete subject falls back with
  unit-not-in-subject;
- a U4/N4 consumer gets unit-mismatch;
- the U3/N3 consumer anchors on the device (positive control);
- verifyPinnedSettlementEvidence refuses a pinned U3/N3 row.

paid-job-flow-evidence-binding.test.ts:
- /complete does not anchor or pin a U3/N3 bundle, while a unit-less bundle
  from the same node still anchors;
- /resume-settlement answers 409 unit-not-in-subject for a unit-scoped pinned
  row, and the job stays at evidence_submitted.

Comments at both subject sites say why they name no unit. No code change in
the gateway.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
LamaSu added a commit that referenced this pull request Oct 3, 2026
…ported onto the hardened LO-EV-9 leg

#341 round 4 (astra E11b SHIP, MERGE-READY) rewrote subject-binding.ts:
- each field is read once, through own descriptors and plainDataCopy;
- no caller code runs;
- it never throws;
- it uses only load-time intrinsics;
- unit and challenge must match exactly, in both directions.
#438 had added step 10 (an optional eventTimeWindow on the subject) to the
older file.

Conflicts:
- subject-binding.ts: took round 4's file and re-added step 10 in its style:
  - eventTimeWindow is read once as own data and copied with plainDataCopy,
    and its bounds are safe integers checked with a captured Number.isInteger;
  - the window is checked on the verified copies after the unit and
    challenge checks, through checkEventTimes;
  - the reason and index are read as own properties, and the refusal is
    null-prototype;
  - #438's header text (step 10, one bundle per settlement unit) is kept.
- device-evidence-settlement.ts: kept #438's destructured row fields and
  receivedAt, plus round 4's E11 F1 comment on the job-and-kernel subject.
- device-evidence-settlement.test.ts:
  - the helper takes both #438's `at` and round 4's `unit`;
  - #341's isolated rule-5 negative gets the trusted context #438 requires
    for session evidence (operatorPrincipalId, receivedAt, events inside the
    window), so that only the scope can refuse it. It still expects
    contract_not_allowed.

The next commit moves checkEventTimes's time path onto load-time intrinsics
too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
LamaSu added a commit that referenced this pull request Oct 3, 2026
…teward #5719)

#341 carries #336's older commits: its port of the plain-data boundary, the
load-time intrinsics and canonicalize is byte-identical to 8dc6ef2's
(canonical.ts, plain-data.ts and primordials.ts hash the same on both sides).
No conflict. Once #341 merges, master holds f249eb4, and #336's diff against
it is #336's own changes again.
LamaSu added a commit that referenced this pull request Oct 3, 2026
… after MERGE NOW's chain (steward #5952)

MERGE NOW merges #336 (#35, carrying #341) before #359. The pre-stage 93dd671 (#359 + #341) then conflicted
in spec/__tests__/canonical-intrinsics.test.ts: an add/add conflict, because #359 and #336 each added that file.

The resolution is the sensors lane's own (bb8b8c3, evidence #5788), reused because neither side has changed
the file since:
- canonical-intrinsics.test.ts: #359's;
- #336's tests move to canonical-legacy-bytes.test.ts and measurement-profile-source-scans.test.ts, verbatim
  from bb8b8c3;
- canonical.ts, job-handler.ts and accounting-kernel.ts auto-merged to exactly bb8b8c3's resolution.

spec build clean; spec 62 files, 1668 tests; kernel-sdk 54; kernel 883; gateway 207 files, 3735 tests; tsc
clean (kernel, gateway).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
LamaSu added a commit that referenced this pull request Oct 3, 2026
…head of their merges (steward #5924)

The one conflict, ipp-adapter.ts's real-mode polling: #502 moved the poll loop into poll()
(the job is read once, a stopped or replaced job's answer is dropped, and the loop is tracked
for quiesceEvidence), and #341 renamed the printer's job number in its execution_completed and
execution_failed payloads to ippJobId, since payload.jobId is reserved for the PCC job (LO-EV-9).
The resolution keeps poll() and applies both renames in it. Its execution_progress keeps
payload.jobId exactly as #341 has it; that is a defect of its own, fixed in the next commit.

#341's emitter commits the job's id into every recorded payload, and refuses an event that
pre-fills another. #502's tests, written before it, are adapted:
- R1's exact list of job B's payloads, and the shared-sensor summary, now carry the job's id.
- Ten absences (not.toContainEqual of an exact object) could no longer fail: no recorded
  payload equals an object without the id. They now match by expect.objectContaining. Five
  mutants that let a late or another job's event be recorded fail 8 of them directly with
  objectContaining and none of them with exact objects; the other 2 sit behind an earlier
  hard assertion in the same test that the same mutants fail first.
- pull-camera Fix A: a camera capture naming another job is now refused by the emitter
  before the tier check sees it, so the run fails as "could not be recorded", naming both
  jobs. The tier check's own refusal of such a capture keeps its direct test.
LamaSu added a commit that referenced this pull request Oct 3, 2026
…s ippJobId, so it is recorded (LO-EV-9)

#341 reserved payload.jobId for the PCC job: the emitter commits it on every event and
refuses an event that pre-fills another value. #341 renamed the IPP adapter's own job number
to ippJobId on every event but one: the real-mode poll's execution_progress still sent it as
jobId. The emitter refused that event, so a real print that reported progress failed:
runPrintJob rejected at its Promise.all over the recorded events (printer-job.ts:254). (A
JobRunner cannot start a real IPP print: its start carries no documentData.)

Reproduced at 93f0852 by the new ipp-adapter-job-binding.test.ts, whose two real-mode
completion cases fail there ("event payload.jobId 77 does not match the step's job-ipp-1").
It drives a fake IPP client in real mode, as adapter-quiesce-evidence does, and pins every
emit site: a completed real print, an aborted one, and a mock one record all their events
under the PCC job, with the printer's job as ippJobId. Reverting any one site's rename
(progress, completed, failed, started, mock completed) fails it: 5/5.
@LamaSu
LamaSu marked this pull request as ready for review October 3, 2026 22:25
@LamaSu
LamaSu merged commit 6f7877c into master Oct 3, 2026
9 checks passed
LamaSu added a commit that referenced this pull request Oct 4, 2026
…into #541

Device-job ids in the tests this PR adds move to ippJobId, as in #521's fold-in: payload.jobId
is the PCC job's under LO-EV-9 (#341). kernel suite green.
LamaSu added a commit that referenced this pull request Oct 4, 2026
…336

The one conflict, spec/src/evidence/index.ts: both sides added an export (#363's
profile-admission, master's principal-id). Both are kept.

One test adapted to #341's hardened LO-EV-9 binding, which runs no caller code: in 'a getter
that answered false to the hash cannot answer true to admission', the probe that counted the
binding's reads of the getter now finds the binding refusing it (malformed-event at event 2)
with 0 reads. The admission assertions are unchanged: an accessor is refused as
input-unreadable before anything is read. spec 2301/2301, tsc clean.
LamaSu added a commit that referenced this pull request Oct 4, 2026
…which follows #363

profile-admission.test.ts auto-merged with #363's probe adaptation to #341's binding. One more
adaptation to #341: in 'admission's own await of binding's answer looks nothing up on it', the
binding now answers with a frozen null-prototype object, so no inherited then is looked up at all
(0, was 1 when its own promise resolved). The test asserts that, and pins why: the answer has no
prototype and is frozen. A polluted Object.prototype.then cannot reach admission through it.
spec green, tsc clean.
LamaSu added a commit that referenced this pull request Oct 4, 2026
…d (N79 round 8 merge-up)

PR #462 conflicted with master in two places. The resolution keeps both sides' properties.

- facades/job.facade.ts, updateStatus: master's N85(a) guard (#475) and N79's refund now compose in ONE
  transaction (escrow-refund.ts, writeJobStatusGuardedWithRefund). The guard decides whether a generic writer
  may write at all, and only a terminal write the guard allows gives the escrow back. Today that refund branch
  is unreachable: escrowForJob needs a session, and N85(a) treats any session as a settlement record. The
  composition keeps N79's property if N85(a) is ever relaxed.
- routes/paid-job-flow.ts, resume-settlement: master's LO-EV-9 re-verification of the pinned evidence (#341)
  is the gate. On ANY refusal, N79's recovery runs: the prior status is restored (not hardcoded) and the escrow
  claim is handed back. The 409 keeps master's message and reason; its error is no_recorded_evidence_bundle
  (no pinned row) or pinned_evidence_unverified.

Tests adjusted to master's semantics:
- N79 tests that expected a generic writer (PATCH status, the operator relay) to finish or refund a paid job
  now expect N85(a)'s 409, with the escrow unchanged.
- N79 resume fixtures use genuine gateway anchors (bundleHash is the canonical envelope hash), since LO-EV-9
  re-verifies them. R4-H2's crank-hash assertion takes the chain form, the 0x hex of master's chainEvidenceHash.
- Master's F1 (#475) configures its release address as the rowless default (ESCROW_CONTRACT_ADDRESS plus
  ESCROW_CONTRACT_VERSION=v1), which N79 round 8 requires for a job with no escrow row. No assertion changed.
- New: n79-mergeup-guarded-refund.test.ts covers the composition: a refund on a guarded terminal write, none on
  a refusal, and both rolled back together.

The resolution is reviewable as the diff from `git merge-tree --write-tree 6b54000 108c778` (tree e15941ad)
to this commit.
LamaSu added a commit that referenced this pull request Oct 6, 2026
…ommitted-metadata

fix(verifier): neither an event's unsigned id nor the events' order decides EvidenceVerifier's verdict; docs: they are uncommitted metadata (E11b/E11c, stacked on #341)
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