Wire matter type into matter creation
S2 built the matter_types catalog but nothing pointed at it. This Story makes it real: a matter now stores a true matter_type_id FK, the matter register's type column is read from a live join instead of a JSON guess, and the placeholder sentinel that pretended a type existed is deleted. A brand-new firm with an empty catalog can still create its first matter — by minting a type inline.
The mental model: before S3, every matter quietly stored an all-zero matter_type_id sentinel and the list "type" was scraped from template_snapshot["template_name"] with a "Blank" fallback — a fiction. S3 replaces the fiction with a fact: a real composite FK (firm_id, matter_type_id) → business.matter_types, a real join for the display value, and a real validation rule. Preset path: the chosen preset's type wins. Scratch path: a type is required — pick one or mint it inline via POST /app/matter-types, then pass its id. No special create-matter coupling; the catalog endpoint does the minting.
Why this exists
Spike-1274 / ADR-115 made Matter Type the coarse "what kind of matter is this" axis. S1 freed the package, S2 shipped the catalog, and S3 — this Story — wires it into the one place it matters: matter creation. The old compatibility surface (an ignored request type code, an ignored forumType, an ignored legacy matterTypeId string, and the LEGACY_MATTER_TYPE_ID_COMPAT sentinel stamped into every response) existed only because there was no real column to read. Now there is, so the request-side fictions retire.
What changed
Migration (0098)
Adds matter_type_id UUID NULL to business.matters and business.matter_templates, each a composite (firm_id, matter_type_id) FK to matter_types (firm_id, id) — RLS-safe, firm-leading, ondelete RESTRICT, firm-leading index. Downgrade drops both.
Real request field
MatterCreateRequest.matter_type_id is now a real UUID. The deprecated ignored fields matter_type_id (old string), legacy_type (type), and forum_type are removed.
Resolution rules
Preset → the preset's matter_type_id auto-sets the matter's type. Scratch → a type is required. Any id supplied is validated as a live matter type in the caller's firm.
Real join, not JSON
_matter_type_display / _matter_type_filter (which read template_snapshot["template_name"]) are replaced by a left join to matter_types for the list type value and the ?type= filter. "Blank" now means untyped (NULL FK).
Sentinel retired
LEGACY_MATTER_TYPE_ID_COMPAT — the all-zero UUID stamped into every response — is deleted along with its export. Response matterTypeId now carries the real FK value.
Delete guard live
MatterTypeService._in_use_references now counts matters and presets referencing a type. Deleting an in-use type is rejected with pramaan.matter_type.in_use; archive is the only removal path.
First-run safety (the system-boundary gate)
The acceptance gate is not a green unit test — it is a real request on a freshly provisioned empty firm whose catalog has zero rows. The new integration test test_app_matters_empty_firm_inline_mint_type_and_create proves the door is not welded shut:
| First-run state | Behaviour exercised end-to-end |
|---|---|
Empty firm, POST /app/matters with no type | rejected with pramaan.matter.matter_type_required — the gate |
Mint a type inline (POST /app/matter-types), then create | 201; matterTypeId is the minted id; type is the real name |
Unknown / non-firm matterTypeId | rejected with pramaan.matter.matter_type_invalid |
Preset carrying a type, request omits matterTypeId | matter's type auto-sets from the preset |
Matter register list + ?type= filter | type reflects the real join (not template_name); filter resolves on the joined name |
| Delete a now-referenced type | 409 pramaan.matter_type.in_use — archive instead |
Scope boundary — response compat lingers one release
The request-side compat fields retire here, but the response-side compat fields stay one release: the deprecated type string and the deprecated matterTypeId field remain in MatterListItem so the app's OpenAPI mirror does not break mid-migration. They are marked deprecated and now carry real values. A later Chore drops them and regenerates both snapshots once the app consumer migrates (design note Chore C; coordinated under ADR-031). The preset's own matter_type_id is wired as a column and read at create, but the preset editor that sets it is a separate app-lane ticket — until it ships, presets carry a NULL type and the matter is created untyped, which is allowed.
How it was verified
uv run ruff checkon every changed/new file — clean.- App + admin apps import cleanly;
make openapiregenerated both snapshots. The change is contained to the matter contract: the request loses three deprecated fields, requestmatterTypeIdbecomes a realuuid, and responsematterTypeIdbecomes a nullableuuid.admin-api.jsonis untouched; re-runningmake openapileaves the diff clean (deterministic). - Unit + contract DTO tests green: matter DTO round-trip with the real FK, and the matters OpenAPI contract test updated to assert the retired request fields are gone and the response compat fields stay deprecated.
alembic headsreports a single head (0098) chained off0097.- DB-backed verification (migration up/down/up round-trip,
alembic check, and the full integration suite including the empty-firm system-boundary test) runs in CI against CI Postgres — the worktree has no Docker daemon, so per repo workflow CI is the system-boundary gate. The integration suite was rewritten to the new contract: everyPOST /app/matterssuccess path now mints/links a real matter type, and the matter register/filter assertions read the real join.
Engineering outcome: a matter's type is now a real, validated, firm-scoped FK — not a JSON scrape and not an all-zero lie. A new firm can create its first matter on day one by minting a type inline, and the catalog's delete guard finally has real references to protect. The app picker + inline-create wiring (separate app-lane ticket) now has a true contract to build against.