Budget header + spend attribution
Closes SURFACE.md §2 rule 5, and wires the budget: field shown in the §1 harness example (line 34).
Verbatim spec
SURFACE.md §2 rule 5 (line 71):
Headers are optional escalation. identity, memory, budget, tools appear only when used. The empty header is the common case.
SURFACE.md §1 harness example (lines 32–36):
export default flow("chief", {
identity: "chief",
memory: { script: true, agent: true },
budget: "$20/day",
})
The budget: field is declared in the surface example but is not accepted, parsed, validated, or enforced anywhere today. Every step run under an authored flow accrues real spend (agent tokens, LLM tokens, wallclock leases) that never gets attributed against a header limit — a flow can silently blow through any intended cap.
Scope
-
Surface accepts the field. FlowHeader.budget accepts:
type Budget =
| string // "$20/day", "$0.10/run"
| { tokens?: number; dollars?: number; wallclock?: string };
The string shorthand parses as <amount>/<window> where amount is a dollar
figure ($0.10, $20) and window is run or day. Both fields optional in
the object form; at least one must be present.
-
Preflight validates the syntax in one deterministic pass before any
probe:
budget_syntax_invalid — string neither matches the shorthand nor an object
with at least one field.
budget_missing_price — the flow's declared model (step, header, or
project default) has no entry in the model pricing table. The registry is
author-owned config; the price table lives in code and is versioned with
the SDK.
-
Each step.completed accrues a spend object on the journal entry:
tokens_input / tokens_output come from the adapter's output — Claude and
Codex both emit token counts today; the SDK worker already reads them for
the lease record.
dollars = (tokens_input * price.input + tokens_output * price.output) / 1_000_000.
wallclock_ms derived from the lease deadlines already recorded.
-
The kernel refuses the next step if accumulated spend exceeds the header at
ANY step completion. The check happens after step.completed is written
(spend is durable) and before the next step.spawned is dispatched. Refusal:
budget_exceeded, carrying the offending totals and the header limit. The run
completes with completionReason: "budget_exceeded"; already-completed steps
remain valid.
Model pricing table
- File:
packages/sdk/src/model-pricing.ts (new).
- Shape:
{ [modelId: string]: { input: number; output: number } } (dollars per
1M tokens, matching the industry convention).
- First-pass entries:
claude-sonnet-4-6, claude-opus-4-7, codex-medium,
codex-large. Add-only over time.
- No remote lookup, no dynamic pricing, no fallback: a declared model without an
entry refuses at preflight. This is deliberate — silent zero pricing is worse
than an explicit refusal.
Acceptance evidence
- A TS flow with
budget: "$0.10/run" running a cheap deterministic loop
completes with success and the journal spend totals sum below $0.10.
- The same flow with an expensive model refuses mid-run with
budget_exceeded; the journal shows the step that crossed the line and the
running total that triggered it.
- Every
step.completed in a run with a budget header carries a non-empty
spend object; runs without a budget header still record spend for
observability but do not gate on it.
- A flow declaring a model with no pricing entry refuses at preflight with
budget_missing_price, naming the model.
- A malformed budget string (
"$20" with no window, or "20/day" with no $)
refuses at preflight with budget_syntax_invalid.
Files to touch
packages/surface/src/flow.ts — accept budget in FlowHeader, export the
Budget type.
packages/sdk/src/preflight.ts — parse + validate; emit
budget_syntax_invalid / budget_missing_price refusals.
packages/sdk/src/model-pricing.ts — new. Frozen table, first entries.
packages/sdk/src/authored-flow-executor.ts — read adapter token counts,
compute spend, attach to step.completed.
packages/sdk/src/worker-cli.ts / worker.ts — ensure token counts flow up
from the adapter (Claude and Codex adapters already parse them; verify the
wire-through to the executor).
kernel/relayflowd/src/spend_tracker.rs — new. Accumulates spend from
each step.completed, gates the next step.spawned with budget_exceeded.
kernel/relayflowd/src/journal.rs (or the equivalent) — accept the new
spend field on step.completed; forward-compatible with older writers by
treating the field as optional.
- Tests:
packages/sdk/tests/budget-preflight.test.ts — the four preflight refusals.
packages/sdk/tests/budget-attribution.test.ts — spend accrues on
step.completed.
kernel/relayflowd/tests/budget_gate.rs — the mid-run budget_exceeded
path.
- New declarative fixture in
testdata/ for a smoke.
Not in scope
- Dynamic per-model pricing lookup (Anthropic/OpenAI billing APIs).
- Per-user / per-org budgets — the header attaches to the flow.
- Budget windows other than
run and day — day here means calendar day in
the daemon's local time; multi-run day accounting rides on the daemon's
journal, not on any external counter. Weekly/monthly/quarterly windows are a
follow-up.
- Budget resets on partial failure / retry — the current retry counts against
the same budget.
- Forecasting or "warning at 80%" behaviors.
- Non-model spend attribution (deterministic steps get
dollars: 0; wallclock
still recorded).
Guardrails for the implementer
- No new kernel verb.
spend is a field on the existing step.completed; the
gate refusal budget_exceeded is a completionReason, not a new verb.
- The pricing table is code, not a JSON asset — it's part of the SDK's signed
surface, not something a project overrides. If we ever want project override,
it's a follow-up with an explicit registry story.
- Keep the executor path deterministic. Do not float the dollars total; use
integer math over microdollars (or fixed-point) and only convert to a JSON
number at journal-write time.
Budget header + spend attribution
Closes SURFACE.md §2 rule 5, and wires the
budget:field shown in the §1 harness example (line 34).Verbatim spec
SURFACE.md §2 rule 5 (line 71):
SURFACE.md §1 harness example (lines 32–36):
The
budget:field is declared in the surface example but is not accepted, parsed, validated, or enforced anywhere today. Every step run under an authored flow accrues real spend (agent tokens, LLM tokens, wallclock leases) that never gets attributed against a header limit — a flow can silently blow through any intended cap.Scope
Surface accepts the field.
FlowHeader.budgetaccepts:The string shorthand parses as
<amount>/<window>whereamountis a dollarfigure (
$0.10,$20) andwindowisrunorday. Both fields optional inthe object form; at least one must be present.
Preflight validates the syntax in one deterministic pass before any
probe:
budget_syntax_invalid— string neither matches the shorthand nor an objectwith at least one field.
budget_missing_price— the flow's declared model (step, header, orproject default) has no entry in the model pricing table. The registry is
author-owned config; the price table lives in code and is versioned with
the SDK.
Each
step.completedaccrues aspendobject on the journal entry:{ "type": "step.completed", "id": "extract", "spend": { "tokens_input": 1240, "tokens_output": 180, "dollars": 0.00396, "wallclock_ms": 4820 } }tokens_input/tokens_outputcome from the adapter's output — Claude andCodex both emit token counts today; the SDK worker already reads them for
the lease record.
dollars = (tokens_input * price.input + tokens_output * price.output) / 1_000_000.wallclock_msderived from the lease deadlines already recorded.The kernel refuses the next step if accumulated spend exceeds the header at
ANY step completion. The check happens after
step.completedis written(spend is durable) and before the next
step.spawnedis dispatched. Refusal:budget_exceeded, carrying the offending totals and the header limit. The runcompletes with
completionReason: "budget_exceeded"; already-completed stepsremain valid.
Model pricing table
packages/sdk/src/model-pricing.ts(new).{ [modelId: string]: { input: number; output: number } }(dollars per1M tokens, matching the industry convention).
claude-sonnet-4-6,claude-opus-4-7,codex-medium,codex-large. Add-only over time.entry refuses at preflight. This is deliberate — silent zero pricing is worse
than an explicit refusal.
Acceptance evidence
budget: "$0.10/run"running a cheap deterministic loopcompletes with
successand the journalspendtotals sum below$0.10.budget_exceeded; the journal shows the step that crossed the line and therunning total that triggered it.
step.completedin a run with a budget header carries a non-emptyspendobject; runs without a budget header still recordspendforobservability but do not gate on it.
budget_missing_price, naming the model."$20"with no window, or"20/day"with no$)refuses at preflight with
budget_syntax_invalid.Files to touch
packages/surface/src/flow.ts— acceptbudgetinFlowHeader, export theBudgettype.packages/sdk/src/preflight.ts— parse + validate; emitbudget_syntax_invalid/budget_missing_pricerefusals.packages/sdk/src/model-pricing.ts— new. Frozen table, first entries.packages/sdk/src/authored-flow-executor.ts— read adapter token counts,compute
spend, attach tostep.completed.packages/sdk/src/worker-cli.ts/worker.ts— ensure token counts flow upfrom the adapter (Claude and Codex adapters already parse them; verify the
wire-through to the executor).
kernel/relayflowd/src/spend_tracker.rs— new. Accumulatesspendfromeach
step.completed, gates the nextstep.spawnedwithbudget_exceeded.kernel/relayflowd/src/journal.rs(or the equivalent) — accept the newspendfield onstep.completed; forward-compatible with older writers bytreating the field as optional.
packages/sdk/tests/budget-preflight.test.ts— the four preflight refusals.packages/sdk/tests/budget-attribution.test.ts— spend accrues onstep.completed.kernel/relayflowd/tests/budget_gate.rs— the mid-runbudget_exceededpath.
testdata/for a smoke.Not in scope
runandday—dayhere means calendar day inthe daemon's local time; multi-run
dayaccounting rides on the daemon'sjournal, not on any external counter. Weekly/monthly/quarterly windows are a
follow-up.
the same budget.
dollars: 0; wallclockstill recorded).
Guardrails for the implementer
spendis a field on the existingstep.completed; thegate refusal
budget_exceededis acompletionReason, not a new verb.surface, not something a project overrides. If we ever want project override,
it's a follow-up with an explicit registry story.
integer math over microdollars (or fixed-point) and only convert to a JSON
number at journal-write time.