Skip to content

feat(FAFF-604): telemetry adapter seam — budget/economics read an engine-declared spend source#496

Merged
alechill merged 7 commits into
mainfrom
faff-604-telemetry-adapter-seam-budgeteconomics-read-an-engine
Jul 25, 2026
Merged

feat(FAFF-604): telemetry adapter seam — budget/economics read an engine-declared spend source#496
alechill merged 7 commits into
mainfrom
faff-604-telemetry-adapter-seam-budgeteconomics-read-an-engine

Conversation

@alechill

@alechill alechill commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Closes FAFF-604.

budget and economics have always read spend from one place: the Claude Code transcript. That was the deepest Claude-only coupling left after FAFF-593 landed the codex spawn family — a codex build lane's spend was simply invisible, so a mixed-fleet run under a dollar ceiling silently under-counted. This makes each engine declare where its spend can be read, and unions the two sources behind one layer.

What changed

backends: gains a telemetry fieldtranscript-jsonl | exec-json-events | none, derive-with-override with family-capability constraints, exactly the auth/egress pattern. anthropic derives transcript-jsonl, codex exec-json-events, everything else none. A backend can't claim a source its family can't serve; that fails at normalize time, where the symptom is a named error rather than a silently missing contribution.

The codex call boundary records spend. codex exec is --ephemeral — no session file, temp cwd already gone — so nothing codex-side survives to attribute later. The caller is the only place that knows both the usage and the run, so runCodexCall gains an injectable sink that appends one record per completed call to a run-owned engine-spend.jsonl. A sink fault warns and leaves the exit code alone: metering must never break a producer dispatch.

One combining layer. measureRunSpend owns the union; budget check and economics both call it. Engine records are run-scoped, so they need no run-start baseline — and because they fold in before the window baseline, the window governor needed no changes at all: a mixed-fleet window ceiling is a true combined total by construction.

The refusal. An engine with telemetry: none under a dollar ceiling refuses at the lights-out mint, where refusing is free. budget check degrades loudly instead — cost: null plus a warning naming both remedies, exit unchanged — because a non-zero exit there is read as unbreached by sentryReadBudget, so refusing that way would fail the whole budget signal open. The waiver (budget.allow_unmetered) lives on the budget block, not the backend record: accepting unmetered spend under a ceiling is a policy owned by whoever set the ceiling.

Honest labels. Additive spend_sources and unmetered_engines on both surfaces, plus a per-row source on economics --by model. All omitted on a single-source run, so unchanged runs emit byte-identical JSON.

Acceptance criteria

  • A run with no engine-spend.jsonl produces byte-identical budget + economics output — asserted down to the absent key, not just the values
  • budget.js:422-534 and parseCodexEvents textually unchanged — proven by function-text comparison against main, not just by test
  • telemetry derives per family, explicit wins, closed enum, family-capability constraints enforced
  • budget.allow_unmetered parsed; a dead waiver naming no configured backend warns in faff config check
  • A successful codex call appends one class-mapped SpendRecord (selftest via injected sink — zero real I/O)
  • engine call --run-dir, latest-run fallback, warn-and-skip outside a run
  • Sink write failure → stderr warning, exit code untouched
  • Mixed-fleet total unions both sources; window draw includes engine spend
  • Estimate mode: measured engine spend still counts, and a partial cost is named as partial on both surfaces
  • Malformed spend lines skipped and counted, never fatal
  • Mint-time refusal names the engine and both remedies; budget check degrades at exit 0
  • unmetered_engines present whenever the fleet has one, waived or not — never reported as spending 0
  • spend_sources + per-row source; both omitted on transcript-only runs

Gates: lint pass, 2,505 tests pass, 0 fail.

Review

Three iterations. The first found seven real problems, including two that would have shipped as silent money bugs: the allow_unmetered waiver was inert on the ledger-envelope path (the normal path), which would have cost operators the dollar ceiling they were trying to keep; and cached codex tokens were counted — and would have been billed — twice, because the Anthropic four-class model is disjoint while codex reports cached input inside input_tokens. The second iteration caught two more introduced by that fix pass, including economics reporting a partial cost as if it were the run total.

The third iteration passed, and the adversarial second opinion (GLM-5.2) returned five findings, all refuted with cited evidence — details in the tracker comment.

CI then went red on faff regions check: budget.js is governance and may not require backends.js (factory), and a lazy require inside a function is still a require edge. That lint has no suppression mechanism by design, so the fix was to move the code — the fleet resolver now lives in backends.js where it belongs, measureRunSpend takes the resolved list as data, and the dispatch shell (exempt by design) is where the two regions meet. A fourth review pass covered the relocation and returned pass.

Two notes against my own account of that fix. The commit message for 878e323 claims it also caught lights-out.js using a symbol without importing it; that is wrong — the symbol was imported from its then-home in ./budget, and the commit merely re-pointed the import. No defect either way, but the claim overstates it. And the local gate ladder passed while CI failed, because faff gates run discovered only lint and unit from a workflow that runs some seventy steps; that gap is filed as discovered scope.

Follow-up (not blocking)

  • economics --by class / --by day reconcile true while covering only the transcript half on a mixed fleet. Discoverable (the breakdown carries source, the same JSON carries spend_sources), but worth tidying.
  • The source label is JSON-only — no column in the rendered --by model table.
  • Unchecked assumption, carried from FAFF-593: the codex turn.completed usage field names are pinned from codex-rs source, not a live binary — none is installed here. The cached-input subset semantics are inferred from codex-rs's non_cached_input() helper. Worth one live codex exec --json call to confirm before a codex lane meters real money. The drift observable is a run whose spend_sources shows an exec-json-events entry with 0 tokens while the codex lane demonstrably ran.

ADR-0091 records the seam.

🤖 Generated with Claude Code

https://claude.ai/code/session_016jMqrXT3E21jv8vrqLYJa2

alechill and others added 7 commits July 25, 2026 17:32
… spend source

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jMqrXT3E21jv8vrqLYJa2
budget and economics stop assuming every engine writes a Claude Code
transcript. Each backend declares where its spend can be read, and the two
sources union behind one combining layer:

- backends: a derive-with-override telemetry field (transcript-jsonl |
  exec-json-events | none) with family-capability constraints, mirroring the
  existing auth/egress pattern. anthropic derives transcript-jsonl, codex
  exec-json-events, everything else none.
- engine-codex: the codex call boundary records one spend record per completed
  call through an injectable sink. codex exec is --ephemeral, so nothing
  codex-side survives to attribute later; the caller is the only place that
  knows both the usage and the run. A sink fault warns and leaves the
  dispatch's exit code alone.
- engine call: --run-dir for in-run dispatchers (explicit, because newest-run
  is an mtime-shaped ownership signal), latest-run for ad-hoc calls, and a
  named notice when there is no run rather than a silent drop.
- budget: engine-spend records fold in run-scoped (no baseline), before the
  window baseline — so tokens, cost and window all read one combined figure
  and the window governor needs no changes at all.
- the refusal: an engine with telemetry: none under a dollar ceiling refuses
  at the lights-out mint, where refusing is free. budget check degrades loudly
  instead (cost: null + warning, exit 0) because a non-zero exit there is read
  as unbreached and would fail the budget signal open.
- economics: additive spend_sources and unmetered_engines labels, per-row
  source on the model axis. Both omitted on a single-source run, so unchanged
  runs emit byte-identical JSON.

The transcript measurers and parseCodexEvents are textually unchanged —
verified by function-text comparison against main, not just by test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jMqrXT3E21jv8vrqLYJa2
…ken double count, one union

Pre-PR review found seven issues; all seven are fixed here.

- envelopeFromLedger dropped allow_unmetered, so the waiver was inert on the
  ledger-envelope path — which beep-boop writes for every run. That did not
  merely ignore the waiver: an unwaived engine forces cost:null, and a null
  cost makes computeBudgetState skip the cost dimension, so the operator lost
  the dollar ceiling they were trying to keep. The waiver is live config
  policy, so it now always reads from fresh config.
- sumCodexUsage added cached_input_tokens alongside input_tokens. The four
  classes are Anthropic's and disjoint; codex reports cached input INSIDE
  input_tokens (hence codex-rs's non_cached_input helper), so those tokens
  were counted — and would have been billed — twice. Cached is now subtracted
  out of the input class, floored at 0.
- measureRunSpend was dead code: cmdBudget and cmdEconomics each hand-rolled
  their own fold, which is exactly the drift the one-measurement principle
  exists to prevent. Both now route through it.
- --by model rows gained a source field even with no engine spend, breaking
  the byte-identical guarantee (which covers the absent key, not just the
  values). The label now appears only on a genuinely mixed run.
- the cost:null refusal fired even with no dollar ceiling armed. Now gated on
  costConfigured, matching the mint-side gate.
- the gateway's producer-dispatch prose didn't thread --run-dir, so every
  in-run codex call would have taken the latest-run fallback the spec forbids
  — the mtime-shaped attribution FAFF-229 retired.
- the budget selftest printed a hardcoded 36 checks; it counts them now (89).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jMqrXT3E21jv8vrqLYJa2
Review iteration 2. Pricing the engine half outside the transcript-gated block
was right, but economics reported the result with no warning — so a run whose
transcript degraded to an estimate showed a confident cost figure covering only
the codex portion. Before the seam that state reported cost_total: null, an
honest unknown; this was a regression from unknown to understated, which is the
never-silently-zero failure wearing a different hat. budget check already warns
in the identical state; economics now says the same thing.

Also drops measureTokensByModelClass from the economics import — the
measureRunSpend rerouting removed its only call site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jMqrXT3E21jv8vrqLYJa2
… red)

CI's regions check caught what the local gate ladder didn't: budget.js is
governance and must never require backends.js (factory) — ADR-0042's
require-graph direction invariant, which has no suppression mechanism by
design, because an escape hatch on a boundary lint is the boundary leaking.
The lazy require inside fleetEngineBackends was still a require edge.

Fixed by moving the code, which is the only sanctioned fix:

- fleetEngineBackends / unmeteredFleetEngines now live in backends.js, their
  natural home — resolving which engines a fleet reaches is a fact about
  backends, not about budgets. Their selftest rows moved with them.
- measureRunSpend takes the unmetered list as plain DATA (absent ⇒ empty), so
  governance is handed the answer instead of reaching for it. A missing list
  can only under-report the refusal, never invent one.
- economics.js and lights-out.js are factory, so they call the resolver
  directly. lights-out was also using the symbol without importing it — it
  only loaded because the line is lazily evaluated.
- cmdBudget takes the resolver injected by the dispatch shell, which is
  exempt from the lint by design and is therefore where the two regions
  legitimately meet.

Worth noting for the gate ladder itself: gate ladder: signal=pass discovery=confident
  pass     LINT             node plugin/skills/faff/bin/faff validate-adapters  (193ms)
  pass     UNIT             node --test  (68471ms)
```faff-contract:quality-gates
{"signal":"pass","rungs":[{"kind":"LINT","status":"pass"},{"kind":"UNIT","status":"pass"}]}
``` discovered only
lint + unit, so regions check ran for the first time in CI. Recorded as
discovered scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jMqrXT3E21jv8vrqLYJa2
@alechill
alechill merged commit ec28e4c into main Jul 25, 2026
6 checks passed
@alechill
alechill deleted the faff-604-telemetry-adapter-seam-budgeteconomics-read-an-engine branch July 25, 2026 19:10
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.

1 participant