Repository navigation
feat(mcp): verified agent loop — event-log spine, next_actions, money gate, live-window backbone - #310
Merged
Conversation
Phase 1 of the verified agent loop. Introduces session_events: one
append-only log per MCP session where every kernel mutation is an event,
so state = fold(log) and the content snapshot becomes a derived
materialization. The foundation for the live window, the verified-loop
envelope, and the human-approval money gate.
- migration 028: session_events table + append_session_event RPC
(per-session monotonic seq under pg_advisory_xact_lock, per-session
idempotency) + an after-insert trigger that fans each row out via
realtime.send to topic session:<id>. One INSERT = persist + broadcast;
the broadcast is a DB-side consequence, not an app dual-write, wrapped
so a missing realtime schema can never block the durable append.
Mirrors the money plane (027): SECURITY DEFINER writer, service-role
SELECT-only, assert_session_caller guard, RLS.
- session-store.ts: SessionEventStore (append + list),
SupabaseSessionEventStore, NoopSessionEventStore, createSessionEventStore.
Best-effort throughout — a spine write never fails a tool call.
- server.ts: every kernel mutation appends a kind:'kernel' event
{tool, args(slim), changed} beside persistSession, gated by isDocWriter;
payload capped at 8KB.
- tests: 10 new spine tests (monotonic seq, idempotent replay, per-session
scoping, payload round-trip, anon null user, never-throws); dispatch
persist test now asserts a writer logs one event and a reader logs none.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A failed tool call now carries structured `next_actions` — an ordered list
of recovery steps, each optionally naming a tool (and ready-to-run args) the
agent can call to get unstuck in one turn — instead of a bare
`Error: <message>` string. The success side already rides on the `changed`
diff; this is the error side of the loop.
- tools/next-actions.ts: suggestNextActions maps (tool, args, message) to
recovery steps — unknown document_id → open_document; bad/missing part_id →
read; no schematic → create_schematic; no board → place_components; unknown
catalog part → search_parts; malformed create params → the type's exact
param shape; kernel trap → retry-simpler; planner-unavailable → report.
- buildErrorResult wires it into the server's central catch (THROWN errors:
registry CRUD, planner, kernel traps, Unknown document_id).
- enrichErrorResult covers tools that RETURN {isError:true} instead of
throwing (the whole ECAD / sheet-metal / DFM surface) — injecting into a
JSON body or appending a parseable tail — so "every failure carries
next_actions" is actually true, not just for CAD CRUD. Carve-outs:
disabled-pack and unknown-tool (not recoverable).
- reuse: CREATE_PARAM_HINTS is now exported from registry-dispatch.
Addresses an adversarial review: update no longer gets a create-flavored
catalog hint (falls through to the inspect floor); the ECAD surface is no
longer bypassed; place_part unknown-part points at search_parts not read.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ated) Phase 4 of the verified loop: agent-orderable custom parts behind the same asymmetric-capability seam as the event log. FLAG-GATED and OFF by default (VCAD_FABRICATE_ORDERING); the live debit_wallet RPC path is unexercised here and needs staging verification before enabling. - authorize_spend: the AGENT proposes a spend authorization for a QUOTED order (DB-backed, revocable, status pending_human) and emits a `propose_order` control event on the session spine. No money moves. - A HUMAN approves out of band in the web app (status → authorized) — never an MCP tool, so the agent cannot approve its own spend. - place_order: places only after human approval; one atomic debit via the debit_wallet RPC (migration 027), order → PAID, `order_placed` on the spine. Refuses pending/revoked/expired authorizations. Fab submission (the idempotent outbox worker) is a deliberate follow-up — orders rest at PAID. - store: createAuthorization / getAuthorization / debit / setOrderState on both impls; the in-memory debit faithfully mirrors the RPC guards (authorized-only, NOT-expired, ceiling, balance floor, replay-match idempotency, one_time consume); Supabase forwards the exact RPC signature. Adversarial security review fixes (all confirmed): - BLOCKER: a crash between debit and the order-state write consumed the one_time authz and stranded a PAID order forever on retry. place_order now accepts a `consumed` authz and lets the idempotent debit finalize it to PAID without a second charge. - MAJOR: the in-memory debit ignored authz expiry, diverging from the RPC — now enforced (+ a pre-check in place_order). - idempotent replay now validates the reused key matches the original terms. - Phase-3 enrichErrorResult no longer attaches a misleading "retry with read" to the disabled/pending-approval money errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 backbone for the live review window. The spine (migration 028)
already broadcasts every appended row over Realtime to topic session:<id>;
this adds the two viewer-facing pieces and the data routes — the browser
viewer app + geometry stream + presence are a deliberate follow-up that
needs a live browser + Realtime to verify.
- tools/live.ts: appendOverlay (a viewer drops a pin/flag/stroke/note as a
kind:'overlay' spine event — structural asymmetry, never touches the
kernel) and listEvents (replay / late-join catch-up).
- http.ts: capability-keyed routes, FLAG-GATED behind VCAD_LIVE_WINDOW
(default OFF → 404 — a new public surface I can't live-verify here):
GET /live/<id>/events[?since=N] → spine events (replay)
POST /live/<id>/annotate → append a viewer overlay
- session-store: SessionEventStore.list takes an optional sinceSeq, pushed
into the PostgREST query so catch-up doesn't pull the whole log.
- changelog: the live, agent-facing next_actions entry (Phase 3).
Adversarial security review fixes (all confirmed):
- the verified token identity is now authoritative for an overlay's author;
an anonymous viewer's self-asserted name is sanitized and `viewer:`-
namespaced so it can never impersonate a real user, 'agent', or 'human'.
- overlay payloads are capped (4 KB) and the annotate body cap is 16 KB
(vs the 10 MiB /mcp default) — no spine bloat / broadcast amplification.
- the replay GET is rate-limited like annotate.
- a null/array/scalar JSON body yields a clean 400, not a 500.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This was referenced Jun 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Turns the vcad MCP into a verified, resumable, token-frugal agent loop with a live human window, built on one primitive: a per-session append-only event log. Geometry mutations, viewer annotations, and money approvals are all events on one spine —
state = fold(log),append = persist = broadcast, and the Receipt / replay / live view are all views of the same log.Four phases, each verified and adversarially reviewed before commit. The two surfaces that can't be live-exercised in dev (payments, Realtime/browser) ship OFF by default.
Commits
8a82f8e7session_eventstable +append_session_eventRPC (per-session monotonic seq under advisory lock, per-session idempotency) + an after-insert trigger that fans each row to Realtime topicsession:<id>. One INSERT = persist + broadcast. Every kernel mutation appends akind:'kernel'event beside the existing persist. Mirrors the money-plane (027): SECURITY DEFINER writer, service-role SELECT-only, RLS.next_actions63d2fa5enext_actions(recovery steps + the tool/args to call), for both thrown errors (central catch) and tools that returnisError(the whole ECAD/sheet-metal/DFM surface). The success side already rides on thechangeddiff.4974b4f5authorize_spend(agent proposes →pending_human+propose_orderspine event) → human approves in-app (never an MCP tool) →place_order(atomicdebit_wallet, order → PAID,order_placedevent). Asymmetric capability: the agent can't approve its own spend.ebee5f97GET /live/:id/events(replay) +POST /live/:id/annotate(viewer overlays askind:'overlay'events on the same spine). The browser viewer app + geometry stream + presence are a deliberate follow-up.Safety posture
VCAD_FABRICATE_ORDERING(money) andVCAD_LIVE_WINDOW(live HTTP) are both default OFF. The livedebit_walletRPC path and the Realtime/browser round-trip can't be exercised in dev, so they need a staging pass before the flags flip on.next_actionsis the one user-facing change (changelog entry included).Verification
tsc --noEmit, the real check — build uses--noCheck) and build clean.028supabase db push --dry-run: clean.viewer:-namespaced), unbounded-payload broadcast amplification (4 KB overlay cap / 16 KB body cap), an unthrottled replay endpoint, and a null-body 500. All fixed.updatemis-hinting, the ECAD surface bypassing recovery,place_partpointing at the wrong tool. All fixed.Reviewer notes
VCAD_FABRICATE_ORDERING,VCAD_LIVE_WINDOW(both default off).supabase/migrations/028_session_events.sql— apply viasupabase db push.PAID); Phase 2 browser viewer app + GLB/fold()geometry stream + presence.packages/kernel-wasm/vcad_kernel_wasm_bg.wasmworking-tree change (a build artifact) is intentionally not included.🤖 Generated with Claude Code