ADR-0057 — Generic reprojection: a projection lives only in its registered apply function; healing is generic replay¶
- Status: Accepted (refines ADR-0048's register-by-row dispatch and ADR-0045's read-side-only shipping; upholds ADR-0056 decision 4; load-bearing for #266)
- Date: 2026-07-21
Context¶
Projections — the §9.4
trigger-maintained incremental tables that turn the append-only event_log into the chart, the
demographic winners, the identity connected-component, and the clinical worklists — were each
populated by a per-type AFTER INSERT … FOR EACH ROW WHEN (event_type = …) trigger. A
CREATE OR REPLACE of one of those trigger functions heals only future inserts; every row
already materialised under the old logic stays wrong. Projection logic is
§9.1 safety-critical, so "the
fix ships but the already-projected rows silently keep the old answer" is exactly the failure class
the floor exists to prevent.
ADR-0045 is the worked example: it corrected
the winner tiebreak but shipped read-side only, so trigger-populated winner tables that had
already settled under the old comparison were healed only where a bespoke per-slice backfill
happened to exist. The tree's only such backfill was cairn_demographic_backfill(), and it ran
on every connect — connect_and_load_schema replays every migration each connect — as a
DISTINCT ON sequential scan over an event_log that carried no event_type index. Cost was
linear in log size, unmeasured at Bet-B volume, and paid on every connect whether or not anything
had changed. Worse, the pattern institutionalised drift: cairn_demographic_backfill()
re-expressed its trigger's winner comparison twice more (a DISTINCT ON sort and an
ON CONFLICT … WHERE guard), kept in lock-step with the trigger by hand and by test. A rule of
"every projection ships its own set-based backfill" would have made that per-slice drift risk
permanent.
Two properties of the codebase made a single generic mechanism possible instead of fifteen bespoke backfills:
- Every Cairn projection is already arrival-order-independent. Set-union sync applies events in
arbitrary order and the multi-node convergence suites pin exactly that (#115).
So a replay of
event_logthrough the same apply logic the live trigger runs converges in any scan order, and a heal-mode replay never needs a downgrade path: a stale winner row is itself some event's tuple, so it is dominated by the true maximum under the corrected comparison. - The "register, don't wire" spine already existed. ADR-0048
established per-type register-by-row with load-time fail-closed validation for the floor-check
hook, and
node_schema(§9.4, the recorded-vs-embedded schema-generation signal) already gave the loader a ready-made "something actually changed" gate.
The mechanism is also load-bearing for ADR-0056 decision 4: reclassification of a deferred unknown-type event must re-adjudicate the deferred door gates first, then reproject the survivors, and the reprojection step must be structurally unable to grant power to an unadjudicated event even when an operator runs it by hand mid-upgrade.
Decision¶
1. One code path — registration is the wiring¶
A projection's fold step is expressed once, as a registry row, never as copied dispatch code.
- A locked table
cairn_projection_apply(event_type, apply_fn, projection_tables text[], run_order, heal_safe, PK(event_type, apply_fn))holds one row per(type, projection)pair.apply_fnnames afn(event_log) RETURNS void— the projection's entire fold step. The composite PK carries both fan-outs: one type may feed several projections (a medication assertion feedsmedication_statementand the dose-timeline seed) and one fn may serve several types (the identity link/unlink pair). - One dispatcher trigger —
cairn_projection_dispatch()behindcairn_projection_dispatch_trg,AFTER INSERT ON event_log FOR EACH ROW— replaces every per-type projection trigger: it looks up the registry rows forNEW.event_typeand calls eachapply_fn(NEW)inrun_order. The former trigger bodies were mechanically refactored to callable form (NEW→ parametere, bodies otherwise unchanged) and theirWHENclauses dropped. An unregistered projection has no way to fire: nothing else runs on anevent_loginsert. heal_safemarks whether replaying the fn over an existing projection converges (insert-or-better). Idempotent winner projections areheal_safe = true; counter-shaped applies (e.g.patient_chart_apply's note-count increment) areheal_safe = false— re-running them over an already-populated table would double-count, so heal mode skips them and rebuild mode (replay-from-truncate) heals them. New projections should be idempotent; aheal_safe = falserow needs a justifying comment.- Exact types only — no
'*'wildcard. The design reserved a type-independent apply row, but the legibility twin turned out to be door-materialised (a column written by both admission doors), not a trigger projection, so there is no type-independent projection. Every apply fn registers under the exact types it handles; the wildcard mechanism was dropped (YAGNI, and a wildcard row would be a standing invitation to a projection that fires on types it was never reviewed against). - ADR-0048 discipline applies in full. Registration is
validated fail-closed at load time — a
BEFORE INSERT OR UPDATEtrigger refuses a row whoseapply_fn(event_log)does not exist (to_regprocedure) or whoseprojection_tablesname a table that does not exist. The table isREVOKEd fromPUBLIC(migration-only). Every converted apply fn hasEXECUTE … (event_log)revoked fromPUBLIC, and each dynamic-dispatch fn pinsSET search_path = publicso a dispatched%Iname cannot resolve into an attacker-shadowed schema. Membership is pinned by a row-count guard mirrored in both Rust and SQL (the #212 two-place pattern), so a missed registration fails CI rather than drifting silently.
2. cairn_reproject(p_prefix, p_rebuild, p_source) — heal and rebuild¶
cairn_reproject scans event_log WHERE event_type LIKE p_prefix || '%' and feeds each event
through the identical dispatch the live trigger uses — the single-logic-path guarantee extends
to replay, not just to live inserts. It records per-type counts and elapsed time in a node-local
reproject_log (the operational record and the test observable, never on the wire).
- Heal mode (default,
p_rebuild = false): no deletes. Convergence follows from arrival-order-independence — insert-or-better applies converge to the corrected winner in any scan order, and an already-correct row incurs no write. Heal converges the wrong-winner class (a projected row that is dominated under corrected logic by an honest event's tuple). It does not heal wrote-garbage — a projected value derived from no event, or an out-of-band tamper whose value ties the winner comparison rather than losing it — because replay can only insert a better candidate, never delete an existing tuple that nothing dominates; that class is the province of rebuild. Heal mode additionally skipsheal_safe = falserows, reporting them inreproject_log.skipped_fns— honest degradation is only honest if it is visible. - Rebuild mode (
p_rebuild = true, wrote-garbage defects only):TRUNCATEs a registered projection table only when every registry row that writes that table falls insidep_prefix; otherwise it refuses with a legible message. This keeps rebuild generic while making it impossible to wipe a table fed by several types (e.g.patient_chart) under a narrow prefix and silently lose the unmatched types' rows. - Idempotency is a property of the apply fns, achieved by keying on event identity, never on
observation shape. Where a projection is an append-only alarm/worklist table (the identity and
medication conflict flags), replay-safety was secured by adding
content_addressto the row's unique key withNULLS DISTINCT— so a replayed event dedups against its own prior projection by event identity, pre-fix legacy rows (nullcontent_address) are left untouched and undeleted (never erase, always overlay, principle 2), and two genuinely distinct events that happen to share an observation shape are not collapsed. A blanket "ON CONFLICT DO NOTHINGon the natural key" would have been wrong for these tables — it dedups by what was observed, not by which event observed it. - Replay runs in the lenient-apply posture. The #192
patient-consistency helper and the component-size caps RAISE on pathology unless the
transaction-local
cairn.remote_applyGUC is on (set byapply_remote_event). Replayed events include remotely-admitted ones, socairn_reprojectsetscairn.remote_apply = 'on'for its transaction: an event a door already admitted stays admitted; replay heals and clamps-and-flags where the strict door would refuse. This is ADR-0056's gate the effect, not the presence applied to replay — reprojection re-runs the projection, it is not a second authoring door. - The apply fns are invoked set-based, not row-by-row. For each
(event_type, apply_fn)pair the replay runs one full-table pass —count(*)over a subquery whose target list calls the apply fn — instead of a per-event PL/pgSQL loop. The apply fn isVOLATILE, so the planner can never prune it: it still runs exactly once per eligible row, but the per-event loop, the per-event dynamicEXECUTE, and the composite-value marshalling are gone.ORDER BYis dropped (licensed by arrival-order-independence); fn interleaving becomes per-fn-full-pass (licensed because sibling apply fns for one type write disjoint tables). This is the mechanism behind the heal-mode speedup in decision 5; it targets loop overhead and therefore leaves rebuild cost — which is dominated by the apply fns' own write work — essentially unchanged, by construction.
3. The #266 safety seam: cairn_replay_eligible(e event_log)¶
Every candidate event on the replay path is routed through
cairn_replay_eligible(e) RETURNS boolean. Today it is constantly true — no deferred events can
exist in event_log yet, because the remote door still fail-closes on unknown types until
#265. ADR-0056's
explicit deferred marker hooks in here, and only here; #266's reclassification flow becomes
re-adjudicate the deferred gates → clear or flag per event → cairn_reproject(type). A manual
mid-upgrade reproject therefore cannot grant power to an unadjudicated deferred event —
ADR-0056 decision 4's "no unattested suppression at every instant" holds by construction, not by
operator discipline. The live-insert path needs no such filter: an event inserted through a door was
adjudicated by that door.
4. Loader heal on generation change, never on every connect¶
cairn_demographic_backfill() and its every-connect call are deleted; the generic mechanism
subsumes them. Its real job — the ADR-0012
carried-not-projected catch-up — arises exactly when new projection capability arrives, and new
projection capability arrives only via a code-plane update, i.e. a schema-generation change.
Both loaders — cairn-node's full connect_and_load_schema and cairn-sync's subset loader (which
carries the projection migrations its apply path depends on) — gain one gated step inside the
existing SCHEMA_LOAD_LOCK: if the recorded generation differs from the embedded one (or is
unknown), run cairn_reproject('', false, 'loader') (full heal replay). Unchanged generation →
zero reprojection work on connect. Unknown generation (fresh DB: free no-op; hand-built rig:
converges once) errs toward healing.
The gated heal runs before the node_schema generation stamp, deliberately. If the heal errors,
the stamp never runs, the recorded generation stays at its old value, and the next connect retries
the full replay-then-heal — the same loud, self-retrying failure mode a broken migration file
already has (a bad db/*.sql blocks connect until fixed; it never silently half-applies).
Stamp-then-heal would invert this: a heal failure after the stamp would leave the generation
already advanced, the next connect would read recorded == embedded, skip the heal, and the
projections would stay silently stale — the worst failure mode, and the reason the order is
load-bearing rather than cosmetic. Operators, tests, and #266 reach the mechanism by hand via
cairn-node reproject [--prefix P] [--rebuild].
5. The written rule, structurally enforced¶
A projection lives only in its registered apply function; healing is generic replay, run by the loader on generation change.
A projection change therefore ships inside a schema-generation bump and heals automatically; a new
projection registers a cairn_projection_apply row or fails CI. This is enforced structurally, not
by prose: a catalog guard asserts that the only AFTER INSERT trigger on event_log is the
dispatcher (the immutability guard is BEFORE UPDATE OR DELETE, unaffected), and the registry
row-count guards pin membership in both Rust and SQL.
Consequences¶
- ADR-0045's failure class is closed
generically. A future read-side projection fix heals its already-materialised rows automatically
on the next generation bump, with no per-slice backfill to write, review, or keep in lock-step. The
drift risk
cairn_demographic_backfill()embodied — winner logic re-expressed in a second dialect — is designed out: there is exactly one expression of each projection's fold. - ADR-0056 decision 4 gets its mechanism.
The reclassify-then-reproject flow #266 needs is this ADR's
cairn_reprojectbehind thecairn_replay_eligibleseam; the "re-adjudicate first, reproject second" ordering is enforced by the seam, not by hope. - New bet: a registry lookup + dynamic dispatch now sits on the live write path, replacing the
per-type triggers' static
WHEN. The measurement (below) shows the delta is far inside budget. The documented fallback, never needed: keep per-type triggers as thin wrappers over the registered apply fns and use registry dispatch only for replay — this retreats on shared dispatch while keeping the single logic path and every other decision intact. - Reviewers of registry changes must hold three invariants (inherited from
ADR-0048): load-time validation is fail-closed; the
registry stays migration-only and
REVOKEd; dynamic-dispatch fns keep theirsearch_pathpin. And one new one: an apply fn registeredheal_safe = truemust genuinely be insert-or-better — validation checks the fn exists, not that it is idempotent. - Retroactive healing is forward-looking only. Pre-clinical, no production data exists; rigs that settled winners under pre-ADR-0045 comparisons are wiped, not healed. The mechanism heals the fleet from here on, not the past.
Measured cost (Bet-B volume; Mac-measured, Pi5 re-run is the authoritative follow-on)¶
Measured on an Apple Silicon Mac dev box (PostgreSQL 18.1 / Postgres.app, cairn_pgx 0.3.0) over a
walking-skeleton-generated corpus of 2,006,000 events across 200 patients — composition
note.added ≈ 90% (1,805,420), demographic.field.asserted ≈ 10% (200,380), patient.created 200.
All figures were cross-verified read-only against the persisted reproject_log before being trusted.
- Write-path latency through the live dispatcher (2,000 single-row inserts against the full ~2M-row log): p50 0.076 ms, p95 0.236 ms. A single 1278.39 ms max is a checkpoint/autovacuum artefact of measuring immediately after a 2M-row bulk load, not a steady-state tail (p95 already excludes it). The Bet-B B1 budget this is sanity-bounded against is p95 3.99 ms @ 2M events on Pi-class hardware, so 0.236 ms clears it by ~17×. This is a cross-rig bound, not a same-rig before/after: the honest same-rig write-path A/B is the Pi5 re-run.
- Heal replay (
cairn_reproject(''), the operationally relevant path the loader runs): the 200,580 events eligible for a registered heal-safe apply fn (patient.created+demographic.field.asserted) in 2.098 s (~95.6k events/s) with the set-based invocation, down from 12.207 s (~16.4k events/s) with the per-event PL/pgSQL loop it replaced — a 5.8× speedup isolating the loop-dispatch overhead. The 1,805,420note.addedrows are correctly skipped (patient_chart_applyisheal_safe = false). - Rebuild replay (
TRUNCATE+ full replay of all 2,006,000 events,note.addedincluded): 54 min 32 s post-optimisation, 49 min 1 s pre-optimisation — the ~11% gap is run-to-run bloat/checkpoint variance from four consecutive full-log operations in one session, not a regression. Rebuild cost is loop-invariant by construction: it re-applies every event, so its cost is dominated by the apply fns' own write work (mostly ~1.8M note-count increments), not by loop dispatch — the same corpus loaded through the live per-row dispatcher (the door-equivalent ingest path) ran at ~1.2 ms/event, so rebuild's ~1.47–1.63 ms/event is re-ingest cost, and the set-based change (which removes loop overhead, not apply work) had nothing left to remove here. - The stop-gate story, recorded honestly. The plan carried a "~30 min, else STOP and reopen the set-based-backfill fallback" gate. It was written against heal mode — the default, operational path the loader runs — which lands comfortably inside it (2.1 s). The first rebuild measurement tripped the gate (49 min); rather than reopen the fallback immediately, the per-event loop was optimised to the set-based invocation (5.8× on heal), then rebuild was re-measured and judged acceptable as-is: it is a rare, human-supervised, narrow-prefix recovery operation whose cost mirrors ingest by construction, not a hot path anything invokes automatically. Rebuild mode is therefore explicitly not covered by a low-latency SLA; if a future recovery runbook ever invokes it at Bet-B-or-larger volume, that path needs its own budget, and 49–54 min at 2M events is the honest number to size against.
- The Pi5/NVMe re-run is the authoritative follow-on (the Bet-B B1 rig, so numbers are comparable), tracked as issue #272. These Mac numbers establish the shape and clear the budget by a wide margin; they are not the same-rig A/B the write-path claim ultimately rests on.
Implementation anchors: the registry, dispatcher, load-time validation, and eligibility seam
live in db/005_submit.sql; cairn_reproject, reproject_log, and the event_log_type_idx
prefix index live in db/039; the gated loader heal lives in both loaders; the sole-dispatcher
catalog guard and registry row-count pins are in crates/cairn-node/tests.