Skip to content

feat(gateway): R9 agent-plan routes — validate (live R10) and accept (seam → VCR deal binding → one R13 consume); money-path gated, 503 until wired - #391

Merged
LamaSu merged 7 commits into
masterfrom
feat/agent-plans-route
Oct 6, 2026
Merged

LamaSu merged 7 commits into
masterfrom
feat/agent-plans-route

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Round 2 (2026-09-29): astra's findings on 3f68ab7 (pack B-391-r1, SHIP-WITH-FIXES) fixed at 1601cae


Draft, stacked on #357 (PlanPresentation) → #356 (accept seam) → #355 (R10) → #351 (compiler). Reconciliation row R9, the HTTP half. It cannot merge before gateway's WP-A (R28, #326), and merging is the operator's call (steward #2461, #2643). Retarget the stack to master before merge (board rule 4).

What it does

An arbitrary external agent submits the plan it composed. The server decides what it costs, who is paid, and what is sealed.

Route What it does Production today
POST /api/agent-plans/validate Read-only R10 pre-check: each node's claim against the live row, with the live re-quote when it is stale. Moves nothing. Wired: live capability and kernel rows (repos.*.findByIds) and the CSD registry.
POST /api/settlement/agent-plans/accept The accept seam (R10 → R11 → R12), then escrow's unit ids and VCR's deal binding, then ONE atomic R13 consume that seals acceptedDealDigest. 503 accept-not-wired, listing what is missing. Consumes nothing.

Accept stays 503 until every piece exists:

Authority

  • Principal. authenticatedPrincipal(req) is the API gate's identity: the key's operatorId, else the SIWE wallet, lowercased. It is never a body field. R13's reservation-issue route must use the same function.
  • Tenant. R10 visibility uses the gate's req.tenantId. A tenantId in the body is ignored (tested).
  • Gating. /api/settlement/ is a money-path prefix, so the scope checker default-denies its writes. After WP-A a caller needs an explicit settlement (or admin) scope, and a wildcard key no longer counts. This PR adds no scope rule; that is WP-A's table.
  • No existence oracle. Another principal's reservation gets exactly the same 404 as a missing one, in the refusal and in the presentation (tested byte-level: wrong-principal never appears).

Order on accept, and what fails closed

  1. One clock reading per request. The seam, the consume and asOf all use it; the seam receives it through a prototype wrapper, so method-style dependencies keep their receiver. A non-finite or negative clock is 500 clock-unavailable.
  2. The seam. Its refusals map to status codes: malformed → 400; not-found/wrong-principal → 404; not-issued/expired → 409; everything else → 422. Each carries the verdicts and a PlanPresentation: a stale plan shows needs-requote, Layer C.
  3. The deal binding (services/agent-plan-deal.ts, pure):
    • VCR's 64-unit limit is checked BEFORE the encoder: 422 too-many-units-for-deal.
    • Then escrow's unit ids, read once and checked against the deal: the same jobs, in the same order, one bytes32 per unit, all distinct. Anything else is 500 deal-binding-failed.
    • Edges come from the submission exactly as the seam evaluated it: the route re-snapshots the body and matches the seam's submissionDigest.
  4. The consume (R13's atomic re-check, digest recompute, consume once and seal). Only an explicit { ok: true } is a seal; anything else is 409 reservation-conflict. A plan that cannot be bound never consumes its reservation.
  5. 200 returns the plan (bigints as decimal strings), the deal bindings, submissionDigest, the seal record, and a PlanPresentation (Layer B, sealed).
  • A thrown server fault is a generic 500 internal-error. Its message is logged, never returned (tested).

VCR deal binding (#2475, #2674; board N22, N25, N37)

binding(node) = { digest: acceptedDealDigest, units: every unit id of the deal in plan order (1..64, distinct), nodeId, requires: unit ids of the node's DIRECT predecessors }.

  • For print → mail: mail requires [unit(print)], and print requires [].
  • A diamond a→b, a→c, b→d, c→d gives d requires [unit(b), unit(c)], in the deal's unit order, and never the unit itself.
  • The direction is as proposed to VCR in #2763; VCR's confirmation is pending. If VCR wants successors instead, the change is one line and its mutation (D3) is already tested.

Tests (src/__tests__/agent-plans-route.test.ts, 17 tests)

REAL on the path: the route, R10, the seam, R12 and PlanPresentation. STAND-INS, labelled as such: the live rows, evidence's gate, the evidence map, the R13 consume PROTOCOL (the trace's, unchanged) and a deterministic encoder. The real escrow compiler (#367) is proven in composition's R14 probe. Covered:

  • validate: current; stale plus re-quote; the gate's tenant, not the body's; 401 and 400.
  • accept:
    • production 503;
    • the whole path, with the seal, the bindings and Layer B;
    • principal from the gate only, and the existence-oracle closure;
    • exactly once: sequential 409, and of two concurrent accepts one 200 and one 409;
    • stale → needs-requote, nothing consumed;
    • 64 units accepted, 65 refused before the encoder;
    • eight encoder mismatches → 500, nothing consumed;
    • four malformed or refusing consumes → 409, never "sealed";
    • one clock read, four broken clocks, a fault that leaks nothing;
    • method-style wiring.
  • bindDeal: the diamond; edge mismatches before the encoder; a throwing encoder propagates.
  • authenticatedPrincipal unit cases.
  • N25 over HTTP (918fefb): each node's inputs come back sealed in its canonicalPlan, and the Layer-B presentation shows the same execution. A non-object or over-bound inputs is 400 and consumes nothing.
  • 21 mutation checks, all killed. Route (10): principal from the body, wrong-principal not hidden, consume before the binding, a truthy consume counted as sealed, the seam given the live clock, a broken clock accepted, a fault message leaked, wiring called detached, tenant from the body, validate without auth. Deal binder (11): the cap off by one, the cap after the encoder, requires direction flipped, duplicate ids, job id or bytes32 or unit count unchecked (the phantom-unit case was added for the last), unsorted requires, unknown or self-loop edges, the encoder before the edge check.
  • Typecheck clean for the route, the binder and the test. Full gateway suite: 3033 passed and 6 skipped, the same as feat(gateway): PlanPresentation read model for caller-authored plans (product section 5) #357 plus these 16. The two capture suites fail to load because @pcc/verifier/dist is not built (as on master); completion-real-tier timed out once at load average 27 and passes alone (3/3).

Not in this PR

  • Economics' netSplitterFor binding. The seam takes no splitNet yet; it needs server facts (#2755), and unitFacts stay empty until kits' resolver exists.
  • The reservation-issue route (R13).
  • A read route for a sealed deal.
  • Emitting the DAG's edges from the compiler itself. requires is derived from the evaluated submission today, whose edges compositionRoot commits.
  • Any scope-table change (WP-A).

Cross-family status: round 1 is queued for the 19:43 PDT codex window at 3f68ab7 (#3177). That head only merges the stack below; the route's own code is 918fefb's.

pcc-composition 8a0f4de0, goal pcc-reconciliation.

🤖 Generated with Claude Code

https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz

LamaSu and others added 3 commits September 24, 2026 15:02
…seam, VCR deal binding, one R13 consume)

An external agent submits the plan it composed; the server decides what
it costs, who is paid, and what is sealed.

- POST /api/agent-plans/validate: a read-only R10 pre-check with the live
  re-quote. It is wired in production today (live rows and the CSD
  registry).
- POST /api/settlement/agent-plans/accept: the seam, then VCR's deal
  binding, then ONE atomic R13 consume. This is a money path, so the scope
  checker default-denies its writes, and WP-A will require an explicit
  settlement scope. It answers 503 accept-not-wired, consuming nothing,
  until the R13 store, #349, the evidence map, the fee policy and escrow's
  encoder exist.
- The principal is the gate's identity, never a body field. Another
  principal's reservation gets the same 404 as a missing one.
- One clock reading per request. A broken clock or an encoder mismatch
  fails closed, and a plan that cannot be bound never consumes its
  reservation. Only an explicit { ok: true } from the store is a seal. A
  server fault is a generic 500 that leaks nothing.
- services/agent-plan-deal.ts (pure) builds VCR's binding (#2475, #2674):
  {acceptedDealDigest, all unit ids in plan order (1..64, distinct),
  nodeId, requires: the unit ids of the direct predecessors}. The 64-unit
  limit is checked before the encoder. The encoder's output is read once
  and checked against the deal.

Tests: 16. 21 mutation checks, all killed. Full gateway suite: 3033
passed, 6 skipped; the known capture suites don't load (unbuilt verifier
dist), and one load flake passes alone.

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
…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 added a commit that referenced this pull request Sep 24, 2026
LamaSu added a commit that referenced this pull request Sep 29, 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>
LamaSu and others added 2 commits September 29, 2026 09:33
…rwards only public consume codes, and checks the unit bijection (astra, round 1 of #391)

astra's review of 3f68ab7 (pack B-composition-391-r1) returned
SHIP-WITH-FIXES.

B (receiver): Object.create(seam, { now }) handed inherited methods the
WRAPPER as `this`, so a seam with private fields (or a WeakMap keyed by the
instance) broke. The seam's eight members are now read once. Its functions
are called on the ORIGINAL seam, and only the clock is replaced. Objects
(revalidation, policy, economics) pass through as they are.

D (hygiene): a consume refusal's reason was forwarded verbatim. Only
PUBLIC_CONSUME_REASONS now reach the client, as 409 with the code; each is
a state of the caller's own reservation, named with the R13 store's
ConsumeRefusal codes. wrong-principal and not-found stay one 404. Anything
else (wrong-payer, a binding, digest or obligation mismatch, a raw store
error, a malformed answer) is logged and answered with a generic 500, never
sealed.

C (defense in depth): bindDeal relied on the compiler's node-to-unit
bijection. It now refuses two nodes on one unit ("binding-not-one-to-one")
and a unit no node maps to ("unit-count").

B (ordering): the success body is built BEFORE the commit, so a failure
while presenting or converting it leaves the reservation issued. Lost
delivery after the commit is not solved here; recovering the sealed deal
stays a production gate.

Tests: 3 new plus one split.
- A seam class with #private fields.
- Both bijection violations.
- Interleaved same-operator nodes (a and c by one operator, b between them)
  binding to their own units, derived from nodeToUnit.
- The consume-answer test split: each public code gives 409 with its code;
  diagnostics, raw store errors and malformed answers give a generic 500
  that leaks nothing.
Full gateway suite: 3052 passed, 6 skipped. The capture suites do not load
without a built @pcc/verifier dist.

Mutations: 6 of 6 killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…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
LamaSu changed the base branch from feat/plan-presentation to master October 4, 2026 02:22
…oute (plain merge, no edits; fresh CI)

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 13:09
@LamaSu
LamaSu merged commit ff71067 into master Oct 6, 2026
16 of 20 checks passed
LamaSu added a commit that referenced this pull request Oct 7, 2026
…/r13-budget-reservations (plain merge, no edits; retarget to master; fresh CI)

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

This branch had an error being deployed

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