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

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_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.