Governance & control

The governance layer

A control plane for the agents that use the Open Brain. On every incoming call it runs one classify step and turns it into three decisions — is this allowed?, which model should run it?, and which expert sub-agents should wrap it? — plus context nudges. It is built in-host: new routes and a Governance/ folder inside the existing .NET 10 service, driven by four operator screens and hot-reloadable JSON policy.

In one sentence: Claude Code hooks call four endpoints on the Open Brain (/hook/user-prompt-submit, /hook/pre-tool-use, /route, /hook/health); a single GovernanceService fingerprints the task and returns a block / allow-with-context verdict, a model recommendation, and a ranked list of expert personas — all governed by three hot-reloadable files (policy.json, routing_table.json, personas.json) an operator edits from four glass-card UIs.

Ships OFF by default (Governance:Enabled=false). When disabled, the two enforcement hooks (/hook/user-prompt-submit, /hook/pre-tool-use) short-circuit to {} — no block, no nudge; the advisory /route and /hook/health stay live but are read-only. Behaviour is therefore unchanged, so it is byte-safe to deploy and gets switched on from the UI when ready.
This is a different gate from the one below. Everything above and below this box is the in-host GovernanceService control plane (policy/routing/personas), which ships off. The section immediately below — the mot-ledger consult gate — is a separate, older, always-relevant mechanism: the Claude Code PreToolUse hook that enforces "call query_ledger before touching code". It is live today, is not part of Governance:Enabled, and is what /api/gate-health and the A/B below actually measure.

The mot-ledger consult gate — what it is today

A Claude Code PreToolUse hook that reads {session_id, agent_id, tool_name} on stdin and denies (or advises, depending on mode) a gated tool call until mcp__mot-ledger__query_ledger has been called recently enough. Gated tools: Grep, Glob, Read, Bash, PowerShell, Edit, Write, MultiEdit, NotebookEdit, WebFetch, WebSearch, Agent, Task, Skill, and any non-mot-ledger MCP tool (pattern ^mcp__(?!mot-ledger__).+). The ledger MCP tools themselves are always exempt, or the gate could never be satisfied.

Single, versioned, user-level wiring (WP1.1a/b)

Until 2026-09-13 this logic was hand-copied into every repo's .claude/settings.json and every worktree beneath it — 602 gate entries across 202 settings.json files, plus 734 duplicated script copies, found and removed in the WP1.1a rollout. It now lives in exactly one place: motmaster-infra/claude-hooks/, installed by that directory's install.sh into a versioned home, ~/.claude/hooks/v<sha>/ (the live install is ~/.claude/hooks/v2bb96b4/), and wired once into the user-level ~/.claude/settings.json — never into a repo-level settings file. A change to gate behaviour is therefore reviewed and tested like normal code in that directory before an operator re-runs install.sh --apply, rather than hand-edited on the live host. WP1.1a's finishing pass (2026-09-13 08:19 UTC) is recorded per-repo: commits 850d21b7 (agent), 2b8e01d (motlogs), 7d9cd39 (scout), b4d0a9b (openbrain), 5ba2f77 (sql), 33bfb82 (wiki), 8f71007 (n8n).

A bash preflight, require-ledger-consulted.sh, wraps the PowerShell logic and guarantees fail-closed even if pwsh itself cannot start — the 2026-07-12 incident that motivated it: a truncated PowerShell startup cache made every pwsh invocation abort with exit 134, which Claude Code does not treat as blocking, so the gate silently vanished for four days while sessions kept editing code. The wrapper purges that cache once and retries before deciding to deny.

State key: (session_id, agent_id)

Every hook keys its per-run state on Get-LedgerGateKey: <session_id>.json for the parent, <session_id>--<agent_id>.json for a subagent (its own file, its own counter), and _nosid in place of a missing session id. This is the fix for the bug where every hook previously keyed on session_id alone, so a subagent's tool calls drew down the parent's 20-minute/20-call budget — two concurrent PreToolUse/PostToolUse wirings racing one shared counter.

LEDGER_GATE_MODE — four behaviours

ModeBehaviour
hard (default)A consult is valid for 20 minutes and fewer than 20 gated calls since; otherwise deny.
first-touchOne consult unlocks exactly one new repo for this key, permanently — no time or call-count re-arm. A repo already consulted never needs touching again.
advisoryNever denies. The first gated call in a repo not yet advised gets a non-blocking additionalContext reminder; every call after that, for that repo and key, is silent.
offAllow everything. No state is read or written by either hook — for genuinely disabling the gate, not "I'll consult eventually".

An unrecognised LEDGER_GATE_MODE value falls back to hard rather than silently disabling the gate on a typo. LEDGER_GATE_BYPASS=1 allows everything, in every mode, but — unlike off — is logged (a line in <state dir>/bypass.log, plus best-effort telemetry) rather than silent. The deny text itself asks for exactly one thing: "call mcp__mot-ledger__query_ledger with the question you actually need answered — name a symbol (type, method, config key, table) — then retry the call."

SubagentStart — handing a subagent the parent's citations

A SubagentStart hook (subagent-start.sh) copies the parent session's most recent citations array (extracted by mark-ledger-consulted.ps1 from a query_ledger response's ```json cited block) onto the freshly spawned subagent's own state key, marked pending_consult=true — so the subagent doesn't have to re-discover files its parent already found — and emits them back as additionalContext so the subagent knows the gate is satisfied and which files were already located. A no-op (silent exit 0) when there is no parent state, no citations, no agent_id, LEDGER_GATE_MODE=off, or LEDGER_GATE_BYPASS=1.

The overlay-refresh PostToolUse hook

A separate PostToolUse hook (overlay-refresh.sh, fire-and-forget, always exits 0) fires after an Edit/Write/MultiEdit inside an accepted checkout-overlay root shape (.claude/worktrees/*, ws-*/motmaster-*, wt-*) and POSTs {root, file} to /api/admin/overlay/refresh so the ledger's checkout overlay (see MCP tools & deploy) re-diffs that root — the design's "an edit is searchable within two minutes" gate. Debounced to one POST per root per 30s (configurable), and never blocks the tool call it's attached to.

The pre-registered gate A/B (WP1.2)

Three arms, assigned per parent session (subagents inherit) and stratified by repo: A hard (today's gate, correctly keyed), B first-touch (one consult unlocks a repo permanently), C advisory+brief (never denies; injects a compact repo map once per transcript instead). Primary outcomes, per session-hour: tree_search_named (a tree-wide search within 3 calls of a query_ledger sharing an identifier — the ledger-attributable grep rate), denials, and edits (a throughput proxy that must not fall) — each reported alongside a tree_search_after_read control.

Schedule: a 7-day A/A week first (every session actually runs hard, but the randomiser draws and records a sham arm, giving a noise floor for every outcome) — started 2026-09-13T07:04:07Z — then an A/B/C window of at least 28 days once flipped live on 2026-09-20, extended in whole weeks to a maximum of 56, with a read-out planned for 2026-10-18. docs/LEDGER_SCALE_PLAN.md, WP2.x, and every CLAUDE.md routing-text change are frozen for the window's duration. Adoption rule: adopt an arm only if it is no worse than A on all three primaries and the ledger's share of tool-result characters falls; prefer C over B if both qualify; abort back to A immediately if any arm's edits rate falls below 60% of A's over a 7-day span.

Until that read-out, docs/ROUTING_GUIDANCE.md — the single source of truth for the routing text propagated byte-for-byte into all eight repos' CLAUDE.md — keeps the hard rule exactly as written, and says so explicitly: "This hard rule, and the PreToolUse gate that enforces it, are under test… Until that read-out, the rule above stands as written." The routing text also carries the three-lane routing table (semantic search vs compiler-verified lookup vs exact-string grep) and the root guidance for worktrees.

/api/gate-health — detecting a gate that goes silent

A dead gate emits nothing, and "no denies" is exactly what a fully-compliant team also looks like — so absence alone can never be the alarm. GateHealth.Compute instead looks for a contradiction between two independent signals already landing in the same GovernanceActivity ring: source="transcript" (prompts tailed from session JSONL — proves humans were working, without touching pwsh) and source="ledger-gate" (verdicts reported by the consult hook itself — proves the gate ran). Sessions active + gate silent for the 60-minute lookback window (with at least 3 prompts, to avoid false alarms from purely conversational sessions) is the alarm: status SILENT. An explicit degraded report (pwsh failed to run the gate at all) beats every other check and names the cause directly; healthy is reported whenever gate verdicts exist in the window; a quiet host with no prompts reads unknown, not healthy — claiming "healthy" from silence would be the exact class of lie this endpoint exists to catch. The endpoint is public (Security:PublicPaths includes /api/gate-health) and returns only aggregate counts and a status string — never prompt text or code — feeding the dashboard's alert tile.

Why it matters

This is the same failure mode that hid for four days in July 2026 — a hook that stops running produces total silence, which is indistinguishable from perfect compliance unless something else proves work was still happening.

Where it runs — in-host, not a separate service

The layer is folded into MOTLogs.ArchitecturalLedger rather than run as a separate (originally planned Python) service, because the host already provides every primitive it needs: minimal-API endpoints, bearer auth with per-developer identity (TokenRegistry, WS1), the GroqService classifier, the benchmark judge/corpus, WS2 OpenTelemetry, and reload-on-change config. So the decisioning service calls the outcome store by in-process method call, not HTTP, there is one systemd unit / one deploy / one backup, and every decision is attributed to a real {developerId, team}. The code lives in Governance/: GovernanceService, GovernanceModels, JsonSnapshotStore<T> + the three stores, GovernanceAudit, PromptClassifier, OutcomeRecordCollection, and GovernanceReplay.

Why it matters

No second language, runtime, or SPOF on the hot path of every developer turn; the outcome store and its LLM-judge are reused, not rebuilt; and the audit trail carries real identity for free.

One decision per call — the fingerprint

GovernanceService.Fingerprint(text) is a deterministic, zero-I/O classifier. It scores the task text against keyword sets to pick a category (perf · sql · security · ui · test · docs · general), extracts touched file paths, estimates context size, and derives a blast radius (touched files + a bump for words like refactor / migration / rollout). That single fingerprint feeds all three outputs, so deny, routing, and persona suggestion always agree on what the task is. An optional LLM refinement (PromptClassifier over GroqService) can sharpen the category; it is timeout-bounded and fail-open, and OFF unless policy.classifier.enabled is set.

The hooks — four endpoints

The Claude Code client posts hook payloads to the service (bearer/loopback-gated, exactly like /mcp). With governance disabled the two enforcement hooks return {}; the advisory /route and /hook/health stay live.

EndpointHookBehaviour
POST /hook/user-prompt-submitUserPromptSubmitDeterministic block-screen → optional classify → lens enrichment → routing advice → persona suggestions, composed into a single additionalContext string. Returns {"decision":"block",…}, a hookSpecificOutput.additionalContext, or {}.
POST /hook/pre-tool-usePreToolUseHottest path: a pure in-memory check of the deny rules — no LLM call, zero-allocation allow. Emits the correct hookSpecificOutput.permissionDecision (deny, or ask for a defer rule), never the silently-ignored legacy {"decision":"deny"} shape.
POST /routecroute / advisoryFingerprints a task and returns {model, escalation_model, confidence, category} from the routing table.
GET /hook/healthfreshness{enabled, policyHash, routingGeneratedUtc, personaCount, degraded, lastError} — drives the health strip + enable toggle on every governance page.
Interactive model routing is advisory — a hook cannot change the live session model, so /route is a nudge (and the lever for headless croute + pinned sub-agents). Deny/defer is enforced by the PreToolUse verdict.

Policy as data — hot-reloadable stores

Three JSON files under Governance:Dir (default <base>/governance) are the whole configuration surface: policy.json, routing_table.json, personas.json. Each is held by a JsonSnapshotStore<T> that loads it once into an immutable in-memory snapshot (read with zero disk I/O on the hot path) and swaps the reference atomically when a FileSystemWatcher sees a change. A file that is missing, unparseable, or fails validation keeps the last-good snapshot and flips degraded — so a bad edit degrades to last-good instead of taking the service down for 300 developers. First run seeds a starter file; every write goes through the validator (unique ids, valid regex, sane bounds) and is audited.

Why it matters

Operators tune the layer as data with instant effect and no restart, and a fat-fingered edit can never black-hole enforcement — the opposite of a validate-or-die config that fails a whole fleet.

1 · Prompt screens & lenses (Policy)

policy.json carries the prompt-facing rules, edited on the Policy screen:

  • Blocked prompts — hard-stop rules matched (substring or regex) against the raw prompt; a hit returns a block verdict with the rule's reason. Seed example: bulk secret exfiltration.
  • Lenses — reusable context reminders injected when their when pattern matches (e.g. the SEC-1 "secrets belong in Key Vault" nudge, or the repo-isolation reminder).
  • Classifier — the optional Groq category refinement (enabled, model, timeoutMs, failOpen), default off.

2 · Not-allowed — deny / defer rules

The Not-allowed screen edits policy.blockedActions: the PreToolUse hard-stops. Each rule narrows by tool name and an optional when predicate over the tool input — a tiny grammar evaluated by GovernanceService.MatchWhen: field ~= 'substr' (contains), field == 'v', field != 'v', or a bare substring across all inputs; field aliases map cmd→command and path→file_path/notebook_path. A rule's decision is deny (blocks) or defer (returns ask — hands the call back to the normal permission prompt). Seed rules: deny Bash where cmd ~= 'rm -rf /'; defer a .env write. A live rule tester runs the real evaluation path so a rule can't drift from production.

The screen also exports a managed-settings snippet: deny rules that name a concrete tool become command-scoped permissions.deny entries (e.g. Bash(rm -rf /:*)) alongside allowManagedHooksOnly, allowManagedPermissionRulesOnly, and disableBypassPermissionsMode. This is the offline backstop distributed to devices via MDM/CI — it fires even when the service is down, and (honestly) a self-administered box's root can still delete it, so true enforcement needs MDM.

3 · Task → Model routing + the replay engine

The Task → Model screen answers "which model is good enough (and cheapest) for each kind of task?". routing_table.json maps a fingerprint category → a bucket {model, escalationModel, confidence, samples}. At route time GovernanceService.Route matches the task's category to a bucket and returns its model; an empty or under-sampled bucket falls back to the table's defaultModel (seed: claude-sonnet-5, escalate claude-opus-4-8, threshold 0.85).

The Task categories tile is real, read-only evidence — TaskClustersAsync mines the live query corpus (motlogs_agent_queries), fingerprints each distinct query, and shows the counts + examples per category. The replay engine's own input sampler, SampleTasksAsync, draws individual fingerprinted tasks from the same corpus.

The replay engine

GovernanceReplay turns those tasks into a measured routing table instead of a hand-set one, reusing the existing Anthropic agentic loop + LLM-judge in BenchRunner (no separate claude -p process, no new vendor). Three phases:

  1. sample — SampleTasksAsync(n) picks N distinct fingerprinted tasks from the corpus.
  2. run — for each task × model, BenchRunner.RunLedgerPathAsync runs the ledger agentic loop against that model and JudgeAnswerAsync scores the answer pass/fail; one OutcomeRecord per run (score, pass, tokens, cost) is written to the motlogs_outcome_records Qdrant collection.
  3. boundaries — DeriveBuckets aggregates pass-rate per (category, model) and picks the cheapest model whose pass-rate ≥ threshold over ≥ min-samples (cost order haiku-4-5 < sonnet-5 < opus-4-8 < fable-5). Categories that no model can satisfy are omitted (they fall back to the default). The result is published via RoutingTableStore.Save and hot-reloaded.

Run it from the CLI — MOTLogs.ArchitecturalLedger --governance-replay sample|run|boundaries [--n --models --threshold --min-samples] — or from the UI's Run replay button, which launches a background run on explicit confirm and polls /api/admin/governance/replay/status for live progress. run is $-spending (Anthropic API) so it only fires on confirm, requires Anthropic:ApiKey, and is guarded cleanly when the key is absent; Estimate cost projects the run count first (e.g. 25 × 3 = 75 model runs + 75 judge calls).

Why it matters

Model choice per task class becomes an evidence-backed, cheapest-capable decision measured on the team's own real tasks — not a guess — and the machinery is the existing judge, not a new benchmark.

4 · Personas — the suggestion engine

The Personas screen is the part the operator most wanted: when a call comes in to do some work, the layer reads it and suggests expert sub-agents to wrap it in — a Performance Tuner, a Legal / Compliance Expert, a Security Reviewer, an Accessibility Auditor, a SQL Optimiser, a Test Writer (the seeded library). personas.json holds each persona {name, expertise, wrapperPrompt (system-prompt seed), recommendedModel, allowTools, denyTools, triggers}. A trigger fires on any populated signal: keywords, a regex, a fingerprint category, a touched-file glob, or a blast-radius floor. SuggestPersonas scores every enabled persona against the task and returns a ranked list with the "why", each pre-pinned to its recommended model (from the routing ladder) and tool scope (composed with the global deny rules).

Suggestions are delivered two ways:

  • Hook path — the UserPromptSubmit additionalContext adds a line like "Consider wrapping sub-agents: Performance Tuner (matches slow/optimi); SQL Optimiser (sql task)." (advisory; the orchestrator decides).
  • MCP tool — a new read-tier tool suggest_personas(task) the orchestrating agent can call explicitly, returning [{persona, name, why, recommendedModel, seedPrompt, allowTools, denyTools}] plus the task category and blast radius, so it can spin the sub-agents up.

A live suggestion tester on the screen runs the real path against a sample task.

The four operator screens

All four are static glass-card pages under wwwroot/, in the existing admin style, reachable from the Governance nav item and gated loopback/tailnet only (the public nginx vhost blocks /api/admin/). They read and write through the same stores, so every save validates + hot-reloads + audits.

ScreenFileEdits
Policygovernance.htmlPrompt screens, lenses, classifier.
Not-allowedgovernance-deny.htmlDeny/defer rules, live rule tester, managed-settings export.
Task → Modelgovernance-routing.htmlCorpus task clusters, routing table, replay run + live progress.
Personasgovernance-personas.htmlPersona library, trigger rules, live suggestion tester.

The admin API behind them lives in Program.cs under /api/admin/governance/* (policy, policy/validate, blocked-actions, test-action, managed-settings/export, task-clusters, routing-table, replay/start, replay/status, personas, personas/suggest, enabled) — all loopback-only, like /api/admin/tokens.

Compose — the advisory prompt studio

A fifth screen, governance-compose.html (POST /api/admin/governance/compose), turns the pipeline into a prompt studio. Enter a task and it runs the same path every inbound call takes — deny screen → fingerprint → route → personas — then adds Groq-generated clarifying questions and token-lean ledger pointers, returning a wrapped, ready-to-run prompt for your Claude Code session. It is purely advisory: nothing is executed or enforced.

OutputComes from
Inappropriate-use flag (short-circuits the rest)the deny screen (policy.json)
Recommended model + escalationthe routing table (routing_table.json)
Expert sub-agents — each with a model and a seed promptthe persona engine (personas.json)
Clarifying questions (fail-open if Groq is down)a Groq call, mirroring the classifier
Ledger pointers — file + intent + a ready orient_me call, never code bodieshybrid search, relevance-gated, off by default
The composed prompt to paste into Claude Codeall of the above, wrapped

Two entry points: the Compose screen itself, and the per-todo Compose button on /todo.html (#71), which deep-links a todo straight in — scoped to its repo — so the todo list feeds directly into the right model, guardrails and sub-agents. Loopback/tailnet-gated like every other admin surface.

Turning it on — the enable toggle

Every governance page shows a health strip with a one-click Enable / Disable button. It posts /api/admin/governance/enabled {enabled}, which writes Governance:Enabled into appsettings.json; because config is loaded with reloadOnChange and the flag is read live, the hooks flip on or off within about a second — no restart. Enabling in production is therefore a button, and the deploy ships the section as appsettings.default.json so the live config is never clobbered.

Security & audit

Hook endpoints share the /mcp bearer + loopback resolution, so each decision carries a WS1 {developerId, team}; the admin CRUD is loopback-only. GovernanceAudit writes one JSONL line per decision under Governance:AuditDir (default logs/governance) with the identity, event, verdict, matched rule, model advised — and the SHA-256 of the prompt, never the raw prompt (SEC / PII). Fail-open is structural: any error on a hook returns {} (allow), and the genuinely-dangerous backstop is the client's managed permissions.deny, not this service.

Why it matters

The control plane is observable and attributable without ever logging a raw prompt, and a service outage degrades to "no nudges", not "no work" — while hard stops survive offline via the managed backstop.

Governance defaults OFF; the classifier and replay run are additionally gated (Groq key / Anthropic key respectively). The replay engine's run phase is the only $-spending part and only fires on an explicit CLI/confirm trigger.

Open Brain — the MOTMaster Architectural Ledger · governance layer · generated from source 2026-07-10