Skip to content

guard: cost_ceiling --check is read-only (header creation moves to append path) - #717

Merged
matt82198 merged 4 commits into
mainfrom
guard/cost-ceiling-check-readonly
Aug 4, 2026
Merged

matt82198 merged 4 commits into
mainfrom
guard/cost-ceiling-check-readonly

Conversation

@matt82198

Copy link
Copy Markdown
Owner

The escape

The read-only meta-gate added in #706 caught a real one: on a fresh tree,

python tools/cost_ceiling.py --check

writes to the state tree. The call chain is

cost_ceiling.check()
  -> read_ledger_total_tokens()
    -> fleet_ledger.parse_ledger_rows()
      -> ensure_ledger_header()   # mkdir <state>/ledger/ + write OUTCOMES-LEDGER.md

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 the ensure_ledger_header() call and return [] when the ledger is absent.

Chosen over patching cost_ceiling.py because parse_ledger_rows is the shared reader behind three consumers, all of which inherited the write and all of which already document read-only semantics:

consumer documented contract actual behavior before this PR
tools/cost_ceiling.py "A ledger that does not exist yet -> spend of 0" created the ledger
tools/cost_projection.py reads rows for burn-rate projection created the ledger
state_store/read_api.py::read_ledger_rows "Returns empty list if file missing" created the ledger

Patching only cost_ceiling would have left two silent writers behind the read facade.

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 evidence

6 new tests written first, RED:

FAILED tests/test_cost_ceiling.py::TestCheckModeIsReadOnly::test_check_api_creates_no_ledger_file
FAILED tests/test_cost_ceiling.py::TestCheckModeIsReadOnly::test_check_daily_period_creates_no_ledger_file
FAILED tests/test_cost_ceiling.py::TestCheckModeIsReadOnly::test_check_windowed_creates_no_ledger_file
FAILED tests/test_cost_ceiling.py::TestCheckModeIsReadOnly::test_cli_check_creates_no_ledger_file
FAILED tests/test_fleet_ledger_append.py::TestLedgerReadPathIsReadOnly::test_parse_ledger_rows_missing_ledger_returns_empty_and_writes_nothing
FAILED tests/test_fleet_ledger_append.py::TestLedgerReadPathIsReadOnly::test_summary_missing_ledger_writes_nothing
6 failed, 5 passed in 0.65s

then GREEN after the fix, with the whole ledger/cost surface re-run:

tests/test_cost_ceiling.py tests/test_fleet_ledger_append.py tests/test_cost_ceiling_spike.py
tests/test_fleet_ledger_harvest.py tests/test_fleet_ledger_injection.py tests/test_tools_fleet_ledger.py
tests/test_cost_pipeline_e2e.py tests/test_wave_ledger_hook.py
134 passed in 95.62s

tests/test_stateapi_read.py tests/test_cost_projection.py
36 passed in 0.60s

Manual reproduction of the exact meta-gate finding, before/after:

$ AESOP_STATE_ROOT=/tmp/ccfresh/state python tools/cost_ceiling.py --check
[cost_ceiling] no wave ceiling configured - skipping (spent=0)
exit=0
$ find /tmp/ccfresh/state -mindepth 1
(empty)

Tests are not just "no crash" — they snapshot the whole state tree with rglob and 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_missing and test_append_wave_creates_header_when_missing prove the append path still creates the header on a fresh tree, and that append_wave — whose idempotency check does a parse_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:

  • Absent ledger -> spend=0, exceeded=False, exit 0. Never trips on a fresh install.
  • Unreadable ledger (permission denied, disk full, lock contention) -> still raises into the fail-safe path: exceeded=True (abort this wave) with tripped=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.py monkeypatch tests, which still pass.

Sequencing with #706 — please merge this PR FIRST

#706 lists cost_ceiling.py in its KNOWN_OFFENDERS map, wired as unittest.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:

$ python -m pytest <706's test_check_mode_readonly.py> -q -k cost_ceiling
_________ TestCheckModeIsReadOnly.test_cost_ceiling_check_is_readonly _________
Unexpected success
1 failed, 30 deselected

That is #706's design working as intended — but it means #706 goes red the moment this fix is on main.

Recommended order:

  1. Merge this PR.
  2. In guard: --check modes must be read-only (meta-gate, GAP6) #706, delete the "cost_ceiling.py" entry from KNOWN_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 --check for cost_ceiling.py going forward, with no xfail.

The remaining KNOWN_OFFENDERS entry (verify_test_suite_count.py) is a separate parallel lane and is untouched here. File overlap with #706 is zero — this PR does not touch tests/test_check_mode_readonly.py, tests/test_cli_help_hygiene.py, or tools/verify_test_suite_count.py.

Gates

secret_scan --staged 0 · encoding_lint --check 0 · claudemd_lint 0 · claudemd_contract 0 · claudemd_sync_gate --check 0 · verify_test_suite_count --check 0 (no new test files, so no count drift) · verify_test_coverage --check 0 · tracker_guard --check 0 · import_resolution_check 0 · import_cycle_check 0 · metrics_gate 0 · watcher_linter 0 · agent_prompt_hygiene 0 · full pre-push hook passed on push.

Full local battery: py rc=0 PASS. node and sh came back red under parallel load but both reproduce green in isolation (fleet-cli.test.mjs 7/7 — the failure was the node test-runner's Unable to deserialize cloned data IPC flake; test_reconstitute.sh 20/20 passed, reported by the runner as [FAIL] ... (exit code: 0)). This diff is Python-only and touches no .mjs or .sh file.

🤖 Generated with Claude Code

…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>
@matt82198

Copy link
Copy Markdown
Owner Author

Evicted from the merge queue: batch #736 (integrate/q-1785730980) red with every member individually green

matt82198 and others added 2 commits August 3, 2026 14:14
…: 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>
@matt82198 matt82198 added the merge-queue Queued for the merge-queue advancer daemon label Aug 3, 2026
@matt82198 matt82198 removed the queue-rejected Evicted from the merge queue (red or culprit) label Aug 4, 2026
@matt82198 matt82198 added queue-rejected Evicted from the merge queue (red or culprit) and removed merge-queue Queued for the merge-queue advancer daemon labels Aug 4, 2026
@matt82198

Copy link
Copy Markdown
Owner Author

Evicted from the merge queue: batch #766 (integrate/q-1785803317) red with every member individually green

@matt82198 matt82198 added merge-queue Queued for the merge-queue advancer daemon and removed queue-rejected Evicted from the merge queue (red or culprit) labels Aug 4, 2026
@matt82198
matt82198 merged commit e2da2ca into main Aug 4, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-priority Jump the merge queue merge-queue Queued for the merge-queue advancer daemon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant