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

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 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

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

  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).