Skip to content

fix(ui-kit): harden the approval / money-action surface (port #282 + close 4 holes) - #342

Open
LamaSu wants to merge 44 commits into
masterfrom
fix/genui-approval-hardening
Open

LamaSu wants to merge 44 commits into
masterfrom
fix/genui-approval-hardening

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Problem

The shipped kit's approval and money-action surface (apps/dashboard/public/ui-kit/v1/pcc-ui.js) still carries every defect PR #282 fixed. #282 is 117 commits behind and conflicts with #288, so it can't merge as-is. Confirmed live on master ac86a404:

  1. Deny could pay. deny.onclick dispatched the manifest's deny action with viaApproval:true, which skips the money gate. A hostile manifest could wire "Deny" to a money POST.
  2. No Idempotency-Key header was ever sent. doPost passed the key as a 4th argument, but Transport.send(method,path,body) dropped it. The gateway's idempotency middleware and the escrow money routes read the header. The key was also a fresh uuid on every click.
  3. The approval window rendered no buttons. renderApproval appended its Approve/Deny bar (class .pcc-win-foot), then _setFoot removed the first .pcc-win-foot it found, which was that bar.
  4. Money detection was narrow. It used 5 verbs and had no namespace backstop, and the button styling and the gate each carried their own copy of the predicate.

What this PR does

Ported from #282: Deny is UI-only; a real Idempotency-Key header; a re-entrancy guard; one approval gate per action; the footer repair; broadened money verbs plus a /api/(escrow|fiat-ramp|compose) namespace backstop, with one predicate for styling and the gate; honest 410 / 404-on-fund / 503 messages.
Not ported: #282's origin hard-bind (_keyAllowedForBase) is superseded by merged #288 (API_ORIGIN pin). #288 is untouched, and its tests still pass.

Closed beyond #282:

  • Encoded-path gate bypass. safeApiPath validates the decoded path but sends the original string, and the gateway decodes it. So /api/fiat%2Dramp/session matched neither the namespace nor any verb, and fired without approval. Money is now classified on the decoded path, and a malformed escape counts as money (fail closed). This is the client-side twin of gateway H1.
  • Stale idempotency replay. fix(onramp-kit): harden money-action dispatch + repair approval window (C-03) #282 cached one key per action forever, but the idempotency middleware is not body-bound: the same key with a different body replays the first response. The key is now stable per (action, request body). It is reused across double-clicks and retries after a failure, a different body gets a new key, and a 2xx consumes it. On the money routes, escrow's durable activity (fund:<address>) is already idempotent per escrow on the server.
  • Structural footer fix. _setFoot now removes only its own tagged meta footer. A consumed approval disables its buttons, and each gate opening allows one Approve.
  • Accepted is not settled. A 2xx money write now reads "Submitted - awaiting network confirmation", and the approval pill reads submitted (waiting), never settled-green. Non-money writes still read "Done". The gate now mirrors its final outcome to the action bar; on master that status stayed at "Working..." forever.

Tests

packages/spec/src/__tests__/ui-kit-money-actions.test.ts: 18 jsdom tests that drive the real render and dispatch path in live mode through a recording fetch stub. They cover:

  • Deny sends nothing, even when wired to a money POST;
  • a triple-clicked Approve produces 1 POST;
  • the header equals the legacy body field;
  • the gate: a namespace path, a percent-encoded path, and a malformed escape all need approval, a rapid second click opens one gate, and the gate's Approve fires 1 POST;
  • the outcome is mirrored to the action bar;
  • key lifecycle: a retry after failure reuses the key, a different body gets a new key, and a 2xx rotates it;
  • a money approval shows submitted, never green;
  • 410 messaging;
  • the fix(genui): pin kit API origin — close sol#1 credential-redirect (on-ramp) #288 pin: a hostile api_base can't redirect the POST.

Results (DGX Spark @ ff2a4337):

Mutation proof: 9 mutations against the committed file.

Mutation Tests failing
Deny dispatches again 1
no header 3
no re-entrancy guard 1
raw-path predicate 2
money success shows green "Done" 1
key never rotates 1
no namespace backstop 2
gate Approve not disabled 1

The two footer protections (the tagged selector and the new call order) are redundant by design, so reverting either one alone survives. Reverting both, which is master's behavior, fails 7.

Relationship to other PRs

Residuals (not in this PR)

Ledger row 37 / STATUS-BOARD PX-2. Reconciliation note: /mnt/sparkbulk/pcc-reconciliation/returns/pcc-genui.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy

LamaSu and others added 7 commits September 2, 2026 13:12
…o refund-as-payment)

statusClass() used a greedy substring regex that rendered any status containing
settl/releas/complet/done/paid/funded/success/approved/active as a green "settled"
pill. So a REFUND ("refunded" contains "funded"), an UNRELEASED/INCOMPLETE/UNSUCCESSFUL/
NOT_APPROVED/INACTIVE/UNDERFUNDED, and every *_ALLOCATED (decided, not final) rendered as
a completed PAYMENT. A refund shown as a payment is the read-route contract's forbidden-
asserter CRITICAL, live in the shipped on-ramp kit.

Replace it with an EXACT normalized-status map keyed off the sec-A conformance table
(V-next finalState/unitState, the 10-state machine) plus the legacy EscrowSummaryDTO enum:
- SETTLED_RELEASED / COMPLETED -> st-settled (operator distribution discharged)
- SETTLED_REFUNDED / REFUNDED  -> new non-green st-refunded ("payer refunded - operator NOT paid")
- RELEASE_ALLOCATED / REFUND_ALLOCATED / in-flight (0-5) / FUNDED / CREATED -> st-waiting
- unmapped / unknown -> new neutral st-unknown, NEVER green (fail closed)
No substring inference for money state. Add honest sec-A direction labels on the settlement
rail, and drop the dishonest rail fallback that inferred 'settled' from releasedCount
(contract rule 12: never key settlement off count/receipt existence).

Adversarial conformance test extracts the shipped <status-map v1> region verbatim (tests the
real bytes, not a copy) and asserts the sec-A table, the never-green invariant, normalization
(casing/whitespace/separators), generic-state tones, and that the old regex is gone. 7/7 green.

Refs: genui-read-route-contract sec-A + rules 1/12; genui conformance matrix v1.4.
Cross-family (sol/GPT-5.6) verdict: the one-line /refund/ guard is insufficient; this is the
exact-map it prescribed. gen-UI owns the fix (defect is in gen-UI's shipped kit + contract).
…d/dispute as green)

EscrowPage.tsx rendered the escrow status GlowBadge with `... : "green"` as the ternary
DEFAULT, so any status other than active/funded -- refunded, disputed, created, unknown --
rendered GREEN: a refunded or disputed escrow shown as a completed payment. Same false-green
money-display bug as the ui-kit statusClass fix, in a parallel formatter (the blast radius sol
predicted: "fixing one helper doesn't help if dashboards independently infer status").

Replace with an exact map defaulting to gray: completed -> green, disputed -> red,
active -> gold, everything else (funded/created/refunded/unknown) -> gray. Matches the
already-honest MilestoneTimeline statusColors + read-route contract rule 1.

Type-correct by inspection (all branches return valid GlowBadge colors: green|gold|red|gray);
a full dashboard typecheck/build is Spark-offload territory and was NOT run here.
Bring PR #313 current with master before extending it (dry-run merge-tree was
clean; no conflicts). No force-push: the PR history is preserved.

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

Master had five competing escrow-status vocabularies (EscrowStatus,
Escrow.status, the dashboard DTO, the V-next UnitState, the context-pack
summary) and every surface re-inferred them; the shipped kit's substring regex
rendered a REFUND as a green completed payment (read-route contract rule 1).

MONEY_STATUS_MAP is the ONE exact table: normalized key -> semantic tone +
honest, direction-explicit label. Green ('settled') is reserved for the three
documented FINAL releases to the operator (SETTLED_RELEASED, COMPLETED,
RELEASED). Refunds are 'refunded' (final, operator NOT paid); allocated-not-
final states are 'waiting'; unknown fails closed. Bare SETTLED is deliberately
unmapped: SettlementResultDTO uses it for operator-paid but the V-next phase
vocabulary uses it for BOTH released and refunded.

Frozen at every level; compile-time coverage records make tsc fail if
EscrowStatus / Escrow.status gains a value with no entry. Browser-safe.
8 tests (sec-A table, never-green invariant, unknown fail-closed,
normalization, full vocabulary coverage, immutability).

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

- <status-map v2> mirrors @pcc/spec MONEY_STATUS_MAP verbatim (the vanilla
  kit cannot import it) and now covers every documented escrow vocabulary, so
  canonical states like released/slashed/releasing/expired no longer render
  'unknown'.
- New moneyStatusClass for MONEY surfaces (the receipt window): money table
  only. An off-schema 'success'/'done'/'ok' on a money response is NOT a
  settlement state and never renders paid (it still tones a generic run/list
  surface via statusClass).
- Conformance test moved into CI: #313's node --test file lived under
  apps/dashboard/public/ (served publicly) and no CI job ran it. The new vitest
  file (a) extracts the shipped region verbatim and proves it equals the spec
  map key for key and agrees with classifyMoneyStatus over an adversarial
  battery, and (b) boots the WHOLE kit in jsdom and asserts the rendered receipt
  pill: refund / allocation / off-schema success / missing status (rule 12) are
  never settled-green. 19 tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
EscrowPage's escrow badge had its own inline ternary (a sixth formatter). It
now calls moneyBadgeColor(), which takes the semantic tone from @pcc/spec
classifyMoneyStatus and only maps tone -> GlowBadge color: green is reserved
for a documented FINAL release; refunded / allocated / unknown are never green.
GlowBadge itself defaults to green, so money badges must pass an explicit color.

A compile-time record lists every value of the dashboard's EscrowStatus type
(tsc fails if the type gains one) and the test asserts each is a KNOWN state in
the canonical map, so no real escrow status renders 'unknown'. 3 tests;
dashboard tsc --noEmit clean; full dashboard suite 223/223.

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

Surgical port of PR #282's still-valid fixes onto current master. #282's origin
hard-bind is superseded by merged #288 (API_ORIGIN pin) and is NOT ported; #288
is unchanged and its tests still pass.

Ported from #282 (all still live on master ac86a40):
- Deny is UI-only. It dispatched the manifest's deny action with viaApproval,
  which SKIPS the money gate: a hostile manifest could wire Deny to a money POST.
- A real Idempotency-Key HEADER. Transport.send dropped doPost's 4th argument,
  so no header was ever sent; the gateway idempotency middleware and the escrow
  money routes read the header, not a body field.
- One effect per click: re-entrancy guard, one approval gate per action.
- The approval window rendered NO buttons: _setFoot removed the first
  .pcc-win-foot, which was the Approve/Deny bar.
- Broadened money verbs + a money-namespace backstop (/api/escrow|fiat-ramp|
  compose); one predicate for button styling and the gate.
- Honest 410 / 404-on-fund / 503 messages.

Closed beyond #282:
- Money classification runs on the DECODED path. safeApiPath sends the original
  string and the gateway decodes it, so '/api/fiat%2Dramp/session' skipped the
  gate. A malformed escape is money (fail closed).
- Idempotency key is stable per (action, request body) and rotates after a 2xx.
  #282 cached one key per action forever, and the middleware is NOT body-bound,
  so a second submission with different values would replay the stale first
  response. Retries / double-clicks of the same body still reuse the key.
- _setFoot only ever removes its own tagged meta footer (structural, not an
  ordering convention); a consumed approval disables its buttons; one Approve per
  gate opening.
- Accepted is not settled: a 2xx money write reads 'Submitted - awaiting network
  confirmation' and the approval pill 'submitted' (waiting), never settled-green.
  The gate mirrors its final outcome to the action bar (it stayed at 'Working...').

18 jsdom tests through the real render + dispatch path in live mode. Kit smoke
8/8; the 9 gateway suites that load the real kit (host mode, #288 pin, bridge,
lifecycle, artifacts) 153/153.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
LamaSu and others added 22 commits September 24, 2026 10:08
…e path the wire carries

Independent review (reviewer-bravo) of #342 found two HIGH gaps plus smaller ones:

- HIGH, gate bypass by URL normalization: the predicate tested the %-decoded raw string, but
  fetch sends new URL(path, origin), and the URL parser strips TAB/LF/CR, trims edge spaces and
  drops '#fragment'. "/api/esc\trow/chain/0xabc/fu\tnd" opened no gate, POSTed to the real fund
  route, and an approval window then turned green "resolved". Now safeApiPath refuses whitespace,
  control characters and '#', and the predicate classifies canonicalPath(): the WHATWG-parsed
  pathname, %-decoded the way the gateway routes it; an encoded / ? # \ has no canonical form
  and fails closed.
- HIGH, the "fail-closed" backstop was a 3-namespace denylist: /api/lob/letters,
  /api/settlement/{submit,flush}, /api/jobs/:id/resume-settlement, /api/near/intent and others
  went ungated. Inverted: every manifest write is money unless it matches an exact allowlist of
  six known non-money writes (artifact save/fork, CSD validate/resolve, feedback x2). The paid
  x402 routes stay off it. A test proves each entry is a real, non-x402 gateway route.
- idempotencyFrom pinned one key across different bodies (a changed request replayed as the
  first): the key is now derived from (reference, body).
- Kit state (__idem/__posting/__gateOpen) lived on the untrusted manifest object, so a manifest
  could strip or pin the key or make an action inert: moved to a kit-owned WeakMap.
- 5xx text claimed "nothing was charged" (an edge 5xx can follow an executed write): now "the
  outcome is unknown"; the 404 funding text only fires for the real escrow fund route; a
  structured error renders as JSON, not "[object Object]".
- Deny's pill asserted a network-side denial: now neutral "not approved here".
- The gate auto-closed at 1.2 s while the request was in flight and a second click opened an
  inert gate: it now stays open until the request settles, and a click while in flight says
  "Already submitted".

Tests: ui-kit-money-actions 66/66 (18 existing, fixtures moved to the real
/api/escrow/chain/:address/fund and an allowlisted form route, +48 new). Full spec 863/863.
The 7 gateway suites that load the kit pass 95/95; mcp-apps-host-integration now approves an
unlisted write through the gate. Full gateway 2981 passed, 6 skipped. tsc (spec, gateway) clean.

agent: pcc-genui (4df1e691)

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

Pins the canonical-path layer on its own: /api%2Ffeedback decodes to an allowlisted
route, but the gateway does not split on %2F, so the kit must gate it. Mutation that
reverts the predicate to a raw decode now fails 4 tests (it previously survived because
safeApiPath's refusal made the layer redundant for every other case).

agent: pcc-genui (4df1e691)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
…id; route data surfaces by data

First part of the #313 review fixes (astra via coord-watch #2465, product-qa #2594, reviewer-bravo):
- Normalization rejects rather than erases: only a plain status string ([A-Za-z0-9 _-]) is
  classified. "RELEASED?", "❌RELEASED", "releaſed", ["RELEASED"] and objects whose toString
  returns a green word are now unknown. Spec and kit mirror are changed together.
- COMPLETED is no longer green. It ends many non-money DTOs (jobs, A2A, steps, batch claims
  where paid != completed), so it now reads "completed - settlement not confirmed".
- List rows and run status pick their table from the DATA: money unless the binding is a known
  non-money read and the row carries no money field. So an escrow row's "success" or "done" is
  never green, while a completed job still is. Generic surfaces check generic states first.
- The EscrowPage milestone badge now comes from the canonical map (no dead "fulfilled" green).

Tests: money-status 35/35 (+8); full spec 832/832; dashboard 223/223; the 7 gateway kit suites
95/95; tsc (spec, dashboard) clean. More #313 fixes follow (source-schema receipt adapter).

agent: pcc-genui (4df1e691)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
…el; no bare-word green

Second part of the #313 review fixes: steward rulings #2490/#2688, astra (coord-watch #2465),
escrow #2580, product-qa #2594.

- Classify by SOURCE SCHEMA: classifySettlementRecord (spec) / settlementRecordClass (kit mirror):
  - V-next /lifecycle: unitState is an ordinal 1..9, pinned to VNextSettlementLib.sol's
    `enum UnitState`; 0 is a read error. finalState, isAllocated and isTerminal must agree, and
    a final state needs all three present.
  - V-next /receipt: finalState counts only when terminal with isAllocated true.
    `null` + allocated reads "decided, not yet paid out".
  - A legacy escrow record goes through the flat table on its status.
  - Anything else (a job, an A2A task) is "not a settlement record".
- The flat word table has NO green entry: bare SETTLED_RELEASED, RELEASED and COMPLETED are not
  settlement reads. The only green is a consistent V-next state 8. AWAITING_FUNDING is unknown.
  Labels are fixed per escrow F4-F6: "not final", "payer not yet refunded", "payout distribution
  discharged", "payees NOT paid".
- Receipt: state by schema; nothing invented (no default "USDC", "payer"/"payee" or
  "escrow-milestone"; missing values read "not reported").
- Run window: a read model by its schema; a full snapshot with no status reads unknown (an
  earlier green is never kept). List/run money rows that are read models use the schema.
- The kit tables are frozen. Compile-time coverage now constrains the real map (`satisfies`).
- EscrowPage: the "Total Locked" and selected-escrow panels no longer glow green.

Tests: money-status 18 + conformance 37 = 55. They cover wire fixtures for states 0-9, every
cross-check disagreement, missing corroboration, receipt shapes, job/A2A records, the kit==spec
adapter over the battery, the ordinal table re-read from the Solidity enum, frozen tables,
no-invention, run null snapshot, and money rows. Full spec 852/852; dashboard 223/223 (after a
spec build); gateway kit suites 95/95; tsc spec + dashboard clean.

agent: pcc-genui (4df1e691)

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

Pins the no-invention rule for the currency line (the earlier test used a receipt with no
amount, so the currency branch never ran; mutation M8 survived). M8 is now red.

agent: pcc-genui (4df1e691)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
…lance window (unregistered route)

operator.json bound a metric to GET /api/fiat-ramp/wallet/balance, which the gateway does not
register (the only balance read is /api/fiat-ramp/cdp/wallet/:address/balance; readmodels #2698),
so the shipped few-shot rendered an error window and taught LLMs a dead route. Money display
belongs in PCC-owned surfaces, so the window is removed rather than rebound. The example still
exercises metric (Active jobs) + list; ui-artifact-examples + kit smoke 18/18.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
…ruling (one descriptor, kit labels, intents)

Implements the steward's ruling on #342 and the cross-family r1 findings (px2-342-moneyactions-astra)
on top of 07f3ede (allowlist + kit-owned WeakMap state).

A. Kit-owned labels (ruling 4, r1#1). The approval window's controls are always the kit's "Approve"
   and "Deny"; approve.label is shown only as quoted, attributed text. The gate's Approve/Cancel are
   kit text and quote the manifest label. Every manifest-labelled control that STARTS a write ends in
   a kit-owned tag ("needs approval" / "asks to confirm" / "sends now" / "blocked" / "unavailable" /
   "via assistant"), so "Deny"/"Cancel" can never dress up a write. Deny and Approve lock the moment a
   submission starts; after a failure Approve may retry (same key) but Deny stays locked, so it can
   never claim "nothing was sent" once a request left.
B. One canonical request descriptor (ruling 3, r1#2). requestDescriptor(action, body, isHost, base)
   validates ONCE into {ok, method, path, canonical, url, money, destination, reason, body, ...}; the
   money decision, button styling, "This will send", rebindApproval, the idempotency intent and the
   transport all use that object. Transport.send(desc, body, key) fetches exactly desc.url with
   desc.method and only re-CHECKS the #288 pin (pinnedUrl: one implementation, also behind _pin;
   credentials omit / redirect error / no-referrer / no-store unchanged). safeApiPath now refuses the
   ambiguous encodings %25 %2F %5C %3F %23 (any case) and C1 controls. An allowlisted route only
   matches exactly (a query string makes it unlisted = gated). describeRealRequest is the display
   projection of the descriptor.
C. Kinds (r1#4): only "post" -> POST and "patch" -> PATCH; "PATCH", "put", "delete", "get", missing,
   etc. are refused with an honest status and send nothing.
D. Every write entry point takes the same policy (r1#4): chain Plan is a kit-synthesized action through
   dispatchAction (Approval gate, Idempotency-Key, busy guard); hosted typed operations get a
   per-action in-flight guard (sync bridge throws release it).
E. Idempotency intents (r1#5): st.keys maps body fingerprint -> key for UNRESOLVED attempts (A -> B ->
   A reuses A's key); a 2xx consumes that fingerprint's key; an accepted MONEY write is one-shot for
   the render ("Already submitted", nothing sent). idempotencyFrom keys are derived from
   (method, canonical route, reference, body), so two routes never share a key.
F. Gate cleanup is per instance (r1#5): close() releases the one-gate guard only if it still owns it.
G. Neutral acknowledgements (ruling 2): non-money 2xx "Done", hosted-op "Done" and the non-money
   approval pill "resolved" use the new neutral st-ack class (no hue); money stays st-waiting
   "Submitted - awaiting network confirmation". st-settled now only comes from read models.

Expectations changed by the ruling (explained):
- 6 spec tests expected an Approval gate to OPEN for paths the kit refuses (a malformed escape and
  five encoded-separator paths). Under ruling 3 a request that fails validation is not a request, so
  it is refused at the click with its reason (no inert gate whose Approve could never send); those
  tests now assert "refused, no gate, nothing sent". /api/artifacts/a%2Fb/fork moved from the
  near-miss (gated) list to the refused list; /api/artifacts?x=1 and /api/artifacts/ were added to it.
- mcp-apps-hardening extracts kit helpers by name; its bundle now also carries NON_MONEY_WRITES and
  the descriptor helpers describeRealRequest closes over. Its assertions are unchanged.

Tests: ui-kit-money-actions 151/151 (was 70; +81 incl. the r1 bypass list, the nine money routes,
kinds, chain Plan, host re-entrancy, A/B/A keys, money one-shot, stale gate timer, neutral acks,
display == wire URL, forged-descriptor transport refusals). Full @pcc/spec 948/948. The 7 gateway
suites that load the kit 95/95. tsc --noEmit clean for spec and gateway (and both touched test files
typecheck in place). node --check pcc-ui.js OK.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
… + the money acknowledgement class

- canonicalPath and safeApiPath are each checked in isolation (sliced from the shipped source):
  with ambiguous encodings now refused at validation, the classifier's own %25/%2F/%5C/%3F/%23
  guard is unreachable through the DOM, so only a layer-level test can keep it from rotting.
- a money 2xx is asserted as "pcc-action-status st-waiting" (gate + mirrored bar), never green;
  the older test only checked the approval pill.

ui-kit-money-actions 154/154 (the touched test file typechecks in place).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
The descriptor copied the request body with Object.assign, and "This will send"
showed only the first amount-like and ref-like field:
- a "__proto__" body key re-parented the copy, so the gate DISPLAYED an inherited
  amount/ref (Amount 1.00, ref benign) while the wire carried only the own keys
  (totalAmount 1000000, escrowId evil);
- a small "amount" could stand in for a larger "totalAmount" that was also sent,
  and the approval window never showed the other body fields.

Now the body is a plain own-key copy (plainBody); a "__proto__" key is refused at
validation (nothing sent, honest reason). The display names EVERY amount/ref field
when there are several, shows every other body field exactly as sent, and formats
only a plain number or decimal string as a sum (true, [1000], "0x0F4240" are shown
raw). The gate's separate args table is folded into the same block.

Tests: 8 new (money-actions 162/162); spec 959/959; gateway kit suites 86/86.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
…lick, not only tagged

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

#313's classifier assumed isAllocated was true for every allocated-or-final state (6-9).
The read routes say otherwise: gateway unit-state-mapper isAllocatedState is true for 6/7
ONLY ("outcome decided, money NOT fully moved"), so a settled /lifecycle or /receipt body
carries isAllocated:false. A genuinely settled unit therefore rendered "settlement fields
disagree" and was never shown as settled -- fail-closed, but wrong, and the hand-written
test fixtures encoded the same assumption (one even pinned the real settled receipt as
unknown).

Now, in the spec classifier and the kit mirror alike:
- lifecycle: allocated = state 6 or 7; `phase` must match VNEXT_PHASE (the mapper's
  PHASE_BY_STATE) when present, and a final state needs finalState, isAllocated,
  isTerminal AND phase;
- receipt (finalState, phase, isAllocated): final only with isAllocated:false and
  phase:"settled" (both present); isTerminal, if present, must agree; "allocated" and
  "in flight" cross-check phase too.

A new gateway suite takes its fixtures from the routes themselves (Fastify inject over a
fake reader) for states 1-9 and classifies every body with the spec AND the shipped kit:
only state 8 is green, from either route, and each tampered field is unknown.

Tests: money-status 18 + conformance 39; spec 854/854; dashboard 223/223; new gateway
suite 3/3.

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

The receipt window fell back to economics.amount and formatted it with fmtUsd. On the
real /receipt route that field is a raw integer in the token's BASE units (read-surface
contract rule 14) and the route sends no tokenDecimals, so a 1 USDC settlement
("1000000") would have rendered as 1,000,000.00.

Now economics.amount becomes a display amount only with the record's own tokenDecimals
(exact string arithmetic, no float); otherwise it is shown as "N base units (decimals
not reported)", with no invented currency. A malformed value is "amount not reported".
Top-level amount/totalAmount (legacy escrow records, already display units) are unchanged.

Tests: 3 new (conformance 42/42) using the route's exact EconomicsRecord shape.

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

An independent adversarial review of #342 @17a8a7f0 (reviewer-charlie, 105 PoC probes
against the shipped kit) returned APPROVE-WITH-NITS: no gate bypass, wire == displayed
method/URL/body, one effect per intent, honest decline controls, no HTTP-driven green,
no fail-open throw. Its display-integrity findings are fixed here:

- F1 (MEDIUM): the approval window rendered the manifest-chosen binding's "what/who/cost"
  ABOVE and LARGER than "This will send" (a stored artifact could read "Free sample -
  no charge - 0.01 USDC" over "Amount 5,000.00 USDC"). Now the request block comes
  first; the bound record follows as attributed context ("The bound record says
  (context, not what will be sent)") and drops its own amount when the request carries
  one; a record amount no request amount matches raises a kit st-failed warning.
- F2: amounts are formatted only when exact; 0.0049 is no longer shown as "0.00".
- F3: no currency is invented: "(no currency in the request)".
- F4: a POST's idempotencyKey row shows the kit's key ("set by the kit when sent"), never
  a body value the kit replaces; a PATCH body's field is shown and sent as is.
- F5: artifact create/fork leave the non-money allowlist: they publish under the
  viewer's identity, so they pass the gate, which shows what is published.
- F6: a synchronous transport throw releases the guard and says nothing was sent.
- N1: a second click while the gate is open says so; N5: a failed record read says
  "Details unavailable".

Tests: 11 new; money-actions 174/174 (tests that used /api/artifacts as the allowlisted
example now use /api/feedback); spec 971/971; gateway kit suites 86/86.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
… it (escrow ruling #3163)

Escrow ruled that master's settlement routes are the target and asked gateway to ADD the
staticcall-authoritative unitState to /receipt (additive), so a receipt consumer can tell
6 (release decided) from 7 (refund decided); genui keys that direction off unitState,
never off finalState.

A receipt with unitState enters the unitState branch, which required isTerminal for a
final state, and /receipt carries no isTerminal, so settled receipts would have
classified "incomplete" the day the field lands. A final state now needs unitState,
finalState, isAllocated and phase (every present field still cross-checked, isTerminal
included when present). Spec and kit alike.

Tests: the route-derived suite classifies each state's real /receipt plus the lifecycle's
unitState (states 1-9: same tones as /lifecycle, green only at 8, 6 vs 7 named) and four
tampers of it (4/4); money-status 60/60. Requiring isTerminal again turns it red.

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

product-steward #3377: so the operator can merge #313 and then #342 without a conflict.
The one conflict is adjacent pill CSS in pcc-ui.js: #313 adds .pcc-pill.st-refunded and
.pcc-pill.st-unknown after st-running, #342 adds .pcc-pill.st-ack there; all three kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… route (astra r2 on #313)

astra (gpt-5.6-sol) round 2 at 8f94649: DO-NOT-SHIP. Each finding was reproduced FIRST with a
failing test against 8f94649's kit (verify-before-fix), then fixed:

- F1 HIGH, shape is not provenance: a settled-SHAPED body bound to /api/jobs/j1 rendered green,
  and so did a baked (unsigned) snapshot. New classifySettlementRead(record, {path, live}) in the
  spec and settlementReadClass in the kit: a FINAL V-next state (settled 8, refunded 9) is shown
  only for a LIVE read of an exact per-unit settlement route
  (SETTLEMENT_READ_ROUTE = /api/settlement/units/0x<64 hex>/(receipt|lifecycle), the route's own
  UNIT_ID_RE). Snapshots, fallbacks, stream events and other routes read "final state not shown -
  not a live read of a settlement route". Receipt, list and run windows pass their liveness.
- F2 HIGH: the generic table painted success/done/completed/ok green, so money data the routing
  heuristic missed (nested economics) turned green. Green means money finally reached the payee
  and nothing else is ever green: generic success words are now a NEUTRAL st-ack.
- F3 MEDIUM: a failed poll kept an earlier green. It now reads "unknown · read failed".
- F4 LOW: classifySettlementRecord's doc comment described the old /receipt rule; rewritten to the
  implemented semantics, and it now says display must go through classifySettlementRead.

Eight earlier expectations encoded exactly what F1/F2 flagged (green from snapshots and from
generic "completed"); they now assert the fixed behavior. New: live-mode checks (exact route,
?asOf, invalid unit id, other leaves, the direction label), kit == spec parity for the gate over
records x sources, and the gateway suite runs the gate on the routes' real bodies.

Tests: spec 864/864; gateway settlement suites 32/32; dashboard 223/223.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s only (astra r2 on #342)

astra (gpt-5.6-sol) round 2 at 60137b1: DO-NOT-SHIP (text-only). Each finding was REPRODUCED
FIRST (verify-before-fix), then fixed:

- F1 HIGH, cloned actions: two structurally identical money actions (same id, method, path,
  body) sent TWO POSTs with different Idempotency-Keys, both sequentially and with both gates
  open (reproduced: 2 POSTs each). Execution state was keyed by the untrusted action OBJECT. New
  kit-owned INTENT_STATE keyed by the exact request (method, pinned wire URL with its query, body):
  every action object describing that request shares one open gate, one unresolved key and one
  money one-shot. The per-object state still applies; the stricter wins.
- F2 HIGH, hosted typed ops: the view injected EVERY registered operation id, and the kit runs an
  injected op with no kit approval and may rerun it (reproduced by trace; not exploitable today:
  the only registered op, capability.request_quote, is read-only, and /mcp/apps admits only
  stateChanging:false ops). The view now injects APP_HOST_OPERATION_IDS, and operation-policy.ts
  asserts at module load (appHostOperationViolations) that each is registered, stateChanging:false
  and approval "none". A money or state-changing op can never reach a hosted view's allowlist.
- F3 MEDIUM: an idempotencyFrom key hashed the canonical PATHNAME, so ?mode=A and ?mode=B got the
  same key (reproduced: identical keys). It now hashes the exact wire URL, query included.

Tests: 3 kit reproductions (now passing), a pure rule test with a synthetic registry, a structural
pin that the view injects the app-safe list, and the served view's exact literal. spec 1034/1034;
gateway MCP/kit suites 134/134; full gateway 2985 passed + 2 load timeouts that pass in isolation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…per-intent gate)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… route, sorted query)

Found while writing #342's round-3 brief: the F1 intent key used the raw wire URL and
JSON.stringify(body), so a manifest could clone a money action with REORDERED body keys (reproduced:
2 POSTs), a %-encoded path character or a reordered query, and mint a "new" intent (a fresh gate
and key) for the same effect. The intent (and the idempotencyFrom key) now use the canonical
request: sorted-key JSON at every depth, and the decoded pathname the gateway routes plus the
decoded query parameters, sorted.

Tests: one new (reordered keys, %61 path, reordered query: one POST each); money-actions 178/178.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…5 on #313)

Reproduced first at 9250c57 with full-render tests: an ordinary POST answering 200 {} and a hosted
typed operation resolving without isError both painted `pcc-action-status st-settled`; a source pin
showed three direct green assignments (doPost, dispatchHostOperation, rebindApproval).

All three now use the neutral st-ack class (the same classes #342 uses), and .pcc-action-status.st-ack
gets its style. Green (st-settled) can now come only from settlementReadClass: a LIVE read of an exact
per-unit settlement route.

rebindApproval cannot be clicked at this head: the approval window's Approve/Deny foot is removed
again by winShell._setFoot, a master bug that #342 fixes. The source pin covers that line.

The new tests answer only their own requests, so a run window left polling by an earlier test stays
frozen instead of being revived (it fired after teardown in the full spec run).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… may tag its labels, #342 ruling 4)

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

Brings #313's astra round-2 fixes (9250c57: the live settlement-read gate, neutral generic success,
a failed poll shows unknown) and round-3 F5 (84a270d: request success is acknowledged, never green).

Resolution: the three F5 hunks (rebindApproval, the hosted-op success branch, doPost's success branch)
keep #342's code, which was already neutral and finer: a money write reads "submitted" (waiting) and
is one-shot; anything else is a neutral "resolved"/"Done" (st-ack). The st-ack status style both
branches added is kept once. #313's F5 tests now find a button by its label prefix, because #342
appends a kit-owned tag to manifest labels (ruling 4).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu and others added 15 commits October 2, 2026 18:47
…green (astra r3 F5 on #313, end to end)

#313 cannot render this path (its approval foot is removed by _setFoot); #342's foot survives, so the neutral outcome of rebindApproval is pinned here.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… status (astra r4 F6 on #313)

Reproduced first at 887ea3c with full-render tests: a list row and a run window on a money read, and
a receipt of a legacy escrow record, showed "paid", "released" and "settled" verbatim inside a status
pill (st-unknown / st-waiting); a baked snapshot of a settled receipt showed "SETTLED_RELEASED"; a job
row showed "settled".

The class decides the colour, and now the text may not claim more:
- money data in list rows and run windows shows the classifier's honest label (dataStatusText);
- a value that claims money finally moved (paid, released, settled, refunded, payout, ...) or is not a
  plain status word is shown as "reported status: <value> - settlement unconfirmed";
- only a final state VERIFIED by settlementReadClass (a live read of an exact settlement route) keeps
  its plain name; receipts keep their separate label and qualify the pill the same way.
The rule is canonical in @pcc/spec (FINAL_MONEY_TOKENS, claimsFinalMoney, statusPillText), mirrored in
the kit's status-map region, and a parity test compares them. One older expectation that encoded the
bare snapshot name now asserts the qualified text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e label and verified branches (astra r4 F6)

A snapshot run window called apply() and then overwrote the pill with the raw status, so a snapshot of {status: "paid"} still read "paid" (reproduced). It now keeps apply()'s text; only a status-less, non-read-model snapshot shows the plain 'snapshot' marker. Three mutation survivors became tests: a run window names a gated-out final by the classifier's label and a verified one by its plain name; money data shows the classifier's honest label.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…empotencyKey (astra r3 F1, F2 on #342)

Reproduced first at 48c156e (ui-kit-money-actions "astra r3 (#342 @48c156e5)"): two money actions that
differ only in a manifest idempotencyKey, in a query parameter or body member the route ignores, or in
the endpoint's spelling (hex case) each got their own intent, and approving both sent TWO POSTs. Two
requests whose repeated query values differ in order derived the SAME idempotencyFrom key.

A request's spelling does not identify its business effect, so the kit no longer claims it does:
- a MONEY intent is the method plus the ENDPOINT (decoded route, lowercased, no doubled or trailing
  slashes), never the query or the body: one gate, one unresolved key, one money one-shot per render;
- while one request's outcome is unknown, a DIFFERENT request to that endpoint is refused (under the
  earlier key the server would replay the earlier result; under a new key it might move money twice);
  only the identical request retries with its key. A second, genuinely different payment to one
  endpoint needs a reload (fail closed). Effect-level identity is the server's to enforce;
- a POST's idempotencyKey is kit-owned: a manifest value never reaches the intent, display or wire
  (a PATCH body's stays plain data, review charlie F4);
- non-money (allowlisted) intents keep the exact canonical request, with query parameters sorted by
  NAME only (stable), so repeated values keep their order.
Two earlier tests encoded the old semantics (a different money request sent under a new key while one
was unresolved; two money requests to one endpoint in one view) and now assert the refusal.

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

Brings #313's astra r4 F6: a pill never shows a rejected money word as a bare status. Clean merge; the spec suite (1063) passes on the merged kit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… r3 F1 on #342)

Two allowlisted non-money POSTs that differ only in a manifest idempotencyKey are one request: the second retries under the first's unresolved key, and the wire carries the kit's key, never the manifest's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… a closed safe vocabulary (astra r5 F7-F9 on #313)

Verify before fix: added describe("astra r5 (#313 @d82cde8b): F7-F9
(verify before fix)") to money-status.conformance.test.ts and ran it
against the unmodified code. All 7 reproduced:

- F7 direct: expected claimsFinalMoney("PAYEE_RECEIVED_FUNDS") to be
  true -> "AssertionError: expected false to be true". The blacklist
  token split never matches "PAYEE"/"RECEIVED"/"FUNDS".
- F7 render (list on /api/escrow, status "PAYEE_RECEIVED_FUNDS") ->
  "AssertionError: expected [ 'PAYEE_RECEIVED_FUNDS' ] to not include
  'PAYEE_RECEIVED_FUNDS'". Rendered bare.
- F8(a) run window live-poll latest line (statusFrom===latestFrom,
  {status:"paid"}) -> "AssertionError: expected 'paid' not to be
  'paid'". The latest line bypassed the pill-text rule entirely.
- F8(b) same run window via a snapshot -> identical failure (the
  snapshot branch calls the same apply()).
- F8(c) receipt timeline entry sourced from ev.status ({status:"paid"}
  in events[]) -> "AssertionError: expected [ 'paid' ] to not include
  'paid'". The timeline fell back to String(ev.status) unqualified.
- F8(d) SSE feed line sourced from ev.status (driven via a real
  ReadableStream stub through the kit's actual streamSSE reader) ->
  "AssertionError: expected [ 'paid' ] to not include 'paid'". Same
  bare fallback in the stream handler.
- F9 non-money list (/api/jobs, status "running!") -> expected
  "reported status: running! - status unverified", got "...-
  settlement unconfirmed". claimsFinalMoney treats any punctuated
  ASCII text as a final-money claim regardless of surface, so a job's
  decorated status got a payment-flavoured qualifier it had no claim
  to.

Fix: replaced the blacklist (FINAL_MONEY_TOKENS + claimsFinalMoney)
with two closed safe-word lists and a 3-arg statusPillText(raw,
verified, money), canonical in money-status.ts and mirrored verbatim
in pcc-ui.js's <status-map v2> block:

- SAFE_STATUS_WORDS (60 words; non-money surfaces) and
  SAFE_MONEY_STATUS_WORDS (16 in-progress/failure words only; money
  surfaces) replace FINAL_MONEY_TOKENS/claimsFinalMoney. Neither list
  contains a payment-final/money-movement word or a success-ish word
  (new tests assert this directly).
- statusPillText: unverified non-empty text is shown as-is only when
  it normalizes to a word on the surface's own safe list; otherwise
  qualified with a suffix matching the surface (money: "- settlement
  unconfirmed"; non-money, new: "- status unverified").
- dataStatusText: money fallback -> statusPillText(s, false, true);
  non-money fallback -> statusPillText(s, false, false) (previously
  both called the same 2-arg form, which is what let F9's qualifier
  leak onto non-money surfaces).
- Receipt pill (renderReceipt): statusPillText(rec[2], ..., true).
- renderRun's apply(): the prominent latest line is now routed through
  statusPillText(latestVal, false, isMoneyData(...)) when latestFrom
  is status-sourced (same field as statusFrom, or a path whose last
  segment matches /status|state|phase/i); free-text messages are left
  untouched. Fixes F8(a) and F8(b) (the snapshot branch calls the same
  apply()).
- SSE handler: when a frame has no ev.type, ev.status is qualified in
  place (via statusPillText) immediately after apply() has already
  consumed the raw value, before the pre-existing feedLine(...) call
  reads it. Fixes F8(d). (Mutates ev.status in place rather than
  introducing a new variable so the pre-existing feedLine(...) line --
  which carries a pre-existing, unrelated middle-dot separator -- stays
  byte-identical in the diff.)
- Receipt timeline loop: an event with no type/name now routes
  ev.status through statusPillText(ev.status, false, true) instead of
  String(ev.status). Fixes F8(c).

Test changes: adapted the F7 direct-function test to the new 3-arg
signature (same meaning: the value is never shown bare); replaced "the
same final-money tokens" with "the same safe-status vocabularies" plus
two new vocabulary-safety tests; extended the kit==spec parity test to
every verified x money combination over an enlarged adversarial
corpus; updated "what the rule says" to the new call shape and the F9
money-vs-non-money qualifier split; split "the pill text is the raw
server value" into an EXPIRED case (on SAFE_MONEY_STATUS_WORDS, stays
bare) and a new REFUND_ALLOCATED case documenting that a decided-but-
not-final word not on the closed money-safe list is now correctly
qualified on the receipt pill (the honest label alongside it is
unaffected). No assertion that a money claim is never shown bare was
weakened.

Verification:
- money-status.conformance.test.ts: 74 passed (74), run 3x, exit 0
  each time, no Errors/Unhandled lines.
- pnpm --dir packages/spec test (full suite): 889 passed (889), run
  2x, exit 0 each time.
- pnpm --dir packages/spec exec tsc --noEmit -p tsconfig.json: clean,
  run 2x.
- pnpm --dir apps/dashboard test: 223 passed (223), exit 0.
- pnpm --dir packages/gateway test: 2986 passed | 6 skipped (2992),
  exit 0, no FAIL/Errors/Unhandled.
- Non-ASCII check on the full diff: empty (one literal ─ escape-
  text artifact introduced while drafting a comment heading was caught
  by this check and replaced with plain ASCII dashes before commit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…text rule (astra r5 F8 residuals on #313)

genui's review of c2dd834 reproduced 5 residuals of F8, each a failing test before this commit:
- a money run's free-text latest line ("Payout released to payee") was shown bare;
- an SSE feed line from ev.type ("PAID") was bare;
- an SSE event with neither type nor status showed its raw JSON bare;
- receipt timeline entries from ev.type / ev.name ("PAYOUT_SENT", "released") were bare;
- a regression: a VERIFIED settled run's latest line was qualified next to its green pill.

Now:
- Run latest line: on a money surface, every latest line passes statusPillText. It is plain only for a
  money-safe word, or when this read is a verified final (a V-next record whose pill class is
  st-settled/st-refunded). On a non-money surface, status-sourced text gets the neutral qualifier, and
  a free-text message is shown as sent.
- SSE feed line: on a money surface every label passes the rule. On a non-money surface a status label
  gets the neutral qualifier, and an event kind is shown as sent. It no longer mutates the event.
- Receipt timeline: every label (type, name or status) passes the rule with the receipt pill's own
  verified flag.
- A flat-table word that shares a final's class (REFUNDED -> st-refunded) is never verified. This is
  pinned for runs and receipts; it was a mutation survivor.

7 new tests; 10/10 mutants killed. spec 896/896, spec tsc clean, dashboard 223/223, gateway 2986
passed (6 skipped).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… where the gateway is idempotent (astra r4 F1 on #342)

Reproduced first (new describe "astra r4 (#342 @36aee944): F1 reproduced
(verify before fix)", against unmodified 36aee94):
- /release/0 vs /release/00 (integer-parsed milestone alias): FAILED,
  expected 2 to be 1 (two independent POSTs, per-endpoint keying let both
  gates open and both send).
- cross-route alias (/api/settlement/release vs the escrow-address release
  route): FAILED, expected 2 to be 1, same cause.
- identical retry after an unknown outcome (500) on a non-idempotent route
  (/api/pool/stake): FAILED, expected 2 to be 1 -- the old code let ANY
  identical retry reuse the key regardless of gateway support.
- positive control: identical retry after 500 on /api/capabilities/quote
  (durable): PASSED already, as the astra verdict predicted.
- after an ACCEPTED money request, a different money request to another
  endpoint in the same view: FAILED, expected overlays() 1 to be 0 -- the
  old per-endpoint "done" never blocked an unrelated endpoint.

Fix (apps/dashboard/public/ui-kit/v1/pcc-ui.js): replaced the per-endpoint
MONEY intent key with ONE shared MONEY_INTENT object for the whole view
(intentState still keys non-money writes per canonical request,
unchanged). doPost now: refuses every further money request once the
intent is accepted (new text: "Already submitted - a money request from
this view was accepted. Reload the page to make another."); refuses every
further money request while unresolved, including an identical retry,
UNLESS the endpoint is in the new closed DURABLY_IDEMPOTENT_MONEY_WRITES
list (new text: "Refused: an earlier money request's outcome is unknown.
Reload and check it before sending another - nothing was sent."); clears
the key on a DEFINITE REJECTION (400/401/403/404/405/422) so a fresh money
request may be made, leaving any other failure (5xx, throw, network
error) as unresolved. Removed the now-unused moneyEndpoint() helper.
Updated the INTENT_STATE comment block to describe the new semantics
honestly.

Gateway pin: exported IDEMPOTENCY_ROUTES from
packages/gateway/src/middleware/idempotency.ts (one-line export, no
behavior change) and added
packages/gateway/src/__tests__/ui-kit-idempotent-endpoints.test.ts, which
regex-extracts the kit's DURABLY_IDEMPOTENT_MONEY_WRITES literal from
pcc-ui.js and asserts it equals IDEMPOTENCY_ROUTES exactly, so the two
lists cannot silently drift.

Updated tests that encoded the replaced per-endpoint semantics (each with
a one-line reason inline):
- "after a FAILED approval, Approve may retry with the SAME key but Deny
  stays locked": switched the approve path from the fund route to
  /api/capabilities/quote -- an identical retry on a non-durable endpoint
  is now refused, so the retry-reuses-key assertion needs a durable one.
- "A/B/A through the Approval gate (a money form)": switched from the
  fund route to /api/capabilities/quote for the same reason, and updated
  B's refusal-text assertion to the new money-specific wording.
- "approve, cancel, reopen, then the OLD gate's delayed close fires":
  switched from the fund route to /api/capabilities/quote so the trailing
  retry-reuses-key assertion still holds; the gate-instance-cleanup
  behavior under test does not depend on which endpoint is used.

Mutation check (4 core rules, pcc-ui.js copied aside before each break,
restored after, packages/spec/src/__tests__/ui-kit-money-actions.test.ts
run each time):
- accepted -> refuse all further money (dropped it.done=true): KILLED
  (2 tests failed).
- unknown -> refuse incl. identical retry except durable (dropped the
  durable check on retry): KILLED (1 test failed).
- definite rejection clears the key (neutralized the clearing): SURVIVED
  on the first pass (0 failures) -- added a new test ("a DEFINITE
  REJECTION (404) on a money write has NO effect: a DIFFERENT money
  request in the same view still succeeds"); re-broke the same rule and
  confirmed it now KILLS (1 test failed, the new test).
- the durable list match (forced isDurablyIdempotentMoneyWrite to always
  return false): KILLED (4 tests failed).
pcc-ui.js restored byte-identical after every mutation (md5 verified; 0
MUTATION- markers remain); git diff shows only the intended changes.

Verification: packages/spec test run twice (41/41 files, 1070/1070 tests,
exit 0 both times, no "Errors"); apps/dashboard test (10/10 files,
223/223 tests, exit 0); packages/gateway full suite run twice (189/190
files, 2990/2997 tests both times) -- the one failure both times is
src/__tests__/completion-real-tier.test.ts timing out at 5000ms under
load (system load average ~31 on 20 cores during the run), unrelated to
this change; re-run alone it passes cleanly (3/3, 370ms), confirming a
pre-existing shared-Spark-load flake, not a regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… rule at every text sink (astra r6 F10-F12 on #313)

Reproduced at 3b2e6e3 (9 failing tests): astra's six, plus three same-class sinks found
by a sink inventory.
- F10: a VERIFIED refund unqualified a "PAID" latest line and a "PAID" receipt timeline entry.
- F11: snapshot timeline feed and latest lines, and live-poll timeline feed lines, were bare.
- F12: a job's message "Payout released to payee" and a job SSE kind "PAID" were bare.
- X1-X3: a money list's status META field, a status-bound list TITLE, and a metric selecting
  a status field were bare.

Now there is one rule per kind of text, at every sink:
- Labels go through statusPillText (the closed vocabulary per surface): statuses,
  status/state/phase fields (isStatusPath), event types and names, timeline entries and
  raw events.
- Free-text messages go through the new reportedText: "reported: <text>", plus
  " - settlement unconfirmed" on money data. It is canonical in @pcc/spec and mirrored in
  the kit, with a parity test.
- Names, ids and amounts are shown as sent.
- F10: secondary text is plain only on a VERIFIED PAYEE PAYMENT (a V-next live exact read
  classifying st-settled). A verified refund keeps its own pill text and vouches for nothing
  else. apply() returns the flag for the poll timeline.

Two r5b expectations changed by F12's ruling: free-text latest lines are "reported: ...",
and non-money event kinds take the vocabulary.

15/15 mutants killed (one survivor, the verified poll timeline, became a positive-control
test). spec 908/908, spec tsc clean, dashboard 223/223, gateway 2986 passed (6 skipped).
completion-real-tier timed out once at load average 38 and passes alone and on the rerun.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#342 carries #313's money-status rule. A non-force merge with no conflicts brings:
- secondary text needs a verified PAYEE payment;
- one text rule at every sink (statusPillText for labels, reportedText for messages).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…y text is repainted under each read (astra r7 F13, F10 on #313)

Reproduced at 9f3f75a (5 failing tests):
- F13: a receipt bound to the exact route with binding.select "claim" painted a nested
  settled object st-settled. A list's rows inherited the route's authority, with or
  without select.
- F10 over time: a verified read left "PAID" plain in the latest line and the poll
  timeline. A later unverified read, or a failed read, kept it.

Now:
- A receipt is verified only when the classified object IS the unprojected top-level
  response (e === r.raw). A list row is never the top-level record, so it is never verified.
- The run window HOLDS its raw latest value and poll timeline, and repaints them under
  each read's verification. An unverified or failed read requalifies what a verified read
  left plain.

7 new tests, including a positive control for the unprojected receipt. 6/6 mutants killed;
one survivor (a failed read must repaint the feed too) became a test.
spec 915/915, spec tsc clean, dashboard 223/223, gateway 2986 passed (6 skipped).

Not in this commit: astra's F11/F12 completeness class (more bound-text sinks). It is
reproduced, and held for an operator decision instead of another round of per-sink patches.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… an effect-reviewed non-money write (astra r5 F1-F3 on #342)

Reproduced at d21af0e (4 failing tests, plus traces):
- F1 (HIGH): an identical retry after a 500 on /api/capabilities/quote was sent: 2 POSTs.
  Trace: the gateway's idempotency middleware is registered nowhere outside its tests
  (server.ts registers only x402Gate). It is also in-memory and re-runs a 5xx, so no
  money route is durably idempotent.
- F2 (HIGH): after a 400 on a money write, a DIFFERENT money request and an identical
  retry were each sent. Trace: settlement.ts + bundler BatchSettler.submit store the
  intent before encodeIntent can throw (BigInt(NaN)), and the route answers 400 after
  the mutation.
- F3 (MEDIUM): chain Plan (an unallowlisted POST, so money) consumed the view's one
  money intent; Execute opened its gate and sent nothing.

Now:
- Once a money request from the view was sent and not accepted, every further money
  request is refused, an identical retry included, until a reload. The durable-retry list
  and the definite-rejection list are removed, so no status code unlocks the view.
- POST /api/compose joins the exact non-money allowlist after an effect review: it plans
  and stores a proposal row, moves no money, and is not payment-gated. Execute
  (/api/compose/:id/execute) and any query spelling stay money.
- The gateway pin test and the one-line IDEMPOTENCY_ROUTES export are removed; nothing
  uses them now.

Updated tests that encoded the replaced semantics, each with a reason in place:
- the failed-approval retry, A/B/A, and gate cleanup now assert refusal;
- describe D: Plan sends now and Execute needs approval;
- echo's durable positive control is removed (its inverse is the r5 F1 test);
- the 404 test asserts refusal.

3/3 mutants killed. spec 1102/1102, spec tsc clean, dashboard 223/223, gateway 2989
passed (6 skipped). Gateway-side follow-ups are routed to gateway: #4981, #5069.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#342 carries #313's money-status rule. A non-force merge with no conflicts brings:
- authority needs the unprojected top-level read, and list rows are never verified;
- the run window repaints held secondary text under each read's verification.

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

This branch has not been deployed

No deployments
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