ADR-0029: Document Content Model — Draft-Freeze, Content-Table RLS, Append-Only Audit¶
- Status: accepted
- Refined by: ADR-0045 (draft attachment working area; IMAGE parts at freeze), ADR-0110 (the append-only trail is not a read model — written by every act, read only to be shown or counted; business questions are answered by promoted facts, not by re-reading the trail)
- Date: 2026-06-30
- Deciders: Nicolas Burri
- Refines: ADR-0001 (storage), ADR-0017 (multi-part content), ADR-0027 (content-plane RLS); relates to ADR-0003
Context¶
Phase 3 introduces the content domain (document, document_version, content_part,
audit_event). Three realization questions are not fully settled by the existing ADRs:
how RLS lands on content tables (ADR-0027 deferred the how to this phase), how a mutable
draft reconciles with the immutable content part of ADR-0017, and how the audit trail's
append-only guarantee (REQ-AUD-002, Part 11 / Annex 11) is enforced.
Decision¶
-
Scope key on every content row; single-table RLS. Every content-bearing row carries its own
scope_id(denormalized ondocument_version/content_part, not joined from the parent), so RLS policies are single-table, non-recursive predicatesscope_id = any (lqms_authorized_scope_ids())(REQ-SEP-003/004/012). This realizes the ADR-0027 move: RLS goes onto the content tables and the placeholder RLS is removed fromscope/mandator. The move is sequenced so the separation proof is never red — the first content table ships with its policy and a green separation test, and only a later migration removes the placeholder and re-targets the suite. Content policies carry noGLOBALread-exception; released-base readability (REQ-SEP-010) is a permission resolved into the authorized scope set (Phase 5), not an RLS escape hatch. -
Draft-freeze content model. A DRAFT version's content is mutable working state;
content_partrows and their hash-addressed blobs are materialized only at content-freeze (on submit), and are immutable thereafter (ADR-0017). The version'smanifest_hashis set at freeze. Byte-exact reproduction (REQ-DOC-007) is guaranteed from the frozen parts + manifest (ADR-0001/0025). Mutable draft content therefore lives in a distinct working area, not incontent_part, preserving an unconditional content-part immutability story. -
Append-only audit by privilege.
lqms_appis granted only INSERT + SELECT onaudit_event(no UPDATE/DELETE grant, no UPDATE/DELETE policy) — REQ-AUD-002 is enforced by privilege omission, with no trigger. Field-level old→new changes are stored asjsonb(REQ-AUD-005); one event per logical operation (REQ-AUD-006 inspection copy).
Consequences¶
- The redundant
scope_idcolumns are kept consistent by the writing service (a deliberate cost for non-recursive RLS predicates and literal REQ-SEP-003 compliance). - After the move, the separation unit test no longer asserts mandator invisibility at the DB
level —
scope/mandatorare control-plane, permission-governed (ADR-0027's accepted trade-off); that guarantee shifts to an application/permission test. - Draft content needs a mutable home distinct from
content_part(a draft-content field/area), defined when the document/version service is built. - Audit immutability has no defense beyond privilege omission; a future erroneous grant (or a
BYPASSRLS admin path) could weaken it. Accepted; a
BEFORE UPDATE/DELETEguard trigger is the documented hardening to add if a later review wants defense-in-depth.
Requirements¶
Relates to REQ-DOC-001..007/014/016/018, REQ-AUD-001/002/005/006, REQ-SEP-003/004/012; no new requirement IDs (realization of existing ones).