Skip to content

Compute sha256 for every sent message at time_sent, by freezing the wire form #42

Description

@markmnl

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. partCacheKey builds content-addressed cache keys as sha256:<hash>:body and 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 messages

Populate 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
  • sha256 is recomputable from wire_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:

  1. run the deflate decision (computeDeflate)
  2. store the compressed artifact as the payload; record is_deflate, wire size, and a new expanded_size column
  3. Encode() the header, store it in wire_header
  4. GetMessageHash(), store sha256

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()storedHash is always present
  • the local-only branch in the psha256 trigger (dd.sql:129), so locally-created replies get psha256 populated like any other. That also fixes a related silent divergence: originalHeader() sets FlagHasPid only when psha256 is 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

  1. Lost notification. pg_notify is not durable: if fmsgd is down or restarting during the transition, that wake-up is gone. The existing poll does not cover it either — findPendingTargets keys off pending recipients, and fmsg-webapi's resolveLocalDelivery marks local recipients delivered immediately, so a local-only message has no pending rows (and addTarget skips the local domain regardless). Mitigation: the sweep must key on time_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".

  2. A reply or add-to created inside the window. A client can send a message and immediately reply to it before materialisation. The psha256 trigger currently leaves psha256 NULL 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 existing resolvePendingChildLinks.

  3. Concurrent delivery workers. deliverMessage runs per (message, domain), so two goroutines can reach materialisation for the same message at once. Today this is safe by accident: ensureSharedHash is UPDATE ... WHERE sha256 IS NULL and 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 write wire_header/sha256/is_deflate/size/expanded_size in one guarded transaction (SELECT ... FOR UPDATE, or keep the WHERE sha256 IS NULL guard). Same guard covers multiple fmsgd instances against one database.

  4. Delivery starting before materialisation. Must become a precondition — deliver only when sha256 is present — otherwise a delivery could ship a form that disagrees with the hash stored moments later.

  5. Mutation after time_sent. Not a race: fmsg-webapi refuses both PUT /fmsg/:id ("sent messages are immutable") and attachment upload ("attachments cannot be added to a sent message") once time_sent is 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, extractShortText and thread body population can no longer serve filepath bytes directly. Today they can, precisely because storage is always plaintext — note copyMessagePayload inflates on receive as well, so this changes both directions.
  • is_deflate currently means two different things and must be reconciled first: fmsg-webapi sets it from isZip(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-docker carries an initialisation copy of dd.sql and would need the new column plus integration coverage.

Migration of existing rows is explicitly out of scope.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions