tidaldb/docs/planning/milestone-10/phase-2/OVERVIEW.md
jx12n ad4134e280 chore: doc consolidation, seven-dimension review fixes, and commit hooks
- 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)
2026-06-08 22:46:28 -06:00

518 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 M0M10p1 tests pass unchanged.