ADR-0107: The Audit Append Gate Follows Assignment Reach, Not Content Reach¶
- Status: accepted
- Date: 2026-08-06
- Deciders: Nicolas Burri ("go with your recommendation for #5" — register
docs/planning/feedback-2026-08-06.md#5), on the second half of the lqms.ch fresh-install dead-end: his invite click failing withnew row violates row-level security policy for table "audit_event". - Relates to: ADR-0106 (the principle this extends — an assignment disclosed scope existence for reflection; here it anchors auditability), ADR-0029/REQ-AUD-002 (append-only trail — untouched), ADR-0012/REQ-ADM-004 (admin ≠ content — preserved, and the reason option (a) was rejected), V011 (the policy that moves), V005 (the function shape this mirrors), V075 (why the predicate needs the cascade: a scope's own creation is audited at the new scope).
Context¶
V011 gates audit appends with the same predicate as audit reads: scope_id ∈
lqms_authorized_scope_ids() — the caller's VIEW-derived set. Every installation-level admin
act (invite, role-create, assignment) audits at GLOBAL, and the bootstrap administrator holds no
VIEW anywhere by design. Result, proven live on lqms.ch: a fresh install's administrator can
perform no audited act at all. The escape the codebase documents (a governance role with VIEW
at GLOBAL) is itself un-grantable in-product, because granting is audited too — the chicken-and-egg
that forced the hand-made interim rows on the lqms.ch database.
Two structural options were framed in the register: (a) bootstrap ships a VIEW-at-GLOBAL coupling role — rejected: it makes the admin account content-visible by default, bending REQ-ADM-004's spirit at exactly the moment auditors probe it; (b) this decision.
Decision¶
- The append policy's predicate becomes assignment reach: the scopes where the caller holds a
role assignment at the scope itself or an ancestor (GLOBAL over all, a MANDATOR scope over
the mandator's scopes) — precisely the V005
lqms_authorized_scopesshape with itsrp.permission = 'VIEW'join dropped. This is the same cascadelqms_effective_permissionsresolves over, so the invariant reads: you may append into exactly the trails your staffing reaches — the same reach your effective permissions cascade over. Anti-forging is preserved; what falls away is only the accident that content visibility was the key to the audit door. - The read policy does not move. Reading a trail remains VIEW-gated (V011
audit_event_readuntouched) — disclosure is content reach, exactly as ADR-0106 left it. - The cascade is required, not a convenience (V075): a scope-creation act audits at the NEW scope, where no assignment can exist yet. Under assignment reach, the creator's GLOBAL (or mandator) assignment covers the newborn scope; under direct-assignment-only it would not, and the dead-end would survive for exactly the act that births projects.
- Since assignment reach is a strict superset of the VIEW-derived set (every VIEW is granted
by some assignment), the policy is a replacement, not a union:
with check (scope_id = any (lqms_assignment_reach_ids())), one newsecurity definerfunction beside its V005 siblings. - The realization proves the dead-end dead: the ADR-0106 fresh-install test grows act
assertions — bootstrap-only, no seed: invite a user, create a role, assign it, create a
mandator+scope, each succeeding and each leaving its audit event. The lqms.ch interim rows
(
QMS Operations Member) are removed once this deploys; the test is the proof they were never needed.
Consequences¶
- One migration (V080): the new function +
drop policy audit_event_append/ recreate with the new predicate. No table shape change, no data change. - The RLS test sweep must cover: pure admin can append at GLOBAL and at a freshly created scope; a caller staffed only in project P cannot append into sibling project Q's trail (the anti-forging negative); read visibility unchanged for all of the above.
- System actions (
actor_id null) run outside the per-request RLS context as before — unaffected. - The audit-trail disclosure story sharpens into one sentence: write where you are staffed, read what you may view.
Amendment 2026-08-08 — enforcement counts resolve on true state (register #20)¶
The first real fresh-install wizard walk (Nicolas, on the v0.9.3 candidate) found the principle's missing third leg. The key-rule freeze guard counts the target project's trace items under the CALLER's RLS — so a content-blind caller counts zero, and zero-because-blind is indistinguishable from zero-because-empty. The guard therefore refused callers without VIEW, which made the wizard's key-rule step structurally uncompletable for the pure administrator (the same population §1 exists to serve). The first half was a plain bug, fixed same day: the guard was anchored at GLOBAL though its own reason names the target project. This amendment decides the second half:
An ENFORCEMENT predicate resolves on true state, not on the caller's view of it. Where a gate
must know a fact about content the caller may not read (here: "how many items already use this
prefix in scope X"), the fact is resolved by a narrow SECURITY DEFINER function that answers
exactly that question — a count, never rows — and the caller-facing gate (the permission check)
stays untouched. Disclosure cost: one number, to a caller who already governs the scope's
configuration, and which the refusal message already printed for VIEW-holders.
The full sentence now reads: write where you are staffed, read what you may view, enforce on
true state. Realization: V085 (lqms_key_usage_count or equivalent), the KeyRuleService swap,
tests including blind-caller-completes-the-wizard and the definer function's no-wider-read pin.
Decider: Nicolas ("A sounds good"), 2026-08-08.