# m10p2: Agent Capability and Scope Controls ## Delivers Per-agent capability tokens that gate signal reads and writes by `SignalScope` (`Local` / `Community` / `Session` / `Agent`) with a hard TTL, plus synchronous, atomic revocation that takes effect in under one second. After this phase, an agent can only write community-scoped signals if it holds a live, unexpired, unrevoked `CapabilityToken` granting write on that scope; every denial emits a typed `PolicyViolation` and an audit-log entry; and granted/revoked capabilities survive restart because they are persisted to durable storage and replayed at startup. This is the enforcement layer that makes the M10 UAT's "`A_experimental` is denied community write" and "`U` revokes `A_trusted` community scope immediately" steps real. Deliverables: - `CapabilityToken { token_id, agent_id, scopes: Vec, created_at_ns, expires_at_ns, revoked_at_ns: AtomicU64 }` and `ScopePermission { scope, read, write }` in new `tidal/src/governance/capability.rs` - `CapabilityRegistry`: in-memory `DashMap>` (keyed by `token_id`) with O(1) live-status lookup, owned by `TidalDb` - Public API on `TidalDb`: `grant_capability(...) -> Result` and `revoke_capability(token_id) -> Result<()>` - `SessionState.capability_token: Option>` binding a session to its agent's token - `PolicyEvaluator` gains a capability-check phase; new `PolicyViolationKind::{InsufficientCapability, CapabilityRevoked, CapabilityExpired}` - Enforcement wired into `session_signal` (`db/sessions.rs`) and `signal_for_tenant` (`db/replication_ops.rs`); both deny out-of-scope writes and audit them - Atomic `revoked_at_ns` gate read at the top of the write path -> revocation is O(1) and synchronous; NEVER routed through the 60s sweeper - Durable persistence: grants written to `Tag::Capability`, revocations to `Tag::CapabilityRevocation`; replayed on startup via `db/session_restore.rs` - `tidalctl agents` and `tidalctl revocations` offline read-only subcommands ## Dependencies - **Requires:** M9 complete (`SignalScope`, `CommunityId`, `SignalProvenance` in `tidal/src/governance/`; WAL V3 event envelope; membership + share-policy machinery). M10p1 complete (`GovernancePolicy`, `GovernanceRegistry`, governance policies loaded post-`with_schema` in `db/open.rs`). M8 session layer (`SessionState`, `PolicyEvaluator`, `AuditLog`, `AgentPolicy`, `SessionWalEvent`). - **Files modified:** - `tidal/src/governance/mod.rs` -- re-export `CapabilityToken`, `ScopePermission`, `CapabilityRegistry` - `tidal/src/schema/validation/policies.rs` -- `AgentPolicy` gains `required_scopes: Vec` and `required_token: bool` (capability binding) - `tidal/src/session/policy.rs` -- `PolicyEvaluator` capability phase; new `PolicyViolationKind` variants - `tidal/src/session/state.rs` -- `SessionState.capability_token` field - `tidal/src/session/audit.rs` -- audit entries already capture `accepted`/`reason`; denial reason strings extended (no shape change) - `tidal/src/db/sessions.rs` -- `session_signal` revocation gate + capability eval before policy eval - `tidal/src/db/replication_ops.rs` -- `signal_for_tenant` capability enforcement for tenant/community writes - `tidal/src/db/mod.rs` -- `capability_registry` field on `TidalDb` - `tidal/src/db/session_restore.rs` -- replay `Capability` / `CapabilityRevocation` WAL events at startup - `tidal/src/storage/keys.rs` -- `Tag::Capability = 0x0E`, `Tag::CapabilityRevocation = 0x0F` - `tidal/src/schema/error.rs` -- `TidalError` capability variants; `SchemaError` capability-binding validation - `tidalctl/src/main.rs` -- `agents` and `revocations` subcommands - **Files created:** - `tidal/src/governance/capability.rs` -- `CapabilityToken`, `ScopePermission`, `CapabilityRegistry` - `tidal/src/db/capabilities.rs` -- `grant_capability` / `revoke_capability` API + persistence - `tidal/tests/m10p2_capabilities.rs` -- integration tests (grant/deny/revoke/restart) ## Research References - `docs/research/tidaldb_wal.md` -- WAL record framing, BLAKE3 per-record checksum (capability/revocation events reuse the session-journal envelope) - `thoughts.md` -- Part V.12 (subject-prefix key encoding; capability keys keyed under the agent's reserved entity range) - `/tmp/m9m10_brief.md` -- section 1.5 (canonical `CapabilityToken` shape), section 2 M10p2 table, section 4 invariants (<1s synchronous revocation, local-profile-intact), section 5 test strategy - `docs/planning/milestone-8/phase-1/` -- doc-format precedent (this OVERVIEW mirrors it) ## Acceptance Criteria (Phase Level) - [ ] `CapabilityToken` carries `agent_id: AgentId`, `scopes: Vec`, `created_at_ns: u64`, `expires_at_ns: u64`, and `revoked_at_ns: AtomicU64` (0 = not revoked); `ScopePermission { scope: SignalScope, read: bool, write: bool }` - [ ] `CapabilityToken::is_live(now_ns)` returns `false` when `now_ns >= expires_at_ns` (TTL elapsed) OR `revoked_at_ns != 0 && now_ns >= revoked_at_ns`; `true` otherwise — verified by unit test across all four corners - [ ] `CapabilityToken::permits(scope, want_read, want_write)` returns `true` only when a matching `ScopePermission` grants every requested mode; absent scope = deny - [ ] `grant_capability(agent_id, scopes, ttl)` returns a `CapabilityToken`, inserts it into `CapabilityRegistry`, and persists a `Tag::Capability` record before returning - [ ] `revoke_capability(token_id)` performs an atomic `revoked_at_ns.store(now_ns)` on the live token (O(1)), persists a `Tag::CapabilityRevocation` record, and is idempotent (second revoke is a no-op) - [ ] A session bound to a token with no community-write `ScopePermission` is rejected on a community-scoped `session_signal` with `TidalError::PolicyViolation` whose kind is `PolicyViolationKind::InsufficientCapability` (matches UAT step 2) - [ ] Every capability denial appends an `AuditEntry { accepted: false, reason: Some(..) }` to the session `AuditLog` - [ ] After `revoke_capability`, the next community-scoped write by that agent is rejected with `PolicyViolationKind::CapabilityRevoked`; measured wall-clock from revoke-return to first-denial is < 1s p99 (the gate is an O(1) atomic read, so effectively immediate) and does NOT depend on the 60s sweeper - [ ] Local-scope (`SignalScope::Local`) writes are NEVER blocked by capability checks — an agent with no token still writes its local profile (local-profile-intact guarantee) - [ ] Grants and revocations survive close/reopen: a token granted then revoked before shutdown is replayed at startup such that a post-restart community write by that agent is still denied with `CapabilityRevoked` - [ ] `tidalctl agents --path ` prints JSON listing each agent's tokens (`token_id`, `scopes`, `expires_at_ns`, live/expired/revoked status); `tidalctl revocations --path ` prints the revocation history. Both are offline read-only scans (no DB open) - [ ] WAL/checkpoint backward-compat: a data dir written by M10p1 (no capability records) opens cleanly; absence of capability records means "no capabilities granted", not an error - [ ] `cargo fmt` clean, `cargo clippy -p tidaldb -D warnings` clean, all unit + `m10p2_capabilities` integration tests pass ## Task Execution Order ``` Task 01: CapabilityToken + ScopePermission ──┐ (governance/capability.rs) │ ├──> Task 03: PolicyEvaluator capability phase Task 02: CapabilityRegistry + Tags ──────────┤ (session/policy.rs, state.rs, error.rs) (registry, keys.rs, mod.rs) │ │ │ v └──> Task 04: grant/revoke API + persistence (db/capabilities.rs, db/mod.rs) │ v Task 05: Write-path enforcement + audit (db/sessions.rs, db/replication_ops.rs) │ v Task 06: Startup replay + restart durability (db/session_restore.rs) │ v Task 07: tidalctl agents/revocations + m10p2 UAT (tidalctl/src/main.rs, tests/) ``` Tasks 01 and 02 are fully parallelizable (pure types vs. registry + tag bytes). Task 03 depends on 01 (needs `permits`/`is_live`). Task 04 depends on 02 + 03. Task 05 depends on 04 (needs grant/revoke + registry). Task 06 depends on 04/05 (replays the same records the API writes). Task 07 depends on all (CLI scans the persisted records; UAT exercises the full grant->deny->revoke->restart path). ## Module Location | File | Status | Contains | |------|--------|----------| | `tidal/src/governance/capability.rs` | NEW | `CapabilityToken`, `ScopePermission`, `CapabilityRegistry`, `is_live`/`permits` | | `tidal/src/db/capabilities.rs` | NEW | `TidalDb::grant_capability`, `revoke_capability`, persistence helpers | | `tidal/tests/m10p2_capabilities.rs` | NEW | Grant/deny/revoke/restart integration tests | | `tidal/src/governance/mod.rs` | MODIFIED | Re-export capability types | | `tidal/src/storage/keys.rs` | MODIFIED | `Tag::Capability = 0x0E`, `Tag::CapabilityRevocation = 0x0F` + `from_byte` | | `tidal/src/schema/validation/policies.rs` | MODIFIED | `AgentPolicy.required_scopes`, `required_token` | | `tidal/src/session/policy.rs` | MODIFIED | Capability phase; new `PolicyViolationKind` variants | | `tidal/src/session/state.rs` | MODIFIED | `SessionState.capability_token` field | | `tidal/src/db/sessions.rs` | MODIFIED | `session_signal` revocation gate + capability eval | | `tidal/src/db/replication_ops.rs` | MODIFIED | `signal_for_tenant` capability enforcement | | `tidal/src/db/mod.rs` | MODIFIED | `capability_registry: Arc` field | | `tidal/src/db/session_restore.rs` | MODIFIED | Replay capability/revocation WAL events | | `tidal/src/schema/error.rs` | MODIFIED | `TidalError` capability variants; `SchemaError` capability validation | | `tidalctl/src/main.rs` | MODIFIED | `agents`, `revocations` subcommands | ## Technical Design ### CapabilityToken and ScopePermission ```rust // tidal/src/governance/capability.rs use std::sync::atomic::{AtomicU64, Ordering}; use crate::governance::scope::SignalScope; use crate::session::types::AgentId; /// One scope's read/write grant inside a capability token. /// /// Absence of a `ScopePermission` for a scope is an implicit deny: a token /// only permits what it explicitly lists. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct ScopePermission { /// The signal scope this permission applies to. pub scope: SignalScope, /// Whether the agent may read signals at this scope. pub read: bool, /// Whether the agent may write signals at this scope. pub write: bool, } impl ScopePermission { /// A read+write grant for a single scope. #[must_use] pub const fn read_write(scope: SignalScope) -> Self { Self { scope, read: true, write: true } } } /// A capability granted to an agent: which scopes it may read/write, and for /// how long. Revocation is an atomic `revoked_at_ns` store on the live token, /// checked synchronously on the write path (NOT via the 60s sweeper). /// /// Cloning is intentionally not derived: the live token is shared as /// `Arc` so that a revoke on the registry copy is visible to /// every session holding a clone of the `Arc`. #[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct CapabilityToken { /// Stable identifier (UUID-like string) used as the persistence + registry key. pub token_id: String, /// The agent this token authorizes. pub agent_id: AgentId, /// Per-scope read/write grants. Empty = a token that permits nothing. pub scopes: Vec, /// Nanoseconds since Unix epoch when the token was granted. pub created_at_ns: u64, /// Nanoseconds since Unix epoch when the token expires (TTL boundary). pub expires_at_ns: u64, /// `0` = not revoked. Otherwise the revocation instant in ns since epoch. /// Atomic so revocation is a lock-free O(1) store visible to all readers. #[serde(with = "atomic_u64_serde")] pub revoked_at_ns: AtomicU64, } impl CapabilityToken { /// Whether this token is currently usable at `now_ns`. /// /// `false` once the TTL has elapsed (`now_ns >= expires_at_ns`) or the token /// has been revoked at-or-before `now_ns`. This is the single source of /// truth for liveness — both the TTL and the revocation gate funnel through /// it, so there is no second code path to keep in sync. #[must_use] pub fn is_live(&self, now_ns: u64) -> bool { if now_ns >= self.expires_at_ns { return false; } let revoked = self.revoked_at_ns.load(Ordering::Acquire); revoked == 0 || now_ns < revoked } /// Whether this token grants the requested access at `scope`. /// /// Liveness is NOT checked here; callers gate on `is_live` first so the /// denial reason (`Expired`/`Revoked` vs `Insufficient`) is distinguishable. #[must_use] pub fn permits(&self, scope: SignalScope, want_read: bool, want_write: bool) -> bool { self.scopes.iter().any(|p| { p.scope == scope && (!want_read || p.read) && (!want_write || p.write) }) } /// Atomically mark the token revoked at `now_ns`. Idempotent: an already- /// revoked token keeps its earlier revocation instant. pub fn revoke(&self, now_ns: u64) { // CAS-from-zero so the first revoke wins and later ones are no-ops. let _ = self.revoked_at_ns.compare_exchange( 0, now_ns, Ordering::AcqRel, Ordering::Acquire, ); } } ``` ### CapabilityRegistry ```rust // tidal/src/governance/capability.rs (continued) use std::sync::Arc; use dashmap::DashMap; /// In-memory index of granted capability tokens, owned by `TidalDb`. /// /// Keyed by `token_id`; a secondary index maps `agent_id -> Vec` so /// the write path can resolve "does this agent hold a live token granting /// (scope, write)?" in O(tokens-per-agent), which is tiny in practice. #[derive(Debug, Default)] pub struct CapabilityRegistry { by_id: DashMap>, by_agent: DashMap>, // agent_id.as_str() -> token_ids } impl CapabilityRegistry { #[must_use] pub fn new() -> Self { Self::default() } /// Insert a freshly granted token (also used by startup replay). pub fn insert(&self, token: Arc) { self.by_agent .entry(token.agent_id.as_str().to_owned()) .or_default() .push(token.token_id.clone()); self.by_id.insert(token.token_id.clone(), token); } /// Fetch a token by id for revocation / inspection. #[must_use] pub fn get(&self, token_id: &str) -> Option> { self.by_id.get(token_id).map(|e| Arc::clone(e.value())) } /// True if `agent` holds at least one live token permitting (scope, modes). #[must_use] pub fn agent_permits( &self, agent: &AgentId, scope: SignalScope, want_read: bool, want_write: bool, now_ns: u64, ) -> bool { let Some(ids) = self.by_agent.get(agent.as_str()) else { return false; }; ids.iter().filter_map(|id| self.by_id.get(id)).any(|tok| { tok.is_live(now_ns) && tok.permits(scope, want_read, want_write) }) } } ``` ### PolicyViolationKind and PolicyEvaluator capability phase ```rust // tidal/src/session/policy.rs (extends the existing enum) #[derive(Debug, Clone, PartialEq, Eq)] pub enum PolicyViolationKind { Expired, CountCap, Denied, NotAllowed, /// No live token grants the (scope, mode) this write requires. InsufficientCapability, /// The agent's capability token was revoked at or before this write. CapabilityRevoked, /// The agent's capability token TTL has elapsed. CapabilityExpired, } ``` ```rust // tidal/src/session/policy.rs (new method on PolicyEvaluator) impl<'a> PolicyEvaluator<'a> { /// Capability phase: run AFTER the existing allow/deny/count/duration checks /// and ONLY for non-local scopes. `Local` scope is exempt — the local /// profile is never gated by capabilities. /// /// `token` is the session's bound token (if any). `now_ns` is the write /// timestamp. Returns the same `PolicyViolation` shape as `check`. /// /// # Errors /// /// `InsufficientCapability` when no token / no matching scope; `CapabilityExpired` /// when the token's TTL elapsed; `CapabilityRevoked` when it was revoked. pub fn check_capability( &self, signal_type: &str, scope: SignalScope, token: Option<&CapabilityToken>, now_ns: u64, ) -> Result<(), PolicyViolation> { if scope == SignalScope::Local { return Ok(()); // local-profile-intact: never gated } let make = |kind: PolicyViolationKind, reason: String| PolicyViolation { kind, signal_type: signal_type.to_owned(), policy_name: self.policy_name.to_owned(), reason, }; let Some(tok) = token else { return Err(make( PolicyViolationKind::InsufficientCapability, format!("no capability token bound for scope {scope:?}"), )); }; if !tok.is_live(now_ns) { let revoked = tok.revoked_at_ns.load(std::sync::atomic::Ordering::Acquire); let kind = if revoked != 0 && now_ns >= revoked { PolicyViolationKind::CapabilityRevoked } else { PolicyViolationKind::CapabilityExpired }; return Err(make(kind, format!("token '{}' not live", tok.token_id))); } if !tok.permits(scope, false, true) { return Err(make( PolicyViolationKind::InsufficientCapability, format!("token '{}' lacks write at scope {scope:?}", tok.token_id), )); } Ok(()) } } ``` ### grant / revoke API and TidalError variants ```rust // tidal/src/db/capabilities.rs impl TidalDb { /// Grant `agent` the listed scope permissions for `ttl`. Persists the grant /// to `Tag::Capability` before returning so it survives restart. /// /// # Errors /// /// - `TidalError::ReadOnly` on a follower node. /// - `TidalError::Storage` if the durable write fails. pub fn grant_capability( &self, agent_id: AgentId, scopes: Vec, ttl: std::time::Duration, ) -> crate::Result> { /* ... */ } /// Revoke a token by id. Atomic O(1) `revoked_at_ns` store on the live token /// + a durable `Tag::CapabilityRevocation` record. Idempotent. /// /// # Errors /// /// - `TidalError::ReadOnly` on a follower node. /// - `TidalError::CapabilityNotFound` if no token has `token_id`. pub fn revoke_capability(&self, token_id: &str) -> crate::Result<()> { /* ... */ } } ``` ```rust // tidal/src/schema/error.rs (new TidalError variants) /// A signal write was rejected because the agent lacks a live capability /// granting the requested scope. Carries the typed denial reason for dispatch. #[error("capability denied: agent '{agent_id}' for scope '{scope}': {reason}")] CapabilityDenied { agent_id: String, scope: String, reason: String, }, /// `revoke_capability` was called for an unknown token id. #[error("capability not found: '{0}'")] CapabilityNotFound(String), ``` ### AgentPolicy capability binding ```rust // tidal/src/schema/validation/policies.rs (AgentPolicy gains two fields) pub struct AgentPolicy { pub allowed_signals: Vec, pub denied_signals: Vec, pub max_session_duration: Duration, pub max_signals_per_session: u32, /// Scopes (beyond `Local`) this policy requires a live capability token to /// write. Empty = community/session writes are unrestricted by capability. pub required_scopes: Vec, /// If `true`, a session under this policy MUST be bound to a live token to /// write any non-local scope. Defaults to `false` for backward compat. pub required_token: bool, } ``` ### Tag additions and persisted record shape ```rust // tidal/src/storage/keys.rs (extend Tag) pub enum Tag { // ... existing 0x01..=0x0D ... /// Capability grant records (M10p2). Capability = 0x0E, /// Capability revocation records (M10p2). CapabilityRevocation = 0x0F, } // from_byte gains: 0x0E => Some(Self::Capability), 0x0F => Some(Self::CapabilityRevocation) ``` ```rust // tidal/src/wal/format/session.rs (extend SessionWalEvent for durable replay) pub enum SessionWalEvent { Start { /* ... */ }, Signal { /* ... */ }, Close { session_id: u64 }, /// A capability token was granted (M10p2). Serialized token bytes mirror the /// `Tag::Capability` storage record so replay reconstructs the registry. CapabilityGrant { token_id: String, agent_id: String, scopes: Vec<(u8, bool, bool)>, // (scope discriminant, read, write) created_at_ns: u64, expires_at_ns: u64, }, /// A capability token was revoked (M10p2). CapabilityRevoke { token_id: String, revoked_at_ns: u64 }, } ``` ## Notes ### Revocation is synchronous and atomic — never the sweeper The <1s p99 revocation gate is an `AtomicU64::load` of `revoked_at_ns` on the session signal write path (`session_signal`, before policy eval) and on `signal_for_tenant`. It is read directly off the `Arc` the session holds, which is the same `Arc` the registry mutated in `revoke`, so the store is visible on the next write with no propagation step. The 60s session TTL sweeper (`db/sweeper.rs`) is explicitly NOT involved — routing revocation through it would blow the SLA by 60x. This mirrors the M9 stop-forward gate (`stop_forward_at_ns`) exactly. ### Local-profile-intact guarantee `SignalScope::Local` writes bypass the capability phase entirely (`check_capability` returns `Ok(())` for `Local`). An agent with no token, an expired token, or a revoked token can still write its local profile. The UAT's "`U` queries local-only ... views" step depends on this: revoking `A_trusted`'s community scope must not touch `U`'s local feed. A routing bug that gates local writes is silent data loss — the integration test asserts the local feed is byte-identical across grant/deny/revoke. ### Capability eval ordering and audit The capability phase runs AFTER the existing duration/count/deny/allow checks so that a session that is expired-by-duration still reports `Expired`, not `CapabilityExpired`. Every capability denial appends an `AuditEntry { accepted: false, reason: Some(..) }` to the session `AuditLog` exactly like the existing policy denials — no new audit structure, just new reason strings keyed by the typed `PolicyViolationKind`. `signals_rejected` is incremented on the same path. ### Backward compatibility is non-negotiable `Tag::Capability` / `Tag::CapabilityRevocation` are new tag bytes; older data dirs simply have none, which decodes as "no capabilities granted". The `SessionWalEvent` enum gains two variants encoded behind a new record-type byte in the session journal; `decode_session_events` already tolerates unknown trailing bytes per-record (length-prefixed + BLAKE3-checksummed), and v1/v2 records continue to decode. `AgentPolicy`'s two new fields default to empty/`false`, so existing schemas build unchanged. ### Token id and entity-range keying `token_id` is a process-unique string (UUID-style). Capability storage records are keyed under a reserved entity-id range derived from a stable hash of `agent_id` so a prefix scan over `Tag::Capability` enumerates an agent's tokens without a DB open — which is what `tidalctl agents` relies on. ## Done When A developer can `grant_capability(agent, [ScopePermission::read_write(Community(c))], ttl)`, bind it to a session, and have community-scoped writes succeed; an agent without that grant is rejected with `PolicyViolationKind::InsufficientCapability` and an audit entry; `revoke_capability(token_id)` causes the very next community write by that agent to fail with `CapabilityRevoked` in under one second (synchronous atomic gate, not the sweeper); local writes are never affected; the grant and revocation survive close/reopen via durable replay; and `tidalctl agents` / `tidalctl revocations` show the capability and revocation history from an offline scan. All existing M0–M10p1 tests pass unchanged.