# 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, pub denied_signals: Vec, pub max_session_duration: Duration, pub max_signals_per_session: u32, // --- new --- pub allowed_read_signals: Vec, pub denied_read_signals: Vec, pub allowed_user_attributes: Vec, pub denied_user_attributes: Vec, pub allowed_profile_overrides: Vec, } 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` is replaced with `HashSet` 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 │ ├── 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.