Story 1501: FN-PRIVILEGE — a hard privilege_flag + an index gate, never a tag
Why this exists (the catastrophe it prevents). In a litigation matter, the lawyer's own draft strategy memo and the opponent's filed affidavit live side by side in the same document register. One is discoverable evidence; the other is attorney work-product that must never be searched, surfaced, or fed to AI as evidence. If "privilege" were modelled as a freeform tag, a single mistag — or a tag silently dropped on import — would push work-product into the evidence search index. That is privilege waiver: the worst-case failure in a legal product, and in most jurisdictions irreversible. This story makes privilege a hard boolean with a physical index gate, so work-product is excluded from evidence machinery by construction, not by anyone remembering to label it correctly.
The shape: one hard column, one partial index, one gate predicate
Three pieces, each load-bearing:
| Piece | What it does |
|---|---|
documents.documents.privilege_flag | BOOLEAN NOT NULL DEFAULT false. Every existing and new document is non-privileged until counsel explicitly marks it work-product. It is a column, not a row in a tags table — it cannot be "forgotten" off a document. |
ix_documents_evidence_indexable | A partial index (firm_id, matter_id) WHERE NOT privilege_flag. This is the index gate, made physical: privileged rows are literally absent from the index the evidence-eligibility query reads, so work-product can never be picked up by a scan over it. |
_evidence_indexable_conditions(...) | The single SQL predicate that defines "may enter the evidence index": status = 'uploaded' AND privilege_flag IS FALSE, firm- and matter-scoped. The evidence search index builder and Mode-2 evidence processing (EPIC-009) both read through this one predicate, so the gate holds in both places — not just the search box. |
upload/finalize -> privilege_flag = false (default) -> IN the evidence gate counsel marks privileged -> privilege_flag = true -> OUT of the gate (index + Mode-2) counsel clears it -> privilege_flag = false -> back IN the gate
Mode discipline
Mode-1 is deterministic now: counsel sets the flag by hand. Mode-2/3 (AI suggests / counsel confirms) is EPIC-009. The column is the same hard boolean in every mode; the worker pipeline, when it lands, sets privilege_flag through the same gated path and is excluded from indexing by the same predicate — no migration needed to add the AI path.
Who can flip it — counsel only, enforced server-side
Setting privilege is the act that protects work-product, so it cannot be a free-for-all. PATCH /app/matters/{matter_id}/documents/{doc_id}/privilege is gated by require_permission("matters:write") — the permission carried only by counsel-tier roles (firm_admin / firm_editor). A read-only firm_member, or support, is rejected with 403 before any write. Authorization lives in the dependency layer, never inlined in the handler.
@router.patch(".../privilege", response_model=DocumentListItem, responses=AUTH_NOT_FOUND_VALIDATION_SERVER_RESPONSES)
async def set_matter_document_privilege(..., _: Annotated[None, Depends(require_permission("matters:write"))]):
return await DocumentService.set_privilege(db, matter_id=..., document_id=..., privilege=req.privilege)
Audit on every flip
Privilege waiver is the worst-case legal failure, so every real change is on the record. A flip that actually changes state emits a typed document.privilege_changed audit row (new access_security verb in the closed registry) recording who (actor stamped from the verified session) and when, scoped to the matter and the document. A no-op set (already in the requested state) is idempotent and emits nothing — the log records changes, not noise. The write lives in the caller's transaction, so the audit row commits atomically with the flag or not at all.
Observability
Per pramaan-docs ADR-124, the manual-span registry is closed and names no document-privilege operation. The PATCH and the evidence-index read are each a single firm-scoped DB write/read inside the request span — covered by the auto-instrumented HTTP server span plus the DB span (the T1 floor). No raw tracer calls, no hand-rolled plumbing span, no file content on attributes. Instrumentation-exempt by the auto tier, documented at the call site, not by omission.
Reading the gate
GET /app/matters/{matter_id}/documents/evidence-index returns the deterministic Mode-1 manifest of exactly what is evidence-eligible: uploaded, non-privileged documents (operationId: appGetMatterEvidenceIndex, response_model=EvidenceIndexManifest, canonical ErrorResponse on every code). Marking a document privileged drops it from this manifest immediately; the document is still fully listable in the register with its privilegeFlag surfaced, so counsel can see and reverse the designation — it just stops being evidence.
Acceptance evidence
| Acceptance criterion | Test / check |
|---|---|
| Column + migration (up/down clean, metadata parity incl. column comment) | make test-db-alembic-roundtrip → alembic check: “No new upgrade operations detected” |
| Index gate enforced: work-product excluded from evidence search end-to-end through the real router + real DB | test_app_document_privilege_flag_gates_evidence_index |
Who can set: a read-only firm_member is rejected 403; the flag never changes | test_app_document_privilege_requires_counsel_role |
| Audit on flip: exactly the two real changes logged (set + clear), who/when, no row on the idempotent no-op | test_app_document_privilege_flag_gates_evidence_index (audit assertions) |
| Gate covers Mode-2 too: one shared predicate read by the search index and the EPIC-009 worker | DocumentService._evidence_indexable_conditions (single chokepoint, documented) |
| Existing document flows unaffected; flag surfaced on the register item | test_app_documents_presign_finalize_list_download_and_audit + privilegeFlag in DocumentListItem |
| OpenAPI snapshots regenerated, clean operationIds, canonical error responses (incl. 403) | make openapi → openapi/app-api.json |
Review gate — can work-product reach the evidence index, or can a non-counsel mark privilege? No. The evidence-eligibility predicate filters privilege_flag IS FALSE and is backed by a partial index that physically excludes privileged rows; the search index and the Mode-2 worker both read that one predicate, so neither can see work-product. Only matters:write roles can flip the flag, enforced in the dependency layer before any write; every flip is an atomic, attributed document.privilege_changed audit row. Privilege is a hard column and a hard gate — never a tag a mistake can drop.
Recall: Story 1501 makes privilege/work-product a hard privilege_flag boolean plus a partial-index gate, so work-product is excluded from the evidence search index and Mode-2 processing by construction; only counsel-tier roles can flip it, server-side, and every flip is audit-logged. The AI suggest/confirm path arrives with EPIC-009 and reuses the same column, gate predicate, and audit verb — no schema change required.