Skip to content

feat(evidence): delegation-scope and event-time rules at settlement, one rule with the oracle - #438

Merged
LamaSu merged 13 commits into
masterfrom
feat/evidence-delegation-time-rules
Oct 6, 2026
Merged

LamaSu merged 13 commits into
masterfrom
feat/evidence-delegation-time-rules

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

What

This PR publishes the evidence lane's rulings on the oracle's audit r3 questions as public, deterministic code, so the gateway and the oracle's /settle apply one rule (lockstep).

Rulings: #3338 → #3345, #3344, and #3425 → #3542. Full text: returns/pcc-evidence-work/oracle-audit-r3-rulings.md.

Rule Where /settle
scope.contractIds is a non-empty string list naming the settling job. An empty list is refused, never "any contract". checkDelegationScope; also fixes the stale comment in identity/ephemeral.ts refuses (b5a458d)
scope.maxSignatures is a safe integer ≥ 1 and covers the bundle's session-signed events. checkDelegationScope (sessionSignedEventCount) scope_signatures_exhausted (#3344)
parentAgentId equals the funded operator's principal id, when the consumer holds it. checkDelegationScope (operatorPrincipalId) parent_not_operator (b5a458d)
Event timestamps are RFC 3339 with an explicit offset. That means uppercase T/Z, years 1970–9999, at most 9 fraction digits, and comparisons in whole seconds. parseEvidenceTimestamp mirrors the vectors
A package's evidenceTimeBounds are decimal strings of Unix seconds (^(0|[1-9][0-9]*)$, a safe integer), as in the canonical settlement-vector golden (1699999500 / 1700000000). This corrects #3542 (bus #3567): the RFC 3339 body #3542 cited is the D1 signature golden, which marks itself "not the schema". parseEvidenceTimeBound mirrors the vectors
Every event lies in [issuedAt, min(expiresAt, receivedAt)] ± 300 s. Package bounds must satisfy start ≤ end and contain every event. checkEventTimes; LO-EV-9 step 10 (optional subject.eventTimeWindow) mirrors the vectors

The gateway's delegation check (device-evidence-settlement.ts) now calls checkDelegationScope and counts the binding's verified events. It keeps the existing reason string contract_not_allowed.

The LO-EV-9 header now states the v1 rule: one kernel-signed bundle per settlement unit (#3543).

Vectors

packages/spec/src/evidence/delegation-rules.vectors.json holds 18 timestamps, 12 scope cases and 14 time cases (including the canonical golden's bounds), computed independently with Python datetime. The oracle mirrors them.

Tests (DGX Spark)

  • Head 4e57037c. spec 870 (+10), kernel-sdk 39, gateway 3015 passed and 6 skipped (+2). tsc is clean for spec and gateway.
  • Mutation check: 9 mutants, each killed:
    • the 1970 floor removed;
    • lowercase t/z accepted;
    • the empty-list check removed;
    • the event-count check removed;
    • skew edges made inclusive;
    • the bounds check removed;
    • the parent check removed;
    • LO-EV-9's window ignored;
    • the gateway's event count omitted.

Stacking

Stacked on #341 (feat/evidence-subject-binding @799cea1d). #358's mint guard will validate the bounds grammar once it merges this.

Producer note: a delegation whose maxSignatures is below its bundle's event count no longer anchors. The oracle's example is the #1551 HP-printer delegation, with maxSignatures 1 and several events.

Draft. Merging is the operator's call. Needs a cross-family review; it touches evidence and money-path verification.

🤖 Generated with Claude Code

LamaSu and others added 2 commits September 28, 2026 20:26
…one rule with the oracle

The evidence rulings on the oracle's audit r3 questions (#3338 -> #3345,
#3344, #3425 -> #3542), as public deterministic code, so the gateway and
/settle apply one rule:

- checkDelegationScope. scope.contractIds is a non-empty string list
  that names the settling job; an empty list is refused, never "any
  contract", and the stale doc comment in identity/ephemeral.ts is
  fixed. maxSignatures is a safe integer >= 1 and covers the bundle's
  session-signed events (the oracle's scope_signatures_exhausted).
  parentAgentId equals the funded operator's principal id when the
  consumer holds it.
- parseEvidenceTimestamp and checkEventTimes. Event timestamps and a
  package's evidenceTimeBounds are RFC 3339 with an explicit offset
  (uppercase T and Z, years 1970-9999, at most 9 fraction digits,
  whole-second comparison). Every event lies inside the delegation window
  with 300 s of skew; bounds must satisfy start <= end and contain every
  event.
- LO-EV-9 step 10: an optional subject.eventTimeWindow, evaluated on the
  hashed snapshots. The header also states the v1 rule of one
  kernel-signed bundle per settlement unit (#3543).
- The gateway's delegation check now calls checkDelegationScope and
  counts the binding's verified events. It keeps the reason string
  contract_not_allowed for contract failures.

Vectors in evidence/delegation-rules.vectors.json were computed
independently (Python datetime): 18 timestamps, 12 scope cases, 10 time
cases. spec 869 (+9), kernel-sdk 39, gateway 3015 passed and 6 skipped
(+2), tsc clean for spec and gateway. Nine mutants are each killed: the
1970 floor, lowercase t/z, the empty-list check, the event-count check,
inclusive skew edges, the bounds check, the parent check, LO-EV-9's
window, and the gateway's event count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s, as the canonical golden has them

Correction to ruling #3542 (bus #3567). The canonical integrated
settlement-vector golden, which gives packageDigest 0xf78103a1 and
matches the gateway fixture g2-settlement-vector-golden.json, carries
evidenceTimeBounds as decimal strings of Unix seconds: start
"1699999500", end "1700000000". The RFC 3339 body #3542 relied on is
the D1 signature golden, which marks itself "not the schema".

parseEvidenceTimeBound accepts exactly ^(0|[1-9][0-9]*)$ as a safe
integer: no numbers, signs, leading zeros, fractions, exponents or RFC
3339. Event timestamps stay RFC 3339 (parseEvidenceTimestamp). The
vectors replace the bounds cases (RFC 3339, leading zero, a JSON number
and an above-max-safe bound are all refused) and add the canonical
golden's bounds as an accepted case. The mutants that allow leading
zeros or accept numbers are each killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Sep 29, 2026
…cimal Unix seconds, or that run backwards

evidenceTimeBounds.start and .end must be decimal strings of Unix
seconds, as the canonical settlement-vector golden carries them
("1699999500" / "1700000000"; ruling #3567). No sign, no leading zeros,
no fraction or exponent, no RFC 3339, no JSON number, and a safe
integer. start must not be after end. The strings are kept byte-for-byte,
so both goldens and the unit fixture digest exactly as before; the
golden's JCS, body hash and packageDigestV2 tests pass unchanged.
Previously any non-empty string was accepted.

The grammar is the same as spec parseEvidenceTimeBound (#438). gateway
3066 passed and 6 skipped (+1), tsc clean. The mutants that drop the
grammar check or the order check are each killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu and others added 5 commits September 29, 2026 19:52
…unt is refused (review E3)

Cross-family review E3 on 4e57037, finding 2 (MEDIUM). Reproduced at
4e57037 with four tests, each returning { ok: true }:
- a sparse contractIds list with the job at a filled index (`every`
  skips holes);
- the job reachable only through an inherited index (`includes` reads
  the prototype);
- sessionSignedEventCount -1 (a safe integer, and <= maxSignatures);
- evidenceTimeBounds inherited from a prototype.

checkDelegationScope now requires every index to be an own string and
finds the job among own indices; the count must be a safe integer >= 0
(refused as scope-signatures-exhausted, no new reason code). checkEventTimes
reads bounds.start and bounds.end as own properties only, as it already
did for event timestamps.

delegation-rules.vectors.json gains scope vector negative_event_count
(45 vectors). The file re-serializes byte-identically, so no other byte
moved. The oracle mirrors these rules, and its count check needs the >= 0.

spec 874/874; tsc clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…and the receipt time (review E3)

Cross-family review E3 on 4e57037, DO-NOT-SHIP. Reproduced at 4e57037,
with the gate open, as tests that anchored or failed:
- finding 1 (HIGH): a delegation whose parentAgentId is " attacker "
  anchored (checkDelegationScope got only { scope }, no operator); so did
  events an hour before issuedAt and events after the gateway received
  the bundle (LO-EV-9 got no eventTimeWindow);
- finding 3 (MEDIUM): recovery of a bundle that was valid when received
  returned session_expired once the wall clock passed expiresAt.

The device anchor now takes a trusted context: the funded operator's
principal id (authoritative, never from the evidence) and the receipt
time (the stored row's createdAt, the gateway's own clock). With both:
- the whole delegation, parentAgentId included, goes to
  checkDelegationScope with operatorPrincipalId;
- the events must lie in [issuedAt, min(expiresAt, receivedAt)] ± 300 s,
  on the hashed snapshots;
- the session is judged valid as of receivedAt (currentTimestamp).
Without both, session evidence fails closed
(session-evidence-needs-trusted-context).

/complete and recovery pass the receipt time but no operator: the gateway
has no authoritative funded operator (kernel.operatorAddress is
self-registered; J1 is the oracle's, at /settle). So session-signed
evidence no longer anchors at the gateway, which is what the verdict
requires until such a source exists. The gate stays closed by default,
so production is unchanged.

device-evidence-settlement 55/55 (6 new; 8 existing session tests now
pass the trusted context and stamp their events inside the window);
gateway tsc clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… and never throws; a zero-count vector (review E3b, M1 + M2)

Cross-family review E3b on 01aff86, DO-NOT-SHIP. Both findings were
reproduced as failing tests at 01aff86 before any source change.

M1 (MEDIUM): checkDelegationScope read delegation.scope, scope.contractIds,
scope.maxSignatures and delegation.parentAgentId through ordinary
prototype-chain lookups. The review's Object.create example returned
{ok:true}, and a throwing getter threw, against the documented "never
throws". Each field (and the length and every index of contractIds) is now
read ONCE as an OWN DATA property through an Object.getOwnPropertyDescriptor
captured at module load: an inherited, accessor or missing field is refused
with the existing reason for a missing one (malformed-delegation for scope
and contractIds, max-signatures-invalid, parent-not-operator), a getter is
never called, and the body is wrapped so nothing throws (a hostile proxy is
malformed-delegation). No new reason code: the oracle's mirror reads the
same way (JSON cannot encode these cases, so they stay JavaScript tests).

M2 (MEDIUM): the lockstep vectors pinned equality, overflow and negative
counts but no zero. Adds the scope vector zero_event_count
(sessionSignedEventCount 0, maxSignatures 10 -> ok), 45 -> 46 vectors. The
file is hand-kept (no generator; it is byte-identical to its own
JSON.stringify(.., null, 2)), so it was written back through that and the
diff is only the added vector. The oracle's mirror must replay it.

delegation-rules.test.ts 14 -> 22 tests, spec 42 files 882/882, tsc clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
… slot and the authorization once, as an immutable snapshot (review E3b, H1)

Cross-family review E3b on 01aff86, DO-NOT-SHIP (HIGH): trusted
authorization and time data were read repeatedly from mutable objects, a
check on one read and the use on another. Reproduced at 01aff86, gate
open, both trusted values supplied, as tests whose slot or authorization
answers differently on a given read; each anchored (source "device"):
- sessionKeyAuthorization undefined on its first read (the window
  decision) and the real one afterwards: the event window was skipped for
  events an hour before issuedAt;
- receivedAt a far-future value only on the read that builds the window's
  notAfter: events after the real receipt were accepted;
- parentAgentId the label the principal signed (" attacker ") on the
  SessionKey read and the funded operator on the checkDelegationScope read.
The same class, found by tracing the read order: the decision re-read
bundleHash and kernelSignature (anchoring a digest that was never
verified), the subject was read four times (the binding and the scope rule
could judge different jobs), and recovery read the row's createdAt twice.
A read-count test over proxies of the input, the slot and the authorization
found 19 fields read 2 to 7 times.

snapshotSessionKeyAuthorization(raw) reads every field ONCE through an
Object.getOwnPropertyDescriptor captured at module load and accepts only
OWN DATA properties of the right type (no accessor, nothing inherited, no
missing required field; scope.allowedActions and contractIds are dense
arrays copied index by index over own strings; derivationPath is an own
string when present). It returns a deeply frozen plain copy, or null
(malformed-session-authorization). Range rules (non-empty list, budget >= 1)
stay with checkDelegationScope and keep their reasons.

- verifyDeviceSignedEvidence reads each input field once at its top and uses
  only locals; the SessionKey, checkDelegationScope, the signature preimage
  and the session-validity check all use the one snapshot.
- verifyDeviceAnchor reads each slot field once at its top, builds the
  window from the snapshot and the one receipt time, passes the same
  snapshot and receipt time on, and returns what it verified; the decision
  is built only from that (digest, a copy of the signature, events, the
  frozen snapshot), never from a fresh read of the candidate.
- resolveSettlementEvidence reads the input's signer, operator and verifier
  once before the loop; verifyPinnedSettlementEvidence reads its input and
  row once.

Every existing reason code is kept and the 55 existing tests pass
unmodified. Reasons that change, all for input that was already refused: an
accessor, inherited or non-integer field in an authorization is now
malformed-session-authorization (a non-integer maxSignatures was
max-signatures-invalid, a non-integer issuedAt or expiresAt
session_key_malformed). A present authorization that cannot be read is
still refused after the not-device-signed, signer, contract-id and trusted
context checks, as before.

device-evidence-settlement 55 -> 73 tests; gateway 188 files, 3039 passed
(6 skipped); gateway tsc clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
… check found it unpinned (review E3b, H1)

Mutation check of the H1 fix (mutants applied to the working tree only):
- verifyDeviceSignedEvidence handing checkDelegationScope the raw object
  survived while the only callers were the anchor, which already passes the
  snapshot. Adds two tests that call it directly: a read-count over proxies
  of its input, the signature and the authorization (each field exactly
  once), and the review's own scenario, a parentAgentId a proxy answers
  differently on its second read (refused as parent-not-operator).
  Both fail at 01aff86.
- copying the subject once was unpinned: the read-count test now also counts
  the slot's subject and signature (a field read twice fails it).
- a receipt time that is not a safe integer (fractional, NaN, infinite,
  beyond 2^53) was unpinned as "not trusted context": now tested.

device-evidence-settlement 73 -> 76 tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
LamaSu added a commit that referenced this pull request Oct 1, 2026
…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
LamaSu and others added 3 commits October 3, 2026 10:48
…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
…ime intrinsics (no RegExp, no Date)

Step 10 of the binding leg calls checkEventTimes. Its parser used an RFC 3339
RegExp, Number(), Date.UTC with a Date round trip, Math.floor and a
call-time .call. Any of those replaced after load could change an answer,
which is the realm class astra and sensors closed for the rest of the leg
(E11b, bus #5381).

- parseEvidenceTimestamp now reads YYYY-MM-DDTHH:MM:SS[.f{1,9}](Z|±HH:MM)
  code unit by code unit. It checks the date against the proleptic
  Gregorian calendar and computes Unix seconds by integer arithmetic.
- parseEvidenceTimeBound is a code-unit decimal check with a safe-integer
  bound.
- checkEventTimes reads the window, the bounds, the events and each
  timestamp as own data through ownData.
- ownData uses primordials' hasOwn instead of a call-time `.call`.
- Behavior is unchanged. delegation-rules-time-parity.test.ts keeps the
  replaced functions verbatim and runs both versions over a seeded corpus:
  60,000 timestamps (boundaries, leap years, offsets, fractions, mutations;
  thousands accepted and thousands refused) and 20,000 bounds. Zero
  differences.
- #438's 46 delegation vectors still pass.
- subject-binding-realm.test.ts covers the time path:
  - two windowed cases (inside, and a window that ends before the events);
  - patches for Date.UTC, Date.prototype.getUTCFullYear, Math.floor,
    Number, Number.isSafeInteger and Number.isInteger;
  - a source scan of the time path in delegation-rules.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…for #438's merge-up after #345 merges

In MERGE NOW's order, #345 (with #341) merges before #438. Folded after it, #438 conflicts in
spec/src/evidence/index.ts: #345 adds `export * from "./evidence-level.js"` and #438 adds
`export * from "./delegation-rules.js"` at the same spot. Both export lines are kept, in merge order.
Nothing else changes in #438's files.

spec tsc clean (no TS2308 between the two modules); spec 53 files, 1371 tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
@LamaSu
LamaSu changed the base branch from feat/evidence-subject-binding to master October 3, 2026 22:44
@LamaSu LamaSu closed this Oct 3, 2026
@LamaSu LamaSu reopened this Oct 3, 2026
…master (steward #6167)

A plain merge: the tree is exactly git merge-tree of 885b444 and cd9d877, with no other change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
@LamaSu
LamaSu marked this pull request as ready for review October 4, 2026 02:23
LamaSu and others added 2 commits October 3, 2026 19:35
… a criss-cross (steward #6415)

A plain merge, no edits: its tree equals git merge-tree of d94da4d and master. #438 carried #345's
pre-staged head, and #345 merged as a merge commit, which left two merge bases (cd9d877, 03d4e46).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…rent master (steward wake-up, 23:2x)

A plain merge, no edits: its tree equals git merge-tree of e2c67a3 and master. CI that ran on an older
master isn't evidence: many PRs merged since.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
@LamaSu
LamaSu merged commit 8d1463c into master Oct 6, 2026
13 of 16 checks passed

This branch had an error being deployed

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