- Eliminate the tidal/ self-contained doc mirror; docs now have two canonical homes (root *.md and docs/), with planning/specs/research/reviews moved up - Remove stale .agents/skills and .ai mirrors; canonicalize skills under .claude/ - Add pre-commit hook + scripts/check-docs.sh doc-guard + scripts/install-hooks.sh - Implement M0-M10 seven-dimension review findings across engine, net, server, and tidalctl (durability, replication, query, WAL, storage, CLI hardening)
518 lines
25 KiB
Markdown
518 lines
25 KiB
Markdown
# 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<ScopePermission>, 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<String, Arc<CapabilityToken>>` (keyed by `token_id`) with O(1) live-status lookup, owned by `TidalDb`
|
||
- Public API on `TidalDb`: `grant_capability(...) -> Result<CapabilityToken>` and `revoke_capability(token_id) -> Result<()>`
|
||
- `SessionState.capability_token: Option<Arc<CapabilityToken>>` 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<ScopePermission>` 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<ScopePermission>`, `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 <dir>` prints JSON listing each agent's tokens (`token_id`, `scopes`, `expires_at_ns`, live/expired/revoked status); `tidalctl revocations --path <dir>` 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<CapabilityRegistry>` 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<CapabilityToken>` 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<ScopePermission>,
|
||
/// 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<token_id>` 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<String, Arc<CapabilityToken>>,
|
||
by_agent: DashMap<String, Vec<String>>, // 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<CapabilityToken>) {
|
||
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<Arc<CapabilityToken>> {
|
||
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<ScopePermission>,
|
||
ttl: std::time::Duration,
|
||
) -> crate::Result<Arc<CapabilityToken>> { /* ... */ }
|
||
|
||
/// 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<String>,
|
||
pub denied_signals: Vec<String>,
|
||
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<ScopePermission>,
|
||
/// 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<CapabilityToken>` 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.
|