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

14 KiB

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:

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:

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_signalsPolicyViolation { 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

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.