Sprint 23 — the curation surface
S22 dumped 105 builtin classification rows into a brand-new cross-tenant kind_registry table and 12 entity-type categories on top of it. The runtime worked — chat extraction created tenant-tier rows on the fly, the resolver picked the right tier, RLS kept tenants separated. But there was no way to look at the catalog, much less curate it. The S22 plan called that out: Phase C is the admin surface. S23 was Phase C.
What shipped
Four PRs in two days. Two were the planned scope; two were hardening that surfaced during the work.
-
PR-A (#303) — read-only catalog viewer. Server-rendered list of every
kind_registryrow at/en/admin/kinds, gated by the existingrequireAdminSession(DOMI_ADMIN_EMAILS allowlist; redirects to home for anyone not on it, no leak that the route exists). Client-side filters by entity_type / tier / region; sort by usage / last-used / first-seen / name; inline-expand reveals description, per-locale display name, parent_key, attributes_schema (pretty-printed JSON), default attributes, metadata, first-sighting context. Mirrors the existing/admin/document-typespage exactly — same shape, different columns, no new architecture. -
PR-B (#305) — admin verbs. Three platform-admin actions on each row:
- Edit
display_name_i18n— the cheap, common-case repair. Typo fixes, missing French translations. - Promote tenant→builtin — the spec §4.3 manual path. Stamps
promoted_at+promoted_by, nullstenant_id, preservesfirst_seen_tenant_idfor provenance. Rejects withKindBuiltinDuplicateErrorif a builtin already holds the(entity_type, key, region_code)coordinate — the merge case stays deferred. - Deprecate — branches by tier per spec §4.4. Builtin rows get
metadata.deprecated_sinceviajsonb_set(existing metadata preserved); tenant rows flip bothtier='archived'andarchived_at = NOW()(the partial unique index then frees the key slot for a re-add). Idempotent on re-clicks. - 8 new real-DB integration tests; 186 total shared tests, all green.
- Edit
-
PR-C (#308) —
metadata.scope = "platform_catalog"stamp. The audit-tenant fallback chain in PR-B (row.tenant_id ?? row.first_seen_tenant_id ?? actor.fallbackTenantId) works at V1 single-tenant scale but misattributes builtin-row admin actions the moment a second tenant exists. The proper V1.5 fix is a dedicatedplatformsystem tenant and a re-homing migration — tracked as issue #306. PR-C is the 6-line insurance that makes the migration a one-query backfill (WHERE metadata->>'scope' = 'platform_catalog') instead of action-string archaeology. -
PR-D (#310) — doc-QA new entities. S22 and S23 added four new entity surfaces (kind_registry + obligations + contacts + transactions) and three platform-admin verbs. The chat-tool layer was kept current in-stride by the api-docs-sync gate from S17. Everything else —
apps/help/workflow pages, OpenAPI schemas, README counts, spec doc change-logs — was carrying invisible debt. PR-D closes it: 6 new help pages (EN+FR), 3 new OpenAPI schemas, README counts refreshed, change-log entries in Data Model v0.1 §17 and a new §11 in API and MCP Surface v0.1. Help site builds 60 pages (was 54).
What surprised me
The audit-tenant fallback chain almost shipped without the scope stamp. PR-B was going to merge with the fallback chain as the only mechanism for attributing cross-tenant catalog edits. At single-tenant V1 it’s invisible — there’s only one tenant, so misattribution is impossible. The trigger for the hardening pass was the framing of the next 18 months: V1 ships to JF; V1.5 opens to one more family member; somewhere in that gap, JF’s catalog-curation actions start showing up in another tenant’s audit log. The right fix needs new schema (a platform tenant row); the right time to do it is V1.5; but the right way to prepare for that fix is now, while the call sites are fresh and there are exactly zero historical rows to migrate. 6 lines + 3 test assertions. Same shape as the per-file UUID test convention from S21 — pay the small fixed cost now to avoid the variable-and-growing one later.
The doc-QA debt was bigger than I thought. The api-docs-sync gate from S17 kept mcp-tools.md current automatically — every new chat tool earned its catalog row at PR time or the build failed. But the gate only covers openapi.yaml paths and mcp-tools.md entries. The OpenAPI components.schemas block lives outside its scope, so ObligationProposal / ContactProposal / TransactionProposal quietly never landed there even though the chat tools that consume them did. Same for the help site (apps/help/): no CI gate (it’s V1.5 work, mirroring the api-docs gate once we have enough history to know what “touched a feature” means at the file level). Without the gate, three new top-level entities shipped without a single user-facing help page. The doc-QA sweep wrote 6 (3 EN + 3 FR) + retrofitted the archive-entity workflow to cover all five entity types instead of just members and assets. The lesson sticking: the gates I have catch what they’re designed to catch and nothing more. Every gap I notice during a sprint should either become a gate or become a tracked manual-discipline item. Otherwise it just festers.
Phase D’s “ENUM → registry” question got answered without doing Phase D. The S22 plan listed Phase D (V1 asset_kind enum → registry) as a future ~2-sprint disruptive migration. The reason to do it: tenants want kinds beyond the 5 V1 builtins (vehicle / residence / appliance / boat / member). The reason to defer: it touches every read-path filtering on assets.kind and the seed catalog already includes V1.5 catch-alls (bicycle / tool / electronics / furniture / other_durable). What S23’s catalog work made obvious: the registry seeds already cover the V1.5 catch-alls, the registry resolver already runs for the other 11 entity_types, and the ENUM column on assets is the only structural blocker — but it’s not blocking anything during dogfood. So Phase D doesn’t move; it just gets clearer that the V1 ENUM is an honest expedient, not a bug.
Sprint cadence dropped further still. S20 was 8 PRs in a sprint. S21 was 10. S22 was 4 in two days. S23 was 4 in one day, and only two of those were planned. The trend is real and worth naming: the patterns are locking in. Every PR in S23 reused a shape from earlier sprints — requireAdminSession from S20, withAudit audit-then-mutate from CLAUDE.md §6, server-action useActionState from the existing /admin/tenants page, partial-update via jsonb_set from the metadata-stamping work. Nothing new architecturally. The cost of any one new feature has stopped being “design + implement + test + doc” and started being just “implement + doc” — because design defaults to the nearest existing pattern, and tests are templated by file structure now. This is what compound interest on infrastructure investment looks like in practice.
Decisions made
- Merge near-duplicates stays deferred until dogfood produces a real duplicate. Spec §4.5 lists merge as the fourth admin verb. It requires walking every entity that references the source key (
obligations.kind,contacts.kind+kindsAdditional[],transactions.kind+category) and migrating them to the target. Substantial work; zero value at single-tenant dogfood scale where there are no duplicates yet. Build it when the actual duplicate appears so the entity-migration shape matches what’s actually needed, not what’s hypothetically clean. - Audit-tenant attribution: defer the fix, ship the filter. Issue #306 captures the V1.5 platform-system-tenant work. The 6-line
metadata.scope = "platform_catalog"stamp shipped now so the eventualUPDATE audit.events SET tenant_id = '<platform>' WHERE metadata->>'scope' = 'platform_catalog'is a one-query backfill. - Spec docs evolve through change-logs, not rewrites. Data Model v0.1 already covers the V1-seed schemas; rather than re-derive its §3-§16 to include
kind_registry/obligations/contacts/transactions, the doc-QA pass added a §17 change-log entry that points to Family Life Entity Model v0.2.md as the authoritative spec for those tables. Same pattern for API and MCP Surface v0.1 — new §11 change-log lists the three new propose_* tools and the three /admin/kinds admin verbs, with a pointer to the entity-model doc. Keeps the original doc’s scope coherent and avoids the “every spec doc grows forever” failure mode. - Admin verbs intentionally absent from
openapi.yamlandmcp-tools.md. Per the §3.8 transport convention: server actions with no third-party-developer or external-agent use case don’t earn rows in those catalogs.kind.update/kind.promote/kind.deprecateare audited and specced in the entity-model doc; they’re just not part of the public API surface.
Where Sprint 24 picks up
Asset detail surface. S22/S23 proved out the kind_registry + attributes_schema pattern on obligations / contacts / transactions; S24 lights it up on the original V1 asset table. PR-A: schema additions (ownership_status enum, primary_photo_document_id FK, documents.asset_id FK), enriched attributes_schema seeds for the 5 V1 asset kinds (vehicle wants VIN + plate + year + make + model; appliance wants brand + model + serial + install_date; residence wants address + year_built + sqft; boat wants HIN + plate + year), and a propose_asset extension to accept the new fields. PR-B: UI — photo upload reusing the documents pipeline, “Linked documents” tab, ownership badge + selector, kind-aware attribute form, and chat auto-linking of newly-uploaded documents to a named asset (“here’s the registration for the Mazda” → documents.asset_id set to the Mazda CX-5).
The point of S24 is to make the dogfood asset detail match what a household actually needs to know about a vehicle or appliance — model number on the dryer when it breaks, VIN on the car when insurance asks, lease end date on the rental, registration PDF attached to the boat. The plumbing already exists (kind_registry, attributes_schema, Ajv validation, the documents pipeline). S24 just wires it up to the asset table the way S22 wired it up to obligations/contacts/transactions.
The 4-week dogfood window is sequenced right after S24. Threat model, PIA, and DR runbook still come last — they describe the system as it actually behaves once dogfood reveals the actual data flows, not as designed-but-unverified.