Story 1877: the label and the identity are two different things — annexure columns + freeing exhibit_label
Why this exists (the catastrophe it prevents). A filing has two kinds of names, and an early schema fused them into one column. The first is the annexure label a drafter writes on a copy that rides a parent's filing — “Annexure A”. The second is the exhibit identity a court assigns to a standalone document — Ex. P-1. Migration 0108 created a single exhibit_label column and bolted on a CHECK constraint, documents_exhibit_label_requires_parent, that says: you may only carry a label if you ride a parent. That welds the door shut for lead documents: a lead document that is itself an exhibit can never be labelled, because the constraint forbids any label on a row without a parent. As long as that constraint stands, the exhibit feature is impossible to build — the schema actively rejects it. This Story does the expand half of an expand/contract: it adds the real annexure columns, copies the existing values onto the right one, and removes the constraint so lead documents are free, while keeping the old column alive and clearly marked dead so nothing in flight breaks.
🛰 Satellite view: add the right columns, free the lead docs
Migration 0140_document_annexure_columns (revises the current head a3f7c2e9b105; the panel’s "use 0138" was overtaken by two 0138/0139 render migrations landing first) is an expand/contract-safe step — additive columns, a backfill, and a constraint drop. Nothing is deleted.
| Move | What | What it buys |
|---|---|---|
ADD filing_label VARCHAR(64) | The annexure label, drafter-set ("Annexure A"). 1:1 with the document instance, so it stays on the row. | A correctly-named home for the sticker, independent of the deprecated column. |
ADD annexure_position INTEGER | A counsel-reorderable index sequence within a filing. | Tabs can be re-ordered without renaming. |
ADD filing_copy_type VARCHAR(16) | Copy provenance: original / certified / notarised / attested. App-validated, no DB CHECK. | Provenance is captured; the enum stays app-owned and free to grow (RFD-DOC-003 D10). |
BACKFILL filing_label ← exhibit_label | Per pramaan-docs ADR-125 D3, every existing exhibit_label value is in fact an annexure label. | No data is lost — the existing labels land on the correctly-named column. |
DROP CHECK documents_exhibit_label_requires_parent | Constraint only. No data touched. | A lead document can finally carry a label — the welded door is open (verified end-to-end after T1b). |
RETAIN exhibit_label, marked DEPRECATED | Column kept; its comment now reads "DEPRECATED (Story-1877): use filing_label… do not write." | Anything still reading the old column keeps working through the contract phase; the deprecation is self-documenting in the catalog. |
✈️ Airplane view: copy, don’t move — so the round-trip is lossless
The upgrade copies exhibit_label into filing_label; it does not move it. The old values stay exactly where they were. That single decision is what makes the downgrade safe and the whole step reversible on a staging clone:
upgrade() downgrade()
───────── ──────────
+ filing_label, annexure_position, COMMENT exhibit_label ← original (0108)
filing_copy_type UPDATE exhibit_label = NULL
filing_label ← exhibit_label (copy) WHERE parent_document_id IS NULL ← clean first
DROP CHECK requires_parent RECREATE CHECK requires_parent
COMMENT the 3 new cols DROP the 3 new cols
COMMENT exhibit_label = DEPRECATED (exhibit_label values never left — intact)
Because the upgrade copied rather than moved, downgrade has nothing to restore into exhibit_label — it simply drops the three new columns and the originals are untouched.
🔬 Microscopic view: the decisions that matter
1. The downgrade must clean before it can re-constrain (RFD-DOC-003 §10 A2)
Here is the trap. Once the upgrade drops documents_exhibit_label_requires_parent, the database permits a lead document (parent_document_id IS NULL) to acquire an exhibit_label. If a downgrade naively re-creates the old CHECK, Postgres validates it against existing rows and the migration fails the moment any such lead-doc label exists. So downgrade nulls out exactly those offending values first:
UPDATE documents.documents
SET exhibit_label = NULL
WHERE parent_document_id IS NULL AND exhibit_label IS NOT NULL;
-- only then:
CREATE CHECK documents_exhibit_label_requires_parent
(exhibit_label IS NULL OR parent_document_id IS NOT NULL)
This is a deliberate, narrow data change on the reverse path — it touches only lead-doc legacy labels that the dropped constraint made possible, and the real value already lives safely on filing_label.
2. No DB CHECK on filing_copy_type — on purpose
The other two enums on this table (processing_status, availability) carry DB CHECKs because their value sets are closed and load-bearing for query correctness. filing_copy_type is different: the copy-provenance vocabulary (original / certified / notarised / attested) is expected to grow as jurisdictions add copy types, and a DB CHECK would force a migration for every new value. RFD-DOC-003 D10 puts validation in the app, where the enum is owned, so the column is a plain VARCHAR(16).
3. The entity comment and the migration comment must be byte-identical
CI runs alembic check, which compares the live schema against the SQLModel Document entity. Column comments are part of that comparison. The comment= on each new Field in entity.py and the COMMENT ON COLUMN string in the migration are therefore kept character-for-character equal — including the deprecation notice now on exhibit_label. A drift between the two is a CI failure, not a cosmetic nit; the migration carries a comment reminding the next editor of this coupling.
4. The entity loses one CHECK, keeps the column
In __table_args__ the documents_exhibit_label_requires_parent CheckConstraint is removed (mirroring the DROP) while documents_parent_not_self stays. The exhibit_label field remains in the model — deprecated, not deleted — alongside the three new fields, so existing read paths compile and run unchanged through the contract phase.
How it was proven
- Migration round-trip on the isolated Docker test DB (
make test-db, Postgres 16 on :55432):alembic upgrade head→downgrade base→upgrade headall clean; single heade9c4a7b1d602after rebasing onto the real heada3f7c2e9b105. - No drift:
alembic checkreports "No new upgrade operations detected" once the entity and migration comments were aligned. - Live schema verification (system boundary, not just unit): queried
information_schema.columns— all four columns present with correct types (filing_label/exhibit_labelvarchar(64),filing_copy_typevarchar(16),annexure_positioninteger);pg_constraintconfirmsdocuments_exhibit_label_requires_parentis GONE;exhibit_labelcolumn comment reads DEPRECATED. - Existing behaviour intact:
tests/integration/test_app_document_filing_link.py(annexures carryingexhibit_labelon real schema, RLS aspramaan_app) — 6 passed. - Verify:
ruffclean on changed files;make openapiproduced no diff (the entity is a DB table, not a response_model — the new columns surface in no DTO, so the OpenAPI mirror is unchanged).
filing_label column (with position and copy-type beside it), the existing values are copied there, and the constraint that forbade lead documents from carrying a label is gone — the mis-named exhibit_label stays only as a deprecated, do-not-write tombstone until the exhibit register lands in T1b.