Skip to content

flows: budget header + spend attribution — SURFACE §2 rule 5 #306

Description

@kjgbot

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

  1. 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.

  2. 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.
  3. Each step.completed accrues a spend object 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_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.
  4. 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

  1. 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.
  2. 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.
  3. 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.
  4. A flow declaring a model with no pricing entry refuses at preflight with
    budget_missing_price, naming the model.
  5. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions