33. Action-time contradiction guard (PreToolUse, opt-in, non-blocking default)¶
- Status: Accepted
- Date: 2026-07-17
- Deciders: kristof (owner); Claude (orchestrator, proposer)
- Relates: ADR-0021 (embeddings + per-note vectors this reuses), ADR-0025 (the cached-embedder / fail-open-on-timeout posture), ADR-0007/ADR-0014 (curation gating), ADR-0030 (capture-time CONTRADICTS classifier — the sibling), ADR-0027 (the portable hook runtime), issue #199
The capture-time contradiction signal (ADR-0030's CONTRADICTS verdict) catches disagreements when a note is written. This ADR adds the other half ADR-0021 deferred: catching a contradiction at the moment a teammate's agent is about to act against a recorded decision. It changes no behavior until the
contradictionGuardflag is turned on (default OFF).
Context¶
Commonwealth already records decision notes and, at capture time, can flag a new note that
CONTRADICTS canon (ADR-0030). But the highest-leverage moment to surface a contradiction is not when
knowledge is written — it is when an agent is about to do something that cuts against a standing
decision (rewrite the datastore layer the team decided against, disable a flag a decision pinned on).
No competitor does action-time knowledge enforcement; #199 scored it the highest-differentiation
item in the API-opportunity audit.
The retrieval machinery already exists: ADR-0021 ships an embeddings provider and a per-note
vectors table, and loadVectors + cosineSimilarity are in core. So the guard can embed a
summary of the pending change and nearest-neighbor it against decision vectors — no new matching
engine.
Two hazards dominate the design:
- This is the tool hot path. A PreToolUse hook fires on every Write/Edit/Bash. Cold-loading a local embedding model costs seconds; even a hosted call is ~100ms. An unbounded check would add perceptible latency to every action, or wedge the session.
- False positives are corrosive. A gate that cries wolf — or worse, blocks — trains users to ignore or disable it. Prior art (selvedge's PreToolUse gate) resolves this with a fail-open, precision-over-recall posture and one-nudge-per-session acknowledgment files; MAMA keeps hook-time semantic search fast with a resident embedding server. The owner's call for #199 was explicit: build it conservatively — it must fire rarely and NEVER hard-stop the user.
The genuine fork was gate vs. nudge: escalate to a permission prompt (ask) so the user must
confirm, or inject a non-blocking warning into the agent's context and let the tool run. A default
gate is higher-friction and, given cosine's false-positive rate on "contradiction" (ADR-0021's exact
reason for deferring this), would gate wrongly often enough to get switched off.
Decision¶
-
Opt-in, default OFF. A new
contradictionGuardfeature flag in coreFEATURE_FLAGS, defaultfalse— unlikesemanticSearch/llmCurator(default on, inert without a runtime), because this flag changes tool hot-path behavior. A team turns it on deliberately. Flag on but no resolvable embeddings provider (ADR-0021) → silent no-op. -
Non-blocking by default;
askis opt-in. On a detected contradiction the guard returns a PreToolUseadditionalContextwarning —⚠ This change may contradict decision [[<id>]]: <title> — … Cited: <path>— injected into the agent's context; the tool still runs. A per-braincontradictionGuard.mode: "warn" | "ask"(default"warn") can raise it topermissionDecision: "ask"for teams that want a stop-and-confirm. Warn, not ask, is the default: the owner wants awareness, not a gate. -
Decision notes only. The guard compares only against non-superseded
kind: decisionvectors — never memory/work-state. A decision is the only note kind whose violation is meaningful at action time. -
Hot-path safety is paramount (fail-open). The whole check runs under a hard
GUARD_TIMEOUT_MS(300ms)Promise.race; on timeout or any error the tool is ALLOWED with at most one stderr breadcrumb. A cheap config read gates first, so the default-off / no-provider case costs a singlestat+read and zero embedding work. Only a compact summary of the change (the Bash command, the new file content, the edited text) is embedded — never whole files — bounded to 2KB. Cold/slow embedders therefore degrade to "no warning this time", never to added latency. This is the same fail-open-on-timeout discipline ADR-0025 uses for hybrid retrieval. -
Session-dedup (one nudge per decision). Once a decision has been warned about in a session, it is not re-warned — a per-session marker file (
contradiction-warned-<session>.json, next to the capture marks), mirroring the extraction recursion guard's marker pattern and selvedge's acknowledgment files. The guard nudges once, not on every keystroke. -
High threshold. A hit is surfaced only at cosine ≥
contradictionGuard.threshold(default 0.82, deliberately high). Better to miss than to cry wolf. -
Reuse the embeddings path; portable hook wiring. The guard hook (
hooks/contradiction-guard.mjs→contradictionGuard()inhooks/lib.mjs) shells to a newcommonwealth-curate contradiction-checksubcommand (which callscheckContradiction()in the curate package), exactly as the capture/neighbors hooks already reach core through curate (ADR-0026/0027). It does not import core or hand-roll cosine. The recursion guard (COMMONWEALTH_DISABLE_HOOKS) already stops the extraction/classifierclaude -pchildren from re-firing it. Wired intohooks.jsonPreToolUse with aWrite|Edit|MultiEdit|Bashmatcher — only for Claude, notcodex-hooks.json(Codex's four-event contract is unchanged).
Consequences¶
- Teams that opt in get an action-time nudge when an agent is about to contradict a recorded
decision, at zero added latency in the common (no-contradiction / cold-embedder) case, and with no
possibility of a hard stop unless they choose
askmode. - With the flag off (default) the tool hot path is unchanged apart from one cheap config read.
- The guard is precision-biased: a high threshold + decision-only + session-dedup means it will miss some real contradictions. That is the intended trade — a guard that cries wolf gets disabled.
- Latency remains the operational risk with the
localprovider (cold model load ≫ 300ms → always fail-open → never fires). A resident embedding server (MAMA-style, warmed at SessionStart) or thehostedprovider are the paths to make it fire reliably; both are follow-ups, out of this ADR. askmode makes the guard blocking by choice; teams that enable it accept the extra friction.