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:
@@ -166,15 +166,21 @@ bool ChatService::deleteConversation(const std::string& conversationId, bool blo
|
|||||||
|
|
||||||
void ChatService::applyControl(const std::string& cid, const std::string& op, const std::string& label,
|
void ChatService::applyControl(const std::string& cid, const std::string& op, const std::string& label,
|
||||||
std::int64_t timestamp, const std::string& txid) {
|
std::int64_t timestamp, const std::string& txid) {
|
||||||
auto it = control_.find(cid);
|
// Route the op to its axis; ignore unknown future ops so they never pollute either reduction.
|
||||||
if (it != control_.end()) {
|
std::unordered_map<std::string, ControlState>* target = nullptr;
|
||||||
|
if (op == "del" || op == "res") target = &control_;
|
||||||
|
else if (op == "sensitive" || op == "unsensitive") target = &sensitive_control_;
|
||||||
|
else return;
|
||||||
|
|
||||||
|
auto it = target->find(cid);
|
||||||
|
if (it != target->end()) {
|
||||||
const ControlState& cur = it->second;
|
const ControlState& cur = it->second;
|
||||||
// A later signal wins; equal timestamps break by txid so the reduction is deterministic and a
|
// A later signal wins; equal timestamps break by txid so the reduction is deterministic and a
|
||||||
// replay of an older op can never flip the resolved state.
|
// replay of an older op can never flip the resolved state.
|
||||||
if (timestamp < cur.timestamp || (timestamp == cur.timestamp && txid <= cur.txid)) return;
|
if (timestamp < cur.timestamp || (timestamp == cur.timestamp && txid <= cur.txid)) return;
|
||||||
it->second = ControlState{op, label, timestamp, txid};
|
it->second = ControlState{op, label, timestamp, txid};
|
||||||
} else {
|
} else {
|
||||||
control_.emplace(cid, ControlState{op, label, timestamp, txid});
|
target->emplace(cid, ControlState{op, label, timestamp, txid});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +196,17 @@ std::vector<std::pair<std::string, std::string>> ChatService::onChainDeletedConv
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
bool ChatService::conversationSensitiveOnChain(const std::string& conversationId) const {
|
||||||
|
auto it = sensitive_control_.find(conversationId);
|
||||||
|
return it != sensitive_control_.end() && it->second.op == "sensitive";
|
||||||
|
}
|
||||||
|
std::vector<std::pair<std::string, std::string>> ChatService::onChainSensitiveConversations() const {
|
||||||
|
std::vector<std::pair<std::string, std::string>> out;
|
||||||
|
for (const auto& entry : sensitive_control_) {
|
||||||
|
if (entry.second.op == "sensitive") out.emplace_back(entry.first, entry.second.label);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
std::string ChatService::identityPublicKeyHex() const {
|
std::string ChatService::identityPublicKeyHex() const {
|
||||||
if (!has_identity_) return {};
|
if (!has_identity_) return {};
|
||||||
|
|||||||
+12
-2
@@ -81,6 +81,14 @@ public:
|
|||||||
// the label carried in its control memo (the peer address / display name at delete time).
|
// the label carried in its control memo (the peer address / display name at delete time).
|
||||||
std::vector<std::pair<std::string, std::string>> onChainDeletedConversations() const;
|
std::vector<std::pair<std::string, std::string>> onChainDeletedConversations() const;
|
||||||
|
|
||||||
|
// Sensitive axis (independent of hide/restore): a conversation whose latest on-chain control op is
|
||||||
|
// "sensitive" is marked sensitive — its local rows are stored under the local vault key and withheld
|
||||||
|
// on a device without that key (including a fresh seed import), until "unsensitive". Re-derived from
|
||||||
|
// chain each session, like the hide axis.
|
||||||
|
bool conversationSensitiveOnChain(const std::string& conversationId) const;
|
||||||
|
// Conversations whose latest on-chain control op is "sensitive", each paired with its control label.
|
||||||
|
std::vector<std::pair<std::string, std::string>> onChainSensitiveConversations() const;
|
||||||
|
|
||||||
// --- Outgoing (compose) ---
|
// --- Outgoing (compose) ---
|
||||||
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
|
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
|
||||||
std::string identityPublicKeyHex() const;
|
std::string identityPublicKeyHex() const;
|
||||||
@@ -131,8 +139,10 @@ private:
|
|||||||
std::int64_t timestamp = 0;
|
std::int64_t timestamp = 0;
|
||||||
std::string txid;
|
std::string txid;
|
||||||
};
|
};
|
||||||
std::unordered_map<std::string, ControlState> control_;
|
std::unordered_map<std::string, ControlState> control_; // hide axis: del / res
|
||||||
// Monotonic reduce: a later (timestamp, txid) wins; a replay of an older op never flips the state.
|
std::unordered_map<std::string, ControlState> sensitive_control_; // sensitive axis: sensitive / unsensitive
|
||||||
|
// 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,
|
void applyControl(const std::string& cid, const std::string& op, const std::string& label,
|
||||||
std::int64_t timestamp, const std::string& txid);
|
std::int64_t timestamp, const std::string& txid);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15133,6 +15133,50 @@ void testHushChatControlReducer()
|
|||||||
feed(dm, "d1", now + 10);
|
feed(dm, "d1", now + 10);
|
||||||
EXPECT_TRUE(!svc.conversationHiddenOnChain(cid));
|
EXPECT_TRUE(!svc.conversationHiddenOnChain(cid));
|
||||||
EXPECT_EQ((int)svc.store().conversation(cid).size(), 1);
|
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.
|
// ChatService: metadata batch -> decrypt -> threaded, deduped in-memory store.
|
||||||
|
|||||||
Reference in New Issue
Block a user