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
+20 -3
View File
@@ -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,
std::int64_t timestamp, const std::string& txid) {
auto it = control_.find(cid);
if (it != control_.end()) {
// Route the op to its axis; ignore unknown future ops so they never pollute either reduction.
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;
// 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.
if (timestamp < cur.timestamp || (timestamp == cur.timestamp && txid <= cur.txid)) return;
it->second = ControlState{op, label, timestamp, txid};
} 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;
}
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 {
if (!has_identity_) return {};
+12 -2
View File
@@ -81,6 +81,14 @@ public:
// 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;
// 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) ---
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
std::string identityPublicKeyHex() const;
@@ -131,8 +139,10 @@ private:
std::int64_t timestamp = 0;
std::string txid;
};
std::unordered_map<std::string, ControlState> control_;
// Monotonic reduce: a later (timestamp, txid) wins; a replay of an older op never flips the state.
std::unordered_map<std::string, ControlState> control_; // hide axis: del / res
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,
std::int64_t timestamp, const std::string& txid);
};