tidaldb/.sdlc/features/m10-agent-capability-boundaries/spec.md

246 lines
14 KiB
Markdown

# Spec: Agent Capability Boundaries
## Overview
tidalDB already enforces per-session policy rules for signal writes through `AgentPolicy` — a schema-declared struct binding an agent session to an allow list, deny list, duration cap, and count cap. This feature extends that foundation to cover two capabilities that are currently unguarded at the policy layer:
1. **Read-path access control** — agents can currently read any user attribute and any signal aggregate from any entity, regardless of their declared policy. Policy should declare which signal types and user attribute keys an agent is permitted to read.
2. **Ranking profile override control** — agents can specify arbitrary profile names in session-influenced queries. Policy should declare which profiles (if any) an agent is permitted to override, and whether it may inject session context at all.
The enforcement point remains the session layer. Policy rules are schema-declared, validated at build time, and evaluated at the call site — not in ad-hoc middleware.
## Problem Statement
A tidalDB deployment running multiple agents — a recommendation agent, a search agent, an analytics agent, a third-party plugin — must be able to constrain each one precisely:
- The analytics agent should read signal aggregates but must not write preference signals or read raw user attributes.
- The recommendation agent writes preference hints but must not access engagement signals from other users.
- A third-party plugin should only be able to observe signals from items the user explicitly interacted with during its session, not global signal state.
- An agent should not be able to swap the ranking profile to one that bypasses quality gates or diversity constraints.
None of these constraints are expressible today. `AgentPolicy` only guards writes. This feature closes that gap.
## Goals
- Extend `AgentPolicy` with `allowed_reads`, `denied_reads`, `allowed_user_attributes`, `denied_user_attributes`, and `allowed_profile_overrides` fields.
- Enforce read-path checks at the call sites for `read_decay_score`, `read_windowed_count`, `read_velocity`, and user attribute reads via session-scoped access.
- Enforce profile override checks when a session passes a profile name to a `Retrieve` or `Search` query.
- Validate the extended policy at schema build time (fail-fast: unknown signal names and unknown profile names are schema errors).
- Produce typed `PolicyViolation` responses for all new violation kinds, extending `PolicyViolationKind`.
- Add audit log entries for all read-path and profile-override violations.
- Preserve zero-cost for sessions that do not use read restrictions (empty lists = unrestricted, same as today).
## Non-Goals
- This feature does not add row-level or entity-level access control (i.e., hiding specific item IDs from a session). That is a separate concern.
- This feature does not add authentication or session token validation. It assumes the caller presents the correct `SessionId`.
- This feature does not add write-path changes beyond what already exists; the existing `allowed_signals` / `denied_signals` / count cap / duration cap logic is unchanged.
- This feature does not enforce network-level isolation between agents. Policy enforcement is cooperative at the API boundary.
- Community-level policy (governing what signals community members can write) is handled by `m10-community-policy-engine` and is out of scope here.
## Behavioral Specification
### Policy Extension
`AgentPolicy` gains five new optional fields:
```rust
pub struct AgentPolicy {
// --- existing fields (unchanged) ---
pub allowed_signals: Vec<String>,
pub denied_signals: Vec<String>,
pub max_session_duration: Duration,
pub max_signals_per_session: u32,
// --- new fields ---
/// If non-empty, only these signal types may be *read* in sessions using this policy.
/// Empty = unrestricted (all signals readable).
pub allowed_read_signals: Vec<String>,
/// Signal types that may never be *read* in sessions using this policy.
/// Evaluated after allowed_read_signals.
pub denied_read_signals: Vec<String>,
/// If non-empty, only these user attribute keys may be read.
/// Empty = unrestricted (all user attributes readable).
pub allowed_user_attributes: Vec<String>,
/// User attribute keys that may never be read.
pub denied_user_attributes: Vec<String>,
/// If non-empty, sessions using this policy may only override the ranking profile
/// to one of these named profiles. Empty = no profile overrides permitted at all
/// when a session is active (session context is still injected, but the profile
/// choice cannot deviate from the application-specified profile).
///
/// Note: if the query has no session or if the policy does not restrict profiles,
/// the query profile is chosen by the application and this field has no effect.
pub allowed_profile_overrides: Vec<String>,
}
```
### SchemaBuilder Extension
`SchemaBuilder::session_policy` gains a builder pattern for the new fields:
```rust
builder.session_policy("search-agent")
.allowed_signals(["search_click", "search_skip"])
.denied_read_signals(["hide", "block"])
.allowed_user_attributes(["locale", "age_range"])
.allowed_profile_overrides(["search", "trending"])
.max_duration(Duration::from_secs(1800))
.add()?;
```
All new lists default to empty (unrestricted for reads, no overrides permitted for profiles when non-empty). Schema build-time validation rejects:
- Signal names in `allowed_read_signals` or `denied_read_signals` that do not exist in the schema.
- Signal names in `denied_read_signals` that also appear in `allowed_read_signals` (conflict).
- Profile names in `allowed_profile_overrides` that do not exist (built-in or declared).
- User attribute keys are not validated at build time (they are untyped free-text).
### Read-Path Enforcement
The read-path methods that are session-aware are gated when a `SessionId` is presented:
#### Signal reads
`TidalDb::read_decay_score_for_session(session_id, entity_id, signal_type, variant)`,
`TidalDb::read_windowed_count_for_session(session_id, entity_id, signal_type, window)`,
`TidalDb::read_velocity_for_session(session_id, entity_id, signal_type, window)`
Each performs a policy read-check before delegating to the underlying signal ledger:
1. Look up the session by `session_id`. If session not found → `TidalError::NotFound`.
2. Retrieve the `AgentPolicy` for the session's `policy_name`.
3. Evaluate allow list: if `allowed_read_signals` is non-empty and `signal_type` is absent → `PolicyViolation { kind: ReadNotAllowed, ... }`.
4. Evaluate deny list: if `signal_type` appears in `denied_read_signals``PolicyViolation { kind: ReadDenied, ... }`.
5. Record in audit log.
6. Proceed to signal ledger.
The existing un-gated `read_decay_score`, `read_windowed_count`, `read_velocity` methods are unchanged and remain available for non-session callers (application-level reads with no session context).
#### User attribute reads
`TidalDb::read_user_attribute_for_session(session_id, user_id, key)` returns the attribute value from user state, subject to:
1. Look up session and policy.
2. Allow-list check on `key`.
3. Deny-list check on `key`.
4. Delegate to user state store.
Direct reads via `get_item_metadata`, `read_user_*`, etc. remain unrestricted (session-unaware).
### Profile Override Enforcement
When `TidalDb::retrieve` or `TidalDb::search` is called with a non-None `for_session` and a profile name that differs from the one the application provided at session start:
1. Retrieve the session and its policy.
2. If `allowed_profile_overrides` is empty → the application-specified profile is used as-is; no override is permitted; if the query's profile name was supplied by the agent (not the application), the query is rejected with `PolicyViolation { kind: ProfileOverrideNotAllowed, ... }`.
3. If `allowed_profile_overrides` is non-empty and the requested profile is in the list → permitted.
4. If `allowed_profile_overrides` is non-empty and the requested profile is not in the list → rejected.
To express "the agent may choose any profile," the application sets `allowed_profile_overrides` to all defined profile names — or a sentinel `["*"]` (which the schema validator resolves to all known profiles at build time).
### New PolicyViolationKind variants
```rust
pub enum PolicyViolationKind {
// existing
Expired,
CountCap,
Denied,
NotAllowed,
// new
ReadDenied,
ReadNotAllowed,
AttributeReadDenied,
AttributeReadNotAllowed,
ProfileOverrideNotAllowed,
}
```
Each new violation kind is:
- Returned as `Err(PolicyViolation)` from the enforcing method.
- Recorded in the session audit log with `signal_type` (or attribute key or profile name) and the policy name.
- Counted in `signals_rejected` (for read violations) or tracked in a separate `overrides_rejected: AtomicU64` on `SessionState`.
### Audit Integration
All new violation events are recorded in the session's `AuditLog` with an `AuditEntry`:
```
AuditEntry {
timestamp_ns: ...,
kind: AuditKind::ReadDenied | AuditKind::ProfileOverrideRejected,
signal_type: "hide", // or attribute key or profile name
policy_name: "search-agent",
reason: "signal 'hide' is in denied_read_signals for policy 'search-agent'",
}
```
`AuditKind` gains new variants to distinguish read denials from write denials. The existing `MAX_AUDIT_ENTRIES` cap and eviction behavior apply unchanged.
### Error surfacing
Policy violations on reads become `TidalError::PolicyViolation` — the same variant already returned for write violations — so callers handle them uniformly. The `PolicyViolation` payload carries the typed kind so callers can branch without string parsing.
### Zero-cost fast path
For policies with empty `allowed_read_signals`, `denied_read_signals`, `allowed_user_attributes`, `denied_user_attributes`, and `allowed_profile_overrides`, all new checks short-circuit in O(1) with a single length test. No per-check allocation. No iteration over empty vecs.
## API Surface Summary
New methods on `TidalDb`:
| Method | Purpose |
|---|---|
| `read_decay_score_for_session(sid, eid, signal, variant)` | Session-gated decay score read |
| `read_windowed_count_for_session(sid, eid, signal, window)` | Session-gated window count read |
| `read_velocity_for_session(sid, eid, signal, window)` | Session-gated velocity read |
| `read_user_attribute_for_session(sid, uid, key)` | Session-gated user attribute read |
Existing methods are unchanged.
New fields on `AgentPolicy` (all default to empty `Vec<String>`):
- `allowed_read_signals`
- `denied_read_signals`
- `allowed_user_attributes`
- `denied_user_attributes`
- `allowed_profile_overrides`
New `PolicyViolationKind` variants:
- `ReadDenied`
- `ReadNotAllowed`
- `AttributeReadDenied`
- `AttributeReadNotAllowed`
- `ProfileOverrideNotAllowed`
New `AuditKind` variants (internal):
- `ReadDenied`
- `AttributeReadDenied`
- `ProfileOverrideRejected`
## Acceptance Criteria
1. A session using a policy with `allowed_read_signals: ["view"]` successfully reads the `view` decay score and is rejected with `ReadNotAllowed` when it attempts to read `like`.
2. A session using a policy with `denied_read_signals: ["hide"]` is rejected with `ReadDenied` when it reads the `hide` signal, regardless of the allow list.
3. A session using a policy with `allowed_user_attributes: ["locale"]` reads `locale` successfully and is rejected with `AttributeReadNotAllowed` when it reads `age_range`.
4. A session using a policy with `allowed_profile_overrides: []` cannot override the profile: query execution uses the application-provided profile name, and an attempted agent override is rejected.
5. A session using a policy with `allowed_profile_overrides: ["search", "trending"]` may override to `search` or `trending`, but not `for_you`.
6. All violations are recorded in the session audit log with the correct `AuditKind`.
7. A policy with all new fields empty behaves identically to the existing `AgentPolicy` (no regression for existing callers).
8. Schema build-time validation rejects a policy referencing a non-existent signal type in `allowed_read_signals`.
9. Schema build-time validation rejects a policy where the same signal appears in both `allowed_read_signals` and `denied_read_signals`.
10. Schema build-time validation rejects a policy referencing a non-existent profile name in `allowed_profile_overrides` (unless the sentinel `["*"]` is used).
11. `signals_rejected` and `overrides_rejected` counters on `SessionState` are incremented correctly and appear in `SessionSnapshot`.
12. The `read_decay_score` / `read_windowed_count` / `read_velocity` methods (without session) are unaffected.
## Implementation Notes
- `PolicyEvaluator` in `session/policy.rs` gains `check_read` and `check_profile_override` methods alongside the existing `check` method. The same struct pattern, same borrowing semantics.
- The new session-gated read methods live in `db/mod.rs` alongside the existing signal read methods. They follow the same `self.ledger()` / `self.sessions` access pattern as the write path.
- `SchemaBuilder::session_policy` builder method returns a new `SessionPolicyBuilder` struct that accumulates the new fields. Validated in the existing `Schema::build` call.
- No WAL changes are required: read-path enforcement is stateless (no durability needed for read denials).
- No storage layer changes are required: no new on-disk structures.
- Benchmark: a deny-list check on a session with 5 denied signals and a 1-element allow list must complete in under 100 ns per check (comparable to a linear scan of 5 strings). If signal counts grow large, a `HashSet` replaces the `Vec` for O(1) lookup — with validation ensuring the same correctness guarantees.