Repository navigation
v0.1.6
This release upgrades cleanly from 0.1.5. A deployment starts on the
configuration it already has, every path that answered still answers, and no
client loses a capability it had. Five changes in an earlier draft broke that
and were reverted to warnings, kept precedences and deprecated aliases;
AGENTS.md now carries the rule so the next release does not have to
rediscover it.
Twenty-seven security advisories are closed by this release — eleven rated
high, sixteen medium — across fast-uri, js-yaml, nodemailer, hono and
qs. Every one of them is a dependency this project pulls in rather than code
it wrote, and every one is now at or past the version that patches it. The
container's Node base image also moves to the current 24-alpine build, which
carries the Alpine and Node patches released since the digest this project had
pinned.
One change is a judgement call rather than a clean pass, and it is named here
rather than left to be discovered. Every MCP tool now declares a closed argument
object, where 57 of the 71 were open. An agent sending an argument nobody
declared used to have it dropped in silence and now gets an error naming it.
Nothing an agent could successfully do before is impossible now — the dropped
argument never had any effect — but a call that returned success will return a
failure, and that is worth knowing before upgrading. It is the whole point of
the change: an open object accepts a hallucinated argument, answers success, and
teaches the model that the argument works.
Added
A category can be put in a group from the categories list. The control
existed, in a modal behind an unlabelled pencil, and no row ever said which group
a category was already in — so a page with a Groups panel showing "0 categories"
and no way to change it read as a feature that does not work. Every row now
carries its group and changes it in place, the add form files a new category
straight away, and a failed read of the groups says so instead of claiming there
are none.
The forecast can project from what a ledger actually does. "What happens
next" counted dated recurrences and nothing else, so a household with months of
real spending and no recurrences saw $0.00 in both money columns with nothing
saying why. A third basis — "Recurring plus what you usually spend" — adds the
average of recent finished periods per category, less whatever a recurrence
already covers, and reports that part separately as typicalSpending and
typicalIncome so a reader can always tell an inferred figure from a scheduled
one. The current period is never part of its own average, matching the rule
trailing_average follows on the budgets side. The browser defaults to it; the
wire default is unchanged, so no existing client's answer moves.
The overview says where the budget stands over the range it is showing, under
Accounts and Spending by category, one row per period the report covers rather
than a total the server never computes — and under it, what the period is made
of: every group and every category you have budgeted, each with what it spent
against what it may actually spend, a bar, and a word for the state it is in. A
budget with nothing spent against it yet is shown like any other, because a
budget is what you set rather than what you spent. A category with no budget is
left out; the panel above already reports that spending, and "£100.00 of —" is
not a budget. Groups are badged the way the budgets page badges them, so a group
budgeted as the sum of its categories is not read as a figure to add to them.
Only the period the range ends in is broken down, which is what keeps a
year-long range from putting twelve expanded periods on a page whose job is a
glance. And the categories in Spending by category are links to the
category, carrying the date range with them.
A long commit and a long import say how far along they are. Committing fifty
staged rows or more, or staging a CSV of fifty rows or more, now draws a
determinate bar counting the rows as the server works through them — validated,
compared, posted for a commit; staged for an import — with the phase and its
true figures in the sentence beside it. Several thousand rows is a minute or
more, and until now that minute looked identical to nothing happening.
There is nowhere else that progress could have come from. Both writes happen
inside one transaction, so a row written to a progress table would be invisible
to every other connection until the whole thing committed, and a count held in
one process cannot be polled by a deployment running two. So the response
reports its own progress: the browser asks with Accept: text/event-stream and
gets Server-Sent Events, ending in a terminal frame carrying byte for byte what
the JSON reply would have. A caller that does not ask sees no change at all —
same status, same body, same headers — which includes every MCP client, since
the agent transport answers in a single JSON object and has no channel a
progress notification could travel on. The transaction is untouched: no query
was added, moved or removed, and a refusal partway through still rolls
everything back and now says so on screen.
Operators running their own reverse proxy in front of a single container should
add proxy_buffering off; and proxy_read_timeout 600s; to it — the snippet in
docs/deployment.md now has both, and the images this repository ships already
did. Without them a long commit was already being cut off at sixty seconds; with
them the progress arrives as it happens rather than in one jump at the end.
Clone transaction, on the row menu of both the transactions list and the
staged queue. The copy opens the staging form prefilled and lands on Staged for
review rather than in the books, minus the original's bank reference — a copy
carrying it would be swallowed by the next import as already-seen.
The staged queue edits in place. A row's date, payee, category and amount
turn into editors where they are — the full form's own editors, with the same
server checks and the same optimistic versioning — so repairing an import is a
click and a keystroke instead of a trip through the modal. A split's category
and amount keep the modal, where the legs they depend on are visible, and a
transfer's category cell stays read-only because a transfer carries none.
A category can be excluded from the categories report, from its row's
menu, because one outsized category flattens every other line in the chart.
The footer re-adds from what is on screen, exactly; pills name what is left
out and put it back; nothing stored changes, and agents reading over MCP see
every category either way.
The application says what it is doing, in Prometheus' text format, at
GET /metrics. It is off until METRICS_ENABLED=true, and then it is
registered rather than refusing, so a deployment that never asked has no such
route at all. Both entrypoints answer: the API reports requests by route and
status, MCP tool calls by tool, ledger writes by kind, idempotent replays, its
connection pool and how long a transaction holds a connection; the scheduler
reports ticks, proposals, reminder sweeps and mail. component="api" or
component="scheduler" sits on every series, so a split deployment scrapes both
and the two never collide. Node's own heap, event-loop lag and garbage
collection come with it.
Nothing in a metric names a person. No label carries a user, an email, an
account or an amount, and a path with an id in it is counted under its route
pattern, so a ledger with ten thousand transactions is one time series rather
than ten thousand. What a scrape does say is how much a deployment is doing,
which is what METRICS_TOKEN is for: set it and the endpoint answers only a
request carrying Authorization: Bearer, leave it unset behind a private
network and a scraper needs no configuration at all. The bundled frontend does
not proxy /metrics, so the browser's own hostname never exposes it, and
turning it on in production without a token says so once in the log.
The Helm chart carries config.metrics.enabled and secret.metricsToken, and
docs/deployment.md has a scrape config for the two containers.
LOG_LEVEL now governs this product's own log lines. It reached exactly one
consumer before — Better Auth's logger — while the thirty-one console calls in
src/server ignored it, so a deployment asking for error still got the
startup banner, the mail notice and the scheduler's warnings. error is quiet
now, and is never itself silenced. The three places that warn about
configuration keep writing directly, because the gate has to read the
configuration to know the level and a warning about a setting cannot be gated by
one. Two more sat outside it in a shape nothing was looking for: the graceful
shutdown and the recurrence scheduler took logger = console as a default
parameter, so "SIGTERM received, shutting down" printed at every level including
the one chosen to silence it.
The log now says what the product is doing, and not only that it started.
debug adds a line per HTTP request, per MCP tool call and per message handed
to the relay; info gains a line per scheduler tick that proposed a row or sent
a reminder, and a tick that found nothing due drops to debug. The gap that
closes is a deployment with /metrics off — the default — where a scheduler
that stopped ticking a week ago produced exactly the log of one that was ticking
every five minutes.
None of those lines carries somebody's ledger. A request names its path and
never its query string, a tool call names the tool and never its arguments, a
message names what it was and never who it went to, and a failing query names
the statement and never the values bound into it. That last one was already true
of the HTTP path and was not true of the MCP path, which logged the error whole
— including, on a database hiccup during token exchange, a live access token.
Seven secrets can be read from a file instead of the environment:
AUTH_SECRET, DATABASE_URL, DIRECT_DATABASE_URL, SMTP_PASSWORD,
GOOGLE_CLIENT_SECRET, SETUP_TOKEN and METRICS_TOKEN. Point NAME_FILE at a file whose contents are the value, and
the value never enters the process environment, so nothing that dumps an
environment can show it.
Set one of NAME and NAME_FILE. Both set is not an error — the environment
variable wins, which is what happened when NAME_FILE did nothing at all — but
it warns and names the file being ignored, because a change to that file will
look like it worked and will not have.
The bundled Helm chart and compose file still pass all seven as environment
variables; docs/deployment.md says what using the file form on either takes.
Budgets. A limit per category per period, compared against what was actually
spent, with nothing in it that writes a posting.
A budget is a standing instruction rather than a row per month. Both ends of its
window are snapped to the period, so any day inside a month names that whole
month and a budget set today applies today. One plan covers every period in its
window, so a budget that runs all year is one row and the
months nobody has reached yet are not materialised by anything. Setting an
amount for a single period overrides the plan for that period alone, and the
report says which of the two produced each figure. Windows for one category may
not overlap, which is what keeps last March answering with what last March
intended when the budget is raised in July.
A budget report shows whole periods. Every other report clips a bucket to the
range asked for, and a budget must not: a limit belongs to a whole period, so
weighing it against part of one reads as money still to spend when the month is
already overspent. A range chooses which periods to show. The period still
running is marked as such, because its spending is a total so far.
The comparison runs on the same date_trunc grid the reports bucket by, so a
limit and its spending cannot land on different months, and it joins from the
budget to the spending rather than the other way, so a category budgeted at two
hundred and spent nothing on reads as nought of two hundred instead of
disappearing. Splits attribute each leg to its own category and transfers
contribute nothing, both because legs are postings rather than because anything
special was written for them.
Deleting a budget leaves the books exactly as they were, because it never
touched them.
A budget can carry what a period did not spend into the next one, which is
the whole of envelope budgeting, and it carries an overspend forward too, as a
debt against the next period rather than something the calendar forgives. A cap
holds the carry inside a number in both directions. Nothing is stored per
period: the carried figures are folded at read time from the same plans, entries
and postings every other figure comes from, so turning it off leaves nothing
behind and a back-dated correction changes every period after it. The fold
reaches back to the budget's own start, up to ten years of months, and a report
that stopped at the bound says so rather than reporting a carry that began from
nothing part way through.
A budget can be saving up for something, which is the same machinery with a
target and a date: each period puts aside what is still needed divided by the
periods left, the figure adjusts as the fund fills, and it asks for nothing once
it is full. There is no amount to type and no method to pick — a budget with a
target and a date is a sinking fund because of what it says.
A budget can work out its own amount three more ways: the average of what
the last few finished periods actually spent, the previous period's amount plus
a percentage, or a share of the income that arrived in the period before. Each
is named by the parameter it needs rather than by a method somebody picks, and
naming two at once is refused. An amount set for a single period still beats all
of them.
A forecast. GET /api/v1/forecast and get_forecast project the balances
forward from the recurrences that already have dates and amounts, period by
period, from what the accounts hold today. Nothing it returns is a balance:
money dated in the future has not moved, and a test holds that boundary rather
than a paragraph — nothing outside the two transports may import the forecast,
the service writes nothing, and a projected figure is named as one. Budgets are
reported beside the projection and added to it only where a recurrence does not
already cover them, so the rent is never counted twice, and a recurrence with no
amount is named rather than counted as nothing.
A budget that carries is an envelope, and the report says what is left to
assign: the money in the accounts the budget is about, less what every
envelope with money still in it has claimed. An account can be taken out of that
perimeter with inBudget, which changes no balance and no report; cards are in
it by default, because spending on a card empties an envelope while no cash has
moved. An overspent envelope claims nothing, since the money has already left.
Categories can be grouped, one level deep. A group either holds a budget of
its own or is whatever its categories' budgets add up to, and which of the two
is declared when the group is made — there is no default, because both are
defensible and the wrong one silently makes every figure on the page wrong in
the same direction. A group's line is reported beside the category rows rather
than among them, so no total counts the same money twice, and deleting a group
leaves every category where it was.
And when a period's income will not cover everything, a funding order says
what comes first. Lower goes first; anything unranked is funded last. The
report then shows how much of each budget the income covers and what is left
unfunded. A ledger that never ranked anything is told none of this, rather than
being told its budgets are unfunded because income happened to land in another
period.
Changed
Every page now has one vertical rhythm, and one place that decides it. The
distance between two sections of a page was a margin on whichever block happened
to be there — eight different numbers across the app, and none at all on the
five blocks that most often sit at page level, so four stacked panels on Budgets
touched and Categories ran four different gaps in one screen. The page container
supplies a single 24px gap now and no section carries a spacing opinion of its
own. docs/standards/web.md §7.4 has the rule and tests/page-stack.test.ts
holds it.
A page's buttons sit in the same place on every page. Transactions had none
in its header at all: its two buttons were a body element dragged up into the
header band by a hardcoded -48px, with a second hand-tuned constant for the
four detail pages that embed the same list. And the header bottom-aligned its
actions against the whole text block, so a two-line description pushed them a
line lower — the entire difference between Recurring and Templates. The header
is two rows now, actions beside the title, and both negative margins are gone.
The Budgets page reads in the order the work happens, and then in time
order: set a budget, the standing budgets that result, the single-period
exceptions to them, the period that is running, then what happens next. An
earlier arrangement put the projection above the month somebody is actually in,
on the argument that the period tables are the longest and belong last. Length
is the wrong reason to order a page: it meant the first figures under the
budgets were ones that have not happened yet, with the month being spent below
them.
Settings is two balanced columns. Three cards shared the narrow one while
Sign-in methods had the wide one to itself, which left a stretch of nothing under
it and stretched single-column password fields across seven hundred pixels. The
sign-in card also did four jobs; the password form is its own card now, with the
note about forgotten passwords back beside it instead of below a Google button.
Templates' Type filter lines up with the search box beside it. It was the one
filter in the app wrapped in a form Field, which stacked a label above it and
made it twenty pixels taller. Recurring has gained the same filter, and both
search boxes have gained the magnifying glass whose space they were already
reserving.
Two accessibility failures the guides had recorded and nobody had closed.
Every table that scrolls sideways is now reachable from the keyboard —
tabIndex={0} with a role and a name on all twelve containers, where before not
one was a tab stop and the far columns of a narrow table could not be reached at
all. And the four tables that named their row in a <td> now use
<th scope="row">, so a row is announced with the thing that identifies it.
Alongside them: every loading region says what it is loading, the categories list
distinguishes "none yet" from "none matching", and the enabled state of a
mass-edit field takes the stronger green a control edge is held to.
A numeric setting outside its range says so at startup instead of falling back
in silence. CSV_MAX_ROWS, CSV_MAX_BYTES, RECURRENCE_TICK_SECONDS,
RECURRENCE_CATCH_UP_LIMIT, RECURRENCE_CLAIM_LIMIT and DATABASE_POOL_SIZE
are all read once as the process comes up, and one that cannot be used is named
in the log with the value it could not use and the number in force instead.
They still fall back. A deployment that meant CSV_MAX_ROWS=1000 and typed
1O00 was importing ten thousand rows and being told nothing, and the silence
was the defect — not the fallback. Refusing would have meant a typo in a tuning
knob taking a ledger offline, on a value the previous release accepted, and
nobody types a cap wrong and wants their accounts down for it.
DATABASE_POOL_SIZE used to refuse on its own and now falls back with the rest,
which accepts strictly more than before.
The scheduler container checks its mail server at startup, which the API already
did. It is the process that sends every reminder and proposal notice, and nobody
is waiting for one of those, so a relay refusing it failed silently and
indefinitely; it now logs the address it will be sending as, or logs the refusal
and goes on proposing. A scheduler with no mail configured says that in one line
too, because a container that was never handed the SMTP settings and one whose
relay answers look identical in a log that says nothing, and a split deployment
assembled by hand is exactly where that happens. Neither line stops it starting:
mail is optional and the schedule is not.
All four images now record the digest of the base they were built on, not only
its tag. org.opencontainers.image.base.digest sits beside base.name, and
every FROM naming a registry image is pinned by digest as well as tag, so the
build is reproducible and the label cannot name a base the image was not built
on. Dependabot watches the bases now, so a pin is raised deliberately rather
than freezing on whatever patches its base had the day it was typed. The runtime
stages still apply the distribution's own updates on every build, so pinned is
not the same as unpatched.
A refund now lowers the category it came back from, instead of raising income.
A deposit credits income and a withdrawal debits expense only when no category
contradicts it. A category whose kind runs against the direction makes the entry
a refund, and its other half posts to the counter-account the direction would
never have asked for. Thirty pounds back from the shop was previously refused
outright with "Choose an income category for a deposit", so there was no way to
enter one at all, and a spending figure could only ever go up.
A draft may say categoryKind alongside categoryName, which is how a refund
into a spending category that does not exist yet is recorded at all: without it
a deposit creates an income category and credits that, and the spending it was
reversing never moves. The transaction form asks the question whenever a name
with nothing behind it is typed — "money you earned" against "a refund of money
you spent" — and stays quiet when the category already exists or the picker is
empty, because there is nothing to decide. A CSV import that may only stage carries the same field
on the staged row, so the kind is the file's decision rather than whichever row
happened to commit first.
A CSV file's rows vote on the kind of a category the file creates: whichever
direction most of them run is what the category is, and a tie is spending. It
used to be created covering both directions when a file held a purchase and its
refund, which is the same defect one layer along, because a category covering
both agrees with whichever direction it is handed.
Naming a category rather than citing its id follows the same rule. A deposit
naming "Groceries" used to widen Groceries to cover both directions, and a
category covering both agrees with whichever direction it is handed, so the
refund credited income, the budget never moved, and every later refund into that
category was broken too. A category running against the direction is now kept as
it is, because that pairing is a reversal rather than an ambiguity. Two rows in
one CSV naming a category nobody has created yet still make one that covers
both, because there is no existing answer to preserve.
One entry may not name both an income and an expense category, because two
counter-accounts would be two movements and only one of them is the one somebody
entered. A bulk edit refuses to turn rows into refunds for the same reason it
refuses to flatten a split: not because it cannot be done, but because it cannot
be done to rows nobody looked at.
Nothing in the reports needed changing. Both counter-accounts already segment as
operating in the cash flow statement, and spending by category already sums
signed postings, so a refund lands correctly without any figure being taught
what a refund is.
Four API paths were renamed to the conventions the rest of them follow.
POST /api/v1/accounts/{id}/archive and POST /api/v1/categories/{id}/archive
are now .../archived, because they take {"archived": boolean} and that is a
state rather than a verb, the way POST /api/v1/transactions/{id}/deleted
already was. POST /api/v1/staged-transactions/delete is now
.../bulk-delete, which is how the same operation over committed transactions
has always been spelled; the two remain two routes, because one voids entries by
posting their reversal and the other removes rows that never posted.
GET /api/v1/staged/{id}/duplicate is now
GET /api/v1/staged-transactions/{id}/duplicate, since there is no staged
collection anywhere else. The MCP tools keep their names.
All four old paths still answer, on the same handlers, marked Deprecation
with a Sunset date. The first version of this renamed them outright, on the
argument that /api/v1 is cookie-only and same-origin so the only client that
could be calling them ships in this image — which is true of this image and not
of the one already running. A browser tab left open across the upgrade is
serving the previous build, and would have met a 404 on the first archive
somebody attempted, with nothing to tell it apart from a bug.
Committing or deleting staged transactions now refuses a selection that leaves
out the version for one of its own rows, and says which row, instead of
reporting it as a version conflict on a row nothing had changed. A repeated id
in the same selection is refused as a duplicate rather than reported as a row
that could not be found. Over MCP both requests also refuse an unrecognised
field rather than dropping it — a body typing expectedVersion where the field
is expectedVersions is refused by name — and over HTTP they still drop it, as
they did in 0.1.5. Tightening the HTTP side was in an earlier draft of this
release and was taken back out: a client that has been sending a stray field
since 0.1.5 keeps working, and the release that refuses it is a later one.
Both discovery documents still advertise all seven scopes. An earlier draft of
this release narrowed the protected-resource document to
openid profile email offline_access ledger:read, on the argument that a client
builds its authorization request from that list and every scope in it is one
more thing somebody is asked to approve before there is anything to approve it
for. That argument still stands and the change does not: a client that read the
document under 0.1.5 and asked for ledger:write would have found the scope it
already holds missing from the list it builds its request from, which is a
capability narrowing on an upgrade. It comes back in a release that can
deprecate it first.
A version conflict reaching an agent now says to read the record again and retry
with the version it reports, rather than to refresh and try again. An agent has
nothing to refresh. The browser keeps its own wording, and the two sentences say
the same thing happened.
The repository documents how it is written, in two sets under docs/standards/.
One describes the interfaces — the browser app, the MCP surface, the HTTP API,
the CSV format and the container — and the other the source. Every rule in the
second says who enforces it: the compiler, the linter, a named test, or nobody
at all, and the rules in that last group are counted on the index page so the
number is visible and can be argued down. Seven of them became tests in the pass
that followed writing them.
Used idempotency keys can be pruned, and are not pruned unless you ask.
Every create, commit and bulk write stores a copy of its response so a retried
request answers the same way twice, and nothing removed those copies — on a busy
deployment that table outgrows the ledger it protects.
IDEMPOTENCY_RETENTION_HOURS sets a window and the scheduler's existing tick
enforces it. It defaults to zero, which means forever, so nothing about your
data changes on upgrade and the number stays an operator's to pick rather than
one this release imposes.
Setting it is safe because the record makes a retry quiet rather than safe: a
repeated create still meets the duplicate check, a repeated commit finds its
rows already committed, and a repeated bulk write still carries a count and
fingerprint that no longer match the set. The sweep removes a bounded batch per
pass, so a first sweep after a year of records drains over a few ticks instead
of locking the table, and it reads by age through an index added for it.
A greyed-out button says why it is greyed out. Eight submit and merge
controls are disabled until the form is ready and one of them had a sentence
beside it. It is the one control that can go completely silent: nothing has been
typed wrongly, so there is no field error, and nothing has been submitted, so
there is no summary — the button is grey and you guess which of the form's
conditions is unmet. Each now carries a sentence under it, wired so a screen
reader hears it as part of the button rather than as text somewhere nearby, and
it names the first thing to fix rather than everything outstanding.
A line chart no longer relies on colour alone. Ten account colours cannot
all be told apart under colour-blind vision — the palette here is the best
available set and is measured at three times the separation of the six it
replaced, which is still not enough by itself. Nine of the ten line series now
carry their own dash rhythm, and the legend swatch beside each one shows the
same rhythm rather than a block of colour, so a line can be matched to its
label by shape. The first series stays solid, which is what a single-line chart
should look like.
What an agent is told about four tools it can call.
list_transactions was 54 characters on the entry point to the biggest
collection in the ledger, saying nothing about the order it comes back in, the
page size, or that a cursor is refused once the ordering or the filters it was
issued for have changed. list_accounts did not say that a balance counts
future-dated postings, which is the one thing about it an agent would otherwise
get wrong. And the two bulk-selection previews described their count-and-
fingerprint pair as something that goes stale, which reads as a race to beat —
it is not: the write re-resolves the filter and compares, so the pair stops
matching the moment the set changes and there is no window at all.
Two tools now say in words what the protocol has no field for. An MCP
annotation can say a tool is destructive and cannot say whether it can be
undone, which are different decisions for whoever approves the call. Merging
categories or payees collapses rows into one and there is nothing to unpick, so
both say so. The four-item list this started from was wrong, and the code caught
it: deleting transactions in bulk already said it posts a reversal that can be
undone, and revoking an agent already said it can be authorised again.
And a write is counted when it commits. Every MCP write hands the service a
transaction the transport opened, so the ledger_writes_total increment
happened while there was still an idempotency record to write and a commit to
survive — a count that could stand for a write that then rolled back, in a
figure that names the books rather than the traffic.
Six smaller things a person would meet. An empty state always has its icon
now — three of the sixteen had none, which left a heading and a sentence
floating in a card, reading as a page that failed to load rather than one that
answered — and its heading takes the level the document needs rather than always
<h3>. A right-aligned column header gets the same tabular figures its cells
get, so a header row of period totals on Reports and Budgets lines up with the
column beneath it instead of drifting a pixel per digit. Full-height layouts
measure the viewport somebody actually has, which matters most in a modal: a
tall form's last field and its submit button used to sit below the fold on a
phone until the browser toolbar hid itself. And the generic muted paragraph is a
Note component rather than a class called .settings-note used on eight files,
none of them Settings; the stacked panel it sat in got the same treatment.
Every field's label, hint and error now reach the control they are about.
They were all on screen and none of them was connected: a label associated by
wrapping rather than by name, a hint rendered after the control with nothing
pointing at it, and no error slot at all — no aria-invalid anywhere in the
app. So a screen reader read a box with a name and no explanation of what to
type, and a field that was wrong said so in colour and in nothing else. A field
that is wrong now says so in three places that agree: the sentence, the control
marked invalid, and the control pointing at the sentence.
One visible consequence: a hint sits above its control now, not below it.
That is GOV.UK's order, and it is also what fixes the deeper problem — a name
computed from a label is that label's whole text content, so a hint inside the
label was becoming part of the control's name ("Amount Up to eighteen decimal
places") instead of its description. The old markup got away with it only
because the hint was not associated at all.
And a split's categories are all named. <Field label="Category"> wrapped
up to fifty rows of three inputs, and a label around that binds to the first of
them — so the first leg borrowed the label and every leg after it had no
accessible name, while the amount and note boxes in the same row had one. The
field is a labelled group now and each picker names itself: "Category for split
2", and so on.
A keyboard user can get past the navigation, and stays where they were.
Four holes, each the same shape: something moved or vanished and focus was left
behind, so the next Tab started at the top of the document — past eleven
navigation links — to get back to a list somebody was in the middle of.
There is a skip link now, first in the tab order, and it lands in the main
region rather than merely scrolling to it. Following a link moves focus to
the page it opened and resets the scroll, where pushState used to do neither —
only on a real navigation, so changing a filter or a sort leaves focus in the
control you are using. Finishing a bulk action puts focus on the sentence
saying what happened, because the button you pressed is inside the selection bar
and the bar goes away with it. And the mobile navigation drawer is a drawer:
the page behind it is inert, so Tab no longer walks it through the scrim,
Escape closes it, focus moves in when it opens and back to the menu button when
it closes — and it closes itself if you widen the window, which used to leave a
scrim over a page with no way to dismiss it.
A pagination cursor is signed. It was base64url of plain JSON, which is
exactly the case AIP-158 names as insufficient obfuscation: a caller could read
it, and worse, build one — and would then be building against an encoding
nothing in this product promises to keep. Every nextCursor now carries an
HMAC keyed to a subkey of your AUTH_SECRET, so a hand-built cursor is refused
rather than merely validated. Signed and not encrypted, deliberately: the
contents are a boundary value and a row id the caller already holds, so there is
nothing to hide, and a scheme a reader can check beats one they have to trust.
And it binds the filters it was issued under. A cursor bound its ordering
and nothing else, so paging through Transactions and changing the search or the
account between pages resumed the walk inside a different collection — rows
from a query nobody asked for, with the row count reporting the truth of the new
collection, so nothing on screen said anything had gone wrong. Every cursor now
carries a fingerprint of the filters, and one that no longer matches is refused
with "This cursor was issued for a different set of filters. Start again from
the first page." Changing the sort order still gets its own sentence, because a
message about the sort when the sort has not moved sends you looking in the
wrong place. Changing the page size does not invalidate anything.
Nothing you do changes, and a cursor 0.1.5 issued still works. A cursor is
held rather than stored — a browser tab keeps one in component state, an agent
may send one back minutes later — so a rolling deploy has a window where a
working client legitimately holds an old one, and refusing it would narrow that
client's pagination. The unsigned form is read and never issued, and stops being
read on 1 March 2027, the same date the four renamed paths stop answering.
Two consequences: replacing AUTH_SECRET invalidates outstanding cursors along
with every session, which is the same thing a sign-out already does to whoever
is mid-list; and every replica needs the same AUTH_SECRET, which was already
true. It costs about seven microseconds to sign and seven to verify, against a
page read that costs milliseconds.
An agent loads 12% less to learn what this server can do. Every id on the
MCP surface was published as "format":"uuid" and, beside it, a 166-character
regular expression saying the same thing — 352 copies of it, 58,432 characters,
11% of the whole tools/list payload. The regex bought nothing: a client that
cannot read format: "uuid" cannot use this API anyway, since every id it will
ever hold came from a list or a create. One shared schema drops it and leaves
the check alone, so a value that is not a UUID is still refused. The write tier
went from 534,682 characters to 471,674 and the read tier from 191,414 to
166,712 — about sixteen thousand tokens back at the write tier, on every
connection.
The words on screen now match the ones written down. web.md §16 and
common.md settle the voice and neither was checked, so the product had drifted
from both in eleven places. "Invalid" is banned outright — it describes the rule
rather than the input — and it shipped seven times, from the CSV preview's
"Amount has invalid decimal or thousands separators" to a setup code the sign-up
screen called invalid to somebody who had just copied it out of the server log.
A button leads with a verb and takes an object, and two shipped as a bare
"Save". The bulk-action bar said "Mass edit" on Transactions and "Edit selected"
on the other two screens, and "Clear" on one where the others said "Clear
selection". Two pages carried an eyebrow repeating their own title. And a staged
row on the transactions list showed a dash where its category should be, so a
row read as uncategorised on one page and categorised on the next.
common.md's table of worked error sentences is now the sentences the product
actually says. Six of its thirteen rows named messages that were nowhere in the
source, which makes a reference into a wish: the next person writes a fourteenth
message rather than reusing one of the thirteen. Three situations it listed have
no message on purpose, and it now says which and why — an empty required field is
the browser's own sentence in the person's own language, an idempotent replay
succeeded so there is nothing to report, and a row belonging to somebody else is
"Account not found" rather than a bare "Not found".
Four additive answers on the HTTP API, none of which changes an existing
one. A malformed request body is now MALFORMED_BODY at 400 rather than
VALIDATION_ERROR, which meant both 400 and 422 and so carried information the
code did not. A 429 carries Retry-After, including on the sign-in routes,
where the library's own non-standard header is mirrored rather than replaced. A
201 carries Location. And both health routes on both processes report which
build is answering, which is the question an operator asks them during a rolling
deploy. GET /api/v1/audit-events is the last list on the API to get a
published schema; it read its two parameters by hand, so ?limit=x reached the
service as NaN.
npm run lint runs oxlint and npm run format runs oxfmt, in place of ESLint
and Prettier. TypeScript 7 forced the linter question — typescript-eslint does
not run on it — and the formatter was measured rather than assumed: oxfmt
reflowed no comment prose in a repository whose comments were 14.9% of its
non-blank source lines at the time, and are around 18% now — the figure moves
with every change, and docs/standards/code/comments.md is where it is measured
and held. Neither is visible in the
running application. Adopting the formatter reformatted the tree once, and two
Pulumi deployment files are nothing but that reflow; no other file changed shape
without a reason beside it.
Fixed
A whole-repository audit before this release ran seventy reviewers over every
file and confirmed 112 defects; every one is fixed. The ones a person could
have met:
The ledger's own counter-accounts obeyed writes. Their ids are published
to their owner by the trial balance, and a wrong version guess leaked the real
one to retry with — from there a person could rename the income account, hand
it an opening balance, archive it (which posts its whole balance to equity and
re-shunts every later income posting the same way), or delete it. Every by-id
account path now shares the read's exclusion: a counter-account answers
not-found, before the version is even looked at.
A bulk edit could make refunds through the other field. Patching a
category that runs against the rows' direction was refused; patching a
direction that runs against the rows' kept categories was not, so
patch: {type: "withdrawal"} over deposits carrying income categories flipped
every row into a refund silently. Both halves of the pair refuse now.
Merges left standing references behind. A category merge rewrote
transactions, staged rows, recurrences and budgets, then hard-deleted the
source out from under template drafts, leaving templates that cannot be saved
or used. A payee merge rewrote neither standing reference, so a recurrence
re-created the merged-away spelling on its next occurrence and the merge
undid itself on a schedule. Both merges rewrite both now, audited like the
rest.
Budget arithmetic, four ways. A sinking fund accepted a carry cap below
its own target and the cap then discarded each period's saving; the pair is
refused with the way out named. "Left to assign" netted a group envelope's
claim against members that claim nothing and came back overstated. A
share-of-income budget's first period always read zero because the income
query never looked one period back. And the forecast projected an incremental
budget flat at its base while the report compounded it, so the two surfaces
answered the same month with different figures — the projection compounds now,
counts a cross-currency arrival as an occurrence, and names the period units
it did not read instead of counting them as nothing intended.
The queue and the browser, in smaller ways. The staged list's search
matched JSON keys, so searching "date" matched every row; its account filter
matched a UUID anywhere in the draft; both filters read the real fields now.
Links that promise rows — the post-import review link, the recurrence
waiting-count — pin the date range that makes those rows visible instead of
opening a this-month queue that hides them. Removing a middle split leg no
longer leaves focus on a button that deletes its neighbour. A refused group
rename no longer stays on screen looking accepted. Restoring an archived
account asks before it moves money, exactly as archiving always did. The
category picker no longer snaps a typed name onto an archived category's id
the server then refuses — the name travels, and revives it, by design. Ledger
writes refresh the budgets page instead of leaving it stale. Timestamps on
Activity and connected agents render where you live, not where the browser
happens to be. And a template holding a cross-currency transfer keeps its
received amount through the browser's editor instead of losing it on every
save; the recurrence form now refuses the mixed split the commit would have
refused every month for ever.
Quietly wrong plumbing. Better Auth's own rate-limit sweeper deleted the
shared brute-force tally ten seconds into its fifteen-minute window; the rows
now carry their expiry and survive it. A reminder whose relay refused the
message counted as neither sent nor failed while its occurrence was already
consumed; refusals count and warn. A malformed recurrence leg amount was a 500
instead of a validation message. Every catch that logged a database error
whole — whose message embeds someone's payees and amounts — goes through the
narrowing logger. And the deferred category kind on a CSV split rode on the
row, one slot for two answers, so one split naming two new categories gave
whichever vote wrote last to both.
The net worth, balance sheet and trial balance charts rendered as filled
polygons. One CSS class set stroke and fill for bars and lines alike, and
source order let its fill defeat the line rule's fill: none: every polyline
closed into a shape, and a report meant to be read as lines read as overlap.
An element selector now pins polylines unfilled whatever the order.
Deployment. The GCP program selected the GKE ingress controller through a
field GKE ignores, so no load balancer was ever provisioned; the class travels
as the annotation GKE reads. The EKS program enabled three autoscalers with no
metrics-server to feed them; it ships one. The Helm chart's third line-up rule
(upload size versus CSV limit) is now really checked at render time. Three new
indexes back the referential actions that scanned whole tables.
The list of dates on a recurrence form could go on describing a schedule that
was no longer on screen. It walked the rule the parser produced while its
dependency array named the raw fields, and those are not the same set: an
interval of 0 and a blank one both read as no usable number to the fields, and
only the blank one parses, so typing over either with the other left whichever
list was already showing. Both previews are worked out during render now, five
dates being cheaper than the comparison that was avoiding them.
Merging two categories moves the budgets onto the target instead of destroying
them with the source row, and refuses when both are budgeted for the same period
rather than picking a winner. Same failure as the prune below, one door along.
A category is no longer tidied away underneath a budget. Moving the last
transaction off a category prunes it, which is right, and the composite foreign
key then took its budgets with it, which is not: the docstring on that prune
already promised that "a category held only by a standing instruction is held
all the same", and a budget is exactly that. Asking to delete a category still
takes its budgets, because that is a decision somebody made and the story says
a budget is never a reason to refuse one.
Creates that write no postings no longer claim to need an idempotency key they
never had. AGENTS.md said every create required one and four of the six did
not, which described the code as broken rather than describing what it does: a
record somebody names is protected by its name being unique, so a second submit
fails instead of duplicating. Only creates that write postings, which have no
natural key, need the key.
Merging two categories can be asked for twice. It is the one write on the API
that nothing protected: a retry after a timeout carries the versions the caller
read before the first attempt, so a merge that had in fact succeeded answered
its own retry with a stale-version refusal, which reads as "it did not happen"
about one that did. POST /categories/merge and merge_categories now take an
optional idempotencyKey, exactly as the payee merge beside them always has,
and the browser sends one per merge rather than per click. Optional because a
0.1.5 client sends nothing; requiring it is a later release's job.
The auth, consent and setup routes answer in a shape the browser's own error
reader can see. Fourteen of them returned a flat {code, message} while the
reader looks inside error, so a wrong password produced a request that had
plainly failed and no sentence saying why. They send both halves now, and each
Zod field error carries a dotted field beside what it already had. Nothing was
taken away: a client reading either shape still works, and dropping the older
half waits for a release where it has been deprecated first.
A second pass, from the guides rather than from the code. The standards
guides record what nothing checks about them, and a sweep of those records found
eight shipped defects that had been written down and left. Every one is fixed
and every one now has a test, so the record and the code agree again. The check
that reads those records was part of the problem: it ended a rule at the next
subsection heading, so a top-level rule was never scanned and the last
subsection in a file borrowed the footer beneath it — four rules were answering
to nothing, one of them the Binding rule that money is never summed in
JavaScript numbers.
Somebody who asks their system for reduced motion gets a busy indicator again.
The blanket rule at the foot of the stylesheet sets
animation-iteration-count: 1, which is right for decoration and froze the
button's spinner into a static icon — a picture of waiting that was not waiting.
It pulses instead of rotating now, which carries the meaning without motion
across the screen.
Four kinds of control show a focus ring when tabbed to. summary — the row
menu's trigger — checkboxes, radios and the scrolling table regions had none,
and the CSV file picker's input is visually hidden, so tabbing to it showed
nothing at all. That last one was a WCAG 2.2 AA failure on the import screen.
A row scrolled to with the keyboard no longer lands under something. Nothing in
the stylesheet declared scroll-padding, against eight sticky or fixed regions;
the merge panel on Categories and Payees is the one people would have met,
because it appears exactly when the list is long enough to scroll.
On a narrow window, the navigation scrim no longer paints over the merge panel.
Both sat on the same layer and the scrim was written second, so DOM order was
deciding. The whole ladder is written down in one place now.
Selected text is readable in dark mode. It was 2.59:1 against its own
highlight, where 4.5:1 is the floor for text somebody chose to style.
Two touching bars in a grouped chart have an edge between them. Adjacent series
run as close as 1.05:1 against each other, which is fine for lines that rarely
overlap and not for bars that share a border.
A spreadsheet cell led by a full-width =, +, - or @ is neutralised on
export, as the ASCII forms already were — Excel and Sheets fold them to the
ASCII leader before deciding whether a cell is a formula. So is one led by a
no-break or zero-width space. An older file re-imports unchanged.
Two people creating an account with the same name at the same moment no longer
both succeed. Categories, payees, templates and recurrences each took a lock
before deciding a name was free; accounts had the check and no lock, and there
was no lock to have taken.
A third pass, over every page, route, tool and service at once. The second
pass read the guides' own records; this one read the guides against the whole
surface, section by section. Most of what it found was already right, and the
value is in the four places it was not.
An empty list now says which kind of empty it is. The transactions browser, the
staged queue and an account's register all showed one message whether a ledger
had nothing in it or a filter had excluded everything, so narrowing a search to
nothing looked identical to having never imported anything — and the way out of
the two is opposite. Each now reads the filters that are actually set and says
either "nothing yet", with the control that fixes that, or "nothing matches this
view", with the way back. The register keys on its opening balance instead,
because a range with no postings in it is not an empty account.
A template bulk edit described a filter form that does not exist. The
selection's own description offered to act on "everything matching the current
filters" — the wording the transactions and queue lists use, where a filter form
is on the page. The templates list is capped and the browser holds all of it, so
there are no filters to match and the sentence promised a scope the request
cannot express.
The four LOG_LEVEL values were written out three times: the type on
AppConfig, the z.enum that parses the variable, and ORDER in log.ts,
which decides what is loud enough to print. Nothing connected them, so they
agreed by coincidence, and a fifth level added to two of the three would have
type-checked. They come from one tuple now, in the configuration layer that
log.ts already reads. Eight more closed sets were spelled twice the same way —
four response shapes on the client, a transaction type, a category kind and an
auth mode — each with the shared type already imported into the same file. The
check that was meant to catch this only looked at type X = "a" | "b"
declarations, so a set restated as a property type was invisible to it; it reads
both now.
And a sweep for dead code found none: no unreachable module, no unused
dependency, and of 263 CSS classes none unreferenced. What it did find was 34
exports nothing outside their own file imported, now narrowed to what actually
uses them, and a client type barrel that is deliberate and stays.
Five pieces of copy no longer name a position the layout does not keep. "The
amount above, every period" described where the Amount field sits, and on a
desktop it sits to the left — the form is a wrapping flex row, so "above" was
only ever true once the row broke. It reads "The amount, every period" now,
which says what the rule does — the amount does not change — and is true at
every width.
The duplicate review promised two records "side by side" in the two states where
nothing is on screen at all, and the grid it meant collapses to one column below
980px anyway. A budget alert said the projection failure meant "nothing below is
a projection" while sitting where the projection would have been, with nothing
under it. And the alert about budgets in other period units told you to switch
"the period above", which on that panel is the forecast's own horizon control;
it names "Budgeting by" now, as the identical alert on the same page already did.
The overview's budget panel could not say it was empty, so it disappeared
instead. The panel was drawn only when the report was loading, had failed, or
had at least one budgeted period; the "Nothing budgeted in this range" message
inside it was drawn only when the report had loaded, had not failed, and had no
budgeted period — the exact complement, so the message was unreachable and the
whole section vanished. Anybody whose budgets sit in another month, or in
another currency, met an Overview with no budget section and nothing saying why,
which is indistinguishable from the feature never having been built. The panel
is always there now and says which kind of empty it is. It had no test of any
kind, which is how a branch that could never run shipped; it has seven.
A design review, run across pages rather than down them. Comparing each
section of the app against the same section on every other page — rather than
reading one page at a time — turned up defects that are invisible from inside
any single screen.
A panel header never stacked. It is a flex row with the title at one end and
what is said about it at the other, and no breakpoint changed that, so on a
phone "September 2026, GBP (so far)" ran down three lines beside a sentence
running down three more. It stacks at 560px now, and §15's table of what that
step does says so.
The date range broke in the middle. Wrapping put the two dates and the word
between them on whatever line they fell on, which stranded "to" at the end of
one line with its date on the next. They are one group now, so the bar wraps
around them.
Every table ended in a stub of a rule. The rule that clears the border under the
last row named td, and a row's first cell is a th — so eight tables drew a
line under their first column and nothing under the rest.
Two standing-budget actions were full-text buttons reading "Change Groceries"
and "Delete Groceries" in a row whose first cell already said Groceries. They
are the icon buttons every other list uses, with the name in the label.
A group's budget policy could not be read: the select was pinned at 160px and
"Adds up its categories' budgets" did not fit, so the control said "Has a budget
of its". It sizes to its longest option now.
The import preview showed a tick over "No file yet" — a success mark for
something that had not started. It shows the file icon the rest of the page
uses.
Reports put the date range below its own options bar, the only page that did not
put it directly under the picker.
Two accessibility defects, one of them on the consent screen. Denying an
agent's request put the spinner on "Allow access" — the button nobody pressed —
while the pressed one only greyed out. Both buttons now show the state of the
answer actually in flight. And the two CSV preview tables were the only
scrolling regions in the app a keyboard could not reach, so the columns past the
right edge could not be read at all.
And fourteen buttons went grey without saying why, against a rule that says
they must. The check meant to catch that had been passing since it was written:
it read a hand-written list of five files, and matched with a pattern that
cannot cross the > inside onClick={() => …}, so an arrow-function-first
button was invisible even in the five it did read. It saw eight buttons and
eight reasons and reported success. Counting properly finds 22. Ten now carry a
reason they did not have — including "Add transaction" on three pages, which
goes grey before you have an account and used to leave a first-time reader with
a dead button and an empty list telling them to add a transaction.
Five procedures that kept being rediscovered are written down. Bringing the
documents back to true after work lands, sweeping the product against the
guides, reviewing the browser app, preparing a release, and cutting one — each
was being worked out again, in the wrong order, every time it came round. They
are .claude/skills/ now, and writing.md names their reader and their mode
beside every other document in the repository.
What keeps them from becoming a second copy of the standards is that a skill
cites a guide and never restates it: a rule written down twice drifts, which is
the defect the guides exist to prevent. What a skill is allowed to hold is the
part a guide has no place for — the order the steps go in, and the traps.
release-prep says twice that the recount goes last, because doing it early
cost four passes in a single session.
tests/skills.test.ts holds the set on disk to the set AGENTS.md promises,
both directions, and refuses a bare dollar-variable in any of them: a SKILL.md
is expanded when it loads, so one of the five quoted a defect report about a
figure that "always showed $0" and the loaded skill said it showed the skill's
own name. Command substitution survives; the bare form does not. Neither is
visible in the file, which is why it is a test rather than a note.
Two pages scrolled sideways on a 320px screen, which the standards make a
binding failure and nothing had ever measured. html and body carry a
min-width: 320px, and that was mistaken for a promise that what sits inside
them fits. Two things were over it: the budget period bar's checkboxes are 325px
of text that was told never to wrap, and the date range's two inputs were pinned
at 135px each inside a group that could not shrink. Both fixed, and the browser
tier now measures document width against window width on five pages rather than
asserting the rule holds.
A disabled button gave one fixed reason for a seven-condition predicate. The
two bulk editors said "Change at least one field above" to somebody who had
changed a field and mistyped a date — the sentence was true in one of the seven
states it was shown in. Each now names the first unmet condition in the order
the form asks for it. And "Create an account first" is no longer asserted while
the accounts are still loading, when nobody knows yet whether there are any.
The overview's budget line disagreed with its own bar. It printed what the
period was allowed while the bar and the badge beside it measured what the
period may actually spend, so a budget carrying money forward read "£450.00 of
£100.00" next to a bar at 90% and a "Nearly there" badge. That is the same
disagreement the category rows below it were written to end, and it had been
left in the line directly above them. It also said "Nothing budgeted in this
range" to anybody budgeting at the group level, where the figure behind the
sentence counts category limits only; it says "No category budgeted" now, which
is what it knows.
Six lists told you they were empty when the truth was that they could not be
read. Transactions, the staged queue, Templates, Recurring, Payees and the
duplicate review each showed "No transactions yet" — or its equivalent — over
the top of an alert explaining that the request had failed. A query that errored
is not a query that is loading, so it fell past the loading branch straight into
the empty state. Telling somebody their ledger is empty when the truth is that
nothing could be fetched is the most consequential way a list can mislead, and
all six now show the failure in the list's own place instead of beside it.
Enter now submits the two budget dialogs. Changing a budget's amount, or
overriding one month, meant reaching for the mouse: both dialogs held their
fields loose in the body with no form around them, alone among the app's
dialogs, so Enter did nothing and the fields sat flush against each other.
A modal's buttons sit where every other modal's buttons sit. The footer
carried padding and a border and no layout of its own, so the five dialogs that
passed their buttons directly left them jammed against the left edge and
touching, while the two that happened to wrap theirs in a container looked
right.
Each report names its first column. It said "Row" on all six, which pairs
with every row's header cell so a screen reader announced "Row: Rent" — the
table's own structure read out as though it were the data. It says Account,
Category, Line or Movement now, depending on what the report actually lists.
A disabled button no longer distorts the bar it sits in. In a selection bar
the reason was laid out as a caption under one button, which made that button as
wide as the sentence — "Commit selected" stretched to 470px while "Edit
selected" beside it stayed normal, sat above the line its siblings were on, and
on a narrower window pushed the duplicate checkbox onto a row of its own. The
sentence takes a line of its own under the whole bar now. In a form the caption
is still under the button, where the actions are the last thing on a stack.
A budget set on a group and on no category was invisible on the overview.
The panel kept a period only when its category budgets came to more than zero,
and a group's own budget is not counted there — so somebody budgeting the way
the 50/30/20 recipe describes saw "nothing budgeted in this range" while the
budgets page showed the group, and one unrelated £1 category budget was enough
to make the whole section appear. The gate asks whether anything is budgeted
now, groups included. Where a period has only group budgets its summary line
shows the period's name alone: the line totals the category budgets, so a figure
there would read "£500.00 of £0.00" with a full red bar directly above a group
row saying £500.00 of £800.00.