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

358 lines
13 KiB
Markdown

# Design: Agent Capability Boundaries
## Summary
This document describes the concrete data structures, module changes, and call-site enforcement points for the agent capability boundary feature. No new modules are introduced. All changes extend existing structures in `schema/validation/policies.rs`, `session/policy.rs`, and `db/mod.rs`.
---
## Module Change Map
```
schema/
validation/
policies.rs ← AgentPolicy: 5 new fields
builders.rs ← SessionPolicyBuilder: new builder methods
session/
policy.rs ← PolicyEvaluator: check_read(), check_profile_override()
audit.rs ← AuditKind: 3 new variants
state.rs ← SessionState: overrides_rejected AtomicU64
db/
mod.rs ← 4 new session-gated read methods
```
No WAL changes. No storage layer changes. No new files.
---
## Data Structures
### Extended `AgentPolicy`
```rust
// schema/validation/policies.rs
#[derive(Debug, Clone)]
pub struct AgentPolicy {
// --- unchanged ---
pub allowed_signals: Vec<String>,
pub denied_signals: Vec<String>,
pub max_session_duration: Duration,
pub max_signals_per_session: u32,
// --- new ---
pub allowed_read_signals: Vec<String>,
pub denied_read_signals: Vec<String>,
pub allowed_user_attributes: Vec<String>,
pub denied_user_attributes: Vec<String>,
pub allowed_profile_overrides: Vec<String>,
}
impl Default for AgentPolicy {
fn default() -> Self {
Self {
allowed_signals: vec![],
denied_signals: vec![],
max_session_duration: Duration::from_secs(3600),
max_signals_per_session: 0,
// new fields: empty = unrestricted
allowed_read_signals: vec![],
denied_read_signals: vec![],
allowed_user_attributes: vec![],
denied_user_attributes: vec![],
allowed_profile_overrides: vec![],
}
}
}
```
Empty `Vec` for the new fields means unrestricted (same behavior as today). No policy field is optional — the absence of restriction is expressed by an empty list.
### New `PolicyViolationKind` variants
```rust
// session/policy.rs
pub enum PolicyViolationKind {
// existing
Expired,
CountCap,
Denied,
NotAllowed,
// new
ReadDenied,
ReadNotAllowed,
AttributeReadDenied,
AttributeReadNotAllowed,
ProfileOverrideNotAllowed,
}
```
### New `AuditKind` variants (internal)
```rust
// session/audit.rs
pub enum AuditKind {
WriteDenied, // existing (renamed from anonymous inline)
ReadDenied, // new: signal read blocked
AttributeReadDenied, // new: user attribute read blocked
ProfileOverrideRejected, // new: profile override blocked
}
```
### `SessionState` extension
```rust
// session/state.rs
pub struct SessionState {
// ... existing fields unchanged ...
pub overrides_rejected: AtomicU64, // new: counts profile override rejections
}
```
`SessionSnapshot` gains `overrides_rejected: u64` in the same location for export via `session_snapshot()`.
---
## PolicyEvaluator Extension
```rust
// session/policy.rs
impl<'a> PolicyEvaluator<'a> {
// existing:
pub fn check(&self, signal_type: &str, state: &SessionState, now: Instant)
-> Result<(), PolicyViolation> { ... }
// new:
pub fn check_read(&self, signal_type: &str) -> Result<(), PolicyViolation> {
// 1. deny list: O(n) scan; short-circuits if denied_read_signals is empty
if self.policy.denied_read_signals.iter().any(|s| s == signal_type) {
return Err(PolicyViolation {
kind: PolicyViolationKind::ReadDenied,
signal_type: signal_type.to_owned(),
policy_name: self.policy_name.to_owned(),
reason: format!(
"signal '{}' is in denied_read_signals for policy '{}'",
signal_type, self.policy_name
),
});
}
// 2. allow list: empty = unrestricted
if !self.policy.allowed_read_signals.is_empty()
&& !self.policy.allowed_read_signals.iter().any(|s| s == signal_type)
{
return Err(PolicyViolation {
kind: PolicyViolationKind::ReadNotAllowed,
signal_type: signal_type.to_owned(),
policy_name: self.policy_name.to_owned(),
reason: format!(
"signal '{}' not in allowed_read_signals for policy '{}'",
signal_type, self.policy_name
),
});
}
Ok(())
}
pub fn check_attribute_read(&self, attr_key: &str) -> Result<(), PolicyViolation> {
// same structure as check_read(), using allowed/denied_user_attributes
// kind: AttributeReadDenied / AttributeReadNotAllowed
}
pub fn check_profile_override(&self, profile_name: &str) -> Result<(), PolicyViolation> {
// if allowed_profile_overrides is empty: no overrides permitted
if self.policy.allowed_profile_overrides.is_empty()
|| !self.policy.allowed_profile_overrides.iter().any(|p| p == profile_name)
{
return Err(PolicyViolation {
kind: PolicyViolationKind::ProfileOverrideNotAllowed,
signal_type: profile_name.to_owned(), // reuse field for profile name
policy_name: self.policy_name.to_owned(),
reason: format!(
"profile override '{}' not permitted by policy '{}'",
profile_name, self.policy_name
),
});
}
Ok(())
}
}
```
The `signal_type` field on `PolicyViolation` is reused for attribute keys and profile names. The `kind` field disambiguates what the string represents. This avoids adding a new enum wrapper to `PolicyViolation`.
---
## Call-Site Enforcement (db/mod.rs)
### Session-gated signal reads
```
read_decay_score_for_session(sid, eid, signal_type, variant)
├── sessions.get(sid) → TidalError::NotFound if absent
├── schema.get_policy(policy_name)
├── PolicyEvaluator::check_read(signal_type)
│ ├── Ok → proceed
│ └── Err → audit_log.record(ReadDenied) + state.signals_rejected++ + return Err
└── ledger.read_decay_score(eid, signal_type, variant)
```
Same pattern for `read_windowed_count_for_session` and `read_velocity_for_session`.
### Session-gated attribute reads
```
read_user_attribute_for_session(sid, uid, key)
├── sessions.get(sid)
├── schema.get_policy(policy_name)
├── PolicyEvaluator::check_attribute_read(key)
│ ├── Ok → proceed
│ └── Err → audit_log.record(AttributeReadDenied) + signals_rejected++ + return Err
└── user_state.get_attribute(uid, key)
```
### Profile override enforcement
Profile override checking is triggered when a query with `for_session` is received and the query's `profile` field is set. The enforcement is applied in the query executor entry path in `db/mod.rs` before delegating to `retrieve_inner` / `search_inner`:
```
retrieve(query)
├── if query.for_session is Some(sid):
│ ├── sessions.get(sid)
│ ├── schema.get_policy(policy_name)
│ └── if query.profile != session.default_profile:
│ PolicyEvaluator::check_profile_override(query.profile)
│ ├── Ok → proceed with query.profile
│ └── Err → audit_log.record(ProfileOverrideRejected)
│ + state.overrides_rejected++
│ + return Err(TidalError::PolicyViolation)
└── retrieve_inner(query)
```
The "session's default profile" is the profile name stored when `start_session` was called (added to `SessionState.metadata` as `"_default_profile"`, or as a dedicated field). If `start_session` did not specify a profile, the session has no default and any profile specified in the query is considered an override.
---
## Schema Build-Time Validation
In `schema/validation/builders.rs`, inside the existing `Schema::build()` call, after signals are finalized:
```
for each session_policy in policies:
for signal_name in policy.allowed_read_signals ++ policy.denied_read_signals:
if signal_name not in schema.signal_types:
return Err(SchemaError::UnknownSignal { signal_name, context: "session policy read list" })
for signal_name in intersection(allowed_read_signals, denied_read_signals):
return Err(SchemaError::ConflictingPolicy { ... })
for profile_name in policy.allowed_profile_overrides:
if profile_name != "*" and profile_name not in schema.profiles:
return Err(SchemaError::UnknownProfile { profile_name, context: "session policy" })
// Sentinel "*" expansion (happens after validation):
if allowed_profile_overrides == ["*"]:
policy.allowed_profile_overrides = schema.all_profile_names()
```
User attribute keys are not validated (untyped free-text).
---
## Builder API
`SchemaBuilder::session_policy` currently returns a `SignalBuilder`-style entry via a method chain. The new builder for session policies uses the same pattern:
```rust
// Usage:
let mut builder = SchemaBuilder::new();
builder.session_policy("search-agent")
.allowed_write_signals(&["search_click"]) // existing: allowed_signals
.allowed_read_signals(&["view", "like"]) // new
.denied_read_signals(&["hide", "block"]) // new
.allowed_user_attributes(&["locale", "age_range"]) // new
.allowed_profile_overrides(&["search", "trending"])// new
.max_duration(Duration::from_secs(1800))
.max_signals(5000)
.add()?;
```
`SessionPolicyBuilder` accumulates the new fields with setters returning `&mut Self` for chaining. The terminal `.add()` call inserts a `PolicyEntry` into the builder.
---
## Performance Characteristics
| Check | Cost when empty | Cost when non-empty (n items) |
|---|---|---|
| `check_read` deny list | 0 ns (len == 0 short-circuit) | O(n) scan, typically n ≤ 10 |
| `check_read` allow list | 0 ns (len == 0 short-circuit) | O(n) scan |
| `check_attribute_read` | same | same |
| `check_profile_override` | 0 ns (len == 0) | O(n) scan |
For deployments expecting large allow/deny lists (n > 50), `Vec<String>` is replaced with `HashSet<String>` at build time for O(1) checks. The `HashSet` is constructed during `Schema::build()` and stored alongside the `AgentPolicy`. This threshold is configurable via a constant in `policies.rs`.
No heap allocation occurs on the enforcement hot path for policies with empty lists. The `Vec::is_empty()` check is O(1) and branch-predictor-friendly.
---
## Sequence: Session Read with Policy Check
```
Application / Agent
│ read_decay_score_for_session(sid=42, eid=100, "like", 0)
TidalDb::read_decay_score_for_session
├── sessions.get(&sid=42) ← DashMap read, lock-free
│ └── Arc<SessionState>
├── schema.policy("analytics") ← HashMap read
│ └── &AgentPolicy
├── PolicyEvaluator::check_read("like")
│ ├── denied_read_signals.is_empty() → true → skip
│ ├── allowed_read_signals.is_empty() → false
│ └── "like" in allowed_read_signals? → false
│ └── Err(ReadNotAllowed)
├── audit_log.record(AuditKind::ReadDenied, "like", now_ns)
├── state.signals_rejected.fetch_add(1, Relaxed)
└── return Err(TidalError::PolicyViolation(violation))
```
---
## Test Surface
Unit tests in `session/policy.rs`:
- `check_read` allow list: allowed signal → Ok, denied signal → Err(ReadNotAllowed)
- `check_read` deny list: denied signal → Err(ReadDenied) regardless of allow list
- `check_read` empty lists: any signal → Ok (zero-cost path)
- `check_attribute_read`: same structure as above
- `check_profile_override` empty list: any profile → Err
- `check_profile_override` non-empty list: listed profile → Ok, unlisted → Err
Integration test in `tidal/tests/m10_agent_capability.rs`:
- Open db with schema defining signals `view`, `like`, `hide` and policies `read-restricted` and `unrestricted`.
- Start session with `read-restricted` policy (allowed_read: `["view"]`, denied_read: `["hide"]`).
- `read_decay_score_for_session` for `view` → Ok.
- `read_decay_score_for_session` for `like` → Err(ReadNotAllowed).
- `read_decay_score_for_session` for `hide` → Err(ReadDenied).
- `read_decay_score` (no session) for `hide` → Ok (unrestricted).
- Start session with profile override policy (allowed_profile_overrides: `["search"]`).
- retrieve with profile `search` → Ok.
- retrieve with profile `for_you` → Err(ProfileOverrideNotAllowed).
- session_snapshot → `overrides_rejected == 1`.
- Schema build with `allowed_read_signals: ["nonexistent"]` → SchemaError.
- Schema build with conflict (`view` in both allow and deny) → SchemaError.