# Spec: Signal Revocation Controls ## Summary Signal revocation gives users the ability to selectively remove the influence of past signals from their personalization layer — scoped by signal type, time range, or agent identity — without deleting the underlying events from the immutable audit log. This is a first-class privacy and trust primitive. --- ## Problem Signal events in tidalDB are appended to the WAL as immutable facts. There is no way for a user to say: - "Ignore my `view` signals on item X from the past 30 days." - "Remove all signals contributed by agent session `abc123` from my profile." - "Treat my engagement with category 'sports' as if it never happened." The vision explicitly requires that "Personalization is user-owned and revocable" and that "Users can revoke agent scope and remove agent-contributed signals from specific personalization layers." This feature implements that requirement. --- ## Goals 1. **Scoped suppression at ranking time** — revoked signals are excluded from scoring without requiring WAL rewrite or recompaction. 2. **Durable, persisted revocations** — revocation records survive restarts. 3. **Immediate effect** — after a revoke call, the next ranking query reflects it. 4. **Non-destructive** — the underlying WAL events are not deleted. 5. **Queryable** — callers can list active revocations for a user. 6. **Revocable revocation** — users can undo a revocation. --- ## Core Concepts ### Revocation Record ``` SignalRevocation { id: RevocationId, // stable UUID user_id: u64, scope: RevocationScope, created_at_ns: u64, active: bool, } ``` ### Revocation Scope Three variants: - `SignalType { signal_type, since_ns?, until_ns? }` — suppress a specific signal type - `TimeRange { since_ns, until_ns }` — suppress all signals in a time window - `AgentSession { session_id_raw: u64 }` — suppress all signals attributed to a session --- ## API ```rust pub fn revoke_signal(&self, user_id: u64, scope: RevocationScope) -> crate::Result; pub fn cancel_revocation(&self, user_id: u64, revocation_id: RevocationId) -> crate::Result; pub fn list_revocations(&self, user_id: u64) -> crate::Result>; ``` --- ## Storage Revocation records are persisted in the `users_engine` under `Tag::Revocation = 0x0F`: ``` Key: [user_id: 8 bytes BE][0x00][0x0F][revocation_id: 16 bytes UUID raw] Value: serde_json of SignalRevocation ``` --- ## In-Memory Index `RevocationIndex` backed by `DashMap>`. The `is_suppressed` check is called in the read path during ranking. It is lock-free for users with no revocations (fast path: no DashMap entry exists). --- ## Read-Path Integration Suppression is applied in `ranking/executor/mod.rs` during scoring: ``` score = ledger.read_decay_score(entity_id, signal_type, idx) if for_user.is_some() and revocation_index.is_suppressed(...): score = 0.0 ``` Uses `HotSignalState::last_update_ns()` for time-bounded checks. --- ## Startup Restore On open, `Tag::Revocation` keys in `users_engine` are scanned and loaded into `RevocationIndex` before the database becomes available for queries. --- ## Correctness Invariants 1. A revocation never modifies the WAL or physical signal entries. 2. After `revoke_signal` returns `Ok`, every subsequent retrieve respects it. 3. After `cancel_revocation` returns `Ok`, suppression is lifted immediately. 4. Revocations survive restart. 5. Overlapping revocations are additive (union semantics).