Story-1279 functions lane migration + contract + sentinel retirement

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 stateBehaviour exercised end-to-end
Empty firm, POST /app/matters with no typerejected with pramaan.matter.matter_type_required — the gate
Mint a type inline (POST /app/matter-types), then create201; matterTypeId is the minted id; type is the real name
Unknown / non-firm matterTypeIdrejected with pramaan.matter.matter_type_invalid
Preset carrying a type, request omits matterTypeIdmatter's type auto-sets from the preset
Matter register list + ?type= filtertype reflects the real join (not template_name); filter resolves on the joined name
Delete a now-referenced type409 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

  1. uv run ruff check on every changed/new file — clean.
  2. App + admin apps import cleanly; make openapi regenerated both snapshots. The change is contained to the matter contract: the request loses three deprecated fields, request matterTypeId becomes a real uuid, and response matterTypeId becomes a nullable uuid. admin-api.json is untouched; re-running make openapi leaves the diff clean (deterministic).
  3. 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.
  4. alembic heads reports a single head (0098) chained off 0097.
  5. 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: every POST /app/matters success 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.