unique(firm_id, name) + seed defaultsRepo: pramaan-functions ยท Parent: EPIC-1489 ยท Follow-up to the
Story-1497 DOCTYPES review.
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:
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.
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)
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.
| File | Change |
|---|---|
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
Contract, re-seeding does not resurrect
it โ the match is availability != 'deleted', case-insensitive,
mirroring the matter-type and participant-label seeds exactly.*_number and call no business.allocate_reference โ the
seed just inserts rows (PRD O1, LOCKED).app.current_firm_id for the insert and restores the prior value in
a finally, so the firm-write RLS policy passes and no firm context
leaks to the next operation.create_firm boundary span; it adds no new external
boundary, so it is instrumentation-exempt by the caller-covered rule.
Run against the isolated Docker test DB at head (make test-db โ
0114):
test_seed_default_document_types_is_canonical_and_idempotent โ
seeds a fresh empty firm, runs the seed twice, asserts the live
catalog equals the full canonical set with no duplicates and includes
Other.test_seed_default_document_types_respects_archived_rows โ
pre-archives a lowercase contract, re-seeds, asserts exactly one
contract row survives and it stays archived (the
case-insensitive skip works).test_admin_firms,
test_webhooks_clerk_route, services) stay green with the new seed
in the create path.make openapi โ no drift (no contract change).
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.