Every governed write resolves to exactly one of three decisions, then either returns immediately or queues for approval — it never silently succeeds. The permission engine and the harness-enforcing PreToolUse hook layer on top of the namespace-policy gate.
Four closed sets define every governed write. The decision is the verdict; the action selects which policy field applies; the level is the gate on that field; the approver resolves an Approve verdict.
The permission engine layers declarative [[permissions.rules]] overlays on top of the namespace-policy gate, with fail-CLOSED defaults across SSRF, governance-error, and federation-signature paths.
The substrate ships with the strict posture on. Each flag below defaults to its secure value; the opt-out column is the env var override for a staged rollout (e.g. peer Ed25519 enrolment) — flip it back once the fleet has caught up.
| Env var | Secure default | Opt-out | Issue |
|---|---|---|---|
AI_MEMORY_PERMISSIONS_MODE |
enforce | =advisory / =off |
K9 |
AI_MEMORY_FED_REQUIRE_SIG |
1 — Ed25519 X-Memory-Sig required |
=0 |
#791 |
AI_MEMORY_FED_REQUIRE_NONCE |
1 — anti-replay X-Memory-Nonce required |
=0 |
#922 |
AI_MEMORY_GOVERNANCE_FAIL_OPEN_ON_ERROR |
0 — fail-CLOSED on rule-consult error | =1 |
#1054 |
AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS |
0 — passphrase file 0400 required | =1 |
#1055 |
AI_MEMORY_SSRF_GUARD_ALLOW_DNS_FAIL |
0 — fail-CLOSED on DNS NXDOMAIN | =1 |
#1053 |
Operator tooling: ai-memory governance migrate-to-permissions [--apply] translates legacy [governance] policies into the [[permissions.rules]] shape. Webhook dispatch is HMAC-signed only — unsigned dispatch is disabled.
v0.8.0 closes three gaps where governance could be consulted but not actually enforced.
The installed Claude Code hook is a type:command entry running ai-memory governance check-action --from-pretool-stdin. It emits permissionDecision:"deny" on a Refuse / Escalate verdict, so a refused tool is actually BLOCKED. The earlier type:mcp_tool form could not enforce — a tool error was non-blocking and its JSON was shown as plain text.
AI_MEMORY_HOOKS_ENFORCE_MODE = off / advisory / enforce. Paired with [hooks].required_events, enforce returns Deny{503} when a required pre-* event has no enabled hook — closing the silent no-enforcement gap where an absent governance hook simply Allowed. Default off + empty required-events is a deliberate no-op self-DOS guard.
The governance_rules.severity CHECK gains escalate alongside refuse / warn / log. A matched escalate rule pauses the action for human review and fails closed (is_allowed() == false), mirroring refusal-wins; the PreToolUse wrapper maps it to permissionDecision:"deny".
Every governed write goes through one function and produces one of three outcomes. The decision is not advisory — callers MAY execute on Allow, MUST reject on Deny, and SHOULD queue + return the pending_id on Pending.
Proceed with the action. Caller receives the new memory or success response. No row in pending_actions.
Reject with HTTP 403. The wrapped String surfaces the human-readable reason — not a placeholder. AI clients can quote it verbatim.
Queued in pending_actions; caller receives the new pending_id and HTTP 202. The action replays once approved.
For every governed action, the daemon walks this tree. The lookup is namespace-local — the policy lives in the namespace's standard memory at metadata.governance; absent a standard, the default policy applies.
Each policy field (write, promote, delete) holds one GovernanceLevel. Pick the field by action; the level determines the outcome shape.
| Level | store action | promote action | delete action | Used when |
|---|---|---|---|---|
| Any | Allow | Allow | Allow | Wide-open namespaces (no gate). Default for write and promote. |
| Registered | Allow if in _agents | Allow if in _agents | Allow if in _agents | Caller must have run POST /agents/register first. Otherwise Deny("agent not registered"). |
| Owner | Allow if caller == agent_id | Allow if caller == agent_id | Allow if caller == agent_id | Default for delete. Compares caller_agent_id to memory.metadata.agent_id. |
| Approve | Pending(id) | Pending(id) | Pending(id) | Routes to ApproverType: Human / Agent(id) / Consensus(N). |
These are the only operations that pass through the governance gate. Reads (memory_get, memory_list, memory_recall, memory_search) bypass governance — they go through the visibility/scope filter instead.
policy.write.policy.delete. Default Owner — unowned memories can be deleted by anyone unless the namespace is opted into stricter rules.policy.promote. Long-tier memories don't expire — promotion is the only edit that affects retention.When a level resolves to Approve, the approver shape determines what unblocks the pending action. ApproverType serializes externally-tagged so policy JSON stays unambiguous.
Out-of-band — UI button, email, Slack approve. Resolves via POST /pending/:id/approve with a human session.
A single specific registered agent must approve. Used for AI-supervisor patterns: junior agents queue, supervising agent approves.
N approval votes from any mix of human/agent registrations. Each vote appends to PendingAction.approvals; clears at threshold.
A namespace's standard memory carries the policy at metadata.governance. Absent a standard, the default applies. The default opens writes/promotes wide and gates deletes to owners with human-approver fallback for any Approve usage.
Defensive defaults: promote, delete, and approver carry #[serde(default)] so partial-policy payloads (a common shape for operator CLIs / test harnesses that only care about write) round-trip instead of 400-ing on missing fields. write remains required.
An Approve verdict isn't terminal — it parks the action. The original payload sits in pending_actions.payload until a decision arrives, then the daemon replays it as if the caller had just submitted it.
policy.write.pending_actions: action_type, namespace, payload (full action JSON), requested_by, status="pending".{"status":"pending","pending_id":"..."}. The HTTP request returns immediately — no blocking poll.POST /pending/:id/approve or /reject). Each consensus vote appends to approvals[]; threshold flips status.approved; decided_by, decided_at stamped.rejected. Payload retained for audit. Caller's original write does not happen.Without propagation, a decision on node-2 wouldn't reach node-1, and a caller on node-1 would still see pending after the action was already approved. Two additive sync_push fields close that gap:
sync_push.pending_decisions carries { id, approved, decider }. Peers apply via db::decide_pending_action. Already-decided rows: idempotent no-op.
sync_push.namespace_meta carries { namespace, standard_id, parent_namespace }. Without this, peers see the standard memory itself but miss the parent-link tuple, breaking inheritance walks.
Decisions also use quorum-fanout writes via broadcast_store_quorum at W of N — a single decision sticks across the federation with the same durability guarantee as a memory write.
Governance is a write-side gate. Reads, federation, and audit live elsewhere. Calling these out so AI clients don't assume the wrong contract.
memory_get, memory_list, memory_recall, memory_search) — those use the scope/as_agent visibility filter, not governance.memory_link) — passes link validation only; KG edges are not gated by the write/promote/delete policy (the hook pipeline can run link-write hooks on top).enforce / advisory / off + [[permissions.rules]] overlays, Bucket 3), and the v0.8.0 hook-presence enforcement + enforcing PreToolUse hook all extend, rather than replace, the namespace-policy gate documented on this page.