Skip to content

treasury: build log#

treasury is my personal finance application. It loads my bank statements, card exports and grocery receipts into a PostgreSQL database, gates statement loads on an exact balance check and receipt loads on a reconciliation with one recorded small rounding tolerance [RUNBOOK.md], and gives me a web app for spending, commitments, a monthly plan, groceries and my accounts. It is for me alone. It began on 3 July 2026 as FALKEN, ETL scripts sharing a container with the exam app that became doe; it got its own container and login on 21 August (63bdb0f), lost the FALKEN name on 2 September (60916d6, 1d3fe7a), and by late September could move money between my own bank pots when I confirm each move (1a2f11a). The history is 115 commits over twelve weeks. Most of the code was written by Claude (Anthropic's model) under my direction. The first commit is co-authored by Claude Fable 5 (4358707), 64 commits carry Claude Opus 5 as co-author and 15 carry Claude Opus 5.5, from 25 September; the other 35 carry no co-author line [gitlog.txt]. The planning documents name a Claude model as author and record my rulings [2026-09-21-runbook-treasury-accounts-and-integration.md].

First commit 4358707, 3 July 2026
Latest commit a72d8b0, 25 September 2026
Commits 115
Stack Python 3.13 ETL scripts (pdfplumber for PDFs, da965c4), PostgreSQL 17 [architecture.md] [2026-09-26-finding-treasury-projection-latency-is-jit.md], a Flask web app under gunicorn and systemd, vendored Chart.js (8017f59), the estate's own CSS design language (7e2e0e2) [README.md]
Where it runs Two Proxmox containers on my home server: CT135 treasury-prod and CT137 treasury-dev, each with its own PostgreSQL [AGENTS.md]. Until 21 August it ran on CT120 beside doe (63bdb0f)
Migrations 43 numbered, plus a squashed baseline, recorded in a ledger (467eaa8, e1ea957, d5d2180)
Tests Shell integration tests against dev, a pytest unit suite on my workstation (318 tests at 2051bfd), and an authenticated API smoke (93ce1da) [README.md]

What this is written from

This log is built from the git history and the project's planning documents. "Current state" means what those records say as of 27 September 2026. Nobody has checked the running system for this document.

What I set out to do#

The first commit's title states the first goal: "FALKEN Phases 1-2: schema extensions, roles, both ETL pipelines, systemd units, docs". Its job was to get my old bank current-account statements and supermarket delivery receipts out of PDFs and into a database that could be trusted, with hard reconciliation gates and "No financial data in this repo" (4358707). The runbook's principles: NUMERIC and Decimal for money, never float; dry run before commit; one transaction per file; least-privilege roles; and unknown merchants sent to a review queue, never guessed. A web app for analysis was "Phase 3", "a separate, later build" [RUNBOOK.md]. It arrived within three weeks (8982013).

The goal changed four times, and each turning point is on record.

  1. Safety before features (August). On 16 August a read of the running system found the app had no authentication at all. The seed written that day set the order: "auth is the deliverable. The fork is the tidying." [2026-08-16-authority-seed-treasury-fork-and-auth.md] The fork followed on 21 August (63bdb0f).
  2. A full personal finance application (2 September). The overnight runbook records my goal as "a fully featured personal finance application: forecasting, planning, budgeting, bank API liaison, a secure public gate for demonstration, and built-in guidance". I ruled for back-end work first and "no front-end route until needed" [2026-09-02-runbook-treasury-overnight-fanout-claude-code.md]; within two days I had asked for cross-filtering and an ingest button (e31309f, 7cd5fef).
  3. A planning tool that pushes to the bank (3 September). "the north star is to use our local treasury as planning tool with a push to bank feature going forward" [2026-09-03-scoping-treasury-planning-and-push.md].
  4. One place for my money, and the power to act on it (21 September). old bank, already recorded closed on 3 September (9ac88ec), was archived, and I stated "bank is my sole financial service". The app was reorganised around accounts, and I ruled that treasury may propose pot moves for me to confirm [2026-09-21-runbook-treasury-accounts-and-integration.md] (fa0a383, 1a2f11a).

Alongside these, groceries grew into their own goal in September: to see when to shop next, so that regular deliveries replace expensive takeaway orders [2026-09-11-runbook-treasury-grocery-cadence.md].

Timeline#

Stage 1. FALKEN: the pipeline and the dashboard (3 to 27 July 2026, 4358707 to 85218be)#

The first commit carried a schema, database roles, two ETL pipelines (bank statements and receipts), systemd units and documentation in 25 files. It records a dry run against real data in which 37 of 37 statements and 19 of 19 receipts reconciled (4358707). The runbook designed a drop-folder pattern with two daily systemd timers [RUNBOOK.md]; they were never installed (see "A pipeline that existed only on paper").

The project shared its home with the exam app that became doe. Two commits on 13 July, a curricula schema and 212 seeded concepts, are exam-app work that sat in this repository until they were removed as fork residue in September (adec225, 61487e3, d77c098). The log does not say how they came to be committed here.

On 21 July 8982013 added "dashboard route v2" and 455fd82 put the web app behind gunicorn and systemd. On 27 July 85218be added a category donut and a top-10 merchants table, checked byte-identical against the copies already running on CT120: it recorded deployed state rather than introducing it. The log is then silent until 21 August.

Stage 2. The fork: authentication and its own container (16 to 26 August 2026, 63bdb0f)#

On 16 August a read of the running system found no authentication on any route (see What went wrong). The seed written that day called enabling the public route "the most dangerous single action in this project" and records my ruling (D1) that auth lives in the app, because Pangolin SSO is for infrastructure access, not applications [2026-08-16-authority-seed-treasury-fork-and-auth.md].

One commit, 63bdb0f on 21 August, did the work. It moved the service from CT120 to CT135 (treasury-prod). The finance database was restored with identical row counts across 12 tables, on a cluster rebuilt as UTF8 because the default SQL_ASCII "would have mangled non-ASCII merchant names silently". Authentication was ported from doe: 13 routes gated, /api/classify and /api/export admin-only, and a separate session secret, because a shared key "would make any doe session valid here, and doe has a second user who must never reach this data". The service now ran as falken_etl rather than root, and the old instance was stopped (63bdb0f). What shipped differed from the fork runbook's plan; see "Plans that did not survive contact".

The seed's changelog of 25 August confirmed the fork from the running system and noted that the gate answered a browser with raw JSON 401 instead of a redirect to /login. By 2 September the smoke recorded pages returning 302 (467eaa8); no commit in the log records that change. It also called the question of whether dropping finance from CT120 had been properly gated "not recoverable"; a day later a doe register dated 24 August answered it, with identical row counts, a content hash and a verified pre-drop dump. The seed keeps one trap: CT120's copy was larger on disk while holding strictly less. On 26 August I ruled that what unblocks a public route is "an admin-only gate inside the treasury app" [2026-08-16-authority-seed-treasury-fork-and-auth.md].

Stage 3. bank by CSV, and rules for the repo (24 to 27 August 2026, a94430b to 860aaa1)#

I had moved my banking to bank in July: the old bank statements stop on 13 July and the bank account opened on 20 July (8c1fda3). On 24 August a94430b added a bank CSV loader that was "INCOMPLETE BY DESIGN": its --commit path refused, because the schema had no external_id column and a re-load could not be made idempotent. It negated Money Out, because "getting this wrong inverts every total silently". Two days later a scoping note recorded that bank's official API would make the manual download step deletable, since "two CSVs are downloaded and un-ingested at the time of writing"; I parked it [2026-08-26-scoping-bank-api-ingest.md].

On 27 August 860aaa1 added an AGENTS.md to the repo. Claude Code looks for rules by walking up from where it is launched, so until then every repo, "including doe and treasury, both production", had been worked on with no rules loaded at all.

Stage 4. The first overnight run (2 September 2026, 03:29 to 05:46, 73e24b6 to 87d39f3)#

Fourteen commits, starting with smoke tests aimed at dev (73e24b6), most on night/ branches merged at 05:02 and 05:46 (c6021ea, 43724cf, 6e42de3, a106d9e, 87d39f3), follow a runbook for overnight Claude Code work written at 01:34 that morning. It confines unattended work to dev, with my premise "keep the data, re-architect around it" [2026-09-02-runbook-treasury-overnight-fanout-claude-code.md]. The commits cite night logs 000, 001, 003 and 004; the log does not say which parts of the night, if any, ran unattended.

  • 467eaa8 added a migration ledger with declared dependencies and a verify script per migration. I had promoted it "from defect to blocker" because a refresh of dev from prod would make applied state unknowable.
  • 5c06bac (migration 08) added ingest_batches, because a CSV export is a range the operator chose, not a statement the bank issued. 6af4bdc (migration 09) fixed its missing grant.
  • 60916d6 renamed FALKEN to treasury in the code, on my ruling, and left alone what a running system depends on: "rename what only a human reads; leave what a running system depends on until someone can stop it".
  • b956a31 proved the schema could be built from nothing, since I had ruled that publishing might mean creating a new prod. It failed twice on real latent bugs first.
  • 61f4e20 (migration 10) fixed grants that named the database literally, and turned up a privilege divergence left for a decision. 845fb34 wired the bank --commit path with two dedup layers: a unique hash per file, and ON CONFLICT (external_id) DO NOTHING for overlapping exports.

Stage 5. The con, and the first unit tests (2 September 2026, 16:31 to 23:41, 1500579 to 5251c96)#

That afternoon I gave Claude a grant of working authority, which the commits call the "CON GRANT". 1500579 (migrations 11 and 12) settled the privilege question (see "The least-privilege design did not survive") and added --reseal, so correcting an applied migration's heading is a recorded act rather than a weakened drift check. 1d3fe7a renamed the live roles to treasury_* and the database to treasury on both containers, rehearsed on dev first.

820a9f2 built the first ingest from bank's API around one constraint: after Strong Customer Authentication a client can read the whole history for five minutes, and then only about 90 days. So it is "a fetch-and-store RACE, not a pipeline": payloads land verbatim (migration 14), progress commits per page, and a rehearsal mode means the first live window is not the code's first run. e1ea957 added a squashed baseline, recorded as a third ledger state by migration 15, so a database can be built from nothing again, and found that the owning role had only ever been made by hand.

In the evening 8017f59 vendored Chart.js, which had sent every viewer's IP to a CDN: "a precondition for opening any public route". c4b4d42 added 105 pytest tests, run on my workstation rather than on the container holding financial data; they found a money-parsing defect at once (3a6f87f). ea45f42 corrected the README: tests/ is not in the deployed tree, so the database tests are copied to CT137 and the smokes run from Dell.

Stage 6. Accounts, transfers and an analysis layer (3 September 2026, 00:29 to 02:48, f7baa31 to 707a969)#

Real bank data arrived, with a bank current account and credit card. Migration 16 (6d5f975) made accounts a table with a declared kind, because "a system that cannot tell them apart will add a debt to a balance"; it refuses an unknown label rather than guess (a504c02). Migration 17 (8c1fda3) models a transfer as one event with two legs, matched "mutually unambiguous or nothing". 7e2e0e2 brought the app onto the estate's design language.

Migration 18 (1e0c50e) built ten analysis views so that every rule about what counts as spending lives in one place: net worth, cash flow, category and merchant spend, recurring payments and a projection. Migration 19 corrected it within minutes (see What went wrong). 93ce1da (with migration 20, category ids on the views) and 82ab659 surfaced it: an account filter, a balances strip, and a Commitments page that lists every candidate that did not qualify, with its reason. e31309f, at my request, made the charts filter the page. 707a969 added trend and seasonality after I corrected an argument for waiting for more data: "the tooling is data agnostic. build the tools on the data in place. the range of data should drive the output. not the data itself." On real data it refused the trend (r² 0.091).

Stage 7. The August statement, old bank retired, and ingest from a button (3 September 2026, 16:23 to 19:35, da965c4 to b83b233)#

I supplied the old bank statement for 15 July to 14 August, exactly the gap. The statement ETL could not run at all until four faults were fixed (da965c4). Then it reconciled, and seven old bank-to-bank transfer pairs matched the figure measured on the bank side before any old bank data existed. When I said old bank "wont feature going forward", 9ac88ec recorded it as closed on 14 August and fixed the coverage model rather than hiding the banner: "absence of transactions is not absence of data".

f4650e3 turned the bank window into a guarded procedure: "anything checkable BEFORE authenticating must be checked before authenticating". The token comes from a file that must be mode 600 or 400, never the command line [bank-window.md]. Then 7cd5fef put ingest behind a button with the phases on screen, as I asked, with job state in the database because gunicorn runs two workers. It warns that the app speaks plain HTTP, so "the public route must not be opened while this form exists without TLS in front of it". Four fixes followed within the hour, one found by reading the code and three on live attempts (b136cdc, 4a0ff54, 7abe6af, b83b233); see "The bank window, learned the hard way".

Stage 8. From records to a plan (4 September 2026, 02:02 to 02:55, 859ef59 to edc1ca4)#

Four commits delivered Phases 0 to 3 of the planning scoping [2026-09-03-scoping-treasury-planning-and-push.md]. 859ef59 promoted staged bank payloads into transactions, correcting the scoping on one point: zero-value card checks are kept, because "rejecting them here would have made two paths disagree about one row". 9499dbc read pots, read-only by construction, because "a retried transfer moves the money twice". b8f8c31 added envelopes, which must be "useful with the push feature switched off" and fill in priority order. edc1ca4 added safe-to-spend and a bills calendar in three migrations, "because the first two were wrong in ways only real data showed". The log is then silent until 9 September.

Stage 9. Bringing prod up to spec, and one classification path (9 September 2026, d392900 to 1a22d03)#

The 3 September scoping had expected prod to be dropped and rebuilt [2026-09-03-scoping-treasury-planning-and-push.md]. On 9 September I gave a new grant with the outcome "fullest functionality on production so dev can be used specifically as a development environment", and prod was brought to main in place [2026-09-09-scoping-con-treasury-prod-to-spec.md]. The scoping found a half-deploy from 6 September and a nav link edited live and never committed (d392900); see "Code, database and repo out of step". f19f88b added ops/deploy.sh, which "refuses to claim success until the container reproduces that manifest exactly".

The rest of the day unified classification across the two ingest paths. 953af43 moved merchant resolution into one module, proven identical to the old code over 647 distinct descriptions, and de13c84 wired the bank path to it. 6fa04eb (migration 33) made reclassification reversible, and 67a8f29 let a mapping be revised from the classifier page.

Stage 10. Groceries: when to shop next (11 to 13 September 2026, 721ad5f to fe758ce)#

The grocery runbook states the aim: a "regular, comfortable grocery turnover that removes takeaway apps as a consideration". Its question is "am I running out, and when should I shop next", and its rule is that "transactions answer 'how much', receipts answer 'what'" and must never be summed [2026-09-11-runbook-treasury-grocery-cadence.md]. 721ad5f added seven /api/grocery endpoints, including days of cover per staple; receipts cover only 19 of 237 supermarket transactions, so the two tiers are kept apart. bd2e874 rebuilt the page with a shared range control, 54a6b32 showed 117 transactions every panel had silently dropped, and e9d057a, 00d5e23 and 1326561 added graded corrections, doe's card layout and a shopping list.

I accepted the pages on prod on 12 September ("it looks good") [2026-09-11-runbook-treasury-grocery-cadence.md]. The same day b4b5ff5 moved the dashboard to the shared range control, because the two pages answered "3 months" with different windows, 1882d83 gave the grocery routes authenticated tests, and a figure check (d3757ab) found dev and prod disagreeing, which led to the tier marker (b312302). On 13 September I called the page "a never ending scroll torture test", and four layout commits followed, the last three on my instructions against dev (56f05f5 to fe758ce).

Stage 11. Accounts as the hub, and moving money (21 September 2026, b48f129 to 2051bfd)#

The day opened by recovering a feature deployed on 20 September that "matched no commit on any branch" (b48f129), then added an Import invoice button and bank CSV upload (9a76eaa, 65d4f0b).

That evening I wrote down the problem: the app had no plain bank view, and "we are still in UI morass mode". I ruled to extend the estate style guide with Metro's hub-and-pivot structure and Fluent 2's list-and-detail behaviour (R1), to fold Import into Accounts (R2), and never to type a CSV closing balance (R4). I stated "bank is my sole financial service", so the bank Stocks & Shares ISA, which the API cannot see, became the one stated account (R3) [2026-09-21-runbook-treasury-accounts-and-integration.md]. Claude built five phases under one grant, committed between 19:07 and 20:09 and recorded as on prod by the 20:13 hand-back: stated accounts, balance snapshots and pots on every fetch (8746f16); four hubs and a register with a running balance (fa0a383); commitments as expected rows (733db6a); a combined projection (8bf61cc); and invoices and payments that open each other (c4b7503). The hand-back was "PARTIALLY MET": my browser check between dev and prod "was not done for any phase" [2026-09-21-runbook-treasury-accounts-and-integration.md].

1a2f11a carried two more rulings. R5 archived old bank. R6 reversed the design line that treasury never moves money, "in one bounded way": treasury proposes a pot move, I confirm each one, and it executes and reads back. My reason: "i feel having you control the account via api will deliver higher accuracy and reliability". One module is the only code that can move money: one request, no retry, a unique id per attempt, limits per move and per day, and no funding of savings from an overdraft. The planned first test, a penny into a pot and back, could not run because my current account was overdrawn and that guard refused it, so I ruled "Withdraw a token amount first". At 21:24 that withdrawal was proposed and confirmed in my browser and read back exactly [2026-09-21-runbook-treasury-accounts-and-integration.md]. Dev was then refreshed from prod and so held prod's pots; f6dca33 and 2051bfd made money movement refuse unless the tier is prod, because "a token pasted on dev would move live money".

Stage 12. The register, annual bills, and the gap after a move (25 September 2026, a9b9ceb to a72d8b0)#

These are the commits co-authored by Claude Opus 5.5. I reported the Accounts page as "a scrolling disaster" when switching tiles; four commits gave every tile the same paged two-half view (a9b9ceb to 827365c), and 827365c made saving an ISA value take two presses and flag a change over 50%, which "would have caught this morning's mistyped ISA figure". b30b10e, 4bd67b9, cf82a60 and a210c0f built "Annual Frictions": one pot that pays every annual bill, with migration 41 and each bill's monthly set-aside worked out in one place. A foreign bill is priced at the latest ECB rate plus a stated 3% margin, and "no rate means no figure, never a guess" (cf82a60). That evening pot move 12 showed net worth counting a move twice until the next fetch; migrations 42 and 43 fixed it (ae0d99d, d5d2180; see "Money counted twice after a pot move"). a72d8b0, the last commit, stopped the smoke leaving test proposals in the move history.

After the log (14 to 26 September 2026)#

Three documents record work outside this repo, told below: a backup defect repaired on 18 September with a new treasury-pg-dump job in another repo [2026-09-14-defect-falken-pg-dump-retention-evicts-real-dumps.md], a JIT slowdown fixed on 26 September [2026-09-26-finding-treasury-projection-latency-is-jit.md], and a scan for leftover FALKEN names [2026-09-26-runbook-falken-name-purge.md].

Features#

What treasury does as of the last commit (a72d8b0, 25 September 2026). The middle column is where a feature arrived; later rework follows.

Getting data in, and keeping it right#

Feature Introduced Later rework
old bank PDF statement ETL with an exact balance gate 4358707 Made runnable, da965c4
supermarket receipt ETL 4358707 Import invoice button, 9a76eaa; receipt-to-payment matcher, c4b7503
bank CSV loader a94430b Commit path and dedup, 845fb34; upload from the page, 65d4f0b; no typed balance, 8746f16
bank API fetch, staged verbatim, committed per page 820a9f2 Guards and token file, f4650e3; import page, 7cd5fef, b83b233; promotion, 859ef59, b48f129
Pots and balance snapshots on every fetch 9499dbc 8746f16
Migration ledger, down scripts, squashed baseline 467eaa8, 5c06bac, e1ea957 Reseal, 1500579; cluster_wide guard, 1d3fe7a
Accounts with a declared kind; transfers as two legs 6d5f975, 8c1fda3 Stated accounts, 8746f16; archive, 1a2f11a; limits, b30b10e
One merchant-resolution step; reversible reclassification 953af43, 6fa04eb Revise a mapping, 67a8f29

Understanding and planning#

Feature Introduced Later rework
Spending dashboard 8982013 85218be, 93ce1da, e31309f, 35d765a, 1a22d03, b4b5ff5
Analysis views, Commitments page, data-quality notice 1e0c50e, 82ab659 Coverage, da965c4, 9ac88ec; trend and seasonality, 707a969
Envelopes, safe-to-spend and a bills calendar b8f8c31, edc1ca4 ISA envelopes, 8bf61cc; live balance, d5d2180
Accounts hub with a register and running balance fa0a383 Expected rows, 733db6a; one view per tile, 827365c
Annual bills through one pot, foreign bills priced from ECB rates b30b10e, 4bd67b9 cf82a60, a210c0f
Pot moves: propose, confirm, execute, read back 1a2f11a Prod-only guard, f6dca33, 2051bfd; live balance after a move, ae0d99d
Groceries: days of cover, graded corrections, shopping list 721ad5f, e9d057a, 1326561 Layouts, 00d5e23, 56f05f5 to fe758ce

Running it#

Feature Introduced Later rework
Session login on every route, own session secret 63bdb0f
Estate design language, no third-party assets 7e2e0e2, 8017f59 Light mode, 8288b08; 4.2.0, fa0a383; button variants, 40949fa
Tier marker, defaulting to "unknown" b312302
Deploy script proved by md5 manifest f19f88b
Smokes, shell migration tests, pytest suite, figure check 63bdb0f, 5c06bac, c4b4d42, d3757ab Dev by default, 73e24b6; chain test, b956a31; smoke cleans up, a72d8b0

What went wrong#

In rough order. Most were found by Claude during the work and written into the commit that fixed them; some I found by using the app.

The app had no authentication at all, and the record said otherwise. Until 21 August the web app served every route to anyone on the LAN, running as root beside doe. The estate's record described one ungated endpoint; reading app.py showed no auth code at all (63bdb0f) [2026-08-16-authority-seed-treasury-fork-and-auth.md]. (The seed counts 14 routes, the fork commit twelve open and 13 gated; the sources do not reconcile them.) Fix: a login gate on every route, its own session secret, a non-root user and its own container (63bdb0f). What changed: exposure became "a gated act, permanently". The seed's changelog then found the record carrying the closed gap as live, and two "unrecoverable" questions answered on disk: "it was asserting a negative without stating the denominator" [2026-08-16-authority-seed-treasury-fork-and-auth.md].

Plans that did not survive contact. The fork runbook planned /opt/treasury, a treasury service user, "Single shared password" with "No user table", and gunicorn bound to 127.0.0.1 [2026-08-16-runbook-treasury-fork.md]. The commit kept /opt/falken, ran as falken_etl, ported doe's user-table auth and served on the LAN; the smokes reach prod's LAN address from Dell (63bdb0f, 73e24b6). The 3 September scoping expected prod to be dropped and rebuilt, and said "The model does not execute transfers" [2026-09-03-scoping-treasury-planning-and-push.md]; prod was brought up to spec in place six days later [2026-09-09-scoping-con-treasury-prod-to-spec.md] (f19f88b), and since 21 September treasury executes pot moves I confirm (1a2f11a). Each left a document asserting a design the system no longer had.

The least-privilege design did not survive. The first runbook promised that falken_etl "cannot DELETE" and that the web app would read views only, so that "A fully compromised webapp cannot modify or trawl raw financial rows" [RUNBOOK.md]. Neither held. Migration 06 carried a default-privilege statement with no FOR ROLE clause, which gave the ETL role DELETE on future tables against 05's stated design; the overnight run found it and left it for a decision (61f4e20, 87d39f3). I ruled that the ETL role holding DELETE is intended, and migration 12's reseal corrected 05's "no DELETE" heading on the record (1500579). The web app has run as the ETL role since the fork [AGENTS.md]. The read-only treasury_webapp role was granted the analysis views on 3 September, but "The application is NOT switched over" (1e0c50e), and nothing later in the log switches it.

Tests aimed at production. Both smoke scripts hardcoded prod, so the overnight procedure would have smoked prod every night, "a §3.2 violation built into the procedure rather than introduced by a mistake". Fix: dev is the default and the target is printed first (73e24b6). Until then the only protection "has twice been someone remembering" (c6021ea).

A ledger that could fall silent. The migration ledger's first run found dev's deployed tree and database disagreeing in both directions. Its own first version wrote the ledger row in a separate psql -c call that failed after the migration had committed: exactly the state the ledger exists to prevent. Fix: migration and ledger row in one transaction (467eaa8).

Grants that never fired. Migration 08's new table was unwritable by the ETL role, and its verify said 08 was fully applied because it only checked that objects existed (6af4bdc). The cause went deeper: the only table default privilege was scoped to a role that never creates tables, so it "had never once fired", and the overnight note had described the problem "backwards" because it measured a probe database (1500579). Migration 05 named the database literally, and the chain test passed anyway (61f4e20); even the extended test passed with the fix deleted until it rebuilt the live pre-state (1500579). Fixes: migrations 09, 10 and 11; a rule that every new table grants explicitly and its verify checks the grant; and an --expect-fail mode, because "A test that cannot fail is not a test" (1500579).

A test renamed the live roles. Running the chain test on a throwaway database applied the rename migration, and role renames are cluster-wide, so it renamed the roles under the running dev app. Caught within a minute; prod untouched. Fix: a cluster_wide flag that bulk apply refuses. The squashed baseline restored the lost ability to replay history that afternoon (1d3fe7a, e1ea957).

A money parser that would have refused real exports. parse_amount accepted "1,000.00" but refused "1000.00", because its pattern came from PDF statements, so one bank CSV row of £1,000 or more would have refused the whole file. Found by the new unit suite on its first run and recorded as a strict expected failure, because changing money parsing on a live system was my call; fixed that night on my ruling and checked over 152,849 generated strings to be strictly widening (c4b4d42, 3a6f87f).

Analysis that was arithmetically right and practically misleading. The first views reported no income for July and August and marked four live direct debits "lapsed", because the old bank statements had stopped: "They have not lapsed. The statements have." (1e0c50e). Recording old bank as closed then made it warn that old bank had stopped reporting (9ac88ec). The first seasonal projection counted the season twice (707a969). Bills built from any merchant seen three times put a retailer in three times in three days and drove safe-to-spend negative by several hundred pounds, and then every live commitment vanished from the calendar because its due date had passed (edc1ca4). Each was found within minutes on real data and fixed by a follow-up migration: 19, 22, 23, 24, 25, 31 and 32. Along the way Claude ran a migration file against the live dev database instead of a scratch one, and its first statement committed: "A PROCESS FAULT, recorded rather than tidied" (707a969).

A pipeline that existed only on paper. The runbook's drop-folder timers never ran. When the August old bank statement arrived, the statement ETL could not run in four independent ways, none a code bug: a missing PDF library, a config file the ETL user could not read, a systemd unit that "could never have worked, and had never been installed", and rows that would have been invisible to every report. Nothing complained "until a real statement was presented, which is the worst moment to discover a missing dependency". The units were rewritten and the bank one proven with the real load (da965c4). The rename had already noted that they ran as root (60916d6), and on 21 September the receipt timer was found not installed on prod (9a76eaa).

The bank window, learned the hard way. Claude found by reading its own code, before I used it, that the approval signal lived in one gunicorn worker's memory, so a click would miss about half the time and waste a real window (b136cdc). Then live attempts failed: the first fetch started from bank's default date and missed five transactions (4a0ff54); four attempts said only transport failure: HTTPError, because hiding everything but the exception class to keep tokens out of messages also hid the status, and a rejected token and an unapproved one "call for opposite actions" (7abe6af); and the dialog asked for the account before the approval that listing accounts depends on (b83b233). On 9 September a poller bug left a live import at "waiting" while the window ran down; it was fixed on prod by hand first (36acb04).

Code, database and repo out of step. On 6 September the web app went to prod without the ETL and SQL it needed, so the import page returned a 500. Fifty-five files were missing, among them 17 migrations prod's ledger recorded as applied, and working memory held four false beliefs about prod, including "bank has never been ingested" [2026-09-09-scoping-con-treasury-prod-to-spec.md]. Live edits were twice left uncommitted: a nav link (d392900) and a whole feature that "matched no commit on any branch", where "the next deploy from the repo would have wiped a live feature" (b48f129). Fixes: ops/deploy.sh, which proves a deploy by md5 manifest (f19f88b), and committing live state before deploying over it. The manifest itself nearly lied: built with find, it carried 175 entries against the archive's 152, because it "tests the workstation, not the shipped artefact" [2026-09-11-runbook-treasury-grocery-cadence.md].

Two ingest paths that disagreed. The bank path shared the classifier but not the merchant step, so 117 API rows went unclassified and none reached the review queue; September stood at "35 unclassified out of 35". Fixed by one shared resolution step and a parity test (953af43, de13c84).

Defects no route test could see. A Save button pushed off the edge of its table (34cbc55), charts drawn black on black because the page never linked the chart stylesheet (65b2206), a drill-down with no way back up that Claude had introduced (35d765a), blank column headings in Firefox (a210c0f), and a header that kept the pre-move balance after a confirmed pot move (a3beb62). I found the Save button, the drill-down and the blank headings in the browser; 34cbc55 says "no test I wrote would have" caught its bug. Claude found two more before they shipped: a PostgreSQL regex whose \b means backspace, so it "ran clean and stripped nothing" (721ad5f), and a chart that ignored taps on a touchscreen (e31309f).

Tests that could not fail, and tests that failed wrongly. A shell test could pass for the wrong reason through an [ a ] || [ b ] && ok || bad chain (5c06bac); a coverage definition was circular, so "data ends when the data ends" held for every account (da965c4); a promotion check used NOT IN against a column NULL for every PDF row and would have "promoted nothing" (859ef59); a test could not tell 0 from 0.00 (b8f8c31); and a cross-check counted the same money three times and "read exactly like a real defect and was not one" (1882d83). The smoke failed three times on facts, not faults: old bank's closing date predating a new column (e058e89), a card paid to exactly zero (cf82a60), and the new tier guard (2051bfd). And migrate.py status reads run as treasury_etl showed every migration ABSENT; it must run as root [2026-09-21-runbook-treasury-accounts-and-integration.md].

Dev and prod answered differently. An independent figure check found the same 84 August transactions, with the same total, classified differently on dev and prod (d3757ab). That led to a ruling that dev must be a byte-accurate copy of prod, and so to a tier marker, because then nothing in the data tells them apart (b312302).

Money counted twice after a pot move. After the first live move on 21 September the current account tile kept the ledger figure until the next fetch, and the record called that lag "expected, not a fault" [2026-09-21-runbook-treasury-accounts-and-integration.md]. On 25 September, after move 12, the same lag was measured on prod as net worth counting the move twice, with safe-to-spend and the envelope plan significantly too high (ae0d99d, d5d2180). The earlier judgement was wrong. Migration 42 makes an account worth bank's own reading in that window, and 43 makes the allocation figure read the same balance. Meanwhile the API smoke had filled prod's move history with cancelled token test proposals, moves 2 to 11, removed by a guarded delete after a verified dump (a72d8b0).

Backups that were not there. On 14 September a survey found the weekly falken-pg-dump job still dumping a finance database on CT120 that no longer existed. Every run since 6 September had written a 20-byte empty gzip named like a real backup, which its keep-twelve retention counted, oldest first: seven more failed Sundays and on 1 November it would have started deleting the four real July dumps, with none left four weeks later. The alert went to an ntfy topic with no subscriber and a twelve-hour in-memory cache, so "detection did not work either". Treasury had no backup job of its own. The repair waited on my standing ruling not to touch CT135 while Claude Code was mid-development, until I cleared it on 18 September. The new job's first real run was rejected by its own faulty check, recorded "rather than quietly fixed" [2026-09-14-defect-falken-pg-dump-retention-evicts-real-dumps.md]. By 26 September the old units were gone [2026-09-26-runbook-falken-name-purge.md].

Documents that lagged the system. AGENTS.md named a role that no longer existed, and Claude had flagged it "in three reports without fixing it, which is the wrong order" (e99a052). The README had the database on CT120 and a rule naming Authentik, which was never used (d77c098). As supplied for this log, docs/architecture.md and docs/RUNBOOK.md still describe CT120, falken_etl and an Authentik-gated Cloudflare tunnel [architecture.md] [RUNBOOK.md].

Slow pages. Plan and Commitments took about 20 and 28 seconds because PostgreSQL was JIT-compiling every query in the stacked projection views, whose cost the planner over-estimated. With JIT off for the database they loaded in 910 ms and 1,136 ms with byte-identical responses; gunicorn's 30-second timeout had been within about a second of killing the slower one [2026-09-26-finding-treasury-projection-latency-is-jit.md].

Where it stands#

This describes the records as of 27 September 2026. Nobody has checked the running system for this document.

Running. treasury runs on CT135 (prod) and CT137 (dev), each with its own treasury database. The service runs as treasury_etl, not root. The deploy path is still /opt/falken and the env file still uses the FALKEN_ prefix, "deliberately, because renaming a live path or a secrets file for cosmetic reasons is a risk taken for no return" [AGENTS.md]. Dev is refreshed from prod on request and carries a tier marker (b312302); money moves only on prod (f6dca33, 2051bfd). Migrations run to 43 (d5d2180). Migration 41 was "Not on prod" when committed (b30b10e); dev refreshes from prod that day counted 31 tables at a210c0f and 33 at ae0d99d, which fits 41's two new tables reaching prod, but no record says so directly. JIT is off for the database since 26 September [2026-09-26-finding-treasury-projection-latency-is-jit.md].

Data and backups. bank is the only bank in use, and old bank is archived with its history kept (1a2f11a). Data arrives by bank CSV upload, by a bank API fetch I approve on my phone, and by receipt PDFs uploaded one at a time (65d4f0b, b83b233, 9a76eaa). No ETL job is scheduled on prod [2026-09-09-scoping-con-treasury-prod-to-spec.md], and the exchange-rate fetch is run by hand (4bd67b9). A treasury-pg-dump job has written real dumps since 18 September, but "no restore has been exercised", and its alerts still reach no subscriber [2026-09-14-defect-falken-pg-dump-retention-evicts-real-dumps.md].

Open problems the sources name:

  • The public route has never been enabled. When last read on 29 August its Pangolin target still named CT120, so enabling it would have produced a 502 [2026-08-16-runbook-treasury-fork.md]. Named preconditions: TLS in front of the import form (7cd5fef), a demo mode with synthetic data, "the precondition for any public gate" [2026-09-02-runbook-treasury-overnight-fanout-claude-code.md], and the admin-only gate I ruled on 26 August [2026-08-16-authority-seed-treasury-fork-and-auth.md].
  • Plaintext copies of the data: the offsite backup is plaintext PII, a standing item due 30 September [2026-09-02-runbook-treasury-overnight-fanout-claude-code.md]; eight plaintext dumps on CT137 were left out of scope on 9 September [2026-09-09-scoping-con-treasury-prod-to-spec.md]; about 90 rollback tarballs sit on the two containers [2026-09-26-runbook-falken-name-purge.md].
  • The FALKEN name purge, ranked on 26 September: first a trust rule and three unused login roles left on CT120, which holds doe's production database; then a stale copy of the old finance database on dev; then the /opt/falken paths, which wait on my 2 September ruling about CT135 being lifted [2026-09-26-runbook-falken-name-purge.md].
  • Found and not fixed on 21 September: moved bills matched by name, so two spellings of the same payee are treated as different payees; income_rhythm has no account column; safe-to-spend may double-count a bill that moved between accounts; analysis.html draws bars for money against the style guide; the "Savings at rest" envelope waits on me naming a pot; and, in my words, "the classifier is going to need an overhaul at some juncture too" [2026-09-21-runbook-treasury-accounts-and-integration.md].
  • Smaller items left: the web app still runs as the write role (1e0c50e); a CSP nonce no header uses (7e2e0e2); a JavaScript-string escaper used for HTML (67a8f29); the em-dash audit of the UI (60916d6); the "All" range floor and analysis.html's missing range parameters (b4b5ff5); migrations 01 to 07 without down scripts [README.md]; the "every line ever bought" table, which "has valuable utility but not in this format" (fe758ce); and stale documents, including a README status block dated 2 September [README.md] [architecture.md] [RUNBOOK.md].

Planned. The planning scoping keeps the north star in my words: treasury as a planning tool "with a push to bank feature going forward". It gated push on four claims to verify with "one token, one read-only session", and lists candidate features such as net worth over time, price-rise alerts and transaction search [2026-09-03-scoping-treasury-planning-and-push.md]. The records overtook it: bounded push shipped on 21 September, with a live move and a /balance read that day (1a2f11a) [2026-09-21-runbook-treasury-accounts-and-integration.md], and the records do not show the scoping updated to say which claims that settled. A rebuild-from-disaster procedure is still owed, with ops/deploy.sh as its first leg (f19f88b).

What I'd tell someone building one#

  1. Read the running system before you trust the record. The record said one endpoint was ungated when none were gated (63bdb0f), and working memory held four false beliefs about prod [2026-09-09-scoping-con-treasury-prod-to-spec.md].
  2. Record what has been applied, and never edit what has run. The ledger turned migration 08's missing grant into a new migration 09, not an edit, and a heading correction into a recorded reseal (6af4bdc, 1500579).
  3. A test that cannot fail is not a test. The chain test passed with the bug it was meant to catch (61f4e20, 1500579).
  4. Make the safe choice the default. Smoke tests default to dev (73e24b6). An unset tier marker reads "unknown", not "prod" (b312302). Money moves only on prod (f6dca33).
  5. Know the blast radius of what you run. A test pointed at a throwaway database renamed live roles, because role renames are cluster-wide (1d3fe7a).
  6. Keep one copy of each rule. Two ingest paths drifted on classification (953af43); two pages answered "3 months" differently (b4b5ff5). Each fix put the rule in one place.
  7. Missing data is not zero. Income did not stop and direct debits did not lapse; the statements stopped (1e0c50e). "Absence of transactions is not absence of data" (9ac88ec).
  8. Tooling that has never run does not exist. The PDF pipeline "existed only on paper" (da965c4), and the backup job had been writing empty files for weeks [2026-09-14-defect-falken-pg-dump-retention-evicts-real-dumps.md].
  9. Deploy from commits, and prove it. A half-deploy and uncommitted live edits nearly wiped a feature (f19f88b, b48f129).
  10. When the real data contradicts a judgement, reverse it on the record. The pot-move lag was "expected, not a fault" on 21 September [2026-09-21-runbook-treasury-accounts-and-integration.md] and a double count on 25 September (ae0d99d); the least-privilege design gave way to rulings (1500579, 1e0c50e).

Sources#

Git history: gitlog.txt, all 115 commits, from 4358707 (3 July 2026) to a72d8b0 (25 September 2026).

Repo documents: [AGENTS.md], [README.md], [RUNBOOK.md], [architecture.md], [bank-window.md].

Planning documents (prj/): [2026-08-16-authority-seed-treasury-fork-and-auth.md], [2026-08-16-runbook-treasury-fork.md], [2026-08-26-scoping-bank-api-ingest.md], [2026-09-02-runbook-treasury-overnight-fanout-claude-code.md], [2026-09-03-scoping-treasury-planning-and-push.md], [2026-09-09-scoping-con-treasury-prod-to-spec.md], [2026-09-11-runbook-treasury-grocery-cadence.md], [2026-09-14-defect-falken-pg-dump-retention-evicts-real-dumps.md], [2026-09-17-scoping-falken-name-purge.md], [2026-09-21-runbook-treasury-accounts-and-integration.md], [2026-09-26-finding-treasury-projection-latency-is-jit.md], [2026-09-26-runbook-falken-name-purge.md].

Gaps in the record: the log is silent from 27 July to 21 August, from 4 to 9 September and from 13 to 21 September, and in two of those gaps code was deployed without being committed. Documents the commits cite but that are not among these sources, so this log does not draw on them: the 000-LOG night and con logs, the privilege-divergence note, the classification scoping of 9 September, and the byte-accurate dev ruling of 12 September.