Skip to content

feat(gateway): plan edits become constraints for the caller's agent, never mutations (product item 10) - #432

Merged
LamaSu merged 10 commits into
masterfrom
feat/plan-edit-intents
Oct 6, 2026
Merged

LamaSu merged 10 commits into
masterfrom
feat/plan-edit-intents

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 29, 2026

Copy link
Copy Markdown
Owner

Draft, stacked on #357 (PlanPresentation). Product pack section 5, item 10: "safe user edits before acceptance become new constraints/intents for the caller's agent; dragging a graph does not mutate execution semantics."

What it does

planEditsToIntent(presentation, edits) turns a batch of untyped user edits, made against a server-built PlanPresentation, into a typed pcc.plan-edit-intent.v1. It goes to the caller's own agent to re-plan against.

PCC never applies an edit to a plan. PCC is not the planner ("agents synthesize; PCC enforces"), and the UI is not authority.

  • Constraints are exclusions and bounds only: exclude a capability for a node, exclude an operator, a maximum price per node, a maximum total, a minimum tier per node, remove a node, and a note.
    • A note carries authority: "none".
    • Nothing ASSIGNS a price, payer, operator or tier. Assignment-shaped ops are unknown-op.
  • Layout is never semantics. move-node and collapse become layout preferences (layers D/E) and never a constraint.
  • Accepted fields are immutable. On a sealed plan (layer B), every semantic edit is refused as plan-sealed; layout still works. An invalid presentation refuses semantic edits the same way.
  • Money context is the server's. The currency comes from the presentation (the compiled preview, or else the one live currency the server re-read). The decimals come from the server's settlement-token table. A currency or decimals value inside an edit is ignored.
    • Price edits therefore work before compilation.
    • Two live currencies, an unsettleable currency, or a preview that disagrees with the table give no-money-context.
  • Total and bounded. It never throws and never mutates its inputs.
    • Each edit field is read once inside one catch, and a throwing getter makes that edit unreadable.
    • A proxied or revoked edits array is one unreadable refusal.
    • Over 256 edits, the batch is refused as a whole with ONE entry, so the work never grows with a caller-chosen length.
  • Deterministic. Duplicates are removed, and the stricter bound wins (min of the price maxima, max of the tier minima). Constraints are sorted by kind, then node, then value, and are independent of edit order.

Not in this PR

The HTTP route that returns the intent to the caller's agent. It belongs with the R9 routes (#391), after #391's first cross-family verdict.

Tests (src/__tests__/plan-edit-intents.test.ts, 46)

The presentations are real (presentPlan over the accept seam) plus hand-typed edge cases. The tests cover purity (deep-frozen inputs), totality (garbage, throwing getters, proxied, revoked and sparse arrays), every malformed value, sealed and invalid gates, the money context, stricter-wins, order independence and layout.

With external-plan-trace: 104/104. tsc is clean.

Mutation checks: 18, 17 killed. E8 (a per-edit currency) is EQUIVALENT: the output is rebuilt with the presentation's currency, and E8b, which changes the rebuild, is killed.

Written by a sonnet subagent (implementer-charlie, 6bc1516) to the lane's spec. The lane's Opus review fixed three issues (d59a22d): bounded work, proxy-safety, and price edits before compile.

pcc-composition 8a0f4de0, goal pcc-reconciliation.

🤖 Generated with Claude Code

LamaSu and others added 9 commits September 28, 2026 18:55
…s, never mutations

Adds planEditsToIntent (product pack section 5, item 10): a pure, total function
that reads untrusted `edits` against a PlanPresentation and produces a typed
PlanEditIntent — exclusion/bound constraints plus layout-only preferences — for
the CALLER's own agent to re-plan against. PCC never applies an edit to a plan
(AGENTS SYNTHESIZE; PCC ENFORCES). Dragging a graph never becomes a constraint.

Read-once per edit inside one try/catch (never inspecting the thrown value),
modeled on plan-presentation.ts's own discipline without touching that file.
Money context (currency/decimals) comes only from the presentation, never the
edit. Sealed/invalid presentations refuse every semantic edit unconditionally;
layout edits still land. Normalization dedups identical constraints, keeps the
stricter bound (min of max-*, max of min-tier), and is independent of edit
order except for refused[].index.

implementer-charlie

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… price edits before compile

This is the owning lane's review of 6bc1516 (implementer-charlie). Three corrections; the first and third fix gaps in the lane's own spec.

- Bounded work. A batch over 256 edits is now refused as a whole, with ONE entry. Before, the code pushed one refusal per element, so a sparse array claiming 2^32 - 1 entries meant unbounded work and memory.
- Proxy-safe. The code assumed a real array, but Array.isArray throws on a revoked Proxy, and a proxied `length` can throw or lie. Both are now read once, inside a guard, and the length must be a safe non-negative integer; otherwise the answer is one 'unreadable' refusal, never a throw.
- Price edits work before compilation, which is when a user edits:
  - the CURRENCY comes from the presentation (the compiled preview's, or else the one live currency the server re-read; two live currencies give no context);
  - the DECIMALS come from the server's SETTLEMENT_TOKEN_DECIMALS table, never the edit;
  - a preview whose decimals disagree with the table is not trusted.
  Before, the decimals came only from compiled money, so every price edit was refused until compile.

Tests: 46 (+3 over 6bc1516, which had 43): the proxy and sparse cases, the pre-compile money context and the no-context cases, and layer B freezing a plan even when its state disagrees. With external-plan-trace, 104 tests pass. tsc is clean for the service, its test and plan-presentation.

18 mutation checks: 17 killed. E8 (the per-edit currency taken from the edit) is EQUIVALENT: the output constraints are rebuilt from the stricter-wins maps with the presentation's currency. That rebuild is the guard, and E8b (the rebuild's currency changed) is killed. The first run had one survivor, E2 (layer B alone does not freeze), which was a test gap and is now killed.

pcc-composition 8a0f4de0, goal pcc-reconciliation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…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
…tion, taken before the edits are read (#432, F1)

F1 (astra, #432 r1): planEditsToIntent copied the basis, then read the untrusted edits array's
`length`, and only afterwards read the presentation's layer, state, nodes and money context. A
getter on `length` could therefore change what the gates decided: a sealed plan accepted a
semantic removal, an invalid one with nodes was revived, and a node list set to null made the later
`.map()` throw.

The presentation is now read exactly once, first, into owned data (`readFacts`: basis, layer,
state, the node ids, the live currencies and the preview's currency and decimals), and every later
decision uses only that snapshot. A node list that is not an array, or holds anything but nodes
with a string id, makes the presentation invalid (no semantic edit passes, no node is known); a
presentation that cannot be read at all is the same, and nothing throws.

Tests: the reviewer's Proxy reproduction (flipping layer and state), the same for an invalid
presentation with nodes, for an added node and a moved money context, the p.nodes = null totality
case, a table of unreadable node lists, and an unreadable presentation. All but the last failed at
d59a22d.

Agent: implementer-india

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

F2 (astra, #432 r1): the note de-duplication key mapped both a plan-wide note (null) and a note on
a node whose id is the empty string to the same string, so only one of the two survived and the
survivor's scope depended on input order.

The key is now the tuple JSON.stringify([nodeId, text]), where a plan-wide note is null. Exact
duplicates are still one note.

Tests: the reviewer's case in both input orders, and the exact-duplicate cases for each scope.
Both failed at d59a22d.

Agent: implementer-india

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz
…ne break (#432, F3: not reproduced)

F3 (astra, #432 r1) says the validators accept a final newline because of `$`. Not reproduced: in
JavaScript `$` without the m flag matches only at the very end of the input, and none of the three
patterns has the m flag. The reviewer's three cases (maxBaseUnits "5\n", an operator plus "\n",
capabilityId "cap-mail\n") already refuse with malformed-value, as does every other line
terminator, leading or trailing. No code change.

The tests are kept as pins, and fail if a pattern ever gains the m flag or a trim.

Agent: implementer-india

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz
…st it only there (#432, F4)

F4 (astra, #432 r1, LOW): the section header and test names claimed order independence without
qualification, but distinct notes keep their input order and conflicting layout edits are
last-write-wins (both intended, both already pinned by their own tests).

planEditsToIntent's comment now states the two exceptions (and that `refused` carries input
indices). The headers and names of the existing order tests are narrowed to what they cover, and
two tests are added: the exceptions themselves with swapped inputs, and every permutation (reversed,
all rotations, 200 seeded shuffles) of a list that has one note and layout edits for distinct
node and kind pairs giving identical constraints and layout.

Agent: implementer-india

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz
…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
…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:21
…ents (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 e32f456 into master Oct 6, 2026
16 of 20 checks passed

This branch had an error being deployed

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