ADR-0035: Fan-out contract as shipped — gate semantics, count disclosure, audit protocol¶
- Status: accepted
- Refined by: ADR-0046 (four-eyes: submitter also excluded)
- Date: 2026-07-02
- Deciders: Nicolas Burri
- Refines: ADR-0034 (whose Decision text stated a slightly stronger contract than the shipped control; ADRs are immutable once accepted, so the reconciliation is recorded here), ADR-0019
Context¶
The 2026-07-02 review round (4 parallel reviewers + verification) compared ADR-0034's stated
security contract against the shipped lqms_fanout_source_event and its surroundings, and found
the wording stronger than the code in three places, plus realization gaps (REQ-NOT-005 had no
producer; rejection raised no task; approval tasks were manually dismissible). This ADR records the
contract as it actually holds after the fixes (V021, handler, services), so an auditor
verifying the control against the ADRs finds exactly what ships.
Decision¶
1. The self-gate, precisely¶
The function writes into derived scopes iff all of: the version's state matches the claimed
event (RELEASED→SOURCE_UPDATED, REVOKED→SOURCE_REVOKED); the version's scope
may_be_derive_source; and the caller is authorized for the source scope
(lqms_authorized_scope_ids()). It does not verify the caller performed the transition
(ADR-0034 said "a release/revoke the caller legitimately acted on") — that stronger property is
delivered by composition, not by the function: the only caller is the lifecycle event handler
inside a real transition's transaction, transitions are one-shot (state-conditional UPDATEs), and
re-invocation is absorbed by the idempotency constraint. lqms_current_user_id() (named in
ADR-0034) is not part of the gate. Accepted as sufficient; requiring the transition's audit row in
the gate remains a possible future hardening.
2. The count is an accepted, deliberate disclosure¶
The function returns (and the audit records) how many recipients were notified. That number is cross-tenant information: the source scope learns how many foreign documents currently derive from its document, and — because absorbed fan-outs count 0 — whether derived tenants have been resolving their tasks. Accepted deliberately: the base operator has a legitimate operational interest in "did anyone get notified", the disclosure is a cardinality only (no identity, no content), and it is confined to the audit trail of the source scope. Rejected alternative: returning a boolean — loses audit value for no confidentiality gain the mandator-separation goal actually demands.
3. Audit protocol: every evaluated fan-out is audited¶
Return protocol: −1 = gate rejected (not a derive-source event by an authorized caller; nothing
evaluated, nothing audited — this is every ordinary project-document release), ≥ 0 = fan-out
evaluated and that many recipients notified. The handler writes a SOURCE_FANOUT audit event in
the source scope for every evaluated fan-out, including zero-recipient ones, so the trail
distinguishes "no derived copies / absorbed by an open task" from "fan-out never ran".
(ADR-0034's unconditional "the fan-out is audited" holds under this reading; auditing the −1 case
would flood every scope's trail with irrelevant entries and is rejected.)
4. Idempotency is a database invariant, and the writes carry integrity checks¶
"At most one OPEN task per (derived scope, document, source-type)" is enforced by a partial unique
index + ON CONFLICT DO NOTHING (V021), not by a racy NOT EXISTS. APPROVAL_REQUIRED is
deliberately outside the index — several roles legitimately hold parallel open approval tasks for
one document. The fan-out additionally requires derived.scope_id = relationship.scope_id, so a
future generic relationship writer cannot use a foreign source_version_id to make the system copy
foreign identifiers into its scope.
5. REQ-NOT-005 realized: notifications are born in the fan-out¶
Each fan-out task is accompanied by an informational notification to the same recipient — the
system's first (and so far only) notification producer, created inside the DEFINER function so no
app-side insert privilege exists (insert/delete on notification and delete on task are
revoked from lqms_app, V021). Known limitation: the message text is composed in SQL in English;
proper i18n of notification content is deferred to the delivery-channel work (REQ-NOT-007).
6. Task-model clarifications (REQ-NOT-002/003)¶
- Rejection raises a
REJECTEDrework task addressed to the version's author; it auto-resolves on resubmit (DONE) or cancel (CANCELLED). - Manual resolution (
markDone) is restricted toSOURCE_*tasks. Review-owned tasks (APPROVAL_REQUIRED,REJECTED) resolve exclusively through life-cycle events — otherwise one role holder could silently erase the whole role's review prompt without an approval existing. - Role-task thresholds use
requirementsMet's author-exclusion rule (the author's approval never counts, ADR-0030), and a role's task reflects that role's requirement; the policy-wide distinct-approver rule remains release-gating, visible through the live reviews queue. SOURCE_*tasks do not auto-resolve (deviation from ADR-0019's blanket auto-resolve): the "underlying action" (re-derive / local rework) is not reliably detectable yet, so resolution is manual. Revisit when derive-refresh lands.
7. Deployment requirement, enforced at startup¶
All content tables are FORCE-RLS, so the DEFINER bypass works only if the function owner (the
migration role) is superuser or BYPASSRLS. A plain owner makes the fan-out silently notify nobody
— failing closed for separation but silent for compliance. This is now a stated deployment
requirement enforced by a startup check (FanoutDeploymentCheck): a misdeployed instance refuses
to boot.
Consequences¶
- ADR-0034 remains the record of the F-N1/F-N2 fork decisions; where its Decision prose and this ADR differ (gate wording, count, audit conditionality), this ADR describes the shipped and binding contract.
- Open, documented residuals: fan-out tasks/notifications are addressed to the derived version's author only (a deactivated author orphans the signal — role-addressed fallback is future work); a review policy changed while a version is IN_REVIEW can strand or omit approval tasks for the affected roles (tasks are created at submit, thresholds read live policy) — regenerate-on-change is future work; both belong to the notification follow-on phase.