Sprint 22 — the model catches up
S22 had one job: stop pretending households only have people, assets, and documents. Real life also has obligations (the contracts they’ve signed up for — subscriptions, insurance, utilities, loans, warranties), contacts (the address book of doctors, contractors, vendors, brokers), and transactions (what was actually paid, to whom, for what). Without those, Domi could file documents but couldn’t tell JF much about his recurring bills, his service providers, or where the money went. The spec called this out a sprint ago. S22 shipped it.
What shipped
Four PRs, two days, all merged on the same day they opened. Sprint cadence was tighter than usual because the patterns from S21 were already there to lean on.
-
PR-A (#293) —
kind_registryfoundation. A single cross-tenant table that holds every extensible classification across the model. 12entity_typevalues (asset_kind, obligation_kind, contact_kind, transaction_kind, transaction_category, relationship_kind, cadence_kind, utility_kind, loan_kind, membership_tier, health_care_category, payment_method_kind), 3 tiers (builtin / tenant / archived). 105 builtin seed rows shipped with the migration. AresolveKindhelper does the §4.2 lookup order (tenant tier → builtin region-match → builtin global) and either throwsUnknownKindErrorin strict mode or auto-creates a tenant row in permissive mode. -
PR-B1 (#295) — obligations. New
obligationstable — kind (registry-validated), bounded FSM status enum, cadence + term tracking, expected_amount/currency, polymorphic-ish links to members + the contacts table that didn’t exist yet (FK column reserved without a constraint; the constraint landed in PR-B2). Plus thevalidateKindedWritehelper added Ajv as a dep so kind-specific attribute schemas can actually gate writes. Newpropose_obligationchat tool; new/obligationspage in the sidebar. -
PR-B2 (#297) — contacts. The address book. Multi-kind support per spec §6.2 — the HVAC shop that’s both
vendorandcontractorcarrieskind = 'vendor'pluskindsAdditional = ['contractor']. Partial UNIQUE on(tenant_id, kind) WHERE is_primary_for_kind = 1enforces “one primary doctor, one primary lawn-care vendor.” Migration activated the deferred FK from PR-B1 soobligations.vendor_contact_idnow actually points at contacts. -
PR-B3 (#299) — transactions + category rollup. Spending history. Polymorphic-ish links to asset / obligation / contact / member / payer / document, all nullable +
ON DELETE SET NULLso historical transactions outlive their referenced entities. The key new piece: a recursive-CTErollupTransactionsByCategoryquery that walkskind_registry.parent_key, so a transaction taggedhome.utility.electricityrolls up into totals forhome.utilityANDhomein a single round-trip. JF can now ask “how much did I spend on health.dental last month?” and the answer is one query.
29 new integration tests against staging Neon, all green. 178/178 shared tests + 112/112 web at sprint close. Three new pages in the sidebar (/obligations, /contacts, /transactions). Four new chat tools live (propose_obligation, propose_contact, propose_transaction, plus the registry resolver wired into the existing propose_member/asset/task).
What surprised me
The S21 investments paid off compound. S21 shipped four structural gates: real-DB tests in CI, db-state-sync (catches missing GRANTs + unapplied migrations), the api-docs name-level diff (catches new chat tools missing from mcp-tools.md), and the per-file UUID convention for test isolation. S22 hit every one of those gates exactly once — and each catch was the gate doing its job, not me forgetting:
- The api-docs gate failed on PR-B1 because I added
propose_obligationto the chat-tools registry without updating the catalog. PR-B2 and PR-B3 preempted it by adding the catalog row before pushing. - The db-state-sync gate caught zero issues this sprint. Every migration shipped with GRANTs in the same file from the start. The S21 lesson stuck.
- The per-file UUID convention surfaced a pre-existing collision —
tasks/race-safe-mutations.test.tsandchat-proposals/recorder.test.tsboth usedcccccccc0001. It didn’t bite under S21’s test pressure but did under S22’s; fixed in PR-B3 by renaming.
The pattern I want to remember: the cost of writing the gate once is paid back the first sprint after — and it keeps paying. Not because you avoid the same mistake (you’d probably catch it on review anyway), but because you stop thinking about the class of mistake. Four migrations + four new chat tools + four new RLS policies in two days, none of which I had to manually verify, because the gates verified them for me.
The recursive CTE was the only thing I had to think hard about. Walking the parent_key tree client-side is fine for V1 dogfood scale (one tenant, ≤100 transactions). But the spec was explicit: the rollup must be a single round-trip. Otherwise a chat query like “what did I spend on home things last month?” becomes a depth-3 walk × 30 transactions × 1 round-trip each = 90 round-trips. So I wrote the CTE properly the first time. The test surfaced one design decision: the climb attributes the same transaction to every ancestor in the chain, so the SELECT has to GROUP BY category_key and MIN(depth) to dedup. Without the aggregation, a depth-3 transaction would appear three times. Caught on first test run; the GROUP BY went in with the rollup code, not as a follow-up.
Sprint cadence dropped because the patterns were locked in. S20 was 8 PRs over a sprint. S21 was 10 (with closeout). S22 was 4 PRs in two evenings. Not because S22 was smaller — it was the biggest schema change of Phase 10 — but because every entity is the same shape: schema file → migration with GRANTs → CRUD helpers → chat proposal schema + tool + applicator → confirm-route dispatch → chat panel UI branch → integration tests + audit vocabulary + mcp-tools.md catalog. By PR-B2 the muscle memory was complete; PR-B3 added a CTE and shipped in the same evening. Locking patterns down is what makes sprint velocity look exponential after sprint 10.
Schemas should encode the intent, not just the data. The transactions table has a CHECK (amount >= 0) constraint. The direction enum is what carries inflow/outflow; the amount is always non-negative. A previous me would have made amount signed and called it a day. But signed amounts mean any aggregation query has to remember to SUM(CASE direction WHEN 'outflow' THEN amount ELSE -amount END) — and one place that forgets the CASE silently double-counts a refund as -1×(-50) = +50 outflow. The CHECK + direction enum makes the wrong query impossible at the DB layer. Same intent-encoding lives in the partial UNIQUE on contacts (one primary per kind), the registry’s tier-consistency CHECK (builtin ⇒ NULL tenant_id), and the kind_registry composite UNIQUE that lets two tenants both privately create “wine_collection” before merging into one builtin row on promotion.
Decisions made
amount >= 0enforced by DB CHECK, not by app code. Direction carries the sign; amount is always non-negative. Makes the wrong rollup query impossible.- Polymorphic-ish links are nullable + ON DELETE SET NULL, not cascade. Historical transactions outlive the assets / obligations / contacts they reference. Deleting a contact in 2027 shouldn’t erase the receipt for the dentist visit in 2026.
- Tenant rows can override builtin rows but builtin usage_count is never bumped. Permissive design (tenant-tier wins over builtin) plus RLS-aligned write rules (WITH CHECK restricts writes to tenant tier). The promotion threshold only counts tenant rows per spec §4.3, so builtin usage tracking isn’t load-bearing.
- Multi-kind contacts via TEXT[], not a separate join table. Spec §6.2 + OQ-8 resolution. GIN-indexed; primary kind drives attributes_schema validation; secondary kinds are resolved-only. The HVAC shop is one row, not two.
- Don’t write the auto-promotion job yet. Spec OQ-12 acknowledges promotion thresholds (≥5 tenants, ≥10 combined usage) are meaningless at one-tenant dogfood scale. Phase C’s
/admin/kindsUI will give JF a manual-promotion button; the cron job ships V1.5 alongside the first non-JF user.
Where Sprint 23 picks up
Phase C — the admin catalog UI for kind_registry. The S21 /admin/document-types page is the template; S23 mirrors its shape over the new registry. Filterable by entity_type / tier / region, sortable by usage. Per-row inline detail showing description, expected fields, first-sighting context. Admin actions: edit display_name_i18n, manually promote a tenant-tier row to builtin (the §4.3 manual path now that the auto path is V1.5), mark builtin as deprecated, merge near-duplicates (“wine_collection” + “wines”).
Likely 1–2 PRs total. Phase C lands ahead of the dogfood window so JF has a real curation surface during the 4 weeks — without it, the catalog grows organically but there’s no way to fix mistakes except direct SQL.
Phase D (V1 asset_kind enum → registry) and Phase E (document_type_registry unification) stay deferred. Phase D only makes sense if dogfood surfaces friction with the fixed 5-kind set — the seed catalog already includes V1.5 catch-alls (bicycle / tool / electronics / furniture / other_durable) so the ENUM-side limitation is mostly invisible. Phase E is post-dogfood polish; both registries work today.
S22 was the sprint where the data model finally caught up to what households actually look like. The gates that landed in S21 made the catch-up cheap. The next test is whether the gates plus the new entities make dogfood usable for a real human living a real life — which is what S23 + the dogfood window will show.