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

123 lines
3.5 KiB
Markdown

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