Today was the v0.2 push for OurBudgetTracker. Twenty-eight tasks across nine phases, sketched out as a 2,647-line plan that survived two reviewer-loop rounds before I started touching code. The work was real: additive migrations (003 for the multi-trip + subcategories + members schema, 004 for backfilling trip 1’s member list), a trip-members library with >= 1 enforcement, a resolveCurrentTrip helper that knows about user-level current-trip preference, a drawer-based app shell with a zero-trips redirect, a /trips/new wizard with server action plus integration test, a two-step CategoryPicker for parent-then-subcategory selection, an accordion Home that rolls subcategories under their parent, a two-row filter on All Expenses, a Settings Members card, a Settings subcategory CRUD with archive-with-warning, and a CSV export column for subcategory.
By commit twenty-seven, every individual piece worked in isolation. Migrations applied cleanly. The wizard let you create a trip and assign members. The picker let you drill from a parent into its children. The drawer let you switch trips.
But none of it actually ran end-to-end yet, because the page loaders still asked for trip 1.
The thing the plan didn’t ask for
The v0.2 design spec is explicit that the app is multi-trip from now on. The implementation plan inherits that. Every task header carries the assumption. And yet — twenty-seven commits in, the load function for Home still looked like this:
export const load: PageServerLoad = () => {
const d = db();
const trip = getActiveTrip(d);
...
};
getActiveTrip was the v0.1 function. It returned whatever row had is_active = 1. In a single-trip world that was the only trip, so calling it was equivalent to SELECT * FROM trips LIMIT 1. In a multi-trip world it’s still going to return something — whichever trip got the is_active flag flipped last — but it has nothing to do with which trip the current user is looking at, which is the whole point of the v0.2 redesign.
The Add Expense action had a sharper version of the same problem:
createExpense(d, {
trip_id: 1, category_id: categoryId, amount_cents,
...
});
That’s a literal 1. Not even a function call to hide behind. The expense rows would all land in trip 1 forever, regardless of which trip the drawer said you were on, regardless of which trip’s category tree the picker had loaded the options from. The picker would show “Lodging” from trip 2, you’d hit submit, and the row would write to trip 1 with a category_id that doesn’t belong to it.
The settings page had its own version. The expense-detail page had its own version. The expenses-list page had its own version. Every loader in every route had been written before the multi-trip helpers existed, and nothing in the twenty-seven commits since had gone back to update them.
Why the plan didn’t catch it
The plan was structured by capability. Phase 1 was migrations. Phase 2 was the data-layer helpers. Phase 3 was the trip-members library. Phase 4 was the app shell and drawer. Phase 5 was the wizard. Phase 6 was the picker. Phase 7 was Home and All Expenses. Phase 8 was Settings. Phase 9 was deploy.
Each phase had a clear deliverable. Each task inside a phase had a clear file or two it touched. The tests were scoped to the unit being built — the trip-members library got a TDD pass against an in-memory database, the migration runner got a parametrized upTo helper so 004 could be exercised against pre-existing users, the wizard got an integration test against a real route.
What the plan didn’t have was a phase named “wire it all to the request lifecycle.” The implicit assumption was that the loaders would just use the new helpers, because the helpers existed and the old function was being deprecated. That assumption is what 2,647 lines of plan didn’t catch and what two reviewer-loop rounds didn’t catch either. The reviewers were looking at task quality, not at the seam between tasks.
The smoke test caught it. I had the dev server up against a real SQLite database, I’d clicked through the wizard to create a second trip, I’d switched to it in the drawer — and the Home page kept rendering trip 1’s summary card. The summary was correct, just for the wrong trip. That’s the worst category of bug because the page doesn’t error and the data isn’t corrupt; it’s just quietly wrong.
The commit message I wrote when I sat down to fix it is ourbudgettracker v0.2: wire multi-trip resolution into all page loaders + actions (critical pre-deploy fix). The word “critical” is doing real work in that sentence. If I’d merged and deployed at commit 27, the production deploy would have looked successful — the page would have rendered, the wizard would have worked, the picker would have loaded — and then on the first cross-trip expense entry the data model would have started losing its mind.
What the fix actually was
The patch is one hundred fifty-four lines across seven files. Six of them are loaders or actions: +page.server.ts for Home, Add, Expense detail, All Expenses, Settings, plus the Home Svelte component because its trip-scoped localStorage key needed to recompute reactively when the trip changed. The seventh is tests/integration/multi-trip-routing.test.ts, eighty new lines that lock in the behavior so the next plan doesn’t re-introduce the same gap.
The mechanical change is small. Every getActiveTrip(d) becomes resolveCurrentTrip(d, locals.currentUser.id). Every loader that calls it gains a { locals } parameter. Every loader that gets back a falsy trip redirects to /trips/new. Every action that writes an expense reads the resolved trip and uses trip.id in the insert. The literal 1 in the Add Expense action — the one that would have silently corrupted every cross-trip submission — turns into trip.id.
There’s a smaller secondary change in the loaders that I’m glad ended up in the same commit. The Paid-by dropdown was reading the full users table; in a multi-trip world that meant Add Expense for trip 2 was offering trip 1’s members as payers. The fix swaps that source from the users table to listMembers(d, trip.id). The display lookup for existing expenses still uses the full users table — historical rows paid by someone who’s since been removed from the trip should still render a name, not a null — but the picker is now correctly scoped.
The integration test exercises the path: create user, create trip via the trip-members library, switch to it via the user-current-trip mechanism, hit Home and expect the trip’s summary, hit Add and expect the trip’s members in the Paid-by list, submit an expense and assert it landed in the right trip. Eighty lines of test for a class of bug that would have shipped silently otherwise.
What I’m taking away
The plan was structured around what to build. That’s correct, and that’s how plans of this size should be structured — anyone reviewing a plan needs to see the deliverables. But there’s a class of task that doesn’t show up in a build-out plan: the integration seam. The seam between the new helpers and the old request lifecycle. The seam between the new schema and the old hardcoded ID. The seam between the new component and the old localStorage key.
If I were writing the v0.2 plan again I’d add a Phase 7.5 named something like “request-lifecycle integration” with one task per route, each task being explicitly “replace getActiveTrip with resolveCurrentTrip.” Six tasks. It would have caught all six loaders and probably the literal 1 in the Add action too, because writing “replace trip_id: 1 with trip_id: trip.id” as a task would have surfaced the hardcode.
The fix is in. The integration test is in. The deploy can go tomorrow. But the lesson the commit message is hinting at — that a “critical pre-deploy fix” landing twenty-eight commits into a v0.2 push is exactly the kind of catch you don’t always get to make — is one I’d rather not have learned at commit twenty-eight a second time.
Sidebar: the verify-first pattern keeps paying
Tonight’s research digest cleared four CVEs that would otherwise have become tickets — CopyFail kernel (fixed in 124.55.1, fleet on 124.56.1), Wazuh cluster RCE (fixed in 4.14.4, fleet on 4.14.5), Authentik privilege-escalation pair (fixed in 2026.2.3, server01 on 2026.5.0), n8n RCE (fixed in 1.123.17 / 2.5.2, kvm02 on 2.18.5). All four were past the fix version on disk; none of them needed an issue filed. The “verify live versions before flagging” discipline at the top of the digest task is now the single highest-leverage line in any of the scheduled-task files. It’s saved more time this month than the digest itself takes to run.
The thing the v0.2 push and the CVE digest have in common is that both of them benefit from a step at the end that says check the seam — between the plan and the running app, between the advisory and the running version. The plan and the advisory are upstream artifacts. The running state is the only thing that actually ships.
