3.5 KiB
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
viewsignals on item X from the past 30 days." - "Remove all signals contributed by agent session
abc123from 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
- Scoped suppression at ranking time — revoked signals are excluded from scoring without requiring WAL rewrite or recompaction.
- Durable, persisted revocations — revocation records survive restarts.
- Immediate effect — after a revoke call, the next ranking query reflects it.
- Non-destructive — the underlying WAL events are not deleted.
- Queryable — callers can list active revocations for a user.
- 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 typeTimeRange { since_ns, until_ns }— suppress all signals in a time windowAgentSession { session_id_raw: u64 }— suppress all signals attributed to a session
API
pub fn revoke_signal(&self, user_id: u64, scope: RevocationScope) -> crate::Result<RevocationId>;
pub fn cancel_revocation(&self, user_id: u64, revocation_id: RevocationId) -> crate::Result<bool>;
pub fn list_revocations(&self, user_id: u64) -> crate::Result<Vec<SignalRevocation>>;
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<u64, Vec<SignalRevocation>>.
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
- A revocation never modifies the WAL or physical signal entries.
- After
revoke_signalreturnsOk, every subsequent retrieve respects it. - After
cancel_revocationreturnsOk, suppression is lifted immediately. - Revocations survive restart.
- Overlapping revocations are additive (union semantics).