Skip to content

feat(gateway): live provider re-read for externally authored plans (R10) - #355

Merged
LamaSu merged 13 commits into
masterfrom
feat/plan-snapshot-revalidation
Oct 3, 2026
Merged

LamaSu merged 13 commits into
masterfrom
feat/plan-snapshot-revalidation

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Round 3 (2026-09-29): astra's findings on d9a2185 (pack 44, DO-NOT-SHIP) fixed at 478dd7c

  • D, the argument edit: both requested-id sets are now built BEFORE their loaders run, and each loader gets a copy of the ids. astra's attack (a loader emptying its argument) is a test.
  • The tenant regression round 2 introduced: duplicates are judged PER ID.
    • A duplicated id with no copy visible to the caller looks exactly like an absent row: the same verdict, and no kernel lookup.
    • A contradiction the caller can see (public or own, plus foreign) is still refused, in either order.
  • K, T, C and the decimal bound were confirmed closed by astra.
  • Evidence: gateway 2984 passed; mutations 6 of 7 killed, plus one equivalent.
  • Round-3 pack: 69-composition-r10-355-r3-478dd7c0.md.

Round 2 (the operator's cross-family review, 2026-09-28)

The operator's ChatGPT review of c48af89 was DO-NOT-SHIP with four blocking findings. All four are fixed in d9a2185, each with a test that fails without it:

  • Kernel status was not an allowlist. judge accepted any string except "suspended", so "offline", "retired" or an unrecognized value still reached current. Fixed: kernel status is now an ALLOWLIST, and only "online" is available. offline, maintenance and expired are unavailable/kernel-not-online; suspended stays operator-suspended; any other string ("retired", "", "ONLINE") is malformed-live-row.
  • tenantId: "" was treated as a real tenant. Fixed: the tenant option must be a well-formed id, or the caller sees public rows only — an empty string is never a tenant — and a row whose tenant is not a well-formed id is visible to nobody.
  • Duplicate live rows could silently pick a winner. Fixed: a REQUESTED id that appears twice in either loader answer makes that answer malformed, in either order, exactly like any other malformed answer. A broken second copy counts too — its id is reported even when the rest of the row cannot be read. Unrequested rows are still ignored.
  • One call could resolve two ways for one type. Fixed: csdForType is called at most once per capability type, so two nodes of one type cannot resolve against two CSDs.
  • Decimal cap. The verdict's resource note: a decimal string over 100 characters is now malformed before any BigInt work.

Tests: 6 new, R10 53/53, and tsc is clean for the service and its tests. 12 mutation checks: 10 killed. T1 and T2 SURVIVE, and they are an equivalent pair — validating the tenant option and validating the row tenant each close the tenant finding alone, because a visible scoped row must equal a validated option; removing both is killed. Both are kept as defense in depth.

Out of scope for this PR, and tracked: the verdict's section B/C5 asks that acceptance bind to row versions within one snapshot. That is the accept path (#356/#391), so it follows their first-round verdicts.

Round-2 pack: 44-composition-r10-355-r2-d9a2185c.md.


Reconciliation row R10 (goal pcc-reconciliation; MUST-CLOSE 3 and 4 of the composition charter): the gateway re-reads live provider rows for a plan an external agent wrote.

Stacked on #351. It uses #351's SETTLEMENT_TOKEN_DECIMALS, tierFromKey and MAX_GROSS_BASE_UNITS. The base retargets to master when #351 lands.

What it does

revalidatePlanSnapshots(claims, deps, { tenantId }) takes, for each node, what the caller claims: capability, price, currency, tier, the kernel it was quoted and the operator it expects to pay (both required: the digest does not cover the operator), and optional cross-checks (type, CSD, deal-snapshot digest). None of it is authoritative. The service then:

  1. re-reads every capability and its kernel live, with one batched load per table;
  2. recomputes the deal-snapshot digest with the decomposer's own inputs. A test pins that it equals the createMatcher digest for the same row;
  3. returns one typed verdict per node, sorted by node id.
verdict meaning
current The claim matches the live row. resolved holds the server's terms: operator, gross in base units, digest, CSD. These are the only values the R12 compiler may take.
stale The live row differs from the claim. diffs lists each field, and live is the re-quote. Never silently accepted: the caller re-submits against live.
missing No such capability (or it is scoped to another tenant; the two look identical), or its kernel is gone.
unavailable The operator is suspended, the settlement address is invalid or zero, or the row's tiers are malformed.
unpriceable There is no exact flat price in a settleable currency.
incompatible The capability type maps to no CSD, so there is no evidence contract to settle against.
invalid-claim The claim itself is malformed, or a node id is duplicated.

Exact money:

  • Price compare. Prices are compared as canonical decimal strings, so equal strings mean equal amounts.
  • Gross. The gross is computed from the live decimal string in base units.
  • What it never uses. It never goes through the digest's toFixed(2), which cannot see 6.504 vs 6.50; a test shows the digest collides there while the verdict is still stale. Nor through capPrice's fallback to 0 for an unpriced row.
  • What it refuses rather than guesses:
    • an unpriced row
    • a missing currency (never defaulted to USDC) or an unknown one
    • variable pricing (it needs a parameterized quote)
    • sub-base-unit precision
    • a price out of range
    • baseCost < minimum, where the real charge would differ from what the digest commits

Payout wallet. payoutAddress is the kernel's operatorAddress. No payout-wallet store exists yet (#1690), and a caller-supplied payout address is never used.

Charter negatives

  • Forged cheaper snapshot, including a digest computed over the forgery: stale, and the server's gross comes back.
  • Provider price change between quote and accept: stale. Re-submitting against the re-quote gives current at the new price.
  • A tier the provider no longer offers: stale.

Not in this PR

Known limit, raised with gateway (#2241). The deal-snapshot digest preimage does not cover the settlement wallet, the measurement profile or a CSD version. R10 compares the operator and the CSD directly instead. Extending the preimage changes every digest, so it needs a versioned digest and is not done here.

Cross-family review (astra)

  1. e20cd89: DO-NOT-SHIP. Findings:

    • (A) Tier default. The decomposer's assuranceTiers ?? [0,1] was mirrored for digest compatibility, so a row with no tiers sold tier 1 as current.
    • (C1) Side channel. Kernels were looked up for foreign-tenant capabilities.
    • (C2) Operator rotation. The operator cross-check was optional and the digest omits the operator, so an operator change since the quote passed as current.
    • (D) Throws. A loader returning [null] threw.
    • (E) Test gaps.

    Fixed in 8472b46:

    • tiers are never defaulted
    • kernelId and operator are required claim fields
    • visibility is applied before any kernel lookup
    • malformed rows get typed verdicts
  2. astra's confirmation of 8472b46: SHIP-WITH-FIXES. All five findings are CLOSED. One gap remained: sparse-array holes passed every, and a shadowed every threw. Fixed in 27a23c6: tiers are read by index into a plain copy.

Review rounds 3 and 4 (astra) — fixed in 9a6da15 and c48af89

  • Round 3: DO-NOT-SHIP. The same getter/proxy/callback class closed in feat(spec): accepted-plan compiler — composition half of MS-01 (R12) #351. Claims and live rows were re-read after validation: a tierKey getter, a loader re-pointing node ids, a csdForType callback mutating the row, a pricing getter splitting gross from digest, and a baseCost getter bypassing the minimum. Throwing getters escaped.

    • Fix: every claim, row (with a pricing snapshot and a fixed-length tier copy), kernel and loader answer is read once into owned data.
    • Failure handling: a failure to read data is a typed verdict; a loader or csdForType call that throws still propagates.
  • Round 4: DO-NOT-SHIP. Four holes remained:

    • the tenant sentinel compared equal
    • wiring reads were unguarded
    • duplicates were counted over valid claims only
    • detached calls dropped this

    Fix:

    • visibility matches strings only
    • an unreadable dependency is a defined TypeError
    • duplicates are counted over every claim whose id was read
    • dependencies are called with their receiver
    • an over-cap list is too-many-claims
  • Round 5 is queued for the codex reset at 14:42.

Tests

  • 33 in packages/gateway/src/__tests__/plan-snapshot-revalidation.test.ts. Each review-driven test fails against the commit before its fix.
  • The existing digest and decomposer-digest tests still pass.
  • A scoped tsc over the new files and the spec sources is clean.
  • 13 mutation checks, each caught by a failing test:
    • drop the exact price compare
    • drop the tier-offered check
    • drop tenant scoping
    • restore the tier default
    • make the operator optional again
    • remove the malformed-row guard
    • default the currency to USDC
    • drop the minimum-charge check
    • case-sensitive operator compare
    • unsorted verdicts
    • swallowed loader errors
    • first duplicate wins
    • allow a suspended operator
  • Additive only: no existing file changes.
  • Consumed by feat(gateway): accept seam for externally authored plans + end-to-end trace (R9 pure part) #356, the accept seam and end-to-end trace, stacked on this PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz

LamaSu and others added 2 commits September 24, 2026 05:51
…econciliation R10)

An external agent's plan names each node's capability and the deal it
believes it has (price, currency, tier, optionally the deal-snapshot
digest). None of that is authoritative. revalidatePlanSnapshots()
re-reads every capability and its kernel LIVE (one batched load per
table), recomputes the deal-snapshot digest with the decomposer's own
inputs, and returns one typed verdict per node: current (with the
SERVER's operator, exact gross in base units, digest and CSD), stale
(field diffs + the live re-quote), missing, unavailable, unpriceable,
incompatible, or invalid-claim.

- Price is compared EXACTLY as canonical decimal strings; the gross comes
  from the live decimal string in base units. Never via the digest's
  toFixed(2) (blind to 6.504 vs 6.50) or capPrice's fallback to 0.
- An unpriced row, a missing/unknown currency (never defaulted to USDC),
  variable pricing, sub-base-unit precision, or baseCost < minimum is
  refused, not guessed.
- The CSD is resolved from the live capability type (no CSD = no evidence
  contract = incompatible); a tenant-scoped capability is invisible to
  other tenants; a suspended operator or an invalid settlement address is
  unavailable; a loader error propagates (an outage is not "not found").
- payoutAddress is the kernel's operatorAddress: no payout-wallet store
  exists yet (#1690), and the caller's is never used.

Charter negatives covered: forged cheaper snapshot (price + a digest over
the forgery) -> stale; price change between quote and accept -> stale,
and the explicit re-quote -> current; tier no longer offered -> stale.
27 tests; the recomputed digest equals the decomposer matcher's digest
for the same row. 10 mutation checks (exact price, tier, tenant, currency
default, minimum, hex case, sort, swallowed loader error, first-duplicate
wins, suspended operator) are each caught. Additive only: no existing
file changes. Stacked on #351 (uses its SETTLEMENT_TOKEN_DECIMALS,
tierFromKey, MAX_GROSS_BASE_UNITS).

pcc-composition 8a0f4de0, goal pcc-reconciliation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz
…s; tenant filter before kernel lookup (astra review of #355)

Astra's cross-family review of e20cd89 returned DO-NOT-SHIP:

- Tier default (money bug): the decomposer's `assuranceTiers ?? [0, 1]`
  was mirrored for digest compatibility, so a row with null/missing tiers
  sold tier 1 as `current`. Tiers are now never defaulted: missing, null,
  empty or out-of-range tiers are `unavailable: malformed-tiers`. SQL NOT
  NULL does not validate the JSON inside the column.
- Freshness: the deal-snapshot digest does not cover the operator, and the
  operator cross-check was optional, so an operator rotation since the quote
  passed as `current`. `kernelId` and `operator` are now REQUIRED claim
  fields (new reasons malformed-kernel-id / malformed-operator).
- Side channel: kernels were loaded for foreign-tenant capabilities before
  the visibility check. Visibility is now applied to the loaded rows first;
  a foreign capability causes no kernel lookup at all.
- Robustness: a loader returning [null] threw on row.id. Unattributable
  rows are ignored (fail closed); a row with a malformed type/kernelId or a
  kernel with a non-string status is `unavailable: malformed-live-row`.
- Tests the reviewer asked for: an adversarial caller payout is ignored;
  the invalid-claim case now also asserts no KERNEL load.

Tests: 32 (5 new/extended); 4 of them fail against e20cd89. 13 mutation
checks, each caught (adds: tier default restored, operator optional again,
malformed-row guard removed). Scoped tsc clean.

pcc-composition 8a0f4de0, goal pcc-reconciliation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz
…onfirmation of #355)

Astra's confirmation of 8472b46: SHIP-WITH-FIXES. All five findings of
the first review are CLOSED (tier default, foreign-tenant kernel lookup,
optional operator/kernel, malformed loader rows, the requested tests).
One robustness gap remained: `tiers.every()` skips the holes of a sparse
array, so [0, <hole>] passed validation and put `undefined` into the
offered tiers and the digest input; and a row whose own `every` was
shadowed threw.

liveTiers() now reads the list by index (a hole is malformed), never
through the row's own methods, caps the length at 16, and returns a plain
copy that both the digest and the tier check use.

Tests: 33; the new one fails against 8472b46. Scoped tsc clean.

pcc-composition 8a0f4de0, goal pcc-reconciliation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz
LamaSu and others added 6 commits September 24, 2026 06:34
…tra round 3 on 27a23c6)

Astra round 3 on #355: the sparse-tier finding is CLOSED, but the broader
getter/proxy/callback class was open, the same class closed in the
compiler (#351):
- Claims were retained and re-read after validation. A tierKey getter
  passed as tier0 and was returned as tier3. A loader could re-point two
  claims to one node id after the duplicate check.
- Live rows were retained across callbacks. A csdForType callback could
  change the row's kernelId after the kernel was selected, mixing k-2's
  identity with k-1's operator.
- Pricing was re-read. A pricing getter could price the gross at $6.50 and
  the digest at $7.25; a baseCost getter could bypass the minimum check.
- Throwing getters on claims and rows escaped the service. A tier list's
  length was re-read.

Now every claim, capability row (with a pricing snapshot and a
fixed-length tier copy), kernel row and loader answer is read EXACTLY ONCE
into owned plain data. Validation, comparison, the digest (capPrice runs
on the same pricing snapshot) and the output use only those copies. The
dependencies and the tenant option are captured once, before any claim.

A loader or csdForType call that THROWS still propagates (an outage must
not read as "not found"). A failure to READ data is a typed verdict:
- invalid-claim "unreadable-claim"
- a row that cannot be attributed is missing
- an unreadable loader answer is unavailable "malformed-live-row"

A dependency that is not a function throws TypeError (a wiring fault).

Tests: 42 (9 new): tierKey getter, a loader re-pointing node ids, a
csdForType callback mutating the row, a pricing getter, a baseCost getter,
throwing claim/row getters, lying tier length, unreadable loader answers,
an unreadable claims list, non-function deps. All 9 fail against 27a23c6.
Mutation checks: 5 new snapshot mutations all caught. Of the 13 prior ones,
12 are caught; "unattributable rows accepted" survives because the guard is
redundant with the requested-id filter (claims carry only validated ids),
so it is kept as defense in depth. Scoped tsc clean.

pcc-composition 8a0f4de0, goal pcc-reconciliation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz
… duplicates across all claims, receivers kept (astra round 4 on 9a6da15)

Astra round 4 on #355: the round-3 read-once fix held. Remaining:
1. leaf() maps every object to one NOT_DATA sentinel, so a malformed
   object tenant on both sides compared EQUAL and granted visibility.
   Visibility now matches strings only. A malformed tenant option means
   "no tenant" (public rows only, never more).
2. Reading deps/opts could throw outside any catch. Those reads are now
   guarded, and a failure is a defined wiring fault: TypeError, the only
   exception the service raises itself.
3. Duplicate ids were counted over fully valid claims only, so [valid x,
   malformed x] gave two verdicts for one node and triggered a load.
   Every claim whose node id was READ now counts (readClaim reports
   idKnown).
4. Detached calls dropped `this`. Loaders and csdForType are now called
   with deps as the receiver (Reflect.apply).
Also: an over-cap claims list is "too-many-claims", not "unreadable". The
1024-claim and 4096-row limits are documented as product limits.

Tests: 47 (5 new: object/function tenants, unreadable wiring, duplicates
with a malformed or unreadable copy in both orders, method-style deps,
boundaries at 1024/1025 claims and 4096/4097 rows). All 5 fail against
9a6da15. 5 mutations each caught, including the tenant case with both
guards removed. Scoped tsc clean.

pcc-composition 8a0f4de0, goal pcc-reconciliation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz
LamaSu added a commit that referenced this pull request Sep 24, 2026
… outcomes carry verdicts and a submission digest

The pattern that closed #351 and #355 under cross-family review, now
applied to the seam (#356) before its confirmation round:
- The submission is copied once (snapshotSubmission: requestId,
  reservationId, each node's fields, edges; lengths read once, lists
  capped, non-primitives become an owned sentinel). The program
  cross-check, R10 and the compile use only that copy. A callback that
  mutates the caller's submission changes nothing.
- The reservation record is read once. A malformed record (non-numeric
  expiresAt, or minTier outside 0..3) is a typed refusal,
  "malformed-reservation".
- Dependencies and policy are read once, before any input. An unreadable
  or non-function dependency is a defined wiring fault (TypeError). Each
  dependency is called with deps as its receiver, including the gate the
  compiler calls. The principal and tenant are read once.
- Every outcome after the snapshot carries `submissionDigest`, a
  type-tagged sha256 of the submission exactly as evaluated, so a read
  model can prove the submission it shows belongs to this outcome. It also
  carries `verdicts`, all of R10's verdicts whenever R10 ran. That field
  moves down from #357, where it belongs to the seam.

Tests: 30 in external-plan-trace.test.ts (7 new). 17 mutation checks each
caught: 7 new (fields read twice, compile edges taken from the caller,
reservation read twice, receiver dropped, unguarded wiring, unguarded
submission read, digest ignoring edges) plus the 10 earlier seam
mutations re-anchored. Scoped tsc clean.

pcc-composition 8a0f4de0, goal pcc-reconciliation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz
… rows, one CSD mapping (ChatGPT review of c48af89)

The operator's cross-family review (ChatGPT, 2026-09-28) of c48af89 was DO-NOT-SHIP with four blocking findings. All four are fixed here, each with a test that fails without it.

- Kernel status is an ALLOWLIST, and only "online" is available. The kernels table's status model is online, offline, maintenance and suspended, and the sweeper can set expired.
  - offline, maintenance and expired are unavailable/kernel-not-online;
  - suspended stays operator-suspended;
  - any other string ("retired", "", "ONLINE") is malformed-live-row.
- Tenants.
  - The tenant option must be a well-formed id; otherwise the caller sees public rows only. An empty string is never a tenant.
  - A row whose tenant is not a well-formed id is visible to nobody.
- Duplicate live rows. A REQUESTED id that appears twice in either loader answer makes that answer malformed. This fails closed, in either order, exactly like any other malformed answer.
  - A broken second copy counts too: its id is reported even when the rest of the row cannot be read.
  - Unrequested rows are still ignored.
- One call, one mapping. csdForType is called at most once per capability type, so two nodes of one type cannot resolve against two CSDs.
- The verdict's resource note: a decimal string over 100 characters is malformed before any BigInt work.

Tests: 6 new, R10 53/53, and tsc is clean for the service and its tests.

12 mutation checks: 10 killed. T1 and T2 SURVIVE, and they are an equivalent pair:
- validating the tenant option and validating the row tenant each close the tenant finding alone, because a visible scoped row must equal a validated option;
- removing both is killed.
Both are kept as defense in depth.

Out of scope for this PR, and tracked: the verdict's section B/C5 asks that acceptance bind to row versions within one snapshot. That is the accept path (#356/#391), so it follows their first-round verdicts.

pcc-composition 8a0f4de0, goal pcc-reconciliation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…uplicates look absent (astra, round 2 of #355)

astra's review of d9a2185 (pack 44) returned DO-NOT-SHIP. K, T, C and the
decimal bound are confirmed closed. Two findings remained open.

D, not fully closed: the kernel duplicate check built its requested-id set
AFTER loadKernels ran. JavaScript evaluates the arguments left to right, so
a loader that emptied its argument emptied the check, and an online-first
pair of contradictory rows was read as current. Both requested-id sets are
now built before their loaders run, and each loader gets its own copy of
the ids.

A regression that round 2 introduced: duplicate detection ran BEFORE
tenant visibility, and any duplicate made the WHOLE answer malformed. So
two copies of a FOREIGN capability answered "malformed-live-row" where an
absent one answers "capability-not-found", which distinguished hidden from
absent. readRows now reports duplicated ids PER ID:
- a duplicated id with ANY copy visible to the caller: its claims are a
  malformed live row, so a contradiction the caller can see is still refused
  and never resolved by order (as astra warned, public plus foreign is
  refused, not filtered);
- a duplicated id with NO visible copy: treated exactly like an absent row,
  with the same verdict and no kernel lookup;
- a duplicate breaks only the claims on its own id.
Kernels are requested only for visible capabilities, so a broken kernel
answer still breaks every visible claim and reveals nothing hidden.

Tests:
- 4 new: astra's exact loader-edit attack on both loaders; foreign
  duplicates equal to the absent case in verdict AND kernel-call count, for
  a public caller and for another tenant; public, own and foreign
  contradictions refused in both orders; per-id scope.
- Full gateway suite: 2984 passed, 6 skipped. The capture suites do not load
  without a built @pcc/verifier dist.
- Mutations: 6 of 7 killed. The survivor is equivalent: loaders given the
  original arrays, which are never read after the calls.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@LamaSu
LamaSu changed the base branch from feat/accepted-plan-compiler to master October 3, 2026 18:14
@LamaSu LamaSu closed this Oct 3, 2026
@LamaSu LamaSu reopened this Oct 3, 2026
@LamaSu
LamaSu marked this pull request as ready for review October 3, 2026 22:32
@LamaSu
LamaSu merged commit 26cc0a9 into master Oct 3, 2026
10 checks passed
LamaSu added a commit that referenced this pull request Oct 3, 2026
 merged; plain merge, no edits)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSBxBEX3sn9DPSqihyjuZE
LamaSu added a commit that referenced this pull request Oct 3, 2026
…after #355 merged; plain merge, no edits)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSBxBEX3sn9DPSqihyjuZE
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