Skip to content

feat(gateway): PlanPresentation read model for caller-authored plans (product section 5) - #357

Merged
LamaSu merged 38 commits into
masterfrom
feat/plan-presentation
Oct 4, 2026
Merged

LamaSu merged 38 commits into
masterfrom
feat/plan-presentation

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Product pack section 5, item 1 (goal pcc-reconciliation): the PlanPresentation read model for caller-authored plans.

Stacked on #356 (the seam), which is stacked on #355 (R10) and #351 (R12).

What it is

presentPlan({ submission, outcome?, sealedDigest?, asOf }) is the typed read model a UI or an agent renders. It is built only from server truth:

  • the submission as proposed
  • R10's verdicts
  • the seam's refusal or compiled deal
  • the reservation store's seal

It is separate from the settlement encoding: nothing here is hashed, signed or funded. It offers no way to accept (item 7); acceptance is the seam plus the reservation consume.

  • Authority is server-assigned (product invariant 2, the five layers). layer: "B" only when the reservation store sealed exactly this plan's acceptedDealDigest. A plan that is compiled but not yet sealed is still "C", and no field in a submission can raise it.
  • Stable node identity (item 2). The caller's node id runs through every state:
    • proposed
    • then current, stale (with diffs and the live re-quote) or refused
    • then compiled, then sealed
    • and into the execution unit: job, milestone, stepId
  • Typed states (item 3).
  • Server snapshots plus an asOf quote timestamp (item 4).
  • FieldDiff for changes (item 5).
  • A preview (item 6): exact gross, fee and net; payouts by recipient; tier per node; reclaimAt. All money is base-unit strings, never floats.
  • The deal's commitments, with a sealed flag (item 8).

The seam change is additive: it now returns every R10 verdict on each outcome after R10 runs, so the model has live terms for every node.

Reuse check: PR #307 (feat/plan-card @ af7467a, the status board's reuse candidate for PX-8)

toPlanCard projects the server decomposer's DecompositionResult for the Runtype widget. I reviewed it.

Carried over:

  • Never show a placeholder price for an unmatched leg. Here a caller's price is labelled proposed (Layer C), and only R10's live price is the server's.
  • Never invite confirming a plan the money path would refuse. Here state and layer come from the seam and the seal, not from the content.

Not reusable for the accepted-plan path:

  • Wrong input. Its input is the server decomposer's output, but the canonical path is caller-authored plans through the seam (invariant 1).
  • Float money. It uses toFixed(2) and Number sums.
  • Hard-coded currency. USDC.
  • No authority. It has no layer, no node→unit identity and no diffs.

Proposal: retire #307 once this lands (product invariant 7). Closing it is the operator's call. Its critical-path ETA idea is noted as a follow-up.

Review round 1 (astra: DO-NOT-SHIP) — fixed in 96e12d9

  • Bound to the submission. An outcome must carry the seam's submissionDigest of the displayed submission; otherwise the result is invalid: submission-mismatch. So a genuine sealed outcome can no longer be shown under another submission.
  • Plan integrity is re-derived. The plan is copied in full, and its acceptedDealDigest is recomputed from the copy; otherwise the result is invalid: plan-integrity. The plan must also be bound to the request, the reservation, the plan id and the exact node set, with every binding naming its own unit; otherwise invalid: plan-binding.
  • A seal is a record, {reservationId, acceptedDealDigest}. It must name this reservation and exactly this digest before the plan is Layer B.
  • Also fixed:
    • verdict statuses are whitelisted
    • a re-quote needs a non-empty, all-stale refusal
    • units must share one reclaimAt (no fabricated "0")
    • every input is read once
    • a malformed input gives state: "invalid", shows nothing, and never throws
  • Tests: 44 in the trace file.
  • Mutation checks: 11 caught.
  • Round 2 is queued for the codex reset at 14:42.

N25 and R15 adaptations (0d3ac12, 1b5e77e)

  • The owned plan copy includes everything the digest commits to: agreementHash, and each binding's planHash plus a field-by-field copy of its canonicalPlan. Inputs and constraints are read once through copyPlanJson; non-JSON is invalid even inside a resealed plan.
  • Binding. Every canonicalPlan must name its own plan, node, job and unit (plan-binding).
  • Display.
    • Compiled and sealed nodes show execution: {planHash, inputs, constraints}, ONLY from the intact, bound plan. It is documented as the caller's content, sealed as agreed, not facts PCC verified: render it as data.
    • The deal shows agreementHash.
  • Refusals. The seam's new refusals render as refused (Layer C), with codes: invalid-execution-json (one code per field) and the economics stage (node plus economics' code).
  • 5 tests; 11 mutation checks, all killed.

Cross-family status: round 2 (the round-1 fixes + these adaptations) is queued for the 19:43 PDT codex window at 1b5e77e (#3177).

Not yet

  • time estimates
  • evidence and program diffs
  • opaque-composition presentation (item 9)
  • turning edits into constraints for the caller's agent (item 10)
  • an HTTP endpoint (the readmodels lane, once R9 lands)

Tests

  • 8 new tests. The file now has 31.
  • 6 mutation checks, each caught by a failing test:
    • layer B without a seal
    • any sealed digest accepted
    • a refusal shown as a re-quote
    • diffs dropped
    • money as a float
    • unsafe coercion
  • The scoped tsc is clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz

LamaSu and others added 20 commits September 24, 2026 06:00
… trace (reconciliation R9, pure part)

acceptExternalPlan(submission, { principal, tenantId }, deps) chains
R10 (live re-read) -> R11 (server-resolved programs + evidence's gate) ->
R12 (compileAcceptedPlan) with every read injected, so the accept decision
is a pure function of (submission, principal, live state). The agent
supplies only the plan's shape and what it believes each node costs; every
settlement term is the server's: operator and payout, gross, currency,
program, evidence requirements, payer (from the reservation), fee and
reclaim time (policy), and the plan id itself (derived from the reservation,
so a caller cannot pick job ids that collide with another plan's).

Refusals are typed by stage: submission, reservation (not-found / not-issued
/ expired / wrong-principal / wrong-request), revalidation (the non-current
verdicts, stale ones carrying the re-quote), currency, program (claimed
mismatch), evidence (no contract for the tier), compile.

external-plan-trace.test.ts is the return contract's end-to-end trace:
agent DAG (listed mail-first) -> accepted deal (2 V-next jobs, one per
operator, canonical order, bigint economics, sealed digest) -> reservation
consumed exactly once (a labelled R13 STAND-IN implementing the consume
protocol: re-check, RECOMPUTE the digest, consume once, seal) -> V-next
units ready (conservation, step ids, root, reclaimAt). Plus the charter's
negatives through the whole seam: forged cheaper snapshot, unapproved
program, expired reservation, principal/request A used for B, obligation
over reservation, double consume (exactly one), duplicate node, a plan
altered after acceptance (digest-mismatch at consume), caller-chosen
planId/payout ignored, currency and evidence refusals, malformed input.
PCC_TRACE_OUT=<path> writes the happy-path trace as JSON.

13 tests; 7 mutation checks (principal, expiry, node currency, program
cross-check, caller planId, missing evidence, request) each caught. Scoped
tsc clean. Stand-ins: in-memory live rows, program registry + gate (#349
draft), CSD tier -> evidence map (evidence owns it), R13 store (#2240).

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
… agent's plan (R9)

Product pack section 5 ("SERVER COMPOSER") and ledger R9: the server
composer is one optional planner, never canonical. The new
submissionFromComposeResponse() only restates a ComposeResponse as the
claims an agent would submit (nodes step-<index>, dependsOn -> edges, the
composer's operator/kernel/type/tier, and its float estimatedPriceUSD
verbatim). R10 then re-reads and compares those claims like anyone's, so a
composer that drifted from the live rows gets `stale` + a re-quote (or
`invalid-claim` for an exponent-form float), never a deal on its own say.
Non-proposed or expired proposals, dangling dependencies and duplicate
step indices are refused before the seam.

Tests: 3 new in external-plan-trace.test.ts (16 total there): accepted
through the ordinary seam; float drift -> stale; 1e-7 -> malformed claim;
expired / over_budget / dangling dep / duplicate index refused. 4 mutation
checks (expiry, status, dangling dep, duplicate index) each caught. 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
Astra's cross-family review of #356: SHIP-WITH-FIXES. It confirmed the
authority chain: payer from the reservation; operator and payout from the
live kernel; gross, currency and program from the server; the
reservationId is only a lookup key, which satisfies the charter's "no
caller reservation id as authority". Fixed:

- Tier: the tier is the principal's purchase choice, constrained by the
  live offer. A reservation may now carry `minTier`, a floor the payer set
  (invariant 11). A delegate spending it cannot downgrade assurance (new
  refusal stage `tier`). The header says so instead of "every term is
  the server's".
- Compose adapter: JSON-shaped values whose coercion throws (status,
  expiresAt or assuranceTier with a null toString) crashed it. Every field's
  type is now checked before any coercion, and a string tier "2" no longer
  becomes "tier2". The validation boundary is explicit: structure here,
  meaning in R10.
- Seam: an empty or missing principal matches nothing. The clock is floored
  (a `Date.now()/1000` clock made BigInt throw). A non-finite clock throws
  rather than compare as "not expired".
- R13 stand-in consume contract, which was overstated: it now re-checks
  plan id and EACH job's payer, and DERIVES the obligation from the units.
  It documents that a recomputed digest proves integrity, not authority
  (a resealed plan with another payer is refused).

Tests: 23 in external-plan-trace.test.ts (7 new). The fix-driven ones fail
against f2d74b3. New negatives: consume-time expiry / principal / resealed
payer / understated total / foreign plan id; a resolved program the gate
rejects; tier below the reservation minimum; nested malformed nodes and
edges; adapter coercion crashes. Mutation checks: seam 10/10 caught,
adapter 6/6 caught. 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
…(product pack section 5, item 1)

presentPlan({ submission, outcome?, sealedDigest?, asOf }) builds the
typed read model a UI or an agent renders for a caller-authored plan.
It uses only server truth: the submission as proposed, R10's verdicts,
the seam's refusal or compiled deal, and the reservation store's seal. It
is separate from the settlement encoding (nothing here is hashed, signed
or funded) and offers no way to accept (item 7).

- Authority is server-assigned. `layer` is "B" only when the reservation
  store sealed EXACTLY this plan's acceptedDealDigest. Compiled but not
  sealed is still "C". No field in the submission can raise it.
- Stable node identity (item 2): the caller's node id is carried through
  proposed -> current | stale (diffs + live re-quote) | refused ->
  compiled -> sealed, and into the execution unit (job, milestone,
  stepId).
- Typed states (item 3); server snapshots plus an asOf quote timestamp
  (item 4); the FieldDiff object (item 5); a preview (item 6) with exact
  gross/fee/net, payouts by recipient, tier per node and reclaimAt, all
  money as base-unit strings; the deal's commitments with a `sealed` flag
  (item 8).
- The seam now returns every R10 verdict on each outcome after R10 runs
  (additive), so the model has live terms for every node.

Tests: 8 new in external-plan-trace.test.ts (31 there). 6 mutation
checks (layer B without a seal, any sealed digest accepted, refusal shown
as a re-quote, diffs dropped, money as a float, unsafe coercion) each
caught. 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
…ers read defensively (astra round 3 on ce18cf4)

Astra round 3: the input-snapshot fix held for ordinary object-shaped
input, but the general guarantee was open:
- a callable object (Object.assign(() => {}, fields)) skipped copying, so a
  splitter could later rename a "validated" reservation that the digest then
  sealed;
- a non-array container (e.g. a revocable proxy) was kept by reference and
  could throw after the snapshot;
- the catch classified the thrown value with instanceof, which a hostile
  value (a revoked proxy) makes throw;
- the program gate's returned answer was read without protection.

Now:
- Every structurally invalid value (a function, a non-array where a list
  belongs, an object where a primitive belongs) becomes NOT_DATA, a frozen
  compiler-owned sentinel that fails every check. Primitives are copied.
  Nothing in the snapshot is a caller reference.
- A bound is recognized by the identity of a token snapshotInput owns; the
  catch never inspects the thrown value.
- readGateResult() reads the gate's answer once and defensively. A
  malformed answer is program-gate-refused "malformed-gate-result"; the gate
  itself throwing stays a server fault.
- The caps are documented as product limits (not ABI) and are enforced
  before any element is read.

Tests: 65 in the file (7 new, 2 strengthened). New: a deep read-counting
proxy proves every input property (fields, nested evidence, edges,
reservation, lengths, indices) is read at most once and that the result
equals the plain compile; callable reservation and node; delayed proxy
revocation; a hostile thrown value; malformed gate answers; each cap at its
limit and one above with zero elements read; a splitter mutating the
caller's reservation and nested evidence. The input-mutation tests now
compare the WHOLE plan and digest to an untouched baseline. The 4 escape
tests fail against ce18cf4. 20 mutation checks each caught. Full @pcc/spec
suite green; 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
… 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
# Conflicts:
#	packages/gateway/src/__tests__/external-plan-trace.test.ts
#	packages/gateway/src/services/external-plan-seam.ts
… input; never throws (astra review of #357)

Astra's first review of #357: DO-NOT-SHIP. The normal path was sound,
but the builder did not enforce its own contract:
- A genuine sealed outcome paired with ANOTHER submission (same node ids,
  different capability, request, reservation or edges) showed the
  replacement as Layer B.
- The seal was two supplied strings compared. A plan altered after sealing
  with its old digest kept still passed, and the seal named no reservation.
- A forged verdict status could mark a node "sealed". An empty refusal read
  as a re-quote. reclaimAt was fabricated as "0". Many malformed outcomes
  threw.

Now:
- The outcome must carry the seam's submissionDigest of THIS submission,
  or the result is "invalid: submission-mismatch".
- The plan is copied in full (every field the deal digest commits to), and
  acceptedDealDigest is RE-DERIVED from that copy. Otherwise the result is
  "invalid: plan-integrity". The plan must also be bound to the
  submission's request, reservation, plan id and exact node set, with every
  binding naming its own unit; otherwise "invalid: plan-binding".
- The seal is a record {reservationId, acceptedDealDigest} that must name
  this reservation and exactly this digest before Layer B.
- Verdict statuses are whitelisted. Re-quote requires a non-empty,
  all-stale refusal. Units must share one reclaimAt (no fabrication).
- Every input is read once. Any malformed input is state "invalid", Layer
  C, with no nodes, money or deal shown (never a mix). The builder never
  throws.

Tests: 44 in external-plan-trace.test.ts (6 new or reworked):
submission-mismatch (capability, edges, reservation), post-seal
alteration, a re-sealed plan bound elsewhere, uneven reclaim times, forged
statuses (plan and refusal), malformed verdicts/outcomes/amounts, a missing
submission digest, throwing getters, an empty refusal, a wrong-reservation
seal, a non-string asOf. 11 mutation checks each caught (8 new plus 3
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
LamaSu and others added 3 commits September 24, 2026 15:19
…nto the sealed deal (N25)

An agent's plan may now say WHAT each node runs on: `inputs` (e.g. the
document hash and page count) and `constraints` (e.g. a deadline), as plain
JSON. That is content, never authority. The compiler (#351, merged in) seals
it through each node's canonicalPlan and planHash.

- snapshotSubmission reads each node's inputs and constraints ONCE through
  @pcc/spec's copyPlanJson. The result is absent, an owned frozen copy, or
  the refusal reason.
- submissionDigest v2 encodes execution JSON by its canonical form. Absent,
  {} and invalid all differ, and invalid evaluates to its reason, just as
  a non-primitive evaluates to NOT_DATA.
- Invalid execution JSON is refused as { stage: "submission", reason:
  "invalid-execution-json", fields }. It names EVERY bad field in
  (nodeId, field) order, and it comes before any reservation read.
- The compiler receives the owned copies; absent means {}.

Tests: 5 new in the trace, 35/35 there and R10 47/47; the files typecheck.
8 mutation checks, all killed: not refused, dropped before the compiler,
the digest ignoring it, a double read, unsorted problems, a reservation
read before the refusal, absent colliding with {}, and constraints dropped.

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
…hows each unit's sealed execution (N25)

This merges #356, which brings #351's agreementHash seal and the per-node
execution contract. PlanPresentation's owned plan copy now includes every
field the deal digest commits to, so its re-derived digest matches:
- agreementHash;
- each binding's planHash;
- a field-by-field copy of its canonicalPlan, with inputs and constraints
  read once through copyPlanJson. Non-JSON is invalid (malformed-outcome)
  even inside a resealed plan.

Additionally:
- The binding check requires each canonicalPlan to name its own plan, node,
  job and unit (plan-binding).
- Compiled and sealed nodes show `execution: {planHash, inputs,
  constraints}`, taken only from the intact, bound plan, never from the
  proposal. The deal shows agreementHash.
- The seam's invalid-execution-json refusal renders as refused, with one
  code per bad field ("<field>:<reason>@<nodeId>").
- The trace test's tail conflict (both sides appended a describe block) is
  resolved by keeping both.

Tests: trace 53/53 (4 new) and the files typecheck. 9 mutation checks, all
killed: planHash or agreementHash not copied, the contract not bound (and
only its node unbound), execution not shown, exec-JSON codes not rendered,
execution JSON not validated, and the deal hiding agreementHash. That last
one survived until a resealed-agreement test was added.

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
…valid execution JSON is 400 and consumes nothing

Merges #357 first (commit "merge: bring #357's N25 presentation..."), so this
branch carries the whole N25 path. A test pins the route's view of it:
- A 200 plan carries each node's canonicalPlan with the agent's inputs and
  its planHash.
- The Layer-B presentation shows the same execution, and the store sealed
  exactly that digest.
- A non-object inputs, or one beyond the key bound (both sendable over HTTP),
  gets 400 with the seam's refusal naming the field. Nothing is consumed.

Route tests 17/17. Full gateway suite 3043 passed, 6 skipped. The known
capture suites don't load (unbuilt verifier dist), and completion-real-tier
timed out at load average ~20 but passes alone (3/3).

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 2 commits September 24, 2026 15:58
…on b: royalties on top)

SeamDeps.economics is optional and present only when an agreement applies.
It is { unitGross, splitNet }: economics' agreementUnitGross and
netSplitterFor, bound per request by the route to the agreement and the
server's facts.

- It is read once with every other dependency. Anything else is the
  defined wiring TypeError. Both functions are called with the binding as
  their receiver.
- The agreement's gross map is read once into owned data; bigints only.
- For each node, the gross must exist and must cover the operator's live
  quote from R10. Otherwise it is a typed refusal at the new "economics"
  stage: agreement-refused (with economics' code), agreement-unreadable,
  unit-gross-missing or quote-not-covered, all before the compile.
- The compiler receives gross = the agreement's, quoteBaseUnits = the live
  quote, and splitNet. So the reservation must cover the GROSSED-UP total,
  and economics can hold the operator's legs to its quote floor.
- Without an agreement nothing changes: one leg, gross = the quote.

Tests: 4 new, trace 39/39, and the files typecheck. 8 mutation checks, all
killed.

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
…ntation shows the economics refusal stage

This merges #356's R15 binding, which also brings #351's quote to the
splitter. Two presentation changes:
- The refusal whitelist gains the seam's new "economics" stage. A missing
  unit gross, a gross below the quote, and an agreement refusal render as
  refused, Layer C, never a re-quote. The node is named, and economics'
  code is shown as a code.
- The `execution` doc now says its inputs are the CALLER's content, sealed
  as what was agreed, not facts PCC verified: render them as data. This is
  the doc nuance noted after the self-review of #357.

The trace's tail conflict (both sides appended a describe block) is
resolved by keeping both.

Tests: trace 58/58 (1 new) and the files typecheck. 2 mutation checks,
both killed: the stage not whitelisted, the code not shown.

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 7 commits September 29, 2026 09:27
…before reading the submission (astra, round 2 of #356)

astra's review of a16095a (pack B-composition-356-r2b) returned DO-NOT-SHIP
under the in-process getter and callback threat model.

A (authority): only the REFERENCE to deps.revalidation was captured before
the submission was read. A submission getter or a reservation callback could
then swap loadCapabilities, loadKernels or csdForType, and R10 would
authenticate the caller's own terms against fabricated rows. The three
callables are now captured and validated with every other dependency, before
any submission property is read. R10 gets only a frozen object of pinned
wrappers that call them with their original receiver.

C (authority): ctx.principal and ctx.tenantId were copied AFTER the
submission was read, so a getter could make the caller the reservation's
owner, or switch its tenant. The context is now read once, before the
submission. The checks, and the order of refusals, are unchanged.

B (digest): tag() encoded a symbol and NOT_DATA alike, and JSON encoded -0 as
0. leaf() now turns a symbol into NOT_DATA and -0 into 0, so the snapshot
never keeps two values that encode alike. Both are semantics-preserving.

Tests: 7 new.
- A: a submission getter, and a reservation callback, swapping all three R10
  dependencies both give the baseline outcome.
- A: a non-function R10 callable is a wiring fault raised before any
  submission property is read.
- A: method-style (class) loaders keep their receiver.
- C: a getter making the caller the owner is still wrong-principal.
- C: a getter switching the tenant gives the same refusal as the original
  tenant (with a control that the tenant matters).
- B: symbols and objects snapshot to one NOT_DATA, -0 to 0, and distinct
  values keep distinct digests.
Full gateway suite: 3020 passed, 6 skipped. The capture suites do not load
without a built @pcc/verifier dist.

Mutations: 6 of 6 killed. S6 (callables not validated) first survived; that
exposed a missing test, which was then added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…code (#356, review round 3)

The seam now takes an owned copy of the submission before reading any field:
only plain objects and real arrays are walked, through their own data
properties, with the Proxy check (which invokes no trap) first. A Proxy or an
accessor anywhere refuses the submission as malformed; an object or array with
another prototype is never read. The authenticated context's principal and
tenant are read the same way. The copy is bounded in depth, value count and
list length. The header states the trust boundary: caller inputs are data,
the dependencies are trusted server wiring.

Tests: a submission getter that rewrites a method-style loader's state, a
Proxy submission, getters in node fields, execution inputs and arrays, a
class instance, a context accessor, the three bounds and a non-plain array.
The earlier read-once tests now assert that the getter never runs. 11 of 11
mutants killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…recondition (#356, review round 4)

astra's round-4 review of 8990a11 (SHIP-WITH-FIXES) found one MEDIUM: the
no-code copy skipped symbol keys without counting them, and it bounds
nothing about an object's width before Reflect.ownKeys enumerates it. That
was reproduced first. At 8990a11, a submission with a symbol-keyed input,
or with more than 1,000,000 symbol keys, was accepted (2 failing tests).

A symbol key now refuses the submission, like an accessor or a Proxy:
JSON cannot produce one, and a skipped key goes uncounted. The header now
states the size bound as the seam's precondition. The submission is
parsed from an HTTP body the gateway caps at 1 MiB (server.ts bodyLimit),
and JavaScript cannot count an object's own keys without materializing
them, so the width bound lives with the caller. A test also pins the
string-key variant, which the value bound refuses.

Tests: external-plan-trace 60/60. Mutation: symbol keys skipped again is
killed (2 tests fail).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz
…lanHash, binding and unit (#357, review round 2b)

M1 (astra, #357 round 2b): planIsBound checked only identity (plan, node, job, milestone), so a
contract edited under its old planHash, with the deal digest recomputed and sealed, still reached
Layer B showing a planHash that its displayed contract does not hash to.

planIsBound now also requires, per node:
- planHash equals planHashOf(canonicalPlan);
- the contract's step, operator, tier and committed program equal the binding's (addresses and hashes
  compare lowercased, since hex case carries no meaning and the compiler lowercases the contract);
- the contract's amount equals the unit's gross as the exact canonical base-unit string, and its
  currency and decimals equal the deal's.
A mismatch is a plan-binding failure: Layer C, an empty invalid presentation.

Tests: the reviewer's reproduction through the stand-in store protocol, plus one test per added
equality (each recomputes the planHash and the digest so only that field disagrees), the hex-case
pins for operator and program, and a resealed-unchanged control. All failed at 1b5e77e.

Agent: implementer-india

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz
… a refusal's two lists must agree (#357, review round 2b)

M2 (astra, #357 round 2b): the checks required only that every verdict named some submitted node
and, with a plan, that the count matched. Two current verdicts for node A could replace A+B (node B
was then shown compiled or sealed without its own live verdict), and for a refusal the top-level
list drove node rendering while the nested list drove the re-quote state, with no agreement check.

presentPlan now requires:
- an outcome that carries verdicts has one per submitted node: every submitted id has a verdict and
  no id has more verdicts than nodes were submitted under it (R10 gives a readable id submitted twice
  one verdict, and an unreadable id one verdict per claim, so the bound is the submitted count);
- for a refusal, the nested verdicts equal the top-level verdicts that are not current: the same
  nodes with the same statuses (the seam builds the nested list as that subset, so this is the
  exact-agreement rule that honest seam output satisfies); an absent nested list is an empty one.
The top-level list is the only source of node states; the nested-only fallback is gone.

Tests: two copies of one verdict (the reviewer's case), a node without a verdict (refusal and plan),
five disagreement shapes plus a left-out node, and pins that the seam's own refusals still present
(mixed refusal, a node submitted twice, two nodes sharing an unreadable id, a pre-R10 refusal).
All the reproductions failed at 1b5e77e.

Agent: implementer-india

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz
…sal agreement check (#357, review round 2b)

M2 follow-up. Mutation analysis of the M2 change found two survivors:
- dropping the per-id upper bound of the cover rule (membership plus coverage only) survived,
  because two copies of one verdict always leave the other node uncovered; a refusal where every
  node has a verdict and one has two, the nested list repeating it, now distinguishes them;
- comparing the two verdict lists by status only survived; the right status on the wrong node
  now distinguishes it.
Tests only; no source change.

Agent: implementer-india

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz
…ment deal (#357, review round 2b)

M1 compares the contract's amount with the unit's gross. Under an economics agreement (royalties on
top) that gross is the agreement's and legitimately exceeds the operator's live quote, so the check
must use the unit's gross and never the quote. This test builds a real agreement deal through the
seam, shows it still presents as sealed, and shows a contract that names the quote instead of the
gross is refused. Tests only; no source change.

Agent: implementer-india

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 Oct 1, 2026
…t-intents

#432 is based on #357. This brings in 3475654 (M1: the execution contract is bound to its planHash,
binding and unit), 22fe64d (M2: verdicts are an exact cover of the nodes and a refusal's two lists
agree) and 2a6a71a (their mutation-driven test additions). No conflicts.

Agent: implementer-india

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 Oct 1, 2026
…ents

Merges 384e097 (a test pinning that M1 keeps presenting an economics-agreement deal). Tests only
on the #357 side; no conflicts.

Agent: implementer-india

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 Oct 1, 2026
…view-completeness

#434 is based on #357. This brings in 3475654 (M1: the execution contract is bound to its planHash,
binding and unit), 22fe64d (M2: verdicts are an exact cover of the nodes and a refusal's two lists
agree) and 2a6a71a (their mutation-driven test additions). The merge is automatic; the two
branches' hunks in plan-presentation.ts and in external-plan-trace.test.ts do not overlap.

Agent: implementer-india

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 Oct 1, 2026
…ed (#434, MEDIUM: duplicate case)

#434 (astra r1, MEDIUM), first half: replacing both verdicts of an accepted outcome with the same
current verdict passed validation and stayed Layer B/sealed at 6248cf9 (the reviewer's second
case; reproduced there).

The merge-up of #357's M2 (22fe64d, an exact one-to-one cover of the nodes) already refuses it as
a malformed outcome, so no source change is needed here. This test pins it on this branch.

Agent: implementer-india

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 Oct 1, 2026
…completeness

Merges 384e097 (a test pinning that M1 keeps presenting an economics-agreement deal). Tests only
on the #357 side; no conflicts. On this branch the same deal also passes the live-terms check.

Agent: implementer-india

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

Pack 133 passed as #356's confirmation (steward #5847), which releases the
held merge-up of the accepted-plan stack. A merge-tree simulation of the
whole stack over master found one conflict: this one.

The conflict was in packages/gateway/src/__tests__/external-plan-trace.test.ts:
#357 and #356 each appended a describe block at the end of the file. Both are
kept, #357's block first, closed explicitly, then #356's. The seam source
(services/external-plan-seam.ts) merged without conflict.

Results: external-plan-trace 93/93. Full gateway suite: 3121 passed,
6 skipped. tsc is clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSBxBEX3sn9DPSqihyjuZE
@LamaSu
LamaSu changed the base branch from feat/external-plan-seam to master October 3, 2026 20:25
@LamaSu LamaSu closed this Oct 3, 2026
@LamaSu LamaSu reopened this Oct 3, 2026
LamaSu and others added 4 commits October 3, 2026 15:46
 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
…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
…am (plain merge, no edits; fresh CI on the green master)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSBxBEX3sn9DPSqihyjuZE
…resentation (plain merge, no edits; fresh CI on the green master)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSBxBEX3sn9DPSqihyjuZE
@LamaSu
LamaSu marked this pull request as ready for review October 4, 2026 02:20
@LamaSu
LamaSu merged commit 2e95389 into master Oct 4, 2026
9 checks passed
LamaSu added a commit that referenced this pull request Oct 4, 2026
…e, no edits; #357 landed, retarget to master)

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 4, 2026
…ain merge, no edits; #357 landed, retarget to master)

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 4, 2026
…e, no edits; #357 landed, retarget to master)

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 4, 2026
…ed once canonicalize refuses it (D5); either order

#359's D5 (canonicalize refuses NaN, a Date and other content with no JSON form) met #357's
external-plan-trace test: its reseal() helper recomputed acceptedDealDigest over a plan whose inputs were
NaN or a Date, which now throws NonCanonicalValueError. Product code is unaffected: presentPlan's own
never-a-throw check passes, and the seam refuses non-JSON inputs at submission.

The test now reseals inside try/catch. Where canonicalize refuses (D5) it asserts NonCanonicalValueError,
since the sealing itself refuses, which is stronger; otherwise it asserts malformed-outcome as before.
93/93 with D5 and 93/93 without it. Approved by the steward (#6532) to ride on #359, which lands second.

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