123 lines
3.5 KiB
Markdown
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).
|