Decisions
ADR-0028: Atomic protocol-state commits
- Status: Proposed; stable-profile implementation inventory recorded
- Date: 2026-07-27
Context
Double Ratchet, handshake, and sender-key steps destroy or replace secrets as they progress. Before the pairwise commit-plan slice, the node persisted advanced cryptographic state in separate SQLite autocommits from message history, replay markers, receipt routes, delivery rows, and outbound queue entries. A process stop or disk error between writes could make a valid inbound ciphertext permanently undecryptable or advance an outbound chain without retaining the only ciphertext produced by that step.
Deferred inbox rows also need at-least-once processing: removing a row before all durable consequences commit turns an ordinary crash into message loss.
The runtime is intentionally single-writer, but that property is not enough. One logical protocol transition must become one durable transaction, and in-memory state and user-visible events must never get ahead of that commit.
Decision
1. The node prepares immutable typed commit plans
Before changing live state, kult-node clones the relevant session, prekey
vault, group chain, group record, and delivery state. It performs cryptographic
work only on those candidate values and creates one bounded typed commit plan:
PairwiseSend;PairwiseReceive;ProfileBootstrap;AuthorityProfileBootstrap;AuthorityMigration;PrekeyPublish;HandshakeReceive;PendingStage;AdmissionStage;AdmissionAccept;AdmissionDiscard;AdmissionSweep;GroupSend;GroupReceive;GroupState;DeviceControl;AuthorityDeviceControl;DeviceLink;AuthorityDeviceLink;DeviceProjection;AttachmentStage;AttachmentState;ReceiptReceive; orMaintenance.
Each plan contains the complete before-state identity/version, resulting sealed logical records, exact queue mutations, replay/seen mutations, source pending row if any, and events to emit after success. A generic collection of arbitrary SQL statements is not a public node API.
The plan validates internal invariants before opening a SQLite transaction: every produced envelope has one queue/delivery owner, every advanced chain has its ciphertext or accepted plaintext consequence, and every consumed one-time secret has the session whose establishment consumed it.
2. One BEGIN IMMEDIATE transaction owns every durable consequence
The store applies a plan in one BEGIN IMMEDIATE transaction.
PairwiseSend commits together:
- the advanced sending session;
- immutable local history, when the operation creates history;
- one durable envelope per target device;
- per-device and aggregate delivery state; and
- any capability or scheduled-message transition consumed by the send.
PairwiseReceive commits together:
- the advanced receiving session;
- accepted immutable history or typed control state;
- seen/replay state;
- the encrypted receipt and its corresponding sending-session advancement;
- expiry/tombstone state; and
- acknowledgement of the exact deferred-inbox row, if present.
HandshakeReceive additionally commits one-time-prekey removal and the new
session for an already accepted or compatibility-path first flight. An unknown
sender instead uses AdmissionStage, which commits one-time-prekey removal,
the isolated candidate session/identity/safety number, bounded first content
and preview, and one sealed provisional request. AdmissionAccept promotes
that exact state; AdmissionDiscard applies Delete or Block; and
AdmissionSweep expires a bounded page. A cryptographically valid stranger
does not become a trusted contact merely because the handshake succeeded.
PendingStage commits one complete encoded carrier envelope and its ingress
class to the bounded sealed pending domain before a direct response or
mailbox-v2 row acknowledgement. It advances no session or chain. A later
consuming plan atomically deletes that exact pending row with the accepted
protocol consequence.
Group plans commit the group sender/receiver chain, group generation or pending announcement state, immutable history/control state, all fan-out envelopes and delivery rows, replay state, and deferred-row acknowledgement together.
Transport I/O, native wake, rendezvous lookup, filesystem export, UI callbacks, and event fan-out never occur inside the database transaction.
3. Memory and events change only after commit
The live node replaces its in-memory candidate state only after the store returns a successful commit receipt containing the committed transaction id and record ids. User-visible events are emitted from that receipt.
Any serialization, sealing, quota, constraint, disk, or commit error discards candidate state and produces no success event. Retrying the original ciphertext starts from the unchanged durable state.
The store transaction may commit successfully while the process stops before memory/event update. On restart, durable state is authoritative. A bounded post-commit event outbox or snapshot resynchronization reproduces presentation without replaying a ratchet step.
4. Deferred work is leased and acknowledged after consequence commit
Reading a pending envelope never removes or rewrites it. Processing names its stable row id in the commit plan, and the same transaction that stores the message/session consequences deletes that one row.
Retryable rows remain durable under their original id. Expired or permanently invalid rows use a bounded maintenance plan that records the terminal reason where diagnostics require it and deletes only the named row. Pending work is read in bounded pages; one corrupt row is quarantined rather than preventing later valid rows from being considered.
5. The store is one writer across processes
Opening or creating a store requires a non-blocking exclusive advisory lock
held for the complete Store lifetime. A second daemon or embedded runtime
receives a typed already-open failure before it loads mutable protocol state.
The daemon also refuses to unlink a socket that accepts a live connection.
The advisory lock complements SQLite transactions; it does not replace them. On Unix, the implementation combines the canonical no-follow sidecar with an owner-only no-follow lock file derived from the opened database's device and inode, so hardlink aliases resolve to the same cooperative writer exclusion without interfering with SQLite's own byte-range locks. Equivalent file-identity qualification remains required on other supported platforms.
6. Crash injection is release evidence
Tests insert deterministic failures:
- before and after every candidate cryptographic step;
- before and after each transaction statement;
- before and after commit, in-memory replacement, and event delivery;
- during disk-full/constraint failures; and
- during restart with deferred, duplicated, reordered, or partially delivered carrier input.
For every injected point, restart must produce exactly one of two states:
- the complete transition is absent and the original input remains safely retryable; or
- the complete transition is durable and replay is idempotently absorbed.
No accepted state may contain a ratchet/chain step without its only ciphertext or plaintext consequence.
7. Implementation and evidence status
The implementation now provides all twenty-four plan kinds above. Legacy
ProfileBootstrap, DeviceControl, and DeviceLink remain explicit
migration/restore compatibility surfaces; current profiles use the matching
Authority* variants. Together they cover
pairwise and group text, edits, polls, roles, authority changes, group
announcements and bounded fan-out, pairwise and group attachments, missing
ranges, ephemeral/view-once state, scheduled activation, call signalling,
late-device delivery, exact deferred-control acknowledgement, retry/expiry,
session repair, current linked-device authority/counter changes, confirmed link
imports, convergence projection, media reconciliation, profile bootstrap, and
presentation recovery. ADR-0030 adds atomic provisional stage, explicit
promotion, Delete/Block retirement, and bounded expiry. Fresh out-of-band one-time-prekey issuance uses
PrekeyPublish; inbound consumption uses HandshakeReceive and cannot commit
without the established session, or uses AdmissionStage and cannot commit
without the isolated provisional request that owns it.
AuthorityProfileBootstrap commits a public account trust anchor, independent
KDA2 device state, and prekey vault inside an unpublished sibling database.
The sibling is file- and directory-synchronized before one atomic replacement
publishes the root-free profile, so interruption leaves either no destination
or one complete openable profile. AuthorityMigration atomically removes an
eligible legacy live root only after its separately exported authority is
confirmed. Recovery initializes a higher epoch, one fresh device and fresh
prekeys before the same sibling-publication boundary.
AuthorityDeviceControl, AuthorityDeviceLink, and DeviceProjection cover
accepted ADR-0026. Quorum approvals, manifest rename/revocation/recovery,
channel counters, convergence events, capability/session retirement and group
rotations commit together; a confirmed pristine target switches public
identity, authority and its bounded selected snapshot in one transaction;
accepted event winners are projected through exact idempotent before/after
plans. Established KDA2 contact endpoint replacement, stale-orphan removal,
and exact capability/session deletion publish as one projection. A link ceremony secret
is retained until its channel commits. The source also commits a small sealed
recovery handle with link approval, allowing a package return value lost after
commit to be resealed after restart. Authenticated target sync removes that
handle. Profiles admit at most 4,094 groups, leaving one AuthorityDeviceControl
transaction enough space for every group-chain rotation, a maximum
4,096-event sync bundle, device authority and recovery retirement.
Each plan validates its bounded before/after relationships before
BEGIN IMMEDIATE. The transaction writes the detached candidate and every
paired ciphertext or accepted plaintext consequence. Complete fresh envelopes
enter the durable pending inbox before parsing or cryptographic work. Top-level
fragments are the bounded exception: assembly advances no ratchet, the
completed inner envelope is staged before deferral, and refused assembly leaves
the fragments unseen for carrier retry.
A sealed presentation marker is written in the same transaction as every
visible transition. If the process stops after commit but before event
delivery, reopening the node emits StateResyncRequired; the marker is removed
only after a later tick acknowledges delivery. The FFI and daemon surfaces
preserve that signal, and the desktop shell responds by re-reading its visible
snapshots.
The deterministic crash suite in crates/kult-node/src/atomic_tests.rs
exercises before/after cryptographic steps, every logical transaction
statement, commit, memory replacement, event delivery, disk-full, constraint,
duplicate, reorder, deferred-input, expiry, session-repair, scheduled
activation, maximum stable-v1 group fan-out, and restart cases for every plan
kind. Linked-device evidence additionally covers retained ceremony secrets,
lost-return recovery, duplicate import, profile group-limit rejection and
event-outbox recovery. Group end-to-end evidence covers partial carrier handoff
and restart; pending-inbox, media and custom-icon evidence covers full quotas,
while media also covers duplicate chunks and interrupted files; profile and
backup evidence covers every atomic-replacement phase and fresh-secret
initialization.
The complete path-by-path disposition is the
atomic transition inventory.
This is not full ADR acceptance. ADR-0026 authority and ADR-0030 first-contact
consent are covered, but the pre-C2 contact-manifest alias bridge remains
quarantined compatibility code. ADR-0032 now commits a complete inbound
envelope through PendingStage before exact mailbox lease acknowledgement,
while live call state remains intentionally process-local. Independent review
and supported-platform sudden-power-loss qualification are also absent. These
gaps keep this ADR Proposed and prevent the implemented matrix from being
presented as universal protocol atomicity.
Alternatives considered
Repair individual failure windows with compensating writes
Rejected. A compensating write cannot reconstruct a destroyed message key after a crash, and each new feature would create another unreviewed ordering graph.
Persist the advanced session before doing anything else
Rejected. It prevents key reuse but loses messages. Safety requires the state advance and its consequence to be one commit.
Queue ciphertext in memory and rely on transport retries
Rejected. A process stop loses the only ciphertext produced by an advanced sending chain.
Put the entire node tick in one database transaction
Rejected. Network and carrier work can block, transaction latency becomes unbounded, and unrelated conversations interfere. Transactions are scoped to one logical protocol transition.
Consequences
- Store APIs become transition-oriented rather than a collection of unrelated row setters.
- Node code must separate candidate state from live state and delay events.
- Receipt generation is part of receive-state planning instead of a later best-effort write.
- Group fan-out may create larger but explicitly bounded transactions.
- Deferred processing, restore, and presentation recovery gain deterministic crash semantics.
- Acceptance requires failpoint and restart evidence, not only happy-path round trips.