tidaldb/.sdlc/features/m10-signal-revocation-controls/design.md

4.2 KiB

Design: Signal Revocation Controls

Overview

Concrete module layout, data structures, storage encoding, read-path integration, and preference vector rebuild strategy.


Tag: Tag::Revocation = 0x0F

Added to storage/keys.rs (0x0E = CommunityMembership, 0x0F = Revocation, 0x10 = PurgeManifest, 0x11 = CommunityLeave).

Key format (stored in users_engine):

[user_id: 8 bytes BE][0x00][0x0F][revocation_id: 16 bytes UUID raw]
Value: serde_json bytes of SignalRevocation

New Files

tidal/src/
  entities/
    revocation.rs          -- RevocationId, RevocationScope, SignalRevocation, RevocationIndex
  db/
    revocation.rs          -- TidalDb methods: revoke_signal, cancel_revocation, list_revocations

Data Structures

RevocationId

Newtype over uuid::Uuid with new(), as_bytes(), from_bytes(). Derives Serialize/Deserialize via the uuid serde feature.

RevocationScope

pub enum RevocationScope {
    SignalType { signal_type: String, since_ns: Option<u64>, until_ns: Option<u64> },
    TimeRange  { since_ns: u64, until_ns: u64 },
    AgentSession { session_id_raw: u64 },  // u64 because SessionId has no serde
}

Note: AgentSession stores session_id_raw: u64 (from SessionId::as_u64()) because SessionId does not derive Serialize/Deserialize.

RevocationIndex

pub struct RevocationIndex {
    inner: DashMap<u64, Vec<SignalRevocation>>,
}

Fast path: is_suppressed returns false immediately if user has no entry in the DashMap (no lock acquisition, just a shard lookup that misses).


TidalDb Field

revocation_index: Arc<crate::entities::RevocationIndex>,

Initialized in both from_config and from_parts as Arc::new(RevocationIndex::new()).


db/revocation.rs — Public API

Three methods on TidalDb:

  • revoke_signal(user_id, scope) -> Result<RevocationId> — validate, persist, upsert index
  • cancel_revocation(user_id, id) -> Result<bool> — cancel in index, re-persist
  • list_revocations(user_id) -> Result<Vec<SignalRevocation>> — read from index
  • revocation_index() -> &Arc<RevocationIndex> — accessor for executor

Internal helper persist_revocation writes to users_engine using serde_json::to_vec.

Public function rebuild_revocations(index, storage) — startup restore (called from db/mod.rs open path).


Read-Path Integration

In ranking/executor/mod.rs, during the signal scoring loop:

score = ledger.read_decay_score(entity_id, signal_type, idx)
last_ts = ledger.entry_last_update_ns(entity_id, signal_type_id)
if for_user.is_some() and revocation_index.is_suppressed(uid, signal_type, last_ts, session_id):
    score = 0.0

entry_last_update_ns is already implemented on SignalLedger (T3).


Startup Restore

rebuild_revocations(index, storage) in db/revocation.rs:

  • Full-keyspace scan of users_engine
  • Filters for Tag::Revocation via parse_key
  • Groups by user_id, calls load_for_user per group

Called from db/mod.rs open path alongside rebuild_collections and rebuild_suggestion_index.


uuid Dependency

uuid is available as a transitive dependency (confirmed: build passes without explicit declaration). Adding it explicitly to Cargo.toml for clarity.


Files Changed / Created

File Change
tidal/Cargo.toml Add uuid = { version = "1", features = ["v4", "serde"] }
tidal/src/storage/keys.rs Add Tag::Revocation = 0x0F; update from_byte, test arrays
tidal/src/entities/revocation.rs New — core types and RevocationIndex
tidal/src/entities/mod.rs Add pub mod revocation; and re-exports
tidal/src/signals/ledger/core.rs Add entry_last_update_ns() helper
tidal/src/db/mod.rs Add revocation_index field; add mod revocation;
tidal/src/db/revocation.rs New — public API + rebuild function
tidal/src/db/state_rebuild.rs Call rebuild_revocations in open path
tidal/src/ranking/executor/mod.rs Apply suppression check during scoring
tidal/src/lib.rs Export RevocationId, RevocationScope, SignalRevocation
tidal/tests/m10_revocation.rs New — 10 integration tests