Story 1497: document_types — firm-wide document type catalog (FN-DOCTYPES)

WHY — the catastrophe this prevents

A litigation matter accumulates hundreds of files — contracts, affidavits, orders, bank statements, photos. Counsel needs to organise the Documents Register by what each file is. Without a controlled vocabulary, "what is this document?" becomes free text: one lawyer types Contract, another contract, a third Agreement, and the register can never group, filter, or roll up consistently. This Story gives the firm a single, governed Document Type catalog — the same shape as Filing Types, but with one deliberate divergence (below).

The governing decision (LOCKED — PRD O1 (document-type catalog))

Document Type mirrors the structure of business.filing_types (id, firm_id, name, description, availability, created/archived) but is firm-wide, NOT scoped per practice area. A document type — Contract, Bank statement, Photo — is universal across practice areas, unlike a filing type (which is procedural and lives inside a proceeding). Two further consequences of "it is just a label, not a court-cited reference":

What shipped (one repo, one PR — pramaan-functions)

Migration 0105 (business + documents schemas)

ObjectChange
business.document_typesNEW firm-wide catalog: id, firm_id, name(128), description(512)?, availability('live'|'archived'|'deleted'), created_by_user_id?, archived_at?/archived_by_user_id?, deleted_at?/deleted_by_user_id?, created_at, updated_at
constraints / indexesCHECK availability, UNIQUE(firm_id, id), partial-unique lower(name) WHERE availability='live', firm-leading lookup indexes
RLSENABLE + FORCE; firm-isolation SELECT, firm-write INSERT, firm-update UPDATE, staff-metadata SELECT; GRANT SELECT,INSERT,UPDATE TO pramaan_app; set_updated_at trigger
documents.documents+ document_type_id uuid NULL FK → business.document_types.id (ondelete RESTRICT) + firm-leading index ix_documents_firm_document_type

Downgrade drops the column/FK/index then the table, policies, grant, and trigger. Roundtrip proven (upgrade head → downgrade base → upgrade head) and autogenerate reports no drift against the entity.

Domain + API

src/domains/document_type/
  entity.py        DocumentType (SQLModel) — no number column
  dtos/            Create/Update/ListQuery + ListItem/DetailView/ListResponse
  repository.py    RLS-scoped reads/writes via app.config_firm_id_or_null()
  service.py       CRUD + visibility lifecycle + delete-guard + audit events
  AGENTS.md        domain doctrine
src/app_api/router_document_types.py   /app/document-types CRUD (settings:write)
RouteBehaviour
GET /app/document-typeslist live (or ?includeArchived=true) — never deleted; search + pagination
POST /app/document-typescreate; case-insensitive name conflict → 409 pramaan.document_type.name_conflict
GET /app/document-types/{id}detail with usageCount/isInUse/canDelete
PATCH /app/document-types/{id}rename/redescribe/availability transition
POST …/archive · …/unarchivevisibility lifecycle (delegate to the update path)
DELETE /app/document-types/{id}soft-delete only when unused; in-use → 409 pramaan.document_type.in_use

Lifecycle & the delete-guard (why archive exists)

A document type is never hard-deleted. availability moves live → archived → deleted; deleted rows are retained forever for FK integrity and audit. DELETE is allowed only when no documents.documents row references the type — otherwise the backend rejects it and the user must archive instead. This is the "archive is broken-by-construction if you cannot list archived rows" rule: management lists honour includeArchived so an archived type can always be restored.

Observability & audit

No bespoke spans (pramaan-docs ADR-124): the CRUD path rides the FastAPI auto-instrumentation floor (HTTP + DB spans). Every mutation emits a typed settings-catalog audit event via emit_catalog_event(kind="document_type", …) — created / edited / deleted, stamped area=settings. The subject kind is registered in the audit registry and the routes are listed in the route-coverage manifest, so the coverage and flush-watchdog gates pass.

Proof — real requests against a real (empty-first-run) DB

Integration tests run a fresh firm with an empty catalog through the live router: create → duplicate-conflict → list → patch → archive → list excludes archived → includeArchived shows it → unarchive → pin a real documents.documents row to the type → in-use DELETE is 409 → unlink → DELETE 204 → detail 404 → audit-log shows the document_type.* sequence. Plus cross-tenant RLS isolation, settings:write permission enforcement, and unauthenticated 401. Contract test asserts the OpenAPI surface (paths, schema titles, the absence of any documentTypeNumber and of matter-type scoping). make openapi snapshots committed (app-api only).

One-sentence recall

Document Type is Filing Type minus the practice-area scoping and minus the number — a firm-wide, archive-not-delete label catalog that the Documents Register groups by, with the document→type link landing now as a nullable FK and the AI fill deferred to EPIC-009.