Repository navigation
feat(gateway): live provider re-read for externally authored plans (R10) - #355
Merged
Merged
Conversation
…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
added a commit
that referenced
this pull request
Sep 24, 2026
…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
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
This was referenced Sep 24, 2026
… 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>
This was referenced Sep 29, 2026
…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
marked this pull request as ready for review
October 3, 2026 22:32
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Round 3 (2026-09-29): astra's findings on d9a2185 (pack 44, DO-NOT-SHIP) fixed at 478dd7c
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:
judgeaccepted any string except"suspended", so"offline","retired"or an unrecognized value still reachedcurrent. Fixed: kernel status is now an ALLOWLIST, and only"online"is available.offline,maintenanceandexpiredare unavailable/kernel-not-online;suspendedstays 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.csdForTypeis called at most once per capability type, so two nodes of one type cannot resolve against two CSDs.Tests: 6 new, R10 53/53, and
tscis 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,tierFromKeyandMAX_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:createMatcherdigest for the same row;currentresolvedholds the server's terms: operator, gross in base units, digest, CSD. These are the only values the R12 compiler may take.stalediffslists each field, andliveis the re-quote. Never silently accepted: the caller re-submits againstlive.missingunavailableunpriceableincompatibleinvalid-claimExact money:
toFixed(2), which cannot see 6.504 vs 6.50; a test shows the digest collides there while the verdict is stillstale. Nor throughcapPrice's fallback to 0 for an unpriced row.baseCost < minimum, where the real charge would differ from what the digest commitsPayout wallet.
payoutAddressis the kernel'soperatorAddress. No payout-wallet store exists yet (#1690), and a caller-supplied payout address is never used.Charter negatives
stale, and the server's gross comes back.stale. Re-submitting against the re-quote givescurrentat the new price.stale.Not in this PR
repos.capabilities.findByIds/repos.kernels.findByIdsandcsdForTypetoCsdRegistry.findUrlByType+csdSlugFromUrl. The row types are structurally compatible, so the route passes them directly.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)
e20cd89: DO-NOT-SHIP. Findings:
assuranceTiers ?? [0,1]was mirrored for digest compatibility, so a row with no tiers sold tier 1 ascurrent.current.[null]threw.Fixed in 8472b46:
kernelIdandoperatorare required claim fieldsastra's confirmation of 8472b46: SHIP-WITH-FIXES. All five findings are CLOSED. One gap remained: sparse-array holes passed
every, and a shadowedeverythrew. 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
csdForTypecallback mutating the row, a pricing getter splitting gross from digest, and a baseCost getter bypassing the minimum. Throwing getters escaped.csdForTypecall that throws still propagates.Round 4: DO-NOT-SHIP. Four holes remained:
thisFix:
too-many-claimsRound 5 is queued for the codex reset at 14:42.
Tests
packages/gateway/src/__tests__/plan-snapshot-revalidation.test.ts. Each review-driven test fails against the commit before its fix.tscover the new files and the spec sources is clean.🤖 Generated with Claude Code
https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz