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.
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.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
| Mode | Behaviour |
|---|---|
hard (default) | A consult is valid for 20 minutes and fewer than 20 gated calls since; otherwise deny. |
first-touch | One 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. |
advisory | Never 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. |
off | Allow 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.
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.
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.
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.
| Endpoint | Hook | Behaviour |
|---|---|---|
POST /hook/user-prompt-submit | UserPromptSubmit | Deterministic
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-use | PreToolUse | Hottest 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 /route | croute / advisory | Fingerprints a task and returns
{model, escalation_model, confidence, category} from the routing table. |
GET /hook/health | freshness | {enabled, policyHash,
routingGeneratedUtc, personaCount, degraded, lastError} — drives the health strip + enable
toggle on every governance page. |
/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.
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 (
substringorregex) against the raw prompt; a hit returns ablockverdict with the rule's reason. Seed example: bulk secret exfiltration. - Lenses — reusable context reminders injected when their
whenpattern 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:
- sample —
SampleTasksAsync(n)picks N distinct fingerprinted tasks from the corpus. - run — for each task × model,
BenchRunner.RunLedgerPathAsyncruns the ledger agentic loop against that model andJudgeAnswerAsyncscores the answer pass/fail; oneOutcomeRecordper run (score, pass, tokens, cost) is written to themotlogs_outcome_recordsQdrant collection. - boundaries —
DeriveBucketsaggregates pass-rate per (category, model) and picks the cheapest model whose pass-rate ≥ threshold over ≥ min-samples (cost orderhaiku-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 viaRoutingTableStore.Saveand 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).
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
additionalContextadds 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.
| Screen | File | Edits |
|---|---|---|
| Policy | governance.html | Prompt screens, lenses, classifier. |
| Not-allowed | governance-deny.html | Deny/defer rules, live rule tester, managed-settings export. |
| Task → Model | governance-routing.html | Corpus task clusters, routing table, replay run + live progress. |
| Personas | governance-personas.html | Persona 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.
| Output | Comes from |
|---|---|
| Inappropriate-use flag (short-circuits the rest) | the deny screen (policy.json) |
| Recommended model + escalation | the routing table (routing_table.json) |
| Expert sub-agents — each with a model and a seed prompt | the 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 bodies | hybrid search, relevance-gated, off by default |
| The composed prompt to paste into Claude Code | all 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.
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.
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