Skip to content

Encrypted-Room Intake Against the Pre-Acknowledgement Job Contract

This is a design note for ADR 0026 Stage C (#532). It contains no code and changes no accepted decision. Its purpose is to make Stage C estimable, because the largest cost driver sits in a layer earlier than the crypto library: the bridge's acknowledgement contract itself.

Stage A is implemented — the crypto-free bridge refuses encrypted rooms with a bounded notice (bridge.md §6.1). Stage B (#231) gives each ghost a device identity. Everything below concerns Stage C.

The invariant that breaks

bridge.md §5.1 and ADR 0016 require the appservice to derive every eligible (Matrix event, target ghost) job and commit it, together with the exact transaction-body hash, in one database transaction before Synapse receives HTTP 200. That single ordering is what makes "acknowledged" mean "durably recorded", what lets capacity be refused fail-closed before any work is admitted, and what makes an exact replay idempotent.

Under Megolm the mention target is inside m.room.encrypted ciphertext. The room key arrives asynchronously and by a different path from the event. Job derivation therefore cannot happen before acknowledgement. This is not a tuning problem; the input required by the current contract does not exist at the moment the contract requires it.

Two verified facts set the shape of everything that follows.

  1. The bridge has no /sync loop. There is no Syncer or Matrix Sync() call anywhere in the module; the only .Sync() matches are os.File.Sync and an OpenTelemetry WithSyncer. The process is driven exclusively by authenticated appservice transactions.
  2. mautrix's supported crypto path requires one. crypto/cryptohelper fails construction unless client.Syncer implements ExtensibleSyncer, and it registers decryption by hooking OnSync. Adopting it therefore adds a second, independent event-ingress path to a process whose ordering guarantees are currently derived from having exactly one.

That second ingress is the hidden structural cost. Appservice transactions define per-room order and the acknowledgement boundary; /sync would deliver the keys and the decrypted events. Neither is authoritative for the other, and reconciling them is the design work below.

1. Acknowledgement ordering, and what replaces the atomic capacity boundary

The bridge must acknowledge first. The alternatives do not survive inspection:

  • Hold the transaction open until the key arrives serializes the whole appservice behind an unbounded wait for material that may never arrive, and blows Synapse's transaction timeout. It also converts one slow key into a homeserver-wide backlog.
  • Reject undecryptable transactions returns non-2xx forever for a room the bridge is legitimately in, so Synapse retries indefinitely.

So Stage C splits intake into two commits:

  1. Pre-acknowledgement: commit a content-free envelope row per undecryptable event, atomically with the transaction hash, exactly where jobs commit today. "Acknowledged implies durably recorded" is preserved verbatim — what is recorded changes, not whether.
  2. Post-acknowledgement, after plaintext exists: derive the (event, ghost) jobs and commit them as ordinary bridge_delegations rows.

The atomic capacity boundary cannot be preserved, and the design must say so rather than pretend otherwise. Capacity is decided before acknowledgement today only because targets are known before acknowledgement. An envelope yields an unknown number of targets — zero to N — so the pre-acknowledgement decision has no quantity to decide on. The replacement is two bounds instead of one:

  • An envelope bound at intake, fail-closed, charged per undecryptable event against the room and global queue capacities. Since each envelope can become at least one job, refusing to accept more envelopes than the room could ever queue preserves the denial-of-service property that the current single bound provides. This is the only bound that runs before acknowledgement, so it is the only one that can still be pre-ACK fail-closed.
  • The existing per-target bound at derivation, which now runs after acknowledgement and can therefore refuse work the homeserver already considers delivered.

The second half is less novel than it looks. Capacity refusals already commit a terminal denied row and hand a content-free record to a post-acknowledgement notice worker through a fixed-size handoff (capacityNotice / releaseCapacityNotices). Stage C reuses that shape; what changes is that a refusal can now be decided late, not merely announced late.

The honest accounting: an operator loses the guarantee that a room over capacity is refused before Synapse is told the event was handled. They keep the guarantee that no acknowledged event is silently dropped, and that every refusal is a durable terminal record.

2. Representing an undecryptable-but-accepted event

The envelope belongs in a new table, not a new state on bridge_delegations.

bridge_delegations is keyed by (matrix_event_id, ghost_mxid) with that pair unique, and every ADR 0016 invariant hanging off it — the immutable target fingerprint, per-room FIFO ordering, lease fencing, capacity counting, agent_version evidence — is per target. An envelope has no target; that is its defining property. Forcing it in requires either a nullable ghost, which breaks the unique key and the FIFO index, or a sentinel ghost, which poisons audit and version evidence with a target that was never resolved.

Proposed bridge_encrypted_intake, one row per matrix_event_id:

  • room ID, sender MXID, origin server timestamp, the owning transaction ID, and the immutable intake fingerprint;
  • a checked state — awaiting_key, derived, denied, expired;
  • bounded attempt and backoff counters, and a deadline;
  • no ciphertext, and no event content of any kind.

That last point is a deliberate retention decision. Storing ciphertext and later obtaining its key would create a decryptable message archive inside the bridge database — a materially larger and longer-lived retention surface than today's "recoverable content is retained only while its workflow needs it, then cleared at the terminal transition". Instead the envelope stores the event ID and the bridge re-fetches the event from the homeserver at derivation time via GET /_matrix/client/v3/rooms/{roomId}/event/{eventId}. The homeserver already retains the ciphertext; this keeps the bridge's own posture unchanged. The mechanism is already proven in this repository — the integration driver uses exactly that endpoint to reload a canonical event for replay.

Derivation then produces the same trimmed recovery payload the plaintext path produces today, and everything downstream of that point is unmodified. This is the seam worth designing to: E2EE changes intake and reply projection, and nothing in between.

Idempotency of derivation is free. The envelope's primary key blocks a second envelope, and the existing (matrix_event_id, ghost_mxid) unique key blocks a second job set, so a crash between decryption and job commit is safely re-runnable.

3. When the key never arrives

This needs a bounded terminal, and the obvious answer is the wrong one.

The bounded parts are straightforward: a configured wait (ENCRYPTED_INTAKE_KEY_WAIT, shaped like the existing INPUT_WAIT_TIMEOUT), bounded m.room_key_request retries with capped backoff, and a terminal expired state with a content-free audit record and a metric.

The trap is the room notice. The bridge cannot know whether an undecryptable event mentioned an agent at all. In a busy encrypted room the overwhelming majority of messages address no agent, so a per-event "I could not read that" notice would announce the bridge's presence on nearly every message — precisely the response amplification the notice plane exists to prevent, and a privacy signal about traffic the bridge is not party to.

The design must therefore be:

  • No per-event notice. At most one bounded per-room notice per key-loss episode, naming the condition and the remedy (re-invite the ghost, or have a member share keys), through the same shared notice plane and the same claim-the-marker-before-spending-capacity ordering the Stage A encrypted-room refusal already uses.
  • A separate audit schema. An expired envelope must not emit fgentic.delegation.v1. That schema's identity is a resolved target, and here no target was ever resolved; emitting one with an invented ghost would corrupt exactly the evidence audit.md tells operators to trust. Stage C needs fgentic.encrypted_intake.v1 with the room, event ID, terminal reason, and no ghost, no target fingerprint, and no a2a_* fields.

4. The crash-recovery boundaries

mise run test:crash-recovery SIGKILLs the real bridge at twelve persisted boundaries: ledger_committed_pre_ack, acknowledged_pre_claim, a2a_accepted_pre_record, control_intent_committed_pre_claim, cancel_accepted_pre_record, continuation_accepted_pre_record, question_accepted_pre_record, progress_accepted_pre_record, pin_accepted_pre_record, result_persisted_pre_matrix, matrix_accepted_pre_record, and long_task_polling.

Nine are untouched: they sit downstream of job derivation, where the design above is deliberately unchanged. Three change meaning and three are new.

Changed:

  • ledger_committed_pre_ack — what is committed is an envelope, not jobs. Its assertion moves from "the reply eventually appears" to "the envelope is reclaimed and derivation runs".
  • result_persisted_pre_matrix and matrix_accepted_pre_record — the projected reply must now be encrypted, so both inherit §5.

New:

  • room_key_received_pre_persist — the key arrived and was used, but nothing was committed. Recoverable only if key persistence and job derivation are separately durable, so that a replacement process still holds the key.
  • decrypted_pre_derivation — plaintext obtained, jobs not committed. Covered by the two unique keys above, but it must be proved, not assumed.
  • outbound_megolm_prepared_pre_send — the reply was encrypted but the send was not acknowledged. See §5.

Twelve becomes fifteen, and three existing assertions are rewritten. That is a fixture change of real size, and it needs matching fault-proxy hooks for the new key and decryption paths.

5. Matrix transaction-ID idempotency under Megolm re-encryption

The question is whether the deterministic Matrix transaction ID still deduplicates when a crash-retry re-encrypts the same reply at a new Megolm ratchet index. Three separate answers, and only one of them is comfortable.

Event creation stays idempotent. Transaction-ID deduplication is enforced by the homeserver on PUT /rooms/{roomId}/send/{eventType}/{txnId} and does not inspect the request body. A retry that re-encrypts identical plaintext into different ciphertext under the same transaction ID is discarded and the original event ID is returned. Re-encryption does not defeat the mechanism.

The scope of that mechanism changes underneath Stage C, and this is the real hazard. Transaction-ID deduplication is scoped to the sending identity — for a normal client, its device. Today the bridge sends through appservice user impersonation. After Stage B each ghost owns an MSC4190-managed device, which moves the deduplication scope onto that device. If a replacement process regenerates or rotates a ghost's device instead of reloading it, the scope changes and a retried send is no longer deduplicated: a duplicate room event, which is the exact failure ADR 0016 was written to prevent. Stage C therefore depends on #231 persisting device identity durably and reusing it across process replacements — not merely on #231 creating a device. The precise scoping key must be confirmed against the pinned Synapse version before this is relied on; it is a behavior of the homeserver implementation, not a guarantee the client can enforce.

Do not use the Megolm index as an identity. Re-encrypting advances the outbound ratchet even though the resulting event is discarded, so the index differs between attempts and is not a deduplication key. This is not a correctness break — receivers never see the discarded ciphertext — but a crash loop silently burns ratchet indices and reaches rotation_period_msgs sooner, so session rotation becomes more frequent under repeated failure. Cost, not corruption.

The consequence for the ledger is one concrete rule: persist the plaintext reply in reply_pending and encrypt at send time, as the plaintext path does today. Persisting ciphertext instead would make a retry unusable after a session rotation and would put message content in the database in a form that outlives the terminal scrub. Keeping the transaction ID as the sole idempotency mechanism keeps the current ledger shape intact.

6. Grounded session estimate for #532

An earlier investigation sized Stage C at two to three sessions while missing the intake collision entirely. Sized against the work above:

Work Sessions Note
Crypto substrate selection, CGO-free build, distroless image, NOTICE/licensing 1 Go/no-go; see below
Key ingress: /sync loop or MSC3202 to-device, reconciled against appservice transaction ordering 1–2 Two independent ingress paths in one process
Intake redesign in §1–§2: envelope table, migration, two-phase capacity, derivation stage, retention 2 The cost driver this note exists to surface
Key-never-arrives path in §3: bounded wait, key requests, terminal state, room-scoped notice, schema 1
Outbound encryption for reply, edit, and artifact paths, plus transaction-ID device-scope durability 1 Blocked on #231 persisting device identity
Crash-recovery fixture: three new boundaries, three rewritten assertions, fault-proxy hooks 1–2
Two-homeserver proof: device provisioning, cross-signing, key backup and recovery, restart, HA 2+ ADR 0015 trigger 3; needs-cluster
Audit schema documentation and ADR acceptance 1
Total 10–13

The first row is a genuine go/no-go, not a task. ADR 0026 §4 records that the CGO-free bridge has no non-deprecated Olm implementation: libolm was deprecated after CVE-2024-45191, CVE-2024-45192, and CVE-2024-45193; mautrix's goolm is an experimental pure-Go reimplementation of that deprecated design; and vodozemac has no Go binding. If none of these clears the bar at the time Stage C is scheduled, Stage C cannot ship in this shape at all, and the alternative is a separate crypto surface under ADR 0012's decomposition budget rather than crypto inside the appservice process. Sizing that alternative is out of scope here.

The estimate also assumes Stage B has landed with durable device identity. Started before that, the outbound and fixture rows are not merely blocked — they are unverifiable, because the duplicate-event failure mode in §5 only appears across a process replacement.

What this note does not decide

It does not select a crypto implementation, does not choose between /sync and MSC3202 to-device ingress, and does not revisit ADR 0026's per-room decision or its Level 2 non-goal. Those belong to Stage C's own ADR. It also leaves ADR 0015's classification controls governing current plaintext partner rooms exactly as they stand: nothing here admits restricted data anywhere new.