Skip to content

0031 — Studio spend ledger, Spend & quota panel, and spend guard

Status: IMPLEMENTED — 2026-07-16 (studio ledger + panel + guard; CLI threshold-unify is a fast-follow, §1 reach note). Date: 2026-07-16 Source: closing the last Produce-right panel of the Command Deck IA (0029 §8 — "Spend & quota (scoped here)") and realising both halves of 0001 §Cost-accounting: © "keryx keeps a usage tally per run/workspace (generations + estimated spend)" and (d) the spend guard (spend.confirm_above — prevents runaways, not generation). Today per-job cost is surfaced transiently (D2, the Cost cue on each job) but nothing persists or sums a reel's spend, the voice quota is buried inside the audio MediaGen widget rather than shown as the phase-scoped telemetry 0029 §8 (D-tel) calls for, and there is no cap — a runaway batch can spend unchecked, the exact scare 0001 §cost was written to remove. Depends on: the Cost model + estimator (pkg/studio/cost.go, spec 0013 §5 / 0016 §5), the async job machinery (pkg/studio/jobs.go, runJobjobs.finish computes the actual cost), the existing quota reporter (pkg/studio/quota.go, GET /api/v1/quota), and the workspace-owned ledger precedent (screen.json, social.json). Fits the decomposition 0028 §3.2 (ProduceView → … SpendQuotaPanel) and the stateless-tool principle (0001 §3.2 — state lives in the reel workspace, not keryx).

1. Goal & scope

Give the Produce phase an honest, always-visible Spend & quota readout:

  • Spend — how much this reel's generation has actually cost so far, broken down by kind (image · VO · music · social), sourced from a persistent per-reel ledger that accumulates each completed paid job's actual cost.
  • Quota — the voice provider's remaining character quota for the period (the existing GET /api/v1/quota), shown as a persistent meter rather than only while auditioning audio.

Two halves, built together (0001 §cost is one design — visibility and the cap):

Visibility. The spend.json workspace ledger + its write hook on paid-job completion; a GET /api/v1/workspace/{slug}/spend summary endpoint; the SpendQuotaPanel.svelte on the Produce right.

The guard. A pre-generation cost gate (spend.confirm_above) that refuses a request whose estimate crosses the threshold pending confirmation, not silently spent. Dual-axis — currency for flat units (image/video/social), native characters for voice (character-billed, 0001 §cost(d)).

Reach — resolved 2026-07-16, then extended. The studio had no guard (startGeneration just spent) — closed here. The cost estimator was studio-only; it's now the shared internal/spend package (Cost · Estimate · Actual · Threshold · Exceeds + the config keys), so the studio and CLI price and cap generation from the same model + same config. CLI unify — DONE (augment, 2026-07-16): reel make now estimates the batch's dual-axis spend (currency for images+music, characters for VO) from the storyboard, prints it, and prompts only when spend.confirm_above is crossed (auto-yes under --yes/CI) — replacing its old always-prompt count-based confirm with the threshold. The guard is per-batch on the CLI (0001 §cost(d): "a batch beyond the threshold") vs per-request in the studio; both read one config. MCP generation, where exposed, rides the studio path's guard.

Out of scope (noted, not built here): - A CLI spend/--json tally command0001 §cost mentions surfacing the tally in CLI output too. The ledger is shared infra a later CLI slice can read; this slice ships the studio view + the cross-surface guard, not a dedicated CLI reporting command. - Refreshing unit rates from a provider pricing API (0001 §cost — "refresh the rates periodically"). Estimates stay config-seeded; actuals are recorded as priced at spend time.

2. The ledger — spend.json in the reel workspace

A workspace-owned append log of spend events, one per completed paid job, written next to the reel's takes/ and screen.json. Stateless-tool: keryx writes it into the workspace it was handed; the owning project (the blog) decides whether to track or ignore it.

{
  "currency": "USD",
  "events": [
    { "ts": "2026-07-16T09:12:03Z", "kind": "card", "unit": "vo",
      "card": 2, "count": 3, "magnitude": 240, "amount": 0.216, "estimated": false },
    { "ts": "2026-07-16T09:14:41Z", "kind": "cover", "unit": "image",
      "count": 2, "amount": 0.06, "estimated": false }
  ]
}
  • One event per successful paid job, appended in jobs.finish when genErr == nil and the job produced takes (len(names) > 0). The Cost.actual(n) already computed there carries the final unit/amount/count/magnitude; the event adds a timestamp + the job's kind/card.
  • An append log, not a running tally — it records every spend, including re-rolls (each re-generation was a real API charge). The truthful "what did this reel cost" is the sum, and the history lets a later view show per-card / over-time breakdowns. (Decision D-a.)
  • Actuals only. Estimates are the pre-spend cue on the live job (D2); the ledger records what was actually spent, on completion. A failed job records nothing (no completed takes → treat as no committed spend for the cue; a partial-spend edge is accepted as under-count, never over). (Decision D-e.)
  • Free jobs never touch it. Render is a free local job (handlers_render.go) — no event.
  • Written through the same afero.Fs the generation runs on (the active worktree fs), so it lands in the reel workspace exactly like takes. Concurrency: appends are serialised per reel (a small mutex / read-modify-write under the job store) so two finishing jobs don't clobber the file.

3. API — GET /api/v1/workspace/{slug}/spend

A pure ledger read (no spend), returning the reel's spend summary:

{
  "currency": "USD",
  "total": 0.276,
  "by_unit": [
    { "unit": "vo",    "amount": 0.216, "count": 3 },
    { "unit": "image", "amount": 0.060, "count": 2 }
  ],
  "events": 2
}
  • Sums spend.json's events by unit + overall. A missing/empty ledger → { total: 0, by_unit: [] } (a reel that hasn't generated anything yet is $0, not an error) — the exposure-auth 401 is the only failure mode (0018).
  • Quota stays where it is: the panel reads the existing global GET /api/v1/quota for the voice character quota; no new quota endpoint. (Image/music providers expose no quota today — the panel shows the voice meter and omits the rest, as MediaGen already does.) (Decision D-d.)

4. SpendQuotaPanel.svelte — Produce right

The third Produce-right panel (0028 §3.2), beneath CardMediaPanel + ContactSheet:

  • Spend — the reel total (e.g. $0.28 this reel) with a per-unit breakdown (VO · image · music · social), each row unit — $amount · N. Loaded from /spend on Produce open and refreshed after any successful media pick (a spend just happened).
  • Quota — the voice character meter (remaining of limit, a --teal fill bar, reset date) from /quota, hidden when the provider reports none (limit === 0), mirroring MediaGen's guard.
  • Phase-scoped telemetry (D-tel): it lives only on Produce, styled through the T2 tokens, verified both themes (0029 §6 DoD). Cost is a cue, not an invoice — the panel says so (a one-line "best-effort estimate" note), consistent with 0016 §5.

5. The spend guard — spend.confirm_above

A pre-generation cost gate in the shared generation path, so CLI, MCP, and the studio inherit one enforcement point (the Publisher/estimator seam pattern — enforce once, not per surface).

The check. Generation already prices the request up front (estimateCost, D2) before committing. The guard compares that estimate against a dual-axis threshold: - spend.confirm_above.amount — currency cap for flat-priced units (image · music · social). Default $10 (0001 §cost(d)). - spend.confirm_above.characters — voice character cap (VO is character-billed). Default 50 000 (0001 §cost(d)).

Config is global + per-project (GTB config layering); a per-project value overrides the global. Setting either to 0 disables that axis (opt-out).

What "the estimate" covers (Decision G-a). Rec: per-request estimate — the cost of the generation about to run (the batch of N takes / the VO line), matching 0001 §cost(d)'s "a batch beyond the threshold". Cumulative-reel-spend guarding (this request + the reel's accrued ledger total) is more protective against death-by-a-thousand-cuts but changes the mental model (a cheap re-roll could be blocked because the reel is already expensive) and couples the guard to the ledger. Flagged as a choice; the ledger makes cumulative possible later without a rework.

Enforcement is server-side, confirmation is per-surface. The estimator returns "would exceed" with the offending axis + the estimate; the generation path refuses unless a confirm token is present: - CLI — an interactive prompt naming the spend + the cap ("this will spend ~$12, over your $10 cap — proceed? [y/N]"); auto-yes under --yes / CI (0001 §cost(d): "prevents runaways, not generation"). Non-interactive without --yes → refuse with a clear message + the flag to pass. - Studio — the generate endpoints return 412 Precondition Failed (not 202) with the estimate + which cap it crossed; MediaGen shows an inline confirm ("Generate anyway — spends ~$X, over your cap") that re-POSTs with confirm=true. Server-enforced, so a client can't bypass it. The SpendQuotaPanel is where the caps are visible/explained. - MCP — side-effecting generation over MCP is already gated off where irreversible; a guard-tripping generate returns the same "would exceed — pass confirm" error so an assistant surfaces it rather than spending silently. (No auto-confirm on MCP — a human decides.)

Placement. The gate lives at startGeneration (studio) and the equivalent CLI generate entry, both reading the estimate they already compute — a small spendguard helper (threshold(cfg) + exceeds(est) → (axis, over)), unit-tested in isolation, injected like the other seams. It prevents runaways, not generation — disabled axes and --yes/confirm=true always pass.

6. Testing / DoD

  • Go unit: spend.json append on jobs.finish (paid success appends; failure + free job do not; concurrent finishes serialise); the /spend summary handler (empty → $0; sums by unit; 401 unauth). The spendguard helper (under/at/over each axis; disabled axis passes; voice guarded in characters, flat units in currency) and the 412/confirm-token path on startGeneration. Table-driven, t.Parallel(), fakes for the fs + estimator — no real providers.
  • godog: (1) generate (fake, no-spend generator) → pick → GET /spend reflects the accrued cost; (2) a generate whose estimate exceeds the cap is refused, then succeeds with confirm. New/ updated .features in features/; CLI guard prompt covered by a scenario with --yes.
  • Vitest: SpendQuotaPanel renders the breakdown + total, shows the quota meter, hides it when limit === 0, reloads after a pick; MediaGen surfaces the 412 over-cap confirm and re-POSTs with confirm=true. Stub the API (the ContactSheet/MediaGen pattern).
  • Playwright: the panel appears on Produce with a non-zero spend + quota meter; an over-cap generate shows the confirm affordance (mocked 412).
  • Docs: docs/components/studio/index.md (ledger + panel + guard), a docs/concepts/ cost-accounting note, the config keys in the config reference, 0029 §9 progress. Green just ci.

7. Open questions / decisions

  • D-g — RESOLVED (2026-07-16): build the guard with the visibility. Both halves of 0001 §cost ship together — the ledger/panel and the spend.confirm_above gate (§5).
  • D-a — RESOLVED (2026-07-16): append log (§2) — truthful record of every spend incl. re-rolls; enables per-card/over-time views later. Growth mitigated by a later prune (D-c).
  • G-a — RESOLVED (2026-07-16): per-request estimate (§5) — matches 0001 §cost(d)'s "a batch beyond the threshold"; a cheap re-roll isn't blocked by an already-expensive reel. Ledger leaves cumulative open as a later toggle.
  • G-b — RESOLVED (2026-07-16): server 412 + re-POST confirm=true. Server-enforced, not client-trusted — symmetric with the CLI --yes.
  • G-c — config keys + defaults. spend.confirm_above.amount (default 10, flat units) + spend.confirm_above.characters (default 50000, voice), global + per-project, 0 disables an axis — straight from 0001 §cost(d).
  • G-d — RESOLVED (2026-07-16): reach = studio + shared config, then CLI unify (augment) landed same day. cost.go → shared internal/spend; reel make estimates + threshold-gates its batch spend from the same config. See the reach note in §1.
  • D-b — tracked or ignored by the owning repo? spend.json is a local ledger like screen.json (transient, machine-written). Rec: keryx writes it; the blog's .gitignore decides — I'll document it as ignorable (not reproducible, but not precious) and leave the choice to the project.
  • D-c — growth / reset. A reel re-rolled many times grows the log. Rec: accept it for now (per-reel, bounded by real activity); a spend prune/reset (CLI + a panel "clear" affordance) can follow if it bites. Flag if you'd rather cap it at N events up front.
  • D-d — quota breadth. Only voice exposes a quota today. Rec: show the voice meter, omit the rest (no fake gauges). Revisit if image/music providers gain quota reporters.
  • D-e — failed/partial jobs. Rec: record only completed takes' actual cost; a failed job logs nothing. Accepts a rare under-count over a phantom charge. Confirm that's the right bias.
  • D-f — currency. One reel = one configured currency (the estimator's). Rec: assume single currency; if it ever changes mid-reel, the summary reports the ledger's stored currency and newer events in a different currency would be a follow-up (not expected in practice).
  • D-g — is displaying spend enough, or do you want the guard now? The spend guard (D2's sibling, 0001 §cost(d)) is out of scope here (§1). Confirm you want visibility first and the cap as its own later slice, rather than bundling them.