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:
-
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.
-
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
AgentPolicywithallowed_reads,denied_reads,allowed_user_attributes,denied_user_attributes, andallowed_profile_overridesfields. - 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
RetrieveorSearchquery. - Validate the extended policy at schema build time (fail-fast: unknown signal names and unknown profile names are schema errors).
- Produce typed
PolicyViolationresponses for all new violation kinds, extendingPolicyViolationKind. - 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-engineand 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_signalsordenied_read_signalsthat do not exist in the schema. - Signal names in
denied_read_signalsthat also appear inallowed_read_signals(conflict). - Profile names in
allowed_profile_overridesthat 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:
- Look up the session by
session_id. If session not found →TidalError::NotFound. - Retrieve the
AgentPolicyfor the session'spolicy_name. - Evaluate allow list: if
allowed_read_signalsis non-empty andsignal_typeis absent →PolicyViolation { kind: ReadNotAllowed, ... }. - Evaluate deny list: if
signal_typeappears indenied_read_signals→PolicyViolation { kind: ReadDenied, ... }. - Record in audit log.
- 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:
- Look up session and policy.
- Allow-list check on
key. - Deny-list check on
key. - 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:
- Retrieve the session and its policy.
- If
allowed_profile_overridesis 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 withPolicyViolation { kind: ProfileOverrideNotAllowed, ... }. - If
allowed_profile_overridesis non-empty and the requested profile is in the list → permitted. - If
allowed_profile_overridesis 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 separateoverrides_rejected: AtomicU64onSessionState.
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_signalsdenied_read_signalsallowed_user_attributesdenied_user_attributesallowed_profile_overrides
New PolicyViolationKind variants:
ReadDeniedReadNotAllowedAttributeReadDeniedAttributeReadNotAllowedProfileOverrideNotAllowed
New AuditKind variants (internal):
ReadDeniedAttributeReadDeniedProfileOverrideRejected
Acceptance Criteria
- A session using a policy with
allowed_read_signals: ["view"]successfully reads theviewdecay score and is rejected withReadNotAllowedwhen it attempts to readlike. - A session using a policy with
denied_read_signals: ["hide"]is rejected withReadDeniedwhen it reads thehidesignal, regardless of the allow list. - A session using a policy with
allowed_user_attributes: ["locale"]readslocalesuccessfully and is rejected withAttributeReadNotAllowedwhen it readsage_range. - 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. - A session using a policy with
allowed_profile_overrides: ["search", "trending"]may override tosearchortrending, but notfor_you. - All violations are recorded in the session audit log with the correct
AuditKind. - A policy with all new fields empty behaves identically to the existing
AgentPolicy(no regression for existing callers). - Schema build-time validation rejects a policy referencing a non-existent signal type in
allowed_read_signals. - Schema build-time validation rejects a policy where the same signal appears in both
allowed_read_signalsanddenied_read_signals. - Schema build-time validation rejects a policy referencing a non-existent profile name in
allowed_profile_overrides(unless the sentinel["*"]is used). signals_rejectedandoverrides_rejectedcounters onSessionStateare incremented correctly and appear inSessionSnapshot.- The
read_decay_score/read_windowed_count/read_velocitymethods (without session) are unaffected.
Implementation Notes
PolicyEvaluatorinsession/policy.rsgainscheck_readandcheck_profile_overridemethods alongside the existingcheckmethod. The same struct pattern, same borrowing semantics.- The new session-gated read methods live in
db/mod.rsalongside the existing signal read methods. They follow the sameself.ledger()/self.sessionsaccess pattern as the write path. SchemaBuilder::session_policybuilder method returns a newSessionPolicyBuilderstruct that accumulates the new fields. Validated in the existingSchema::buildcall.- 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
HashSetreplaces theVecfor O(1) lookup — with validation ensuring the same correctness guarantees.