guard: cost_ceiling --check is read-only (header creation moves to append path) - #717
Merged
Merged
Conversation
…pend path) `python tools/cost_ceiling.py --check` on a fresh tree WROTE to the state tree. Call chain: check() -> read_ledger_total_tokens() -> fleet_ledger.parse_ledger_rows() -> ensure_ledger_header(), which mkdir'd <state>/ledger/ and wrote OUTCOMES-LEDGER.md just for asking "how much have we spent?". Check modes must never mutate. Fix is at the source, in fleet_ledger.parse_ledger_rows(): drop the ensure_ledger_header() call and return [] when the ledger is absent. Chosen over patching cost_ceiling because parse_ledger_rows is the shared reader behind cost_ceiling, cost_projection AND state_store/read_api.read_ledger_rows -- all three inherited the write, and all three already document "returns empty list if the file is missing". ensure_ledger_header() is now documented as WRITE-PATH-ONLY and still runs from append_ledger_line/harvest/rotate, so appends create the header exactly as before. TDD: 6 new tests failed first, then passed. - tests/test_cost_ceiling.py::TestCheckModeIsReadOnly -- API + CLI + daily + windowed check() leave a state-tree snapshot byte-identical; plus a "read-only is not blind" test proving an existing ledger is still summed. - tests/test_fleet_ledger_append.py::TestLedgerReadPathIsReadOnly -- reader purity for parse_ledger_rows/summary, and REGRESSION coverage that append_ledger_line + append_wave still create the header (and append_wave stays idempotent) now that the reader no longer materializes it. Contract preserved: a missing ledger is still spend=0 / exit 0 (never trips on a fresh install); an UNREADABLE ledger still raises into the fail-safe path (exceeded=True, tripped=False). Absent means zero; unreadable means stop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This was referenced Aug 3, 2026
Merged
Owner
Author
|
Evicted from the merge queue: batch #736 (integrate/q-1785730980) red with every member individually green |
…: lines #751 moved the per-tool index out of tools/CLAUDE.md into each tool's own INDEX: header line, from which tools/INDEX.md is generated, because the inline list was the top merge-queue conflict surface. This branch was cut before that and still carried its documentation update as an edit to the old inline list. Resolved by taking main's 33-line tools/CLAUDE.md and porting this branch's updated description(s) into the tool's own INDEX: line, then regenerating tools/INDEX.md: cost_ceiling.py, fleet_ledger.py No documentation content lost; only the obsolete inline index lines are gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 4, 2026
Owner
Author
|
Evicted from the merge queue: batch #766 (integrate/q-1785803317) red with every member individually green |
Merged
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.
The escape
The read-only meta-gate added in #706 caught a real one: on a fresh tree,
writes to the state tree. The call chain is
Asking "how much have we spent?" materialized the ledger. Check modes must never mutate.
Fix location, and why there
The fix is at the source, in
fleet_ledger.parse_ledger_rows(): drop theensure_ledger_header()call and return[]when the ledger is absent.Chosen over patching
cost_ceiling.pybecauseparse_ledger_rowsis the shared reader behind three consumers, all of which inherited the write and all of which already document read-only semantics:tools/cost_ceiling.pytools/cost_projection.pystate_store/read_api.py::read_ledger_rowsPatching only
cost_ceilingwould have left two silent writers behind the read facade.ensure_ledger_header()is now documented as WRITE-PATH-ONLY and still runs fromappend_ledger_line/harvest/rotate, so appends create the header exactly as before.TDD evidence
6 new tests written first, RED:
then GREEN after the fix, with the whole ledger/cost surface re-run:
Manual reproduction of the exact meta-gate finding, before/after:
Tests are not just "no crash" — they snapshot the whole state tree with
rgloband assert byte-identical before/after, for the API, the CLI,period=daily, and the windowed path.Regression coverage (the failure mode this fix could plausibly introduce):
test_append_path_still_creates_header_when_missingandtest_append_wave_creates_header_when_missingprove the append path still creates the header on a fresh tree, and thatappend_wave— whose idempotency check does aparse_ledger_rows()read before its write — stays idempotent now that the read no longer pre-creates the file.Contract preserved
The subtle distinction in
cost_ceiling's docstring is intact:spend=0,exceeded=False, exit 0. Never trips on a fresh install.exceeded=True(abort this wave) withtripped=False(no persistent.HALT, so a transient I/O fault doesn't wedge the fleet).Absent means zero; unreadable means stop. Verified by the existing
test_cost_ceiling_spike.pymonkeypatch tests, which still pass.Sequencing with #706 — please merge this PR FIRST
#706 lists
cost_ceiling.pyin itsKNOWN_OFFENDERSmap, wired asunittest.expectedFailure(deliberately not a skip, so that a fix landing surfaces loudly).I verified the interaction empirically by running #706's test file against this branch:
That is #706's design working as intended — but it means #706 goes red the moment this fix is on main.
Recommended order:
"cost_ceiling.py"entry fromKNOWN_OFFENDERS(its docstring already instructs exactly this: "Delete an entry the moment its fix merges"), then merge guard: --check modes must be read-only (meta-gate, GAP6) #706 — at which point the meta-gate enforces read-only--checkforcost_ceiling.pygoing forward, with no xfail.The remaining
KNOWN_OFFENDERSentry (verify_test_suite_count.py) is a separate parallel lane and is untouched here. File overlap with #706 is zero — this PR does not touchtests/test_check_mode_readonly.py,tests/test_cli_help_hygiene.py, ortools/verify_test_suite_count.py.Gates
secret_scan --staged0 ·encoding_lint --check0 ·claudemd_lint0 ·claudemd_contract0 ·claudemd_sync_gate --check0 ·verify_test_suite_count --check0 (no new test files, so no count drift) ·verify_test_coverage --check0 ·tracker_guard --check0 ·import_resolution_check0 ·import_cycle_check0 ·metrics_gate0 ·watcher_linter0 ·agent_prompt_hygiene0 · full pre-push hook passed on push.Full local battery: py rc=0 PASS.
nodeandshcame back red under parallel load but both reproduce green in isolation (fleet-cli.test.mjs7/7 — the failure was the node test-runner'sUnable to deserialize cloned dataIPC flake;test_reconstitute.sh20/20 passed, reported by the runner as[FAIL] ... (exit code: 0)). This diff is Python-only and touches no.mjsor.shfile.🤖 Generated with Claude Code