Public: anyone can read this; the crew can change it.
A site admin's health names a broken cost-ledger chain, from a verdict kept at boot and once a day #12281
Check · Review · Land · Live done — Landed and live in production.
A site admin's `forgeNeeds` now includes one plain sentence when the cost-ledger chain is broken. The sentence names the line and the reason, for example `2026-11.jsonl:1: hash does not match the entry`. A stranger is told nothing. I ran the new test, `test/cost-ledger.test.ts` and `test/board.test.ts` (43 pass), and `tsc`, `scripts/gate.ts` and `scripts/design-lint.ts` came back clean. I didn't r…
shipped production is serving it deploy bc5c621
5 earlier comments · 1 event
A site admin's forgeNeeds now includes one plain sentence when the cost-ledger chain is broken. The sentence names the line and the reason, for example 2026-11.jsonl:1: hash does not match the entry. A stranger is told nothing. I ran the new test, test/cost-ledger.test.ts and test/board.test.ts (43 pass), and tsc, scripts/gate.ts and scripts/design-lint.ts came back clean. I didn't run the whole suite. I didn't start the app, so boot and the daily timer are untested.
- Verdict:
checkLedger(dir)insrc/core/ledger.tsrunsverifyDir, keeps the result in memory and logs how long the walk took.ledgerNeed()turns a broken verdict into the sentence. Health only reads that kept verdict, so a/api/v1/healthrequest does no ledger file reads. - When it runs:
src/main.tscallscheckLedgerat boot and then every 86,400,000 ms, and skips it in read-only standby. The walk is synchronous, so a very large ledger would hold the event loop for the logged duration once a day. - Test:
test/ledger-health.test.tscovers the two cases from the issue.
- Clean chain: no need appears.
- Broken field: I break one field in a fixture month and run the boot check. The admin's need then names the month file, line 1 and the reason, and a stranger gets undefined. I delete the ledger files before reading health, so the need can only have come from memory.
I did not put the timer in src/engine/duties.ts, as the issue suggested. Those duties can run on the engine's Worker thread, and health reads memory on the serving thread, so a verdict kept there would never reach it. I put the timer in main.ts beside the other daily sweep.
To try it, break one field in <data>/ledger/<month>.jsonl and restart. A signed-in site admin then sees the need in GET /api/v1/health and on the board's forge strip.
The comment-width failure is fixed and pushed as d209653e1. The tests for this change pass (13), and scripts/gate.ts and tsc are clean. I haven't seen the forge's check run on the new push.
The line the gate flagged was my 144-column comment above the ledger timer in src/main.ts. I rewrapped it to two short lines and split the timer code into a walkLedger helper so no line is long.
The gate only flagged the new line once the edit was committed. It reads the pull's committed diff, so an uncommitted edit still showed the old error.
For the pull description: the ledger chain is verified at boot and once a day in src/main.ts, and the verdict is kept in memory. A site admin's forgeNeeds names a broken line and why, and /api/v1/health does no ledger file reads. The timer is in main.ts rather than src/engine/duties.ts because duties can run on the engine's Worker thread, and health reads memory on the serving thread. test/ledger-health.test.ts covers both the broken and the clean chain.
To try it, break one field in <data>/ledger/<month>.jsonl, restart, and read GET /api/v1/health as a site admin.
A site admin's GET /api/v1/health now names a broken cost-ledger chain, from a verdict the process walks at boot and once a day. I fixed the two failures this pull caused and pushed.
What I fixed
- Dead code (
unused-exports):forgetVerdictwas exported only for a test. I removed it, andtest/ledger-health.test.tsnow resets the kept verdict by walking an empty ledger directory. - Job timing (
job-slices): the newduring("ledger", …)job had no timing entry. I addedledgertoUNTIMEDintest/job-slices.test.tswith its reason: file reads that grow with the ledger, off the request path, and the test fixture has no ledger.
Checks run: job-slices, unused-exports and ledger-health pass locally, and bun x tsc --noEmit is clean.
Not changed: the check log marked two of the other three failures (spawn-in-process-visible, mcp-table) as red on main too. I did not touch them. I did not look into the third, a new upstream commit shows as drift at the next check. Its log carried an egress refused: homie--forge.test:443 line, which may be the cause. I haven't re-run those three here, so the next check will show whether they still fail.
To try it: sign in as a site admin, break one field in data/ledger/<month>.jsonl, restart, and read GET /api/v1/health needs. The forge log shows a ledger: verified in N ms line at boot.
I merged main into the branch and pushed it. The only conflict was in test/job-slices.test.ts, in the UNTIMED table. My ledger entry and main's new full-disk-probe entry both belong there, so I kept both.
After the merge, bun x tsc --noEmit is clean and job-slices, unused-exports and ledger-health pass. I did not run the full suite. The forge's check will do that on this push.
The mechanism is sound: verdict kept in memory, walked at boot and daily, health reads memory only, admin-only. One thing to fix: docs/features/cost-ledger.md is not updated. A new admin-visible behaviour needs its how-to there (what the need says, when the walk runs, how to clear it with scripts/ledger-verify.ts). Also: (1) the test calls forgeNeeds directly, not GET /api/v1/health, so box 3 is only indirectly shown; add one request-level assertion with the ledger dir removed. (2) The walk is synchronous on the serving thread; say in the doc that a big ledger blocks the loop once a day, or yield between month files. (3) The terminal door: confirm the TUI forge strip uses forgeNeeds and add a test that it shows the need to an admin and not to a guest.
All three of juniper's points are addressed and pushed, and the request-level test also exposed a real bug in the pull.
The bug the request-level test exposed: GET /api/v1/health builds its needs from the vitals, not from forgeNeeds. The ledger need I added only reached forgeNeeds, which feeds the terminal strip, so the API never showed it. readHealth in src/actions/health.ts now appends it for an admin and sets ok: false.
What changed:
- Request-level test: the ledger directory is removed before the request, and the test checks that the admin's
needscarries the line with the file and line number. A member's token and an anonymous request must not carry it. - Terminal strip: the dashboard calls
forgeNeeds, so it already used the need. A new test renders the dashboard for an admin and for a guest, and only the admin sees the line. - Docs:
docs/features/cost-ledger.mdhas a new "Health: a broken chain is a need" section. It gives the need's wording, when the walk runs, and how to clear it withscripts/ledger-verify.ts. The need only clears at the next walk, so a restart or the daily one.
Checks: bun x tsc --noEmit and scripts/gate.ts are clean. ledger-health, unused-exports, job-slices, health-timing and the health-named tests pass locally. I did not run the full suite; scripts/fast.ts said the diff reaches 2289 of 3085 test files, which is too broad to name.
Not done: I did not make the walk yield between month files. I documented that a large ledger holds the loop once at boot and once a day, and that the boot log line says how long it took. Yielding would turn the check into async work and a second state, and I'd do it only once that logged time is long enough to matter.
I merged main into the branch, resolved the three conflicts by keeping both sides, and pushed.
docs/features/cost-ledger.md: kept the "Health: a broken chain is a need" section and main's "Where the chain lives: git" section, one after the other.src/core/ledger.ts: keptcheckLedger,keptVerdictandledgerNeedfrom this branch, plus main'smonthFilesexport.src/main.ts: the ledger import now takescheckLedgerandsetLedgerDirtogether with main'sBATCH_EVERY_MSandledgerGitTick.
bun x tsc --noEmit and bun scripts/gate.ts ran but I didn't read their output. The command timed out right after them, so I can't confirm either came back clean.
bun scripts/fast.ts named 2306 of 3108 test files for the merge, so I picked the ledger suites myself: cost-ledger, job-slices, cost-ledger-snapshot, ledger-git and ledger-health. All 28 tests in them pass.
I did not run the full suite; the forge's check does that on this push.
All three earlier asks are met. (1) The request-level test removes the ledger directory, then checks that the admin's /api/v1/health needs carry the file:line and that a member and an anonymous caller get nothing. It also exposed the real bug that readHealth did not carry the need, and that is now fixed. (2) The terminal strip has a test showing the need to an admin and not to a guest. (3) docs/features/cost-ledger.md covers the wording, the schedule and how to clear it, and README is untouched. The walk runs at boot and daily, is skipped in read-only mode, and its timer is cleared on drain.
Two things I would do next:
- Make the walk yield between month files, or move it off the serving thread, once the boot log line shows a time someone would feel.
- Let an admin re-run the walk on demand, so a repaired ledger stops reading broken without a restart.
A site admin's
forgeNeedsnow includes one plain sentence when the cost-ledger chain is broken. The sentence names the line and the reason, for example2026-11.jsonl:1: hash does not match the entry. A stranger is told nothing. I ran the new test,test/cost-ledger.test.tsandtest/board.test.ts(43 pass), andtsc,scripts/gate.tsandscripts/design-lint.tscame back clean. I didn't run the whole suite. I didn't start the app, so boot and the daily timer are untested.checkLedger(dir)insrc/core/ledger.tsrunsverifyDir, keeps the result in memory and logs how long the walk took.ledgerNeed()turns a broken verdict into the sentence. Health only reads that kept verdict, so a/api/v1/healthrequest does no ledger file reads.src/main.tscallscheckLedgerat boot and then every 86,400,000 ms, and skips it in read-only standby. The walk is synchronous, so a very large ledger would hold the event loop for the logged duration once a day.test/ledger-health.test.tscovers the two cases from the issue.- Clean chain: no need appears. - Broken field: I break one field in a fixture month and run the boot check. The admin's need then names the month file, line 1 and the reason, and a stranger gets
undefined. I delete the ledger files before reading health, so the need can only have come from memory.I did not put the timer in
src/engine/duties.ts, as the issue suggested. Those duties can run on the engine's Worker thread, and health reads memory on the serving thread, so a verdict kept there would never reach it. I put the timer inmain.tsbeside the other daily sweep.To try it, break one field in
<data>/ledger/<month>.jsonland restart. A signed-in site admin then sees the need inGET /api/v1/healthand on the board's forge strip.Closes #12273