Story 1528: DOCTYPES follow-ups โ€” case-insensitive unique(firm_id, name) + seed defaults

Repo: pramaan-functions ยท Parent: EPIC-1489 ยท Follow-up to the Story-1497 DOCTYPES review.

๐Ÿ›ฐ๏ธ Satellite โ€” WHY this exists (the catastrophe it prevents)

Story-1497 shipped the firm-wide business.document_types catalog, but left a door welded shut on day one. A brand-new firm logs in, opens the Documents Register, and the document-type dropdown is empty. Every firm's very first real action โ€” "tag this file as a Contract" โ€” fails, because nobody put a Contract in the catalog. A controlled vocabulary that starts empty is no vocabulary at all: the first lawyer is forced to free-type, and the moment two lawyers free-type the same idea with different casing (Contract vs contract), the register can never group or roll up consistently again.

This Story closes both halves of that gap, the two follow-ups the 1497 review flagged:

  1. Uniqueness โ€” two live types in one firm can never collide on name, compared case-insensitively.
  2. A non-empty first run โ€” every new firm is born with a canonical starter catalog, the same way it is already born with default matter types, participant labels, and firm roles.

โœˆ๏ธ Airplane โ€” the analogy and the shape of the fix

Think of the catalog like the tabbed dividers in a physical case binder. Follow-up (1) is the rule "no two dividers may carry the same label" โ€” and the librarian reads labels case-blind, so Contract and contract are the same divider. Follow-up (2) is the pre-printed starter set of dividers that ships inside every new binder so counsel never opens an empty one.

The whole catalog domain (entity, repository, CRUD API, RLS, audit) already exists from 1497. This Story adds nothing to the wire contract โ€” it is a data-state fix, not a surface change. make openapi confirms zero drift.

Where the seed plugs in (firm provisioning)

FirmService.create_firm(...)        # one transaction, caller-owned
  โ”œโ”€ insert firm + subscription
  โ”œโ”€ seed_default_participant_labels   (existing)
  โ”œโ”€ seed_default_matter_types         (existing)
  โ”œโ”€ seed_default_document_types  โ†โ”€โ”€  NEW (this Story)
  โ””โ”€ seed_default_firm_roles           (existing)

๐Ÿ”ฌ Microscopic โ€” what shipped

Follow-up 1 โ€” case-insensitive uniqueness (already in place; verified, no migration)

The required constraint already shipped inside Story-1497's migration 0109 (f3a1c7e90b24, PR #383) as the partial-unique index uq_document_types_firm_lower_name_live:

CREATE UNIQUE INDEX uq_document_types_firm_lower_name_live
    ON business.document_types (firm_id, lower(name))
    WHERE availability = 'live';

This is exactly the filing-types parity the AC asks for: per-firm, case-insensitive (lower(name)), and scoped to live rows so an archived contract never blocks creating a new one. Adding a second migration for the same index would fail (CREATE INDEX name collision) and break the single-head rule, so the correct action is to verify and rely on it, not duplicate it. Alembic head is unchanged at 0114 โ€” single head preserved.

The application layer already enforced this case-insensitively too: repository.find_live_by_name matches on lower(name) == name.casefold() and raises 409 pramaan.document_type.name_conflict; the unique index is the last-line DB guarantee under a race.

Follow-up 2 โ€” seed defaults on firm-create (NEW)

FileChange
src/domains/document_type/service.py + DEFAULT_DOCUMENT_TYPE_NAMES (12 canonical labels, ending in Other) and + seed_default_document_types(db, *, firm_id, created_by_user_id)
src/domains/firm/service.py wire the seed into create_firm, alongside the existing matter-type / participant-label / firm-role seeds
tests/integration/test_app_document_types.py + idempotency/canonical test and + archived-row-respect (case-insensitive) test

The starter set (a firm can rename/archive any of these afterward):

Contract ยท Affidavit ยท Pleading ยท Order ยท Judgment ยท Notice ยท
Correspondence ยท Bank Statement ยท Invoice ยท Photograph ยท
Identity Document ยท Other

The seed contract โ€” titled by reason, not by mechanism

Proof โ€” real requests against a real (empty-first-run) DB

Run against the isolated Docker test DB at head (make test-db โ†’ 0114):

One-sentence recall

1497 already gave the DB the case-insensitive unique(firm_id, lower(name)) WHERE live guard, so this follow-up's real work is the missing non-empty first run: every new firm is now born with a canonical, idempotent, archive-respecting starter set of document types โ€” the same seed pattern as matter types and participant labels, just without a number.