feat(chat): add an independent "sensitive" axis to the control reducer

Routes control ops to one of two independent per-conversation reductions: the
hide axis (del/res) and a new sensitive axis (sensitive/unsensitive), so a
conversation can be deleted AND sensitive without the two states clobbering
each other. Unknown future ops are ignored (not stored in either axis).
Adds conversationSensitiveOnChain() + onChainSensitiveConversations(); the
reduction stays monotonic by (compose-time, txid). Reducer-tested, including
axis independence and that marking sensitive neither hides nor erases a thread.

Phase B, step 1 (reducer) of per-conversation local-key encryption (policy level).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 17:25:36 -05:00
co-authored by Claude Opus 4.8
parent 796d639ba3
commit 669b8b7ca5
3 changed files with 76 additions and 5 deletions
+44
View File
@@ -15133,6 +15133,50 @@ void testHushChatControlReducer()
feed(dm, "d1", now + 10);
EXPECT_TRUE(!svc.conversationHiddenOnChain(cid));
EXPECT_EQ((int)svc.store().conversation(cid).size(), 1);
// ---- Sensitive axis: independent of the hide axis. Build control metadata directly with explicit
// timestamps so the monotonic ordering is deterministic (not subject to compose-time ties). ----
auto ctl = [&](const std::string& op, const std::string& c, std::int64_t ts, const std::string& txid) {
std::vector<HushChatTransactionMetadata> md(1);
md[0].txid = txid;
md[0].type = HushChatHeaderType::Control;
md[0].op = op;
md[0].conversation_id = c;
md[0].reply_zaddr = "zs-me";
md[0].sender_public_key_hex = rm.public_key_hex;
md[0].payload_memo = "Peer";
md[0].sent_at = ts;
return svc.ingest(md, {}, ts);
};
EXPECT_TRUE(!svc.conversationSensitiveOnChain(cid));
EXPECT_EQ(ctl("sensitive", cid, now + 100, "s1"), 0); // a control adds no message
EXPECT_TRUE(svc.conversationSensitiveOnChain(cid)); // marked sensitive
EXPECT_TRUE(!svc.conversationHiddenOnChain(cid)); // but NOT hidden (independent axis)
EXPECT_EQ((int)svc.store().conversation(cid).size(), 1); // and the thread is not erased
{
auto sens = svc.onChainSensitiveConversations();
EXPECT_EQ((int)sens.size(), 1);
EXPECT_EQ(sens[0].first, cid);
}
// A conversation can be hidden AND sensitive at the same time.
ctl("del", cid, now + 200, "d2");
EXPECT_TRUE(svc.conversationHiddenOnChain(cid));
EXPECT_TRUE(svc.conversationSensitiveOnChain(cid));
EXPECT_EQ((int)svc.store().conversation(cid).size(), 0); // the hide erased the view; sensitive persists
// unsensitive (later) clears sensitive and leaves the hide axis untouched; an older replay can't re-set.
ctl("unsensitive", cid, now + 300, "u1");
EXPECT_TRUE(!svc.conversationSensitiveOnChain(cid));
EXPECT_TRUE(svc.conversationHiddenOnChain(cid));
ctl("sensitive", cid, now + 100, "s1"); // replay the older sensitive
EXPECT_TRUE(!svc.conversationSensitiveOnChain(cid));
// An unknown future op touches neither axis.
ctl("futurecustomop", "conv-y", now + 400, "x1");
EXPECT_TRUE(!svc.conversationHiddenOnChain("conv-y"));
EXPECT_TRUE(!svc.conversationSensitiveOnChain("conv-y"));
}
// ChatService: metadata batch -> decrypt -> threaded, deduped in-memory store.