feat(chat): tier-3 reassembly accumulator in the chat service

Second half of tier-3 larger messages: the receive side. ChatService now buffers
the slices of a multi-transaction message (keyed by the shared message id) as
they arrive — across transactions and across scans — and reassembles the whole
ciphertext once every chunk is present, then decrypts and surfaces it as one
message. The buffer is NOT persisted: a rescan re-sends every slice, so it
re-derives from chain exactly like the control reducer, and survives a restart
mid-receive (the next full scan re-feeds the siblings and completes it).

Ingest's per-message build/decrypt/guard logic is refactored into one shared
lambda so a reassembled tier-3 message passes the identical block / on-chain-hide
/ sensitive-vault / tombstone checks as a single-tx message. A completed group is
retired after it surfaces (deduped across scans by its stable canonical txid —
the smallest sibling txid); a group withheld only because the sensitive vault is
locked is kept so a later unlock + scan completes it. Our own slices (sender ==
us) are skipped — the local echo records the outgoing message. The pending map is
bounded (256 groups) against a flood of never-completing partials.

Unit-tested (testHushChatTier3Reassembly): a ~9000-char message's first slice
alone surfaces nothing; once every slice has arrived it reassembles, decrypts to
the original and surfaces once; a re-scan dedups; our own slices never ingest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 12:31:42 -05:00
co-authored by Claude Opus 4.8
parent cc08854237
commit 5e33bf2c41
3 changed files with 166 additions and 24 deletions
+16
View File
@@ -153,6 +153,22 @@ private:
};
std::unordered_map<std::string, ControlState> control_; // hide axis: del / res
std::unordered_map<std::string, ControlState> sensitive_control_; // sensitive axis: sensitive / unsensitive
// Larger-messages tier 3: a big message split across several transactions arrives one slice at a
// time (siblings share a message id). We accumulate the slices here until every chunk is present,
// then reassemble + decrypt + emit the whole message. Not persisted — a rescan re-sends every slice
// so the buffer re-derives from chain (like the control reducer); a completed group is retired.
struct Tier3Pending {
int total_parts = 0;
std::string secretstream_header_hex; // "e", shared by every slice
std::string sender_public_key_hex; // "p", shared
std::string conversation_id;
std::string reply_zaddr;
std::int64_t sent_at = 0;
std::string canonical_txid; // smallest sibling txid — the reassembled message's stable key
std::unordered_map<int, std::string> chunks; // global index -> ciphertext-hex chunk
};
std::unordered_map<std::string, Tier3Pending> tier3_pending_; // message id -> accumulating slices
// Monotonic reduce: route the op to its axis (hide vs sensitive), ignore unknown ops (forward-compat),
// and within an axis a later (timestamp, txid) wins so a replay of an older op never flips the state.
void applyControl(const std::string& cid, const std::string& op, const std::string& label,