Decisions
ADR-0015: Encrypted, resumable attachment chunks over a bounded bulk lane
- Status: Proposed
- Date: 2026-07-13
Context
ADR-0014 gives Komms a versioned encrypted
content frame, stable content ids, authenticated capability negotiation, and an
honest unsupported-content path.
It deliberately defines only Text. Files, recorded audio, images, video, and
their previews need a second design because the existing message path is capped
at one 65,535-byte unpadded plaintext and its fragmentation layer reassembles at
most 128 KiB. Treating that fragmentation layer as file transport would retain
an entire object in message/reassembly state, make restart and cancellation
ambiguous, and risk spending scarce LoRa airtime on an unbounded transfer.
The attachment path must preserve Komms's existing promises:
- attachment keys, filenames, media types, hashes, and chunk positions remain inside authenticated encryption;
- a random attachment key is distributed only by an ADR-0014 content frame carried through the pairwise ratchet or sender-key group message;
- each bounded chunk authenticates independently and can survive restart, duplication, loss, and reordering;
- sender-key group content is encrypted once, while each member may consent, resume, reject, or complete independently;
- endpoint schedulers enforce carrier policy without exposing attachment metadata, while old relays continue forwarding known outer envelope kinds;
- no plaintext media, thumbnail, filename, or transfer index touches disk;
- every decoder and durable queue has a fixed count and byte ceiling; and
- old or downgraded clients either decline the feature before send or retain an honest unsupported content record. They never render binary data as text.
This ADR decides the F3 wire, cryptographic, storage, compatibility, and carrier contract. It does not implement file-picker UI, media codecs, editing, malware scanning, expiry, remote deletion, or real-time call media.
Decision
1. Attachment is content kind 0x0002
ADR-0014 content format v1 assigns kind 0x0002 to Attachment. Its payload is
a canonical manifest; the ADR-0014 content_id identifies the attachment offer
as a conversation event. A separate random object_id identifies each byte
object described by that offer. References to the attachment use the author and
manifest content_id, never an object hash, chunk address, filename, or outer
envelope id.
All integers below are little-endian. The manifest payload is:
manifest_version(1) = 01
flags(1) = 00
attachment_key(32)
object_count(1) = 01 or 02
repeated object_count times, ordered by role:
role(1) = 00 primary | 01 preview
object_id(16)
total_len(8)
chunk_data_len(4) = 49,152
chunk_count(4)
content_hash(32) = BLAKE3 of the exact unpadded object bytes
media_type_len(1)
media_type(media_type_len)
filename_len(2)
filename(filename_len)The payload consumes the complete ADR-0014 frame and is at most 1,024 bytes.
attachment_key and every object_id are independently random. Object ids are
unique within the manifest. The primary descriptor is mandatory and first. A
single optional preview descriptor follows it and means “preview of the primary
object”; previews cannot refer to another preview or contain a filename. This
two-object limit is intentionally below ADR-0014's common collection cap of 64
and nesting cap of four.
chunk_count is exactly ceil(total_len / 49,152); a zero-length primary has
zero chunks and the BLAKE3 hash of an empty input. A primary is at most
536,870,912 bytes (512 MiB), therefore at most 10,923 chunks. A preview is at
most 262,144 bytes (256 KiB), therefore at most six chunks, and its media type
must be exactly image/jpeg or image/png. Receivers still use a sandboxed
decoder. These are v1 protocol limits, not suggested UI defaults.
media_type is 1–127 ASCII bytes containing a lowercase IANA-style type/subtype
without parameters. It is a display/decoder hint, not trusted authority; the
receiver still sniffs only inside a sandbox after authentication and never
auto-opens an executable. filename is absent when its length is zero and is
otherwise at most 255 valid UTF-8 bytes. Senders strip directory components.
Decoders reject NUL, C0/C1 controls, /, \\, the names . and .., and
invalid UTF-8. Receivers display a safe generated name if the field is absent or
cannot be represented safely on the local platform. Paths are always chosen by
the receiving application, never by the manifest.
An implementation advertises (content v1, Attachment) only when it implements
this complete manifest and the bulk-lane v1 contract below. The existing text
send APIs remain unchanged; a new attachment API must not smuggle bytes into a
Text frame.
2. Chunks are fixed, independently sealed records
Each object is split into 49,152-byte data chunks. The final chunk may contain fewer bytes. Before chunk encryption, the sender constructs exactly 49,156 bytes:
actual_len(4) || data(actual_len) || zero_pad(49,152 - actual_len)actual_len is 1–49,152 and must equal the manifest-derived length for that
index. Empty objects have no chunk record. Fixed records make retry ciphertext
stable and prevent a short final chunk from bypassing the bulk carrier policy.
The 32-byte attachment key is never used directly as an AEAD key. For each object and index:
K_object = HKDF-SHA-256(
salt = object_id,
ikm = attachment_key,
info = "KAT-object-v1" || scope || scope_id || manifest_author ||
manifest_content_id || role
)
K_chunk = HKDF-SHA-256(
salt = u32_le(index),
ikm = K_object,
info = "KAT-chunk-v1"
)The chunk record is XChaCha20-Poly1305 ciphertext under K_chunk with the
all-zero 24-byte nonce. This is safe because every conversation, manifest,
object, and chunk index uses a distinct derived key; that full tuple must never
be reused with different plaintext. The associated data is the exact
concatenation of:
"KAT-chunk-ad-v1" || scope || scope_id || manifest_author ||
manifest_content_id || object_id || role || u32_le(index) ||
u64_le(total_len) || u32_le(actual_len) || u32_le(chunk_count) ||
content_hashThe sealed chunk is always 49,172 bytes. A receiver checks all manifest-derived
fields before AEAD open, writes no plaintext to disk, and hashes the streamed
unpadded bytes. Completion requires both every chunk AEAD to verify and the
final BLAKE3 hash to equal content_hash. An AEAD or final-hash failure marks
the transfer corrupt, deletes partial plaintext buffers, retains no renderable
file, and requests no automatic retry until the user or policy explicitly does
so.
The local chunk address is BLAKE3(sealed_chunk). Deduplication is deliberately
limited to identical ciphertext: random attachment keys and object ids prevent
cross-message or cross-sender plaintext-equality leakage. The transfer map from
(manifest content id, object id, index) to this address is sealed metadata.
Intermediaries never receive plaintext hashes or local addresses as routing
fields.
3. A receipt-compatible bulk lane carries control and chunk records
The manifest is an ordinary ADR-0014 content frame. Chunks and transfer control
are terminal protocol records, not chat messages, and travel through a separate
bulk lane tunneled inside the existing encrypted EnvelopeKind::Receipt path.
They are pairwise-ratchet encrypted and padded before entering the ordinary
durable envelope queue.
A decrypted receipt body beginning with 00 00 ff 4b 41 42
(empty receipt || 0xff || "KAB") is a bulk record; other bodies continue to
the existing capability or receipt decoders. The leading 00 00 is the complete
canonical Postcard encoding of an empty current ReceiptPayload. A pre-F3
endpoint therefore accepts the record as a harmless empty receipt or rejects it
terminally, while old transports and relays forward the already-known outer
kind. Bulk records are never acknowledged with an ordinary delivery receipt.
The canonical common header is:
magic(6) = 00 00 ff 4b 41 42
bulk_version(1) = 01
operation(1)
flags(1) = 00
scope(1) = 00 pairwise | 01 group
scope_id(32) = pairwise conversation hash | group id for group
manifest_author(32)
manifest_content_id(16)
object_id(16)
payload_len(4)
payload(payload_len)The complete unpadded bulk body is at most 65,535 bytes and must be consumed exactly. Unknown versions, operations, flags, or scopes are terminally ignored after authentication; malformed lengths are rejected before allocation. The session peer, scope, author, manifest id, and object id must resolve to one stored Attachment manifest before any operation changes state.
An inbound Chunk or Cancel that acts as the serving side is accepted only
from the manifest author's pairwise session. In a group, RequestMissing,
Complete, Cancel, or Reject from a receiver is accepted only from a member
who was entitled to the manifest when it was sent. These checks are durable
transfer metadata, not inferred from the current roster alone.
For a pairwise scope, scope_id is
BLAKE3("KAT-pairwise-scope-v1" || min(IK_A, IK_B) || max(IK_A, IK_B)), with
the two 32-byte identity keys sorted bytewise. For a group it is the 32-byte
group id. This scope remains inside the pairwise ratchet and exists only to stop
a valid bulk record from being transplanted between conversations.
Operations are:
| Value | Name | Canonical payload |
|---|---|---|
0x01 |
RequestMissing |
`role(1) |
0x02 |
Chunk |
`role(1) |
0x03 |
Complete |
`role(1) |
0x04 |
Cancel |
reason(1) |
0x05 |
Reject |
reason(1) |
At most 64 missing ranges appear in one request. Ranges are sorted, disjoint,
non-empty, within chunk_count, and canonicalized by merging adjacent ranges.
More gaps require another request. sealed_len is exactly 49,172 and index
must belong to the selected object. Complete is valid only after the receiver
has durably committed every verified chunk and the final object hash. Reason
codes are a fixed enum (user, unsupported, quota, low_storage,
policy, corrupt); free-form remote error text is forbidden.
The manifest is the offer. Receiver consent creates RequestMissing; the same
operation resumes after restart and retries loss. The receiver persists a chunk
and its bitmap bit atomically before its next request or Complete. Duplicate
and reordered chunks are idempotent. Cancel stops the sender and releases
unreferenced partial data; a later explicit request may start again. Reject
is a durable receiver decision for that manifest until the user changes it.
Senders pace requests and chunks with bounded exponential backoff and stop
automatic retry after 30 days without authenticated progress; the manifest
remains in history and the user may resume later.
All bulk-lane records are placed in at least the existing 4,096-byte padding
bucket, and Chunk records use the 65,536-byte bucket. Updated schedulers also
tag them as bulk independently of size. The size floor is defense in depth for
older bridges whose existing 4 KiB airtime ceiling cannot inspect the encrypted
bulk marker.
4. Pairwise and group transfers share one blob encryption
In a pairwise conversation, the manifest travels in the ordinary Double Ratchet message and the two peers exchange bulk records over that same pairwise session.
In a sender-key group, the Attachment manifest is encrypted once as an ordinary group message and fanned out under ADR-0012. The attachment key is therefore available to every member entitled to that group message, just like its text. Each member consents and requests missing chunks independently over its pairwise ratchet with the original manifest author. The author reuses the exact same sealed chunk bytes for every member; neither the file nor a chunk is encrypted again per recipient. Pairwise wrappers, delivery tokens, and retry state differ, but the bulk ciphertext does not.
Only the original manifest author serves v1 chunks. Peer-assisted group seeding
would reveal possession and needs a separate authorization and abuse design.
Adding a member does not replay old manifests or attachment keys. Removing a
member cannot revoke bytes or keys already delivered to that member; a member
entitled to the manifest may finish or resume that object after removal. Removal
only prevents future group content under ADR-0012's rotated state. A group
Attachment offer is enabled only when every current co-member advertises (v1, Attachment), preserving the existing capability intersection and honest
encrypt-once fan-out.
5. Carrier policy is enforced before airtime
Attachment activation depends on F4's fresh per-peer carrier
verdict. A
sender may create and seal local state while offline, but it must not enqueue
the manifest or any bulk record until every intended recipient has both exact
Attachment support and a fresh bulk verdict:
- direct internet or LAN: eligible for offer and transfer;
- mailbox or sneakernet with configured capacity: eligible, subject to local and carrier quotas;
mesh_only: held withAwaitingFasterLinkand zero bulk records offered to an airtime transport;offline_or_unknownor an expired verdict: held, never guessed eligible.
The manifest queue item and every bulk-lane item are tagged as bulk. The
scheduler rejects them for every CostClass::Airtime transport even when they
would fit a link MTU. It does not fragment a file as one envelope. Existing
envelope fragmentation may split one already-bounded bulk record for a
non-airtime link with a smaller MTU, but transfer progress and retry remain
chunk-based.
This rule is origin-enforced because intermediaries remain metadata-blind. An
old opaque relay cannot identify a small manifest it received over another
carrier, but chunk and bulk-control size floors exceed the shipped bridge
airtime ceiling. The unavoidable residual is at most the ordinary padded
manifest envelope, never media bytes. Acceptance tests measure the updated
origin and require an attachment attempt under a fresh mesh_only verdict to
emit zero frames on its mesh transports. Any future tiny LoRa allowance needs
measured HIL airtime, a new carrier-policy decision, and a smaller protocol
profile; v1 has no exception for recorded clips or previews.
6. Consent, quotas, and lifecycle are explicit
Receiving a valid manifest creates history plus a sealed Offered transfer
record; it does not allocate the declared total size or download automatically.
Defaults are:
- explicit consent for every file;
- optional user-configured auto-download only for previews or primary objects
at most 262,144 bytes while a fresh direct internet/LAN
bulkverdict exists; - at most eight active inbound and eight active outbound objects per peer;
- at most 32 active objects globally;
- at most 1 GiB of incomplete sealed chunks globally;
- a 2 GiB default media-store quota, configurable up to a 64 GiB hard ceiling; and
- at least 256 MiB of filesystem free space retained after any chunk commit.
The lowest applicable cap wins. Counts and bytes are checked with overflow-safe arithmetic before creating rows, files, bitmaps, or buffers. A receiver may lower limits and reject an offer; raising the 512 MiB per-object or 64 GiB store ceiling requires a later ADR. Quota or low-storage failure is visible locally and sends only its fixed reason code.
Transfer states are Offered, AwaitingConsent, Queued, Transferring,
Paused, Complete, Rejected, Cancelled, Corrupt, and Unavailable.
RPC, CLI, UniFFI, desktop, Android, and iOS expose these states, verified byte
progress, total bytes, safe filename/media-type hints, and the carrier hold
reason. They never expose attachment keys, raw unsupported frames, local chunk
paths, relay addresses, missing-range bitmaps, or ciphertext hashes.
Previews are generated or decoded locally in a sandbox, stored sealed, and governed by the same quota and cleanup rules. No plaintext temporary file is used: import, hashing, chunking, assembly, thumbnail generation, playback, and export use bounded streams or OS-protected handles. If a platform API requires a plaintext export, it is an explicit user action to a user-selected location; Komms closes the handle promptly and cannot promise deletion from that external location.
7. Media uses sealed files plus transactional metadata
Large media blobs do not live in SQLite. kult-store adds a media domain key,
sealed transfer/object metadata tables, and a private media directory beside
the database. Each already end-to-end-encrypted chunk is sealed again under the
local media-domain key with a fresh random XChaCha20-Poly1305 nonce and
chunk_address || "KAT-store-chunk-v1" as associated data. The filename is the
hex chunk address, so a copied directory leaks only ciphertext equality and
approximate fixed chunk counts/sizes, never plaintext equality, names, types,
keys, positions, contacts, or conversations. SQLite maps those addresses to
sealed transfer state.
The schema migration is additive: existing message and group-message record
shapes do not change, and opening an old store creates only the new media tables
and private directory. Every new sealed metadata record begins with a u8
record version and is length/count checked before Postcard decoding; an unknown
record version is quarantined as unavailable rather than partially decoded. The
directory is created with owner-only permissions where the platform supports
them. No migration reads or rewrites old message bodies eagerly.
A chunk commit writes and fsyncs a same-directory temporary file, atomically
renames it, then transactionally records the address and bitmap bit. Startup
removes stale temporary files and unreferenced chunk files; a missing referenced
file moves the object to Unavailable rather than inventing completion.
Cancellation, rejection, corruption, message deletion, and quota eviction drop
references transactionally, then garbage-collect unreferenced files. Completed
media remains until explicit local deletion or quota policy evicts it. Remote
deletion and expiry remain later ADRs.
The mnemonic backup (KKR2 when this ADR was accepted; current KKR4)
continues carrying message and group-message bodies, therefore it carries
Attachment manifests and their keys, but it excludes media files,
incomplete-transfer state, bitmaps, and bulk queues. No backup-format bump is
needed for F3. After restore, manifests render as Unavailable; once sessions
and capabilities recover, the user may request the bytes again if the original
author still has them. Outbound attachments may likewise become unavailable
after restore. A future optional media-inclusive backup must be streaming,
quota-bounded, and versioned separately; it must not silently append gigabytes
to the current mnemonic backup. Plaintext export includes only complete media
the user explicitly chooses and warns that the destination is unencrypted.
8. Compatibility fails closed and preserves history
A peer that has not authenticated exact (v1, Attachment) support is
ineligible before send. Pairwise UI names the incompatible/unknown peer; group
UI names every blocking member. No legacy text placeholder is sent because it
would create a false attachment event on one side only.
If stale capability state, downgrade, restore, or a race nevertheless delivers
an Attachment frame to an ADR-0014 client that does not implement it, that
client stores the exact frame, acknowledges durable receipt, and renders the
generic unsupported row required by ADR-0014. A pre-ADR client never advertises
support and interprets an accidental bulk record as an empty receipt or terminal
decode failure, not chat text. The upgraded sender receives no Complete, holds
or expires transfer activity, and retains its local manifest.
Malformed manifests are stored as ADR-0014 Malformed content and never create
media state. Unsupported manifest versions or flags remain Unsupported.
Malformed or unknown bulk records are terminal no-ops and are never reflected
to chat UI, notifications, search, or previews.
9. Verification gates
Implementation is not accepted until it includes:
- golden vectors for the Attachment manifest, bulk common header, every operation, HKDF outputs, chunk AEAD, and empty/final/full chunk boundaries;
- boundary tests at 0, 1, 49,151, 49,152, 49,153, 262,144, and 512 MiB logical lengths without allocating the largest object in one buffer;
- arbitrary-input property tests and dedicated manifest, bulk-record, range, and transfer-state fuzz targets with allocation and recursion assertions;
- pairwise and group tests for consent, encrypt-once chunk reuse, independent member progress, member add/remove, missing pairwise sessions, and original author unavailability;
- restart/partition tests for every durable boundary: before temp-file fsync,
after rename, before/after SQLite commit, after chunk receipt, and before
Complete; - loss, duplicate, reorder, missing-range canonicalization, cancellation, rejection, corruption, final-hash mismatch, quota, low-storage, and garbage collection tests;
- compatibility tests with pre-ADR, ADR-0014-only, downgraded, and restored peers, including old receipt parsing of the bulk prefix;
- carrier matrices for direct internet, LAN, mailbox, sneakernet, bridge, and LoRa proving bulk-tagged work never reaches an airtime transport and a file is never handed to whole-envelope reassembly;
- copied-database/media-directory inspection proving plaintext names, types, hashes, keys, bytes, transfer ranges, and conversation links are absent;
- KKR4 backup/restore tests proving manifests survive while media and transfer state are intentionally absent; and
- cross-surface tests proving identical states and safe errors in RPC/CLI, UniFFI, desktop, Android, and iOS, including background interruption and platform temporary-file cleanup.
Alternatives considered
- Use existing envelope fragmentation for the whole file. Rejected: its 128 KiB reassembly cap and 24-hour partial state are for bounded envelopes, not files. Raising them would invite memory/disk denial of service and give no consent, restart, quota, or cancellation boundary.
- Put chunks directly in Attachment content messages. Rejected: history would fill with transport records, each chunk would need chat-content ids and receipts, and an old client could surface thousands of unsupported rows. The terminal bulk lane keeps transfer state out of conversation presentation.
- Add a new outer
EnvelopeKind::AttachmentChunk. Rejected: deployed relays reject unknown outer kinds, and every intermediary would learn which traffic is media. The encrypted receipt-compatible lane preserves known routing behavior and hides operations and metadata. - Use plaintext hashes as global chunk addresses. Rejected: equal files or chunks from unrelated conversations would become a copied-store and endpoint equality oracle. Addresses hash randomized ciphertext and deduplicate only an identical sealed chunk.
- Random nonce stored with every attachment chunk. Rejected for the transfer ciphertext: retry and group fan-out need byte-identical chunks. A unique HKDF-derived key per object/index makes a fixed nonce safe. Local at-rest sealing still uses fresh random nonces.
- One giant encrypted media blob. Rejected: one lost byte forces a full retry, completion requires whole-file buffering, and crash cleanup cannot distinguish verified progress.
- Per-member group attachment encryption. Rejected: it breaks ADR-0012's encrypt-once scaling and makes one logical attachment consume N full encryptions and N distinct stores. Pairwise request wrappers are small; sealed chunk bytes remain identical.
- Automatic download based only on MIME or filename. Rejected: both are authenticated sender claims, not safety verdicts, and carrier/storage state can change immediately. Consent, byte caps, sandboxing, and F4 gating remain authoritative.
- Store media as SQLite BLOBs or plaintext files. Rejected: large BLOBs amplify database copies and backups; plaintext files violate the storage promise. Sealed files plus transactional sealed metadata bound both risks.
- Include all media in the mnemonic backup. Rejected: current backup construction is a single bounded file assembled in memory, and silently adding up to 64 GiB changes its operational contract. Manifests remain portable; media backup is a future explicit streaming format.
- Allow tiny LoRa attachments immediately. Rejected until physical HIL measurements allocate an airtime budget and F4 can report it honestly. The first F3 profile has one simple rule: bulk never uses airtime-class links.
Consequences
- Attachments become resumable, cancellable, quota-bounded, and compatible with pairwise, group, mailbox, sneakernet, internet, and LAN delivery without turning message fragmentation into file transport.
- A manifest costs at most 1,024 bytes inside the existing content frame. Each chunk carries fixed-record and double-encryption overhead and small control records use a 4 KiB padding bucket; this intentionally trades bandwidth on bulk-capable links for restart safety, metadata protection, and a mesh guard.
- Group manifests and file bytes are encrypted once, but the original author maintains independent per-member request/progress state and must remain available to serve v1 downloads.
- The store gains filesystem lifecycle and crash-consistency obligations beyond SQLite. Backups remain small and compatible at the cost of media being re-downloadable rather than restored.
- F3 implementation cannot activate before F4 provides fresh
bulkcarrier verdicts. Local codec, crypto, and store work may land earlier behind a disabled API, but no shell may offer sending based on transport guesswork. - The fixed kind, manifest, chunk size, limits, key schedule, bulk prefix, state model, and backup exclusion become compatibility law. Revisit them only with a new version if HIL measurements justify a bounded mesh profile, files above 512 MiB become a product requirement, peer-assisted group seeding is designed, or streaming media-inclusive backup is accepted.