Repository navigation
Compute sha256 for every sent message at time_sent, by freezing the wire form #42
Description
Proposal
Compute and store msg.sha256 for every sent message at the moment time_sent is stamped — the draft→sendable transition — irrespective of whether any recipient is on another domain.
Today the hash is a side effect of the first outbound delivery, so a message whose recipients are all local never gets one:
103|@bob@example.com|<NULL> -- bob -> carol, same domain
102|@alice@hairpin.local|2716a9cc... -- crossed the wire
That is defensible (while a message is local-only, nothing external can reference it) but it costs more than it saves.
Why
- Auditability. "Every sent message has a content hash" is a one-line invariant. Today the rule is "…unless every recipient happened to be local, or it hasn't been delivered yet", which cannot be checked without reproducing the sender's logic.
- It deletes special cases rather than adding them (see below).
- fmsg-webapi already wants it.
partCacheKeybuilds content-addressed cache keys assha256:<hash>:bodyand returns""when the hash is missing, so local-only messages are silently non-cacheable in the thread API. - Storage. If the wire form is materialised at send, the host can keep only the post-deflate artifact instead of the plaintext, and stop recompressing the same payload once per destination domain.
Design: freeze the wire form, don't just compute a hash
The hash problem is really a which form? problem. Per SPEC §2 the message hash covers the header "exactly as transmitted", and the header carries the deflate flag, wire size and expanded size — so compressing changes the hash. sender.go already documents what happens when that is got wrong:
Deflate must be applied BEFORE the shared hash is computed... Hashing the undeflated form recorded a sha256 the receiving host never computes, so cross-host replies bounced with code 6 (parent not found).
So hashing at time_sent only works if the transmitted form is decided and frozen at time_sent too. There is already a column for that answer:
wire_header bytea -- received messages: the exact wire header bytes (fields 1-13) ...
-- null for locally-authored messagesPopulate wire_header for locally-authored messages as well, at time_sent. Then:
- the deflate form is frozen by construction — the stored header bytes are the decision, so no later delivery can hash a different form, and that whole bug class stops being expressible
sha256is recomputable fromwire_header+ payload for every sent message, forever- delivery becomes "ship the stored header + stored payload" instead of rebuilding a header and recompressing per domain
- the column's meaning becomes uniform instead of "received messages only"
Sketch
At the time_sent transition, once:
- run the deflate decision (
computeDeflate) - store the compressed artifact as the payload; record
is_deflate, wiresize, and a newexpanded_sizecolumn Encode()the header, store it inwire_headerGetMessageHash(), storesha256
Delivery then reuses all of it. The sender already prefers a stored hash (sharedHash := m.storedHash), so that half needs no change.
What it removes
- the on-the-fly fallback in
msgFields.sharedHash()—storedHashis always present - the local-only branch in the
psha256trigger (dd.sql:129), so locally-created replies getpsha256populated like any other. That also fixes a related silent divergence:originalHeader()setsFlagHasPidonly whenpsha256is non-empty, so a reply created while its parent was still hashless would go out as a thread root rather than a reply - per-domain recompression in
deliverMessage - plaintext-at-rest for locally-authored messages
Where the materialisation runs
A — fmsgd, on the notification it already receives. notify_msg_sent fires on the draft→sent transition and notifies for every recipient, local ones included; the worker currently discards local-domain targets in addTarget. Single repo, reuses existing code, roughly 60–100 lines. Asynchronous, so time_sent is not null and sha256 is null is briefly true.
B — fmsg-webapi, synchronously in POST /:id/send. Gives the hard invariant and allows check (time_sent is null or sha256 is not null). Costs a new fmsg-webapi → fmsgd dependency (pkg/fmsg) and requires promoting the deflate policy out of cmd/fmsgd/deflate.go, which also moves wire-format decisions into the client API — fmsgd's job everywhere else.
Suggest A, with B still available later since pkg/fmsg already exports Encode, GetMessageHash and HashPayload.
Race conditions under A
-
Lost notification.
pg_notifyis not durable: if fmsgd is down or restarting during the transition, that wake-up is gone. The existing poll does not cover it either —findPendingTargetskeys off pending recipients, and fmsg-webapi'sresolveLocalDeliverymarks local recipients delivered immediately, so a local-only message has no pending rows (andaddTargetskips the local domain regardless). Mitigation: the sweep must key ontime_sent is not null and sha256 is null, with the notification as an optimisation only — consistent with the existing "payload is advisory; the worker re-polls fully on any wake-up". -
A reply or add-to created inside the window. A client can send a message and immediately reply to it before materialisation. The
psha256trigger currently leavespsha256NULL in that case; if the local-only branch is simply deleted, the child is either rejected or silently unlinked. Mitigation: keep deferred linking and backfill children when the parent materialises — the mirror of the existingresolvePendingChildLinks. -
Concurrent delivery workers.
deliverMessageruns per (message, domain), so two goroutines can reach materialisation for the same message at once. Today this is safe by accident:ensureSharedHashisUPDATE ... WHERE sha256 IS NULLand both workers compute identical bytes. Once an artifact is written too, two compressors can race on the payload file. Mitigation: write the artifact temp+rename, and writewire_header/sha256/is_deflate/size/expanded_sizein one guarded transaction (SELECT ... FOR UPDATE, or keep theWHERE sha256 IS NULLguard). Same guard covers multiple fmsgd instances against one database. -
Delivery starting before materialisation. Must become a precondition — deliver only when
sha256is present — otherwise a delivery could ship a form that disagrees with the hash stored moments later. -
Mutation after
time_sent. Not a race: fmsg-webapi refuses bothPUT /fmsg/:id("sent messages are immutable") and attachment upload ("attachments cannot be added to a sent message") oncetime_sentis set, so the frozen form cannot drift. Worth an explicit test.
Under B, races 1, 2 and 4 cannot occur; 3 only matters if delivery workers ever materialise, which they no longer would.
Cross-repo consequences
- fmsg-webapi read paths must inflate. Storing post-deflate artifacts means
GET /fmsg/:id/data, attachment download,extractShortTextand thread body population can no longer servefilepathbytes directly. Today they can, precisely because storage is always plaintext — notecopyMessagePayloadinflates on receive as well, so this changes both directions. is_deflatecurrently means two different things and must be reconciled first: fmsg-webapi sets it fromisZip(data)("the body is a ZIP file") while fmsgd reads it as the protocol's zlib-deflate flag. Filing separately — it is an active bug, not just a naming problem.fmsg-dockercarries an initialisation copy ofdd.sqland would need the new column plus integration coverage.
Migration of existing rows is explicitly out of scope.
🤖 Generated with Claude Code
Activity
markmnl commentedon Sep 17, 2026
Completed through option B described in this issue: fmsg-webapi synchronously calls the shared finalizer before committing a send. The transaction stores time_sent, SHA-256, the exact wire header, and the prepared wire representation together, including for local-only messages and reactions. Add-to batches receive their own finalized identities. fmsgd reuses those representations for federation and challenge responses instead of choosing a new encoding during delivery.
Implemented in:
The additional suggestion to retain only compressed storage was not adopted: expanded download copies remain alongside the prepared wire payloads. That is a separate storage optimization and does not leave the send-time hashing requirement outstanding.
Closing as completed.