ADR-0045 — Collation-independent projection winner tiebreaks¶
- Status: Accepted (refines principle 1; relates to ADR-0031, #115)
- Date: 2026-07-10
Context¶
The trigger-maintained projections resolve their "current winner" with a tuple comparison whose
tiebreak keys include TEXT columns compared under the database's default collation —
node_origin/asserted_origin, and the final total-order keys value/display/use_key.
Default collation is a node-local property (locale/ICU configuration), not a function of the
event set.
So two nodes running different default collations can replay the identical append-only event
set and converge on different display winners for an exact (provenance_rank, hlc_wall,
hlc_counter) tie. This is a silent violation of the set-union convergence guarantee (principle 1)
in the safety-critical projection layer. It is not data loss — full history is retained
append-only in event_log; the divergence is confined to which retained assertion is displayed
as current.
This is real, not merely Byzantine. #115
hardened the same layer against a Byzantine same-origin HLC-triple collision (a broken signer
reusing its own (wall, counter, origin) triple) by appending the collation-free
content_address (BYTEA) as the final tiebreak, but explicitly deferred the origin TEXT
comparison to this issue. The cross-origin (wall, counter) tie — two different honest
nodes independently stamping the same millisecond wall + same counter — needs no misbehavior at
all, just coincidence, and it is decided by the collation-sensitive origin compare before
content_address is ever consulted. So the cross-origin case this ADR closes is strictly more
likely than the Byzantine case #115 already fixed.
Decision¶
Every projection winner tiebreak comparison over a TEXT key MUST be made under COLLATE "C"
(byte order of the identical-on-every-node UTF-8 encoding). This applies to the shared
cairn_hlc_overlay_wins predicate (the five standing-state overlays: patient_chart,
patient_link, chart_dispute, chart_identity_state, name_repudiation) and every demographic
projection trigger and display VIEW (patient_identifier, patient_demographic, patient_name,
patient_address).
Every Cairn node stores the same UTF-8 bytes for the same signed event (Postgres
server_encoding = UTF8), so a COLLATE "C" comparison yields an identical total order on every
node regardless of that node's default/locale collation — exactly the convergence property the
projection layer needs.
Alternatives rejected¶
convert_to(x, 'UTF8')::byteacomparison. Identical result (Cis byte order of the UTF-8 encoding), but costlier per comparison and less idiomatic than aCOLLATEclause.- Carry
content_addressinto the demographic projection tables, mirroring #115's fix for the standing-state overlays. Truly canonical, but a schema change to five projection tables plus threadingcontent_addressthrough every demographic trigger — andvalue/displayare still needed as columns for display regardless. Disproportionate to the defect.
Consequences¶
- Easier: the display winner is now a deterministic function of the event set alone, not of
node-local locale configuration — federation-wide convergence holds for the
origintiebreak step exactly as #115 already made it hold for the Byzantinecontent_addressstep. - Harder / binding on future work: this invariant binds all future projection slices. A new
tiebreak over a TEXT key silently reintroduces the divergence unless it is written under
COLLATE "C"— reviewers of new projection triggers/VIEWs must check for this. - Scope:
content_address(BYTEA) remains the final Byzantine tiebreak for the standing-state overlays; this ADR only makes theoriginstep ahead of it collation-safe. Projection-read-side only — no wire/event-format/floor-gate change, no new event type, no on-wire SCHEMA change. - Scope of the codebase audit (what was not touched, and why): the change was audited
codebase-wide for TEXT tiebreak keys.
min UUIDperson-canonicalization (db/018) uses theuuid<operator, already collation-free. Theactor_current(db/004) andtrust_peer(db/007) VIEWs were excluded because they carry no TEXT winner tiebreak — they resolve on(recorded_at, seq)/ local ingest order, not on a collated string. That local-clock tiebreak is a deliberate, documented design (single-author fortrust_peer; intentional local ordering foractor_current) and its convergence properties are a distinct question from collation, out of scope here. This audit closed the collation hazard specifically; it is not a full projection-layer convergence audit.
Canonical home: sync §6.1.