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, runJob→jobs.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 (
startGenerationjust spent) — closed here. The cost estimator was studio-only; it's now the sharedinternal/spendpackage (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 makenow estimates the batch's dual-axis spend (currency for images+music, characters for VO) from the storyboard, prints it, and prompts only whenspend.confirm_aboveis 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 command — 0001 §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.finishwhengenErr == niland the job produced takes (len(names) > 0). TheCost.actual(n)already computed there carries the finalunit/amount/count/magnitude; the event adds a timestamp + the job'skind/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.Fsthe 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/quotafor 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 rowunit — $amount · N. Loaded from/spendon Produce open and refreshed after any successful media pick (a spend just happened). - Quota — the voice character meter (
remaining of limit, a--tealfill 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 with0016§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.jsonappend onjobs.finish(paid success appends; failure + free job do not; concurrent finishes serialise); the/spendsummary handler (empty →$0; sums by unit; 401 unauth). Thespendguardhelper (under/at/over each axis; disabled axis passes; voice guarded in characters, flat units in currency) and the412/confirm-token path onstartGeneration. Table-driven,t.Parallel(), fakes for the fs + estimator — no real providers. - godog: (1) generate (fake, no-spend generator) → pick →
GET /spendreflects the accrued cost; (2) a generate whose estimate exceeds the cap is refused, then succeeds with confirm. New/ updated.features infeatures/; CLI guard prompt covered by a scenario with--yes. - Vitest:
SpendQuotaPanelrenders the breakdown + total, shows the quota meter, hides it whenlimit === 0, reloads after a pick;MediaGensurfaces the412over-cap confirm and re-POSTs withconfirm=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), adocs/concepts/cost-accounting note, the config keys in the config reference,0029§9 progress. Greenjust 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 thespend.confirm_abovegate (§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-POSTconfirm=true. Server-enforced, not client-trusted — symmetric with the CLI--yes. - G-c — config keys + defaults.
spend.confirm_above.amount(default10, flat units) +spend.confirm_above.characters(default50000, voice), global + per-project,0disables an axis — straight from0001§cost(d). - G-d — RESOLVED (2026-07-16): reach = studio + shared config, then CLI unify (augment) landed
same day.
cost.go→ sharedinternal/spend;reel makeestimates + 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.jsonis a local ledger likescreen.json(transient, machine-written). Rec: keryx writes it; the blog's.gitignoredecides — 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
spendprune/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
currencyand 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.