13 KiB
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
// 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
// session/policy.rs
pub enum PolicyViolationKind {
// existing
Expired,
CountCap,
Denied,
NotAllowed,
// new
ReadDenied,
ReadNotAllowed,
AttributeReadDenied,
AttributeReadNotAllowed,
ProfileOverrideNotAllowed,
}
New AuditKind variants (internal)
// 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
// 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
// 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:
// 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_readallow list: allowed signal → Ok, denied signal → Err(ReadNotAllowed)check_readdeny list: denied signal → Err(ReadDenied) regardless of allow listcheck_readempty lists: any signal → Ok (zero-cost path)check_attribute_read: same structure as abovecheck_profile_overrideempty list: any profile → Errcheck_profile_overridenon-empty list: listed profile → Ok, unlisted → Err
Integration test in tidal/tests/m10_agent_capability.rs:
- Open db with schema defining signals
view,like,hideand policiesread-restrictedandunrestricted. - Start session with
read-restrictedpolicy (allowed_read:["view"], denied_read:["hide"]). read_decay_score_for_sessionforview→ Ok.read_decay_score_for_sessionforlike→ Err(ReadNotAllowed).read_decay_score_for_sessionforhide→ Err(ReadDenied).read_decay_score(no session) forhide→ 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 (
viewin both allow and deny) → SchemaError.