feat(FAFF-604): telemetry adapter seam — budget/economics read an engine-declared spend source#496
Merged
alechill merged 7 commits intoJul 25, 2026
Conversation
… spend source Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jMqrXT3E21jv8vrqLYJa2
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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jMqrXT3E21jv8vrqLYJa2
alechill
deleted the
faff-604-telemetry-adapter-seam-budgeteconomics-read-an-engine
branch
July 25, 2026 19:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 atelemetryfield —transcript-jsonl | exec-json-events | none, derive-with-override with family-capability constraints, exactly theauth/egresspattern. anthropic derivestranscript-jsonl, codexexec-json-events, everything elsenone. 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 execis--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, sorunCodexCallgains an injectable sink that appends one record per completed call to a run-ownedengine-spend.jsonl. A sink fault warns and leaves the exit code alone: metering must never break a producer dispatch.One combining layer.
measureRunSpendowns the union;budget checkandeconomicsboth 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: noneunder a dollar ceiling refuses at the lights-out mint, where refusing is free.budget checkdegrades loudly instead —cost: nullplus a warning naming both remedies, exit unchanged — because a non-zero exit there is read as unbreached bysentryReadBudget, 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_sourcesandunmetered_engineson both surfaces, plus a per-rowsourceoneconomics --by model. All omitted on a single-source run, so unchanged runs emit byte-identical JSON.Acceptance criteria
engine-spend.jsonlproduces byte-identical budget + economics output — asserted down to the absent key, not just the valuesbudget.js:422-534andparseCodexEventstextually unchanged — proven by function-text comparison againstmain, not just by testtelemetryderives per family, explicit wins, closed enum, family-capability constraints enforcedbudget.allow_unmeteredparsed; a dead waiver naming no configured backend warns infaff config checkengine call --run-dir, latest-run fallback, warn-and-skip outside a runbudget checkdegrades at exit 0unmetered_enginespresent whenever the fleet has one, waived or not — never reported as spending 0spend_sources+ per-rowsource; both omitted on transcript-only runsGates: 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_unmeteredwaiver 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 insideinput_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.jsis governance and may not requirebackends.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 inbackends.jswhere it belongs,measureRunSpendtakes 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 returnedpass.Two notes against my own account of that fix. The commit message for
878e323claims it also caughtlights-out.jsusing 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, becausefaff gates rundiscovered 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 dayreconciletruewhile covering only the transcript half on a mixed fleet. Discoverable (the breakdown carriessource, the same JSON carriesspend_sources), but worth tidying.sourcelabel is JSON-only — no column in the rendered--by modeltable.turn.completedusage 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'snon_cached_input()helper. Worth one livecodex exec --jsoncall to confirm before a codex lane meters real money. The drift observable is a run whosespend_sourcesshows anexec-json-eventsentry with 0 tokens while the codex lane demonstrably ran.ADR-0091 records the seam.
🤖 Generated with Claude Code
https://claude.ai/code/session_016jMqrXT3E21jv8vrqLYJa2