# 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` ```rust pub enum RevocationScope { SignalType { signal_type: String, since_ns: Option, until_ns: Option }, 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` ```rust pub struct RevocationIndex { inner: DashMap>, } ``` 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 ```rust revocation_index: Arc, ``` 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` — validate, persist, upsert index - `cancel_revocation(user_id, id) -> Result` — cancel in index, re-persist - `list_revocations(user_id) -> Result>` — read from index - `revocation_index() -> &Arc` — 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 |