ADR-0031: Released-base readability enforcement & copy-on-derive mechanics¶
- Status: accepted
- Date: 2026-07-01 (accepted; drafted 2026-06-30)
- Deciders: Nicolas Burri
- Relates to: ADR-0002 (copy-on-derive),
ADR-0027 (RLS content/control plane),
ADR-0029 (content model & freeze),
ADR-0026 (RLS is the hard backstop),
ADR-0021 (
derived-fromprovenance)
History. Drafted autonomously (night-shift 2026-06-30) to tee up Phase 5, then reviewed and accepted by Nicolas on 2026-07-01: Option A chosen, with the performance constraints below folded in. The what was already baselined (REQ-DOC-010 copy-on-derive; REQ-SEP-010 permission-gated, released-only base readability;
DERIVE/BASE_READin the V004 catalog); this ADR fixes the enforcement mechanism for "released-only" base readability, which touches the top quality goal (mandator/project separation) and the RLS backstop. The one implementation sub-question — the lifecycle state a derived copy lands in — was resolved at the 2026-07-01 checkpoint: DRAFT (re-review locally).
Context¶
Copy-on-derive (ADR-0002) lets a project adopt a released global-base version as a full,
independent copy with a derived-from provenance link. Two reads of global-base content are in
scope (REQ-SEP-010):
- Explicit browsing — a user holding
BASE_READvia a global-scope role assignment may read released base content (e.g. to decide what to derive). - Implicit during DERIVE — a user authorized to
DERIVEinto the target scope may read the specific released source version for the duration of the derive, even withoutBASE_READ.
The tension is with the uniform content-RLS policy. Every content table
(document, document_version, content_part) carries a scope_id and the policy is
scope_id = any (lqms_authorized_scope_ids()) (V007/V009/V010). The V007 comment already records
the intended direction: "released-base readability is a permission resolved into the authorized
scope set." But a scope set is row-blind: adding the global scope to a user's authorized set
would expose all global content — including base drafts — whereas REQ-SEP-010 grants only
released base content. content_part has no state column (state lives on document_version),
so "released-only" cannot be expressed by scope_id membership alone.
The project's standing principle is that RLS is the hard backstop (ADR-0026, RISK-001/002): a buggy or injected query must not be able to cross a separation boundary. The decision is where and how "released-only base readability" is enforced so that this guarantee still holds for base drafts.
Options Considered¶
Option A — State-aware permissive RLS policy (RLS remains the backstop)¶
Add a second, PERMISSIVE RLS policy (OR-combined with the tenant-isolation policy) to the content
tables, granting read-only access to released global-base rows when the session user holds the
right. Requires a session variable for the acting user id (e.g. lqms.current_user_id) and a
SECURITY DEFINER predicate lqms_may_read_base(user).
document/document_version:USING (s.type = 'GLOBAL' AND state = 'RELEASED' AND lqms_may_read_base(...))(document has no state → join to its current released version, or expose only via version).-
content_part:USING (exists (select 1 from document_version dv where dv.id = version_id and dv.state='RELEASED' and dv.scope_id = <global>) AND lqms_may_read_base(...)). -
Pros: RLS stays the true backstop — even a forgetful query cannot read a base draft; faithful to ADR-0026; uniform "resolve into the policy" direction the schema already anticipates.
- Cons: more complex RLS (a correlated subquery on
content_partreads); introduces acurrent_user_idsession var alongside the scope-set var; small per-read cost; the implicit-during- derive case still needs the acting user to be recognised as "may read base" for that transaction.
Option B — Resolve the global scope into the authorized scope set (rejected)¶
Treat BASE_READ like VIEW: a resolver adds the global scope id to the authorized set.
- Pros: zero new policy; reuses
lqms_authorized_scopesshape exactly. - Cons: over-exposes — the authorized set is row-blind, so this leaks base drafts, not just released content. Violates REQ-SEP-010. Rejected.
Option C — Application-enforced read path; global base stays out of RLS¶
Keep content-RLS strict (global base never enters any authorized set). Serve both reads through a
narrow, audited SECURITY DEFINER path that always filters state = 'RELEASED' and the global
scope — e.g. lqms_released_base_* functions for browsing, and the derive operation reads the
specific released version through such a function.
- Pros: no RLS change; no new per-row subquery; "released-only" lives in one place; the implicit- during-derive case is natural (the function is the operation).
- Cons: for base content, RLS is no longer the backstop — a bug in the definer path could expose base drafts. Narrower blast radius than it sounds (one small, audited path, only ever selecting RELEASED), but it is a deliberate, documented exception to ADR-0026 for base reads.
Decision¶
Option A for browsing readability (keep RLS as the backstop for base drafts, consistent with
ADR-0026), combined with handling the implicit-during-derive case through the same
lqms_may_read_base predicate also returning true when the acting user holds DERIVE in some
target scope for the duration of a derive transaction — or, if that proves to overreach, scoping the
implicit read to the single source version inside the derive operation.
Rationale: the separation backstop is the project's defining invariant; paying a little RLS complexity to keep "no base-draft leak" enforced by the database (not by careful code) is the trade the rest of the architecture has consistently made. Option C remains the documented fallback if, against expectation, the performance constraints below cannot be met.
Copy-on-derive mechanics (independent of A/C, all already baselined — for confirmation):
- Source must be a RELEASED version in a derive-source scope (
scope.may_be_derive_source, today the global base). Validate before any copy (REQ-DOC-010). - Authorization: caller holds
DERIVEin the target scope (functional permission, ADR-0012) and the target scopemay_hold_records/ accepts the type (type activated, REQ-CAT-006). - Copy is byte-exact and content-addressed: the released version's
content_partrows and their blobs are re-materialized into the target scope as a newdocument+document_version. The copy lands in state DRAFT (resolved at the 2026-07-01 implementation checkpoint): the adopting scope re-reviews and releases it under its own review policy before it becomes effective — conscious adoption with local approver/four-eyes accountability (ISO 13485), and the content may be adapted before local release. Blob store is per-scope partitioned (ADR-0001), so the copy is physically written under the target scope. - Identity: the derived document retains the source document ID (REQ-DOC-002/011);
uniqueness is per
(scope, document_id), so the same ID legitimately appears in source and target. - Provenance: record a version-level
derived-fromrelationship (ADR-0021, REQ-REL-001/002) bound to the exact source version. - Policy fit: the derive validates that the target scope's applicable review-policy roles are activated/staffed, else prompts to activate/staff or adapt the policy copy — an auditable config change (REQ-CAT-006).
- Independence after derive: a later source release does not change the copy; affected scopes are notified and adopt consciously (REQ-DOC-012, REQ-NOT-005). Disposal of a derived copy is independent (REQ-DOC-018).
Performance (Option A constraints)¶
An RLS policy is an implicit filter folded into every plan; a PERMISSIVE policy is OR-combined with the tenant-isolation policy. The cost is governed by what each predicate does per statement vs. per row. The chosen design must observe these constraints so Option A adds ~zero cost on the hot path (a tenant reading its own scope):
- Session-only,
stablehelpers.lqms_may_read_base(user)depends only on session state and is markedstable, so it is evaluated once per statement, not per row. For every user without base-read it collapses tofalseand the entire base-read branch is pruned from the plan — a non-base-reader pays nothing. - Constant-gate the only expensive predicate.
content_parthas nostatecolumn, so released-only requires anEXISTSback todocument_version. Write it asscope_id = <global> AND lqms_may_read_base(user) AND exists(… dv.state='RELEASED' …)so the two leading per-statement constants gate the subquery: it fires only for global-scope content_part rows and only for a base-reader. Normal tenant queries (filtered by their ownscope_id) never touch it. - Inject the global scope id as a GUC (like
lqms.authorized_scope_ids) so the policy compares against a constant rather than sub-selecting "which scope is GLOBAL". - Index support.
content_part(version_id)(already an FK) makes each rareEXISTSa PK-index probe;document_version.idis the PK. - Watch leakproofness / planner ordering. RLS treats USING predicates as security barriers; a
non-
leakprooffunction can force the RLS filter to run before other filters and occasionally hurt a plan. Because app queries already filter byscope_id, the blast radius is small — but it must be verified withEXPLAIN.
Acceptance: an EXPLAIN/benchmark pass against the ADR-0018 / REQ-NFR targets, on both the
common tenant-read path and the base-read/derive path, before the readability mechanism is called
done. If the targets are unexpectedly missed, fall back to Option C for base reads.
Consequences¶
- Implementation checkpoint still decides two sub-questions before/while building: the derived copy's initial lifecycle state (DRAFT-for-re-review vs RELEASED-as-adopted), and whether the implicit-derive read is predicate-based or operation-scoped.
- Option A requires a new
lqms.current_user_idsession var set wherever content is read (extendAuthorizedRequestContext/RlsScopeContext) and a GUC for the global scope id, pluslqms_may_read_baseand the permissive policies — all under the existing pooled-connection- isolation test discipline (REQ-SEP-011) and the performance constraints above. - Either way, copy-on-derive needs: a
DeriveService, thederived-fromrelationship table (if not yet present — to confirm against ADR-0021's status), and notification hooks (REQ-DOC-012), which can follow as their own slices. - Copy-on-derive is now unblocked for implementation under this accepted decision.