Sprint 24 — the asset that knows itself
S24 had one job: make the asset detail surface match what a household actually needs to know about a thing it owns. Pre-S24, an asset row was a name + a kind + a custodian list. Post-S24, it’s all of that plus: ownership posture (owned / leased / rented / borrowed / other) with a lease/rental term, kind-aware attributes (VIN + plate + year + make + model for vehicles; brand + model + serial + install date for appliances; address + sqft + heating type for residences; HIN + registration for boats), a primary photo, and a list of linked documents (receipts, manuals, registrations, insurance binders).
None of that needed new architecture. S22 built the kind_registry + attributes_schema primitive and proved it out on obligations / contacts / transactions. S23 built the admin curation surface. S24 lit the same plumbing up on the original V1 assets table.
What shipped
Four PRs — three planned, one in-sprint surprise.
-
PR #314 — Vercel-region privacy policy correction. JF re-read the privacy policy and noticed it listed Vercel as a US-region sub-processor. The actual Vercel Function Region for
domi-webanddomi-helpisca-central-1(Montréal). Two-file en+fr docs-only fix, but a Law 25 disclosure should always be factual. Caught by JF reading the doc, not by any CI gate — some classes of error (factual claims in legal copy) only humans can catch. -
PR-A (#316) — schema + seeds + chat tool. New
asset_ownership_statuspgEnum. Three new columns onassets:ownership_status(NOT NULL DEFAULT ‘owned’ so back-filled rows aren’t silently mis-classified as borrowed),ownership_starts_at,ownership_ends_at. New nullable FKassets.primary_photo_document_id→documents(id)(photo is a documents row — reuses R2 + RLS + audit, no parallel storage path). New nullable FKdocuments.asset_id→assets(id)so any document can be tied to an asset. Both ON DELETE SET NULL so neither side cascade-deletes the other. Thedocuments.asset_idFK is declared in raw SQL to dodge the circular import — same deferred-FK pattern S22 PR-B1 used forobligations.vendor_contact_id. Migration 0029 enriches the 4 V1asset_kindbuiltin rows with full JSON Schemas for their kind-specific attributes.propose_assetaccepts the new ownership fields and Ajv-validatesattributesagainst the registry schema BEFORE the audit-then-mutate wrap fires (same ordering invariant the obligations/contacts/transactions applicators ship — a rejected payload leaves no phantom audit row). -
PR-B (#318) — detail page UI. New
/[locale]/assets/[id]page. Three sections: hero (photo placeholder + ownership badge + custodian summary), edit form (display name + kind + ownership + lease term + custodians + kind-aware attribute fields driven by the registry’sattributes_schema), linked-documents list (per-row “Set as photo” forimage/*mimes, “Unlink from asset”). Per-asset upload form POSTs to/api/documentswithassetIdso the resulting documents row is linked at INSERT — no follow-up call. The graph entity panel gains a “View details” link for asset nodes.getAssetDetailquery bundles asset + custodians (archived filtered, sorted by name) + linked docs (sorted newest-first) + a convenienceprimaryPhotopointer. -
PR-C (#320) — chat auto-link + image preview. When JF uploads a file in chat with a phrase like “here’s the registration for the Mazda CX-5”, the resulting documents row gets
asset_idset at INSERT. The resolver is a pure-function single-match-wins: lowercase + normalize punctuation, word-boundary.includes()per non-archived asset display name, return null on zero or multiple matches (ambiguity wins over guessing). Plus a newGET /api/documents/[id]/contentroute — auth + RLS + R2 stream + nosniff + private cache — so the hero photo renders the actual bytes via<img src="/api/documents/{id}/content">instead of the V1 placeholder.
22 new tests (19 real-DB integration + 9 unit + 9 from PR-C minus 3 overlap math). 208 total shared tests, 112 web, all green. The build cleared on every PR.
What surprised me
The conservative-V1 matcher had to fail two tests that seemed reasonable. First draft of matchAssetFromText allowed partial-name match (Civic alone matches Honda Civic) and separator-less normalization (CX5 matches CX-5). Both surfaced as failing tests on first run. Re-read the V1 design: ambiguity wins over guessing — JF can have multiple Hondas, and CX5 is genuinely a different token to the matcher than CX-5. Rewrote the tests to assert the conservative behavior and added a “decisions table” in the PR description so the rationale stays attached to the code. The cost of failing closed: the user has to type the full asset name to auto-link, or fall back to the manual “link a document” affordance in the detail page. The cost of failing open: occasionally a document gets linked to the wrong Honda, audit log fills with corrections, dogfood trust erodes. Conservative wins for V1.
The api-docs gate caught the slip exactly as designed. I added apps/web/src/app/api/documents/[id]/content/route.ts in PR-C after the local pre-flight sweep had run. The sweep showed 12 routes ↔ 12 paths green. Then the route landed. Then I committed and pushed without re-running the gate. CI caught it: 13 route files vs 12 paths entries. The fix was straightforward — add the GET op to openapi.yaml + the assetId form-field to the existing POST op — but the lesson is structural: when any new route.ts lands, the api-docs gate must be re-run as the last local check before commit, not as part of an earlier sweep. Wrote it into the sprint closeout. The S19 name-level diff is one of the gates that earns its weight on the day you’d otherwise drift quietly past it.
Bi-directional FKs need a deferred-FK pattern, and the pattern is now templated. Declaring assets.primary_photo_document_id → documents(id) AND documents.asset_id → assets(id) via Drizzle .references() would create a circular import. The first time we hit this was S22 PR-B1 (obligations.vendor_contact_id → contacts before contacts existed). PR-A re-applied the same pattern: assets-side uses Drizzle .references(); documents-side declares the column without .references() and lands the constraint via raw SQL in the migration. Documented inline in both assets.ts and the migration so a future reader doesn’t try to “fix” the asymmetry. This is the kind of thing that becomes invisible technical debt once you have three of them and no comments — keeping the pattern explicit and named (“deferred-FK pattern, S22 PR-B1 precedent”) is what makes it cheap to add a fourth.
Sprint cadence held: 4 PRs in one day, three of them substantial. S22 was 4 PRs in two days. S23 was 4 PRs in one day. S24 was 4 PRs in one day, including the in-sprint privacy-policy surprise. The pattern is no longer surprising — it’s what S20+ infrastructure investment looks like in practice. Every PR in S24 reused a shape from a prior sprint: the validateKindedWrite Ajv gate from S22 PR-A on both the propose and update paths in S24, the audit-then-mutate ordering rule throughout, the per-file UUID test tag convention on every new test file, the deferred-FK pattern from S22 PR-B1, the server-action + useActionState pattern from /admin/tenants for the detail page, the upload-document pipeline from S3 for the photo. Nothing genuinely new architecturally — just composition.
The V1 ramp is done. S24’s close is the last code-side V1 ramp item. The remaining critical-path work for V1 launch readiness is all browser-driven or paperwork: the #181 graph perf trace and #150 WCAG verification (both have runbooks; both are JF-driven evening tasks), then the 4-week dogfood window, then threat model + PIA + DR runbook (intentionally last so they describe the system as it actually behaves, not as designed). Sprint 25 opens with the browser sessions and the dogfood kickoff. No more “this PR adds a foundation” entries on the milestone list — every remaining item is “this PR fixes what dogfood surfaced.”
Decisions made
- Ownership posture is a typed enum, not a free-text tag.
owned / leased / rented / borrowed / other. The DB default is'owned'so back-filled existing rows aren’t silently mis-classified. Lease/rental term lives inownership_starts_at+ownership_ends_at(date-only, same shapeacquired_at/retired_atalready use). - Photo is a
documentsrow, not a separateasset_photostable. One R2 path, one extraction pipeline, one set of RLS/audit rules.assets.primary_photo_document_idpoints at a documents row. The mime check (image/*) gates the “Set as primary photo” affordance on the per-row UI. - Chat auto-link fails closed on ambiguity. Partial names don’t match. Separator-less names don’t match. Multiple matches return null. The user can always link manually from the detail page.
- The
assets.kindpgEnum stays for V1. Phase D (ENUM → registry) would unlock user-defined asset kinds, but the 5 V1 builtins + the V1.5 catch-all kinds already seeded in the registry cover the dogfood set. Phase D moves only if dogfood surfaces friction — and the registry-validation path runs on the ENUM string value, so the lift to migrate later is purely a column-type change, not a data-model rework. - The V1.5 photo-serve integration test gap is documented as ⚠️, not closed now. R2-stub + Neon-ephemeral combination is its own infrastructure investment. The auth + RLS shape mirrors S21’s documents detail page, which is integration-covered. Tracking the gap in the regression-suite is the right shape for V1.
Where Sprint 25 picks up
The two JF-driven browser sessions first, with runbooks ready:
- #181 — graph perf trace. Open
/graphagainst the production dataset, capture the OpenTelemetry trace viadocs/perf-graph-runbook.md, verify the 60fps interaction budget holds at JF’s actual household scale. Should be ~30 minutes if nothing surprising surfaces. - #150 — WCAG verification. Work through
docs/wcag-verification-plan-s12.md— keyboard nav, screen reader spot-check, contrast verification — against the live app. Should be ~2 hours.
Both have runbooks; neither needs a PR if results pass. If either surfaces a fix, that becomes the first S25 PR.
Then the 4-week dogfood window opens. S25 → S28. Sprint shape is reactive — fix what JF actually hits during use. The Requirements §11 success criteria (≥20 predicted tasks, ≥12 documents, <30s upload→fact latency, 100% members onboarded, ≤$100/mo opex) measure at end-of-S28.
Threat model + PIA + DR runbook are sequenced AFTER dogfood, on purpose. The paperwork should describe the system as it actually behaves. Writing the threat model now would describe a design hypothesis; writing it after 4 weeks of real use describes a system whose actual data flows + actual perf posture + actual failure modes are known. Per JF direction 2026-05-15, the docs are V1-critical-path but last in line.
S24 was the last code-side V1 ramp sprint. Everything from here is exercise.