Skip to content

feat(mcp): verified agent loop — event-log spine, next_actions, money gate, live-window backbone - #310

Merged
ecto merged 4 commits into
mainfrom
claude/pedantic-tesla-2dfa13
Jun 23, 2026
Merged

ecto merged 4 commits into
mainfrom
claude/pedantic-tesla-2dfa13

Conversation

@ecto

@ecto ecto commented Jun 23, 2026

Copy link
Copy Markdown
Owner

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

Phase Commit Summary
1 — event-log spine 8a82f8e7 session_events table + append_session_event RPC (per-session monotonic seq under advisory lock, per-session idempotency) + an after-insert trigger that fans each row to Realtime topic session:<id>. One INSERT = persist + broadcast. Every kernel mutation appends a kind:'kernel' event beside the existing persist. Mirrors the money-plane (027): SECURITY DEFINER writer, service-role SELECT-only, RLS.
3 — next_actions 63d2fa5e Every failed tool call returns structured next_actions (recovery steps + the tool/args to call), for both thrown errors (central catch) and tools that return isError (the whole ECAD/sheet-metal/DFM surface). The success side already rides on the changed diff.
4 — money gate (flag-gated) 4974b4f5 authorize_spend (agent proposes → pending_human + propose_order spine event) → human approves in-app (never an MCP tool) → place_order (atomic debit_wallet, order → PAID, order_placed event). Asymmetric capability: the agent can't approve its own spend.
2 — live-window backbone (flag-gated) ebee5f97 Capability-keyed GET /live/:id/events (replay) + POST /live/:id/annotate (viewer overlays as kind:'overlay' events on the same spine). The browser viewer app + geometry stream + presence are a deliberate follow-up.

Safety posture

  • VCAD_FABRICATE_ORDERING (money) and VCAD_LIVE_WINDOW (live HTTP) are both default OFF. The live debit_wallet RPC path and the Realtime/browser round-trip can't be exercised in dev, so they need a staging pass before the flags flip on.
  • Phases 1 and 3 are live: the spine is passive infra, and next_actions is the one user-facing change (changelog entry included).

Verification

  • 274 mcp tests pass, typecheck (tsc --noEmit, the real check — build uses --noCheck) and build clean.
  • Migration 028 supabase db push --dry-run: clean.
  • Three adversarial review workflows (phases 2/3/4) ran before each commit; all confirmed findings were fixed:
    • Phase 4 blocker — a crash between the debit and the order-state write consumed the one-time authz and stranded a paid order forever on retry. Fixed so an idempotent retry finalizes it to PAID without a second charge. Plus: expired-authz bypass, idempotency replay-match validation, misleading-error cleanup.
    • Phase 2 — author forgery into the audit spine (verified identity is now authoritative; anonymous names are 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.
    • Phase 3 — update mis-hinting, the ECAD surface bypassing recovery, place_part pointing at the wrong tool. All fixed.

Reviewer notes

  • New env flags: VCAD_FABRICATE_ORDERING, VCAD_LIVE_WINDOW (both default off).
  • New migration supabase/migrations/028_session_events.sql — apply via supabase db push.
  • Deferred (need a live env): Phase 4 staging-test of the real debit RPC + the fab-submission outbox worker (orders rest at PAID); Phase 2 browser viewer app + GLB/fold() geometry stream + presence.
  • The pre-existing packages/kernel-wasm/vcad_kernel_wasm_bg.wasm working-tree change (a build artifact) is intentionally not included.

🤖 Generated with Claude Code

ecto and others added 4 commits June 22, 2026 16:39
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>
@vercel

vercel Bot commented Jun 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

4 Skipped Deployments
Project Deployment Actions Updated (UTC)
mecheval Ignored Ignored Jun 23, 2026 2:55am
vcad Ignored Ignored Jun 23, 2026 2:55am
vcad-docs Ignored Ignored Jun 23, 2026 2:55am
vcad-mcp Ignored Ignored Jun 23, 2026 2:55am

Request Review

@ecto
ecto merged commit b5b4c14 into main Jun 23, 2026
12 checks passed
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