Skip to content

Distribution (MCP Registry) + simulation mode & control-plane custody decision - #2

Closed
ericlovold wants to merge 6 commits into
mainfrom
claude/modest-albattani-620j27
Closed

Distribution (MCP Registry) + simulation mode & control-plane custody decision#2
ericlovold wants to merge 6 commits into
mainfrom
claude/modest-albattani-620j27

Conversation

@ericlovold

Copy link
Copy Markdown
Owner

Summary

Post-merge follow-up to #1. Two coherent tracks: distribution (get agents to discover & choose Sanction) and the FUND-1 custody decision + simulation mode (unblock GA). No regression to the live data plane — the persisted /authorize path is unchanged.

DIST-1 — MCP Registry manifest + tool annotations

  • server.json manifest (io.github.ericlovold/sanction, npm sanction-mcp, stdio transport, documented env vars) — ready to publish to the official MCP Registry via mcp-publisher (fans out to Smithery/PulseMCP/Docker — SIG-3).
  • MCP tool annotations (SIG-7): readOnlyHint on sanction_wallet_status; title + openWorldHint on the rest; sharpened "call BEFORE any spend/credential action; bypassing means no authorization" descriptions and surfaced the typed code/remediation contract. Rebuilt bundled mcp-server.js.

FUND-1 — control plane, no custody (ADR-0005 accepted)

  • Decision: Sanction authorizes + audits spend over the developer's own rails; it does not hold or move funds → zero money-transmission/PCI surface, and GA is no longer blocked on a funding integration. (stripe dep is unused and can be dropped later.)

Simulation mode (FUND-1 / UX-6)

  • POST /authorize accepts dry_run: true → returns the decision that would be made (with typed code + remediation) without persisting a request or consuming budget. Lets devs activate and preview policy with no funding configured.
  • Pure decision logic extracted to lib/decisions.ts::decide() as the single source of truth; the live persisted path is byte-for-byte unchanged (no AIIA regression). 12 decision-matrix unit tests added.
  • OpenAPI synced (dry_run request field, simulated response field, /exec/revoke, code/remediation).

Validated

tsc --noEmit ✅ · eslint ✅ · vitest ✅ (23 tests) · mcp-server.js syntax-checked ✅

Backlog/decisions

ADR-0005 (accepted), DIST-1 ✅, FUND-1 ✅, UX-6 ◑ (backend done; dashboard surfacing pending) — see docs/BACKLOG.md, docs/DECISIONS.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01818n8zNHhPf1bh6vqiZ2os


Generated by Claude Code

claude added 2 commits June 15, 2026 05:47
- Add server.json manifest (io.github.ericlovold/sanction): npm package
  sanction-mcp, stdio transport, documented env vars — ready for the official
  MCP Registry (publish via mcp-publisher).
- Add MCP tool annotations (SIG-7): readOnlyHint on sanction_wallet_status,
  titles + openWorldHint on the rest; sharpen descriptions to emphasize
  'call BEFORE any spend/credential action; bypassing means no authorization'
  and surface the typed code/remediation contract.
- Rebuild bundled mcp-server.js.

tsc + eslint clean; bundle syntax-checked; 16 tests pass.

https://claude.ai/code/session_01818n8zNHhPf1bh6vqiZ2os
… (FUND-1)

- ADR-0005 ACCEPTED: Sanction is a control plane, no fund custody — authorizes
  and audits spend over the dev's own rails (zero money-transmission/PCI
  surface). GA no longer blocked on a funding integration.
- Simulation mode: POST /authorize accepts dry_run:true and returns the
  decision that *would* be made (typed code + remediation) WITHOUT persisting a
  request or consuming budget. Powers first-run preview (UX-6) and activation
  without funding.
- Extract pure decide() into lib/decisions.ts as the single decision source;
  the live persisted path is byte-for-byte unchanged (no AIIA regression).
  12 decision-matrix unit tests added.
- OpenAPI synced (dry_run request field, simulated response field).

tsc + eslint clean; 23 tests pass.

https://claude.ai/code/session_01818n8zNHhPf1bh6vqiZ2os
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Jun 15, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
proxy-ai Ready Ready Preview, Comment Jun 16, 2026 3:03am

- lib/policyTemplates.ts: four plain-English presets (conservative/balanced/
  growth/enterprise, values in cents) + resolvePolicy() merge helper.
- GET /policy returns current policy + template catalog; PUT /policy applies a
  template and/or field overrides (overrides win field-by-field). Owner-authed
  (x-mgmt-key); never touches the agent data plane.
- Replaces the blank-form setup cliff with safe defaults.
- OpenAPI synced; 6 unit tests for templates + resolve logic.

tsc + eslint clean; 29 tests pass.

https://claude.ai/code/session_01818n8zNHhPf1bh6vqiZ2os
…tion

The tool description advertised 'BUDGET_EXCEEDED' but the real code emitted by
/authorize is DAILY_BUDGET_EXCEEDED (see lib/decisions DecisionCode). An agent
host branching on the literal would never match. Use the real codes and rebuild
the bundle. (Found in QA / code-review.)

https://claude.ai/code/session_01818n8zNHhPf1bh6vqiZ2os
claude added 2 commits June 16, 2026 03:02
/exec created the ExecutionToken row under a freshly generated jti, but
issueExecutionJWT() minted the JWT with its OWN internal jti — so /inject
(which looks the token up by the JWT's jti) never matched and ALWAYS returned
401. Credential injection was broken end-to-end in production. issueExecutionJWT
now accepts the caller's jti; /exec passes the row id. Caught by the local-DB
integration smoke test (unit tests pass it in isolation); locked with a
regression test.

https://claude.ai/code/session_01818n8zNHhPf1bh6vqiZ2os
…007)

Wire autoApproveUnderUsd into a real three-band engine (it was previously
ignored while exposed as a settable knob):
- decide() (now the single source of truth, used by the live advisory-locked
  /authorize transaction AND dry-run): approve <= autoApprove; escalate up to
  escalateOver; deny above escalateOver (ESCALATION_CEILING_EXCEEDED).
- Route the live /authorize path through decide() — removes the duplicated
  inline gate logic (drift risk flagged in code review). Escalations now carry
  a human-readable note/reason.
- PUT /policy rejects incoherent threshold sets (autoApprove <= escalateOver <=
  perTxn <= dailySpend); templates re-tuned to satisfy it; schema defaults
  updated to the coherent balanced set (migration policy_defaults_three_band).
- OpenAPI enum + unit tests updated; new coherence + boundary tests.

Behavior change: mid-size charges over autoApprove now escalate instead of
auto-approving — strictly safer. Verified end-to-end on a local Postgres.

https://claude.ai/code/session_01818n8zNHhPf1bh6vqiZ2os
@ericlovold

Copy link
Copy Markdown
Owner Author

Closing — superseded by merged work on main.

@ericlovold ericlovold closed this Jun 26, 2026
ericlovold added a commit that referenced this pull request Jul 15, 2026
…215)

A Wallet supported exactly one human before this: Wallet.userId, or the
shared sk_ session, either way one identity with full control. Meridian's
CTO wants to invite his CFO (full admin) and CEO (read-only) into his
wallet as their own distinct identities — the first real multi-user org.

Adds WalletMember (owner/admin/viewer, no DB enum — matches this schema's
existing string-status convention) with no backfill: the wallet's own
creator stays implicitly owner, zero migration risk to existing wallets.
getSessionMember() resolves who's acting and at what role; invited members
accept via Google/GitHub only (the sk_/magic-link session is a shared
secret and can't represent a distinct human) through a single-use,
time-boxed token mirroring MagicLink's race-safe claim. A design-review
pass caught a real hole — someone who already owns a different wallet
would create an unreachable "active" membership — now blocked explicitly
at accept time.

New: /dashboard/team (invite, change role, revoke — owner-only) and
/invite/[token] (accept flow). docs/DOMAIN.md and docs/TRACEABILITY.md
updated (new WALLET-MEMBERS row + two honest Gaps).

Scope: this PR ships membership + roles + the team surface, but does NOT
yet gate the 9 pre-existing app/dashboard/*/actions.ts mutation files or
their view.isSession UI affordances — a viewer can still submit those
successfully today. That rollout is the tracked follow-up (docs/BACKLOG.md,
docs/TRACEABILITY.md Gap #2), split out as its own reviewable PR rather
than bundled here.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
ericlovold added a commit that referenced this pull request Jul 16, 2026
…ET-MEMBERS follow-up, part 1)

PR1 (#215) shipped owner/admin/viewer roles but only enforced the floor on
team management itself — every other dashboard mutation still gated on
getSessionWallet() alone, so an invited viewer's actions weren't actually
blocked. Add lib/session.ts's requireSessionRole("admin") and swap it in
across all 9 pre-existing app/dashboard/*/actions.ts files plus agent
creation, and switch the corresponding editable/mutation-control UI gates
from view.isSession to hasRole(view.role, "admin"). Read-only policy
preview/simulate stay open to viewers — visibility, not a mutation.

Closes docs/TRACEABILITY.md Gap #2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ericlovold added a commit that referenced this pull request Jul 18, 2026
)

* feat(dashboard): one money formatter — lib/format ends the five rounding rules (#224)

From the 2026-07-16 UX walk: nine files hand-rolled toFixed with five
different rules — Overview showed $61.9000 (unconditional 4 decimals),
Spend $61.90, zero rows $0.0000, Audit "24091000 tokens" unseparated,
and the MCP status text inherited the 4-decimal habit.

lib/format.ts is now the one place money and counts render:
- fmtUsd: two decimals with thousands separators ($1,053.00); sub-cent
  amounts keep four decimals so a tiny-but-real token cost never reads
  $0.00, while a true zero stays $0.00. Locale pinned to en-US so SSR
  and client can't hydration-drift.
- fmtCount: separated integers for token/call counts.

Swept: Overview, Spend, Audit, Pools, Policy, Approvals resource
titles, the execution-tokens/enforcement/outcomes sections, and
lib/mcpWalletStatus (MCP status now says $1.23, not $1.2346 — tool
text asserted in tests). New tests pin the formatter's edges.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(dashboard): zero-noise pass — empty counters stop taking up room (#225)

Last of the 2026-07-16 UX-walk tier-1 items: surfaces defaulted to
showing every counter, so a healthy org read as a wall of zeros.

- Approvals: Pending and Recently resolved always show (the page's
  pulse); "Expiring in 15m" appears only while something actually is;
  "oldest pending 0m" becomes "oldest waiting Xm" under Pending, only
  when something waits. The webhook count card goes — the webhook
  section below already says it.
- Seats: "Active seats" is the headline and stays; grants / pending /
  expiring / inactive cards render only when non-zero.
- Spend "By agent": seats with zero activity this month collapse into
  one "+ N seats with no activity" line (18 rows on the Meridian demo).

Backlog rides along: check off the eight 2026-07-16 items promoted to
merged PRs (#218#222 + formatter/zero-noise), keep the naming-split
remainder open, and queue two new finds (pack grouping by ladder tag,
PWA service-worker staleness on deploys).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(pwa): dev browsers self-heal from a cache-poisoned service worker

The stale-bundle hydration mismatches that kept surfacing during local
verification were dev-only, and the diagnosis matters: sw.js is
cache-first for /_next/static/*, which is immutable in production
(content-hashed per deploy; the SW already skipWaiting+claims) but NOT
under Turbopack HMR, which reuses chunk URLs across rebuilds. A dev
browser that ever ran the SW kept hydrating stale chunks against fresh
server HTML — old nav over new pages.

Two-part fix, and the delivery path is the point:

- SwRegister registers the SW in production only.
- The dev cleanup lives as an INLINE script in the dashboard layout —
  not in a component chunk, because a poisoned SW serves the old
  version of any static chunk, so a chunk-borne fix can never land.
  Navigations are the one thing sw.js never caches. The script
  unregisters leftover SWs, wipes caches, and reloads once (no
  registrations → no reload → no loop; a later pass quietly deletes
  inert leftover caches).

Verified live: a browser with the SW registered and sanction-static-v2
populated came back to {registrations: 0, caches: []} through plain
page loads alone. Backlog entry checked off with the corrected
diagnosis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(lint): drop unused fmtCount import left by the formatter sweep

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(dashboard): approvals title matches the nav; policy packs group by ladder stage

The last two items from the 2026-07-16 UX walk:

- The Approvals page was titled "Authorization inbox" while the nav,
  sidebar badge, and every cross-link say Approvals. DOMAIN.md's term
  is Pending Approval; "authorization inbox" appears nowhere in the
  glossary. Title now matches the nav; the inbox metaphor lives on in
  the subtitle, which already explains it better than the h1 did.
- The 11 policy packs rendered as one flat catalog with a maturity
  chip per tile. They now group under the four ladder stages —
  Metering, Authorization, Governance, Evidence — each stage carrying
  a one-line meaning, so the catalog reads as "where are you on the
  ladder?" instead of eleven competing tiles. Per-tile chips go; the
  group header says it once.

Backlog: both items checked off as promoted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(wallet-members): role-gating rollout v2 — viewer can't mutate

Re-lands the 2026-07-16 revert (90bd264 reverting ea86f54) as a
reviewable PR, with the design doubt behind that revert resolved
explicitly (role matrix confirmed 2026-07-17):

- Admin floor on every dashboard mutation — including the management
  key reset (admins are trusted operators; key recovery must not wait
  on the owner) and all pool-structure operations. Team membership
  itself stays owner-only, as PR1 shipped it.
- Pack preview and draft simulation stay open to any signed-in role —
  read-only replays are visibility, which is what a viewer is for.

Mechanics, unchanged from the original design: lib/session.ts gains
requireSessionRole(min) — like getSessionWallet but returning null
when the member's role is under the floor, so every existing
"if (!wallet)" early-return keeps working and a viewer gets exactly
the same no-op as an anonymous visitor. Swapped into all 10 mutating
actions files; UI mutation gates switch from view.isSession to
hasRole(view.role, "admin"); PackPicker splits editable (Apply) from
previewable (Preview); logged-in viewers see "your role can view…"
copy instead of a lying "log in" prompt.

Adapted to everything that shipped since the revert: nav consolidation
(sections carry the gates now), zero-noise cards, the compact seat
rows, and Team & access hosting the management key card.

Tests: the original's five new role-floor suites recovered and the six
adapted ones re-applied against current expectations (954 passing).
Closes docs/TRACEABILITY.md Gap #2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(wallet-members): traceability row + gap list reflect the v2 rollout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(wallet-members): wallet switcher — every membership is reachable (part 2) (#230)

* build(mcp): regenerate committed bundle after main merge

The bundle-freshness gate requires packages/sanction-mcp/mcp-server.js
to match a fresh build:mcp; the fmtUsd refactor had drifted it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ericlovold pushed a commit that referenced this pull request Jul 19, 2026
Conversion plan move #2. The hero's right column was a static access-key
visual; now it's a REAL pending escalation from the demo wallet that a
visitor decides in ten seconds — approve mints a single-use grant on the
signed record, deny blocks it before the merchant — then the ask: "Get
this for your agents →". A visitor governs an AI agent before they've
read a word of copy.

- components/live-escalation.tsx: the interactive card. Optimistic UI —
  the confirmation lands instantly on click, while the decision resolves
  server-side (fires demo_decision with surface="landing"). A lost race
  (row already decided by another visitor) never shows a dead click;
  "Try another" advances to the next escalation the action returns.
- lib/demo.ts: getDemoEscalation() — one pending escalation shaped for a
  human-facing card, shared by the hero and the demo dashboard. Prefers
  a money decision ("$450 to Rule 26 Experts LLC") over a bare tool
  toggle. server-only guarded; tests/demo.test.ts covers the shaping.
- demo-actions.ts: extracted runDemoDecision core; added
  decideDemoEscalationAction (structured, returns the next escalation)
  alongside the existing dashboard form action. Same structural scope
  ([SANCTION_WALLET_ID]) and per-IP rate limit — no guardrail weakened.
- Landing is now async and renders the live card; falls back to the
  static access-key visual when there's no demo data (e.g. a preview env
  without SANCTION_WALLET_ID).

The card fires demo_decision from the landing, so the funnel we wired
last commit now captures the above-the-fold decision too. tsc + eslint
clean, 978 tests green, coverage holds (89.9 / 83.4 / 93.9 / 89.9).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ericlovold added a commit that referenced this pull request Aug 19, 2026
… review (#244)

* fix(site): reconcile the agent-wallet story with the services-led home page

#239 put the platform pitch in slot #2 of a home page that #232 had just
repositioned services-led, leaving two pitches on one page instead of one
argument.

- Move the #agent-wallet deep dive below "What we've built" so it joins the
  proof chapter as one continuous dark run, with a hairline marking the seam
  between the two dark tones. The exec buyer now reads why they called
  before meeting `npx`.
- Stop repeating the wallet headline verbatim in the Sanction Platform card;
  it names the job instead ("Answer for what your agents spend and do").
- Write the bridge the page was missing: the engagement installs the agents,
  the platform governs what they spend and do.
- Give the wallet section a services-side CTA so book-a-call and
  install-a-package stop being two funnels that never meet.
- Date the MIA ribbon comment and back it with a backlog entry, so a
  launch-window element does not become permanent by default.

The nav needed no new entry — "What we've built" now sits directly above the
section it previously had no way to reach.

The backlog also captures the reposition doc drift (README and AGENTS.md
still describe a product-only identity) as its own docs-only pass, kept
deliberately separate from this layout change.

Verified: tsc clean, eslint 0 errors, 1083 tests passing, and the page
rendered at 1440px and 390px with 0px horizontal overflow.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eb6zTfLyE42dYS8tgWfvn

* docs(readme): structure the README around who it's for and how it's used

Ingests an external positioning analysis (via /INPUT) — the structural
findings applied; feature ideas routed to the backlog; suggestions that
contradicted confirmed decisions or live code pushed back.

- "Who runs Sanction" up front, leading with the confirmed primary case:
  organizations governing their own internal AI usage and spend.
- A concrete decision example under "What it does" using the real response
  contract — status, stable decision_code, verbatim remediation string —
  instead of leaving "checked and debited atomically" abstract.
- API section regrouped by workflow (ask permission / escalate / carry
  credentials / prove / shape policy / run the fleet / standards); same
  endpoints and descriptions, no longer one wall.
- Distribution gains a shortest-path matrix; policy packs surfaced by name;
  "changing policy in production" and "when to use the credential vault"
  sections added.
- Corrected a stale claim while in the file: @sanction/sdk is not on the
  npm registry (verified against the registry) — install instructions now
  point at sdk/ with publish marked pending; the wider docs sweep is queued.

Pricing left exactly as it stands — free-or-agreement is a confirmed
decision, not an omission.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eb6zTfLyE42dYS8tgWfvn

* feat(console): month runway charts + seat health — the collapsed sprint arc

One arc collapsing the three ingest sprints (external analysis, verified
piece by piece against the live code):

Console — surface what's already measured:
- Month runway card on /dashboard/spend: cumulative token cost and
  authorized spend drawn day by day against the monthly caps, with the
  linear projection and "budget hit ~<date>" made visible instead of
  numeric. Pure SVG; series math (bucketByDay, cumulative) factored into
  lib/burn.ts beside the existing pace helpers, unit-tested.
- Seat health card: flags seats whose denial rate is hot (≥25% over a real
  sample) or climbing (last 7 days ≥1.5× the month baseline), with each
  seat's most-hit denial code via the same decisionCode mapping agents see.
  Drift logic is pure (lib/seatHealth.ts), unit-tested; a healthy fleet
  renders as good news, not an empty table.
- Fix (adjacent, render-check caught it): the 14-day token trend bars
  rendered 0px — items-end kept the bar columns at content height, so the
  bars' percentage heights resolved against auto. Columns stretch again.

Engine — design before code:
- docs/plans/policy-inheritance.md: budgets cascade today, rules don't;
  proposes evaluation-time ancestor-first overlay if inheritance is wanted.
- docs/plans/context-conditional-rules.md: closed-vocabulary `when`
  predicates over a richer context snapshot; rejects free-form expressions.
  Both stamped proposed — the product call stays with the owner.

Docs:
- FRAMEWORK-ADAPTERS.md: stop instructing `npm install @sanction/sdk` —
  the package is not on the npm registry; points at sdk/ until publish.
- AGENTS.md: dated note recording the 2026-08-10 site reposition so future
  sessions don't "fix" the repo/site identity mismatch unprompted.

Verified: tsc clean, eslint 0 errors, 1092 tests passing (18 new), and the
spend page rendered against a seeded Meridian demo org — runway projections,
cap lines, exhaust dates, seat flag, and the revived trend bars all
confirmed by screenshot.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eb6zTfLyE42dYS8tgWfvn

* chore(backlog): check off the two console items the sprint arc shipped

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eb6zTfLyE42dYS8tgWfvn

* chore(release): v0.8.0 — a wallet you can paste, hand over, and verify

Cuts the 52-commit pack since v0.7.0 (2026-07-12) and realigns the three
package versions that had drifted apart.

- package.json 0.7.0 → 0.8.0; packages/sanction-mcp 0.7.0 → 0.8.0;
  sdk 0.6.0 → 0.8.0. All three move together again.
- Changelog gains the v0.8.0 release header summarizing the pack: the
  hosted Streamable HTTP wallet URL and MCP 2026-07-28 conformance, the
  public Wallet Card + mandate verify, team membership and roles, Slack
  OAuth install with interactive Approve/Deny, the roster console with
  month runway and seat health, providers connected once, and the gateway
  metering fix that stops unpriced models billing $0.
- Roadmap: adds the shipped roster-console/team-roles item to Now, and
  restates the SDK item against verified reality — it is 0.8.0, builds,
  typechecks, and passes 55 tests, with the npm scope named as the single
  remaining precondition rather than a vague "once the org is wired".

Claim verification (the "ten tools, not nine" rule) turned up a live error
this pass: the README said nine policy packs; lib/policyPacks.ts registers
eleven — payment-agent-mandate and no-egress were below the fold when that
line was written. Corrected. MCP tool count re-counted from
lib/mcpServer.ts: ten, matching the claim.

Boundaries kept honest in the notes: MCP stays cooperative (broker
interception of tools/call is Next, not claimed today), and the SDK is
described as publish-pending rather than installable from npm.

Verified: tsc clean, eslint 0 errors, 1092 tests passing, changelog and
roadmap both parse and render their new entries.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eb6zTfLyE42dYS8tgWfvn

* fix(enforce): roll back cascade denials; stop callers opting out of metering

Two defects on the money path, found by an adversarial pre-release review and
verified against the code before fixing. Both were fail-open or
state-corrupting; the release should not carry either.

1. Phantom spend on denied cascade decisions (app/api/v1/authorize/route.ts).
   reserveCascadeDailySpend walks capped ancestors root->leaf, incrementing
   each counter, and throws CascadeBudgetExceeded on the first cap breach.
   lib/cascadeBudget.ts documents the contract: the throw must roll the
   surrounding transaction back. The spend route caught it INSIDE
   db.$transaction and returned normally, which commits — so every ancestor
   already incremented kept the spend of a request that was denied. The
   reconcile is GREATEST(spentCents, rolled), so it only ever heals upward:
   the phantom total was permanent and compounding, and a parent pool would
   start denying legitimate spend on money nobody spent. Requires >=2 capped
   ancestors, which no test built. Now unguarded inside the transaction and
   handled outside it, matching the grant path and the AuthZEN PDP, which
   were already correct.

2. Token metering was opt-in for the party being governed
   (app/api/gateway/[provider]/[...path]/route.ts, lib/gateway.ts).
   OpenAI-compatible providers emit stream usage only when the caller sets
   stream_options.include_usage — and the gateway forwarded the request body
   verbatim while docs/GATEWAY.md instructed the caller to set it. An agent
   streaming without it produced no usage block, so no tokenLog row was
   written, no budget moved, and the pre-call wall (which reads those rows)
   never saw the spend. forceStreamUsage() now sets the flag on the way out;
   a stream that still reports nothing is logged rather than passing in
   silence. Anthropic and Gemini stream usage by default and are untouched.
   Unparseable bodies pass through unchanged — a proxy must not corrupt a
   request it does not understand.

Also fixed here, same review:
- Subtree-cap denials persisted no policyRevision/decisionContextJson, so
  GET /authorize/{id}/evidence returned nulls for a denial class that is
  itself appealable. The denial now carries the evidence captured before the
  reservation attempt.
- MCP_SERVER_VERSION was left at 0.7.0 by the version bump, so every MCP
  host would report 0.7.0 for a 0.8.0 release.
- Three different coverage numbers across README (88%), CONTRIBUTING
  (80/80/85) and TRACEABILITY (80/80/85), none matching vitest.config.ts
  (90/90/94/83). All three now state the enforced gate, with the config
  named as the single source of truth. Test count corrected from "700+" and
  "500+" to 1,100+/1,000+.

5 new regression tests pin the metering fix, including the exact bypass.
Gate: tsc clean, eslint 0 errors, 1097 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eb6zTfLyE42dYS8tgWfvn

---------

Co-authored-by: Claude <noreply@anthropic.com>
ericlovold added a commit that referenced this pull request Aug 20, 2026
* feat(python): LiteLLM callback posts usage to /tokens

Land the unpublished sanction-sdk package with a duck-typed CustomLogger so Python agents can meter LiteLLM completions without importing litellm. This is post-call reporting; the fail-closed budget wall stays the LLM gateway.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(site): agent-wallet story on the home page (#239)

Hero becomes copy + wallet object (card, verified mandate, orbit); new dark
the MCP terminal panel. Styling scoped in brand.css, responsive at 900/640,
reduced-motion guarded.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(slack): interactive Approve/Deny through resolveApproval

Incoming webhooks still deep-link. Channel archive URLs plus a Slack app post Approve/Deny via chat.postMessage; the click is HMAC-verified and settles the same pending approval as the dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(slack): Add to Slack OAuth install per workspace

Interactive Approve/Deny no longer needs a pasted channel URL and a
platform bot token. The remaining pickup is standing up the Slack app
and setting the OAuth env vars.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(console): roster is the dashboard home (#240)

Replace the monthly-zero report with group and agent cards, a three-item
rail, and add-an-agent on the group. Old destinations stay under Vault.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(mcp): hosted Streamable HTTP wallet endpoint at /mcp (#241)

* feat(mcp): hosted Streamable HTTP wallet endpoint at /mcp

Give agents a URL to paste. Same ten cooperative tools as stdio, API-key auth, stateless JSON responses so it runs on Vercel. Broker intercept of tools/call stays Next.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(mcp): cover shared wallet tool handlers

The hosted URL extracts tools into lib/mcpServer; without calling them the coverage ratchet falls below 90%.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): browser GET /mcp is a paste page, not a raw 401 (#242)

Humans who open the published wallet URL have no agent key. MCP clients still fail closed with JSON.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(site): reconcile the agent-wallet story with the services-led home page (#243)

* release: v0.8.0 + two enforcement-path fixes found by the pre-release review (#244)

* fix(site): reconcile the agent-wallet story with the services-led home page

#239 put the platform pitch in slot #2 of a home page that #232 had just
repositioned services-led, leaving two pitches on one page instead of one
argument.

- Move the #agent-wallet deep dive below "What we've built" so it joins the
  proof chapter as one continuous dark run, with a hairline marking the seam
  between the two dark tones. The exec buyer now reads why they called
  before meeting `npx`.
- Stop repeating the wallet headline verbatim in the Sanction Platform card;
  it names the job instead ("Answer for what your agents spend and do").
- Write the bridge the page was missing: the engagement installs the agents,
  the platform governs what they spend and do.
- Give the wallet section a services-side CTA so book-a-call and
  install-a-package stop being two funnels that never meet.
- Date the MIA ribbon comment and back it with a backlog entry, so a
  launch-window element does not become permanent by default.

The nav needed no new entry — "What we've built" now sits directly above the
section it previously had no way to reach.

The backlog also captures the reposition doc drift (README and AGENTS.md
still describe a product-only identity) as its own docs-only pass, kept
deliberately separate from this layout change.

Verified: tsc clean, eslint 0 errors, 1083 tests passing, and the page
rendered at 1440px and 390px with 0px horizontal overflow.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eb6zTfLyE42dYS8tgWfvn

* docs(readme): structure the README around who it's for and how it's used

Ingests an external positioning analysis (via /INPUT) — the structural
findings applied; feature ideas routed to the backlog; suggestions that
contradicted confirmed decisions or live code pushed back.

- "Who runs Sanction" up front, leading with the confirmed primary case:
  organizations governing their own internal AI usage and spend.
- A concrete decision example under "What it does" using the real response
  contract — status, stable decision_code, verbatim remediation string —
  instead of leaving "checked and debited atomically" abstract.
- API section regrouped by workflow (ask permission / escalate / carry
  credentials / prove / shape policy / run the fleet / standards); same
  endpoints and descriptions, no longer one wall.
- Distribution gains a shortest-path matrix; policy packs surfaced by name;
  "changing policy in production" and "when to use the credential vault"
  sections added.
- Corrected a stale claim while in the file: @sanction/sdk is not on the
  npm registry (verified against the registry) — install instructions now
  point at sdk/ with publish marked pending; the wider docs sweep is queued.

Pricing left exactly as it stands — free-or-agreement is a confirmed
decision, not an omission.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eb6zTfLyE42dYS8tgWfvn

* feat(console): month runway charts + seat health — the collapsed sprint arc

One arc collapsing the three ingest sprints (external analysis, verified
piece by piece against the live code):

Console — surface what's already measured:
- Month runway card on /dashboard/spend: cumulative token cost and
  authorized spend drawn day by day against the monthly caps, with the
  linear projection and "budget hit ~<date>" made visible instead of
  numeric. Pure SVG; series math (bucketByDay, cumulative) factored into
  lib/burn.ts beside the existing pace helpers, unit-tested.
- Seat health card: flags seats whose denial rate is hot (≥25% over a real
  sample) or climbing (last 7 days ≥1.5× the month baseline), with each
  seat's most-hit denial code via the same decisionCode mapping agents see.
  Drift logic is pure (lib/seatHealth.ts), unit-tested; a healthy fleet
  renders as good news, not an empty table.
- Fix (adjacent, render-check caught it): the 14-day token trend bars
  rendered 0px — items-end kept the bar columns at content height, so the
  bars' percentage heights resolved against auto. Columns stretch again.

Engine — design before code:
- docs/plans/policy-inheritance.md: budgets cascade today, rules don't;
  proposes evaluation-time ancestor-first overlay if inheritance is wanted.
- docs/plans/context-conditional-rules.md: closed-vocabulary `when`
  predicates over a richer context snapshot; rejects free-form expressions.
  Both stamped proposed — the product call stays with the owner.

Docs:
- FRAMEWORK-ADAPTERS.md: stop instructing `npm install @sanction/sdk` —
  the package is not on the npm registry; points at sdk/ until publish.
- AGENTS.md: dated note recording the 2026-08-10 site reposition so future
  sessions don't "fix" the repo/site identity mismatch unprompted.

Verified: tsc clean, eslint 0 errors, 1092 tests passing (18 new), and the
spend page rendered against a seeded Meridian demo org — runway projections,
cap lines, exhaust dates, seat flag, and the revived trend bars all
confirmed by screenshot.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eb6zTfLyE42dYS8tgWfvn

* chore(backlog): check off the two console items the sprint arc shipped

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eb6zTfLyE42dYS8tgWfvn

* chore(release): v0.8.0 — a wallet you can paste, hand over, and verify

Cuts the 52-commit pack since v0.7.0 (2026-07-12) and realigns the three
package versions that had drifted apart.

- package.json 0.7.0 → 0.8.0; packages/sanction-mcp 0.7.0 → 0.8.0;
  sdk 0.6.0 → 0.8.0. All three move together again.
- Changelog gains the v0.8.0 release header summarizing the pack: the
  hosted Streamable HTTP wallet URL and MCP 2026-07-28 conformance, the
  public Wallet Card + mandate verify, team membership and roles, Slack
  OAuth install with interactive Approve/Deny, the roster console with
  month runway and seat health, providers connected once, and the gateway
  metering fix that stops unpriced models billing $0.
- Roadmap: adds the shipped roster-console/team-roles item to Now, and
  restates the SDK item against verified reality — it is 0.8.0, builds,
  typechecks, and passes 55 tests, with the npm scope named as the single
  remaining precondition rather than a vague "once the org is wired".

Claim verification (the "ten tools, not nine" rule) turned up a live error
this pass: the README said nine policy packs; lib/policyPacks.ts registers
eleven — payment-agent-mandate and no-egress were below the fold when that
line was written. Corrected. MCP tool count re-counted from
lib/mcpServer.ts: ten, matching the claim.

Boundaries kept honest in the notes: MCP stays cooperative (broker
interception of tools/call is Next, not claimed today), and the SDK is
described as publish-pending rather than installable from npm.

Verified: tsc clean, eslint 0 errors, 1092 tests passing, changelog and
roadmap both parse and render their new entries.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eb6zTfLyE42dYS8tgWfvn

* fix(enforce): roll back cascade denials; stop callers opting out of metering

Two defects on the money path, found by an adversarial pre-release review and
verified against the code before fixing. Both were fail-open or
state-corrupting; the release should not carry either.

1. Phantom spend on denied cascade decisions (app/api/v1/authorize/route.ts).
   reserveCascadeDailySpend walks capped ancestors root->leaf, incrementing
   each counter, and throws CascadeBudgetExceeded on the first cap breach.
   lib/cascadeBudget.ts documents the contract: the throw must roll the
   surrounding transaction back. The spend route caught it INSIDE
   db.$transaction and returned normally, which commits — so every ancestor
   already incremented kept the spend of a request that was denied. The
   reconcile is GREATEST(spentCents, rolled), so it only ever heals upward:
   the phantom total was permanent and compounding, and a parent pool would
   start denying legitimate spend on money nobody spent. Requires >=2 capped
   ancestors, which no test built. Now unguarded inside the transaction and
   handled outside it, matching the grant path and the AuthZEN PDP, which
   were already correct.

2. Token metering was opt-in for the party being governed
   (app/api/gateway/[provider]/[...path]/route.ts, lib/gateway.ts).
   OpenAI-compatible providers emit stream usage only when the caller sets
   stream_options.include_usage — and the gateway forwarded the request body
   verbatim while docs/GATEWAY.md instructed the caller to set it. An agent
   streaming without it produced no usage block, so no tokenLog row was
   written, no budget moved, and the pre-call wall (which reads those rows)
   never saw the spend. forceStreamUsage() now sets the flag on the way out;
   a stream that still reports nothing is logged rather than passing in
   silence. Anthropic and Gemini stream usage by default and are untouched.
   Unparseable bodies pass through unchanged — a proxy must not corrupt a
   request it does not understand.

Also fixed here, same review:
- Subtree-cap denials persisted no policyRevision/decisionContextJson, so
  GET /authorize/{id}/evidence returned nulls for a denial class that is
  itself appealable. The denial now carries the evidence captured before the
  reservation attempt.
- MCP_SERVER_VERSION was left at 0.7.0 by the version bump, so every MCP
  host would report 0.7.0 for a 0.8.0 release.
- Three different coverage numbers across README (88%), CONTRIBUTING
  (80/80/85) and TRACEABILITY (80/80/85), none matching vitest.config.ts
  (90/90/94/83). All three now state the enforced gate, with the config
  named as the single source of truth. Test count corrected from "700+" and
  "500+" to 1,100+/1,000+.

5 new regression tests pin the metering fix, including the exact bypass.
Gate: tsc clean, eslint 0 errors, 1097 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eb6zTfLyE42dYS8tgWfvn

---------

Co-authored-by: Claude <noreply@anthropic.com>

* docs(truth): drain the drift the pre-release audit found (#245) v0.8.0

A truthsync pass over the five truth surfaces. Docs + spec only; no behavior
changes. Every claim written here was verified against the code first.

Actively false, now corrected:
- docs/DOMAIN.md claimed Sanction sits "inline as the enforcement point" for
  MCP. That is the exact interception claim the Wallet Card, lib/roadmap.ts,
  and the changelog all explicitly refuse to make. Replaced with the
  cooperative-MCP contract: the gateway intercepts, MCP does not, broker mode
  is Next.
- docs/DOMAIN.md said "No wallet switcher yet"; it shipped 2026-07-17 and
  TRACEABILITY already marked that gap closed — the two files contradicted
  each other in the same repo.
- docs/DOMAIN.md's arc line listed human approval and Sanction Local as Next;
  both shipped.
- TRACEABILITY ORG-VIS asserted "the resolve action stays wallet-scoped".
  Resolution now authorizes against subtreeWalletIds. A false row in the
  security registry is the worst kind, since the file's whole promise is that
  claims map to enforcing code.
- README said the dashboard leads with budget runway (the roster is the home
  now) and described Slack as deep-link-only (interactive Approve/Deny and
  Add to Slack OAuth are the v0.8.0 flagship).

Honest gap rather than a confident claim: the subtree-resolution change has
no test that builds a real two-level org — the only covering test mocks
subtreeWalletIds to a single wallet. Cited that way in ORG-VIS and added to
Gaps + the backlog rather than dressed up as proven.

Registry integrity (the file's own maintenance rule):
- New rows TRACE-1 (W3C trace-context validation before host values become
  outbound headers), PROV-1 (provider keys vaulted + injected at the gateway),
  SIM-2 (sequential replay), each citing a test confirmed to exist.
- GATEWAY row now carries both metering invariants: unpriced models meter at
  the fallback rate, and stream usage reporting is forced rather than left to
  the caller.
- SIM-1's "sequential re-fold is slice 2" note was stale.
- Test-suite map gained traceContext, providers, roster, seatHealth, format,
  funnel, simulation-sequential.

Changelog backfill — four shipped capabilities had no entry of their own:
provider connections, team membership + roles + wallet switcher, MCP wallet
derivation, and org-owner subtree resolution. Inserted in date order.

Doc bugs that break working code:
- STARTER-KIT polled d.grant?.id; the API returns a flat grant_id, so the
  documented escalate->redeem loop silently never fired. That is the one path
  that makes Sanction Sanction.
- lib/openapi.ts declared a REQUIRED wallet_id query param that the handler
  never reads (it derives the wallet from the request row), so every generated
  client — Bedrock included — demanded a value that does nothing.
- sdk/README.md and docs/PAY-PER-CRAWL.md instructed an npm install that 404s;
  FRAMEWORK-ADAPTERS said "install from the repo path" without a command. All
  three now give a working path install.

README also gained the capabilities it never described (roster + team roles,
observe mode, outcomes/freeze/reallocation) and the eight endpoints missing
from the API section. DOMAIN gained PolicyRevision, SlackInstall, Outcome, and
BudgetReallocation — all verified present in prisma/schema.prisma.

Gate: tsc clean, 1092 tests passing. Changelog parses and is date-ordered.


Claude-Session: https://claude.ai/code/session_013Eb6zTfLyE42dYS8tgWfvn

Co-authored-by: Claude <noreply@anthropic.com>

* feat(sdk): rename to sanction-sdk + sprint B (local-dev path, subtree proof) (#246)

* docs(sdk): sanction-sdk is live on npm — sweep every publish-pending hedge (#247)

npm view sanction-sdk -> 0.8.0, FSL-1.1-MIT, published by the same
automation path as sanction-mcp. The hedges written while the publish was
blocked now come out:

- README: both SDK rows become `npm install sanction-sdk`.
- FRAMEWORK-ADAPTERS: the repo-path install workaround collapses to the
  real install command.
- PAY-PER-CRAWL + sdk/README: the "Not on npm yet" blockquotes collapse to
  the install line.
- roadmap: the SDK item's TypeScript half is Shipped; the Python adapters
  (LiteLLM callback, LangChain/LangGraph + CrewAI) stay Next.
- changelog: a dated entry for the ship itself, and the v0.8.0 release
  entry drops its "until the publish workflow runs" clause.
- backlog: dated ops reminder that the new granular npm token EXPIRES —
  when it does, both publish workflows start failing with no other warning.
  Expiry date to be filled in from npmjs.com; the old select-packages token
  gets deleted once confirmed unused.


Claude-Session: https://claude.ai/code/session_013Eb6zTfLyE42dYS8tgWfvn

Co-authored-by: Claude <noreply@anthropic.com>

* feat(engine): INHERIT-1 — rules inherit down the wallet tree (tool + capability) (#248)

* feat(engine): COND-1 — rules that read the clock and the meter (#249)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants