# Reasoning Guard

Reasoning Guard keeps a visible, deterministic record of decision-relevant
checks an agent identifies or promises. It prevents an unsupported causal claim,
completion claim, or permanent mutation from silently replacing evidence work
that could change the decision.

It does not call a model or create another workflow. One shared engine consumes
only allowlisted host-visible summaries and the host events already routed
through `prd_hook_dispatch.py`.

## Modes and categories

`reasoning_guard.mode` is one of:

- `report` (default): run the complete analysis and atomically update the local
  report, but never block, inject instructions, change routing, or otherwise
  alter execution.
- `on`: write the same report and block guarded boundaries when a relevant
  obligation remains unresolved. A post-tool boundary that has already executed
  is surfaced as a corrective instruction; it cannot undo the tool.
- `off`: perform no analysis, enforcement, or report write. An existing report
  is preserved.

Each category accepts `inherit`, `on`, `report`, or `off`. `inherit` follows the
global mode. A global `off` is a hard master switch, regardless of overrides.

```json
{
  "reasoning_guard": {
    "mode": "report",
    "backfill": {
      "mode": "recent",
      "limit": 20
    },
    "categories": {
      "evidence_follow_through": "inherit",
      "causal_claims": "inherit",
      "permanent_mutations": "inherit",
      "completion_claims": "inherit"
    }
  }
}
```

`reasoning_guard.backfill.mode` controls how much existing host-visible history
is inspected when a Codex or Claude session is first observed:

- `recent` (default): process the newest `reasoning_guard.backfill.limit`
  summaries. The default limit is `20`.
- `live`: process only the newest existing summary, then follow every complete
  summary appended afterward. This has the smallest startup read.
- `full`: process every complete visible summary in the trusted rollout, then
  follow new summaries. Initial latency and temporary memory grow with rollout
  size; use it when complete session history matters more than startup cost.

The limit is a positive integer from 1 through 100,000 and is used only by
`recent`. Changing modes during a session backfills only previously unseen
summaries; the compact byte-coverage ledger prevents older summaries from being
counted again.

The built-in profiles use `off` for `off` and `lean`, `report` for `balanced`,
and `on` for `thorough`. Use the normal configuration command or UI to change
the global mode or one category; no separate guard service exists.

## Explicit structured directives

Heuristic prose detection remains useful for low-confidence reporting, but it
is never blocking authority. A hard guard can arise only from an explicit
directive supplied as the `reasoning_evidence_check` payload object (or its JSON
string form), or from a visible `Evidence-Check: {JSON}` marker in a supported
text field.

Directive version 1 is strict: the object must contain exactly `version`,
`kind`, `claim`, `required_evidence`, `expected_result`, and `blocking`.
An explicit directive is therefore reviewable and portable across hosts.

```text
Evidence-Check: {"version":1,"kind":"evidence-check","claim":"The selected adapter is active.","required_evidence":[{"type":"tool-result","tool":"adapter-status","acceptance":[{"predicate":"outcome_is","value":"success"},{"predicate":"json_field_equals","path":"active","value":true}]}],"expected_result":{"active":true},"blocking":true}
```

`blocking: true` means an unresolved explicit obligation can block categories
whose effective mode is `on`. `blocking: false` records the same typed
obligation without granting blocking authority. Inferred obligations are always
forced to report-only even if a category is configured `on`.

Each required-evidence entry uses type `tool-result`, names one tool, and has one
or more acceptance predicates. The allowlist is:

- `outcome_is`: requires `success` or `error`.
- `json_field_equals`: compares a bounded dot path such as `active` or
  `adapter.status` with a bounded JSON value.
- `output_contains_all`: requires every bounded string in `values`.
- `output_contains_none`: rejects any bounded string in `values`.

There is no expression evaluation, arbitrary regular expression, shell
execution, model call, or network call. A directive is limited to 4 KiB, four
evidence requirements, eight predicates in total, bounded collections and
nesting, and sanitized strings. Secret-shaped keys are rejected.

## Obligation lifecycle

The heuristic parser is deliberately narrow. It requires a bounded
discriminating check and a consequential decision area. It recognizes explicit
commitments, identified alternatives, and anchored active-check summary forms
such as `Investigating`, `Tracing`, `Diagnosing`, and `Assessing` when the same
summary also names a decision signal such as a cause, failure, anomaly,
difference, impact, behavior, or change. Merely mentioning a config, route,
hook, model, or policy in cosmetic or documentation work is insufficient.
Codex Markdown emphasis around generated headings (for example,
`**Investigating ...**`) is treated as a heading boundary rather than semantic
content.
Commitment classification is clause-local: a conclusion or ordinary promise to
report later cannot borrow check and decision words from another sentence and
become an obligation.
Explicit directives enter at `PLANNED` with high-confidence source metadata;
heuristic obligations are low-confidence and non-blocking.

The lifecycle is:

1. `IDENTIFIED`: plausible alternatives and a bounded discriminating check are
   visible.
2. `PLANNED`: the agent explicitly commits to the check.
3. `EXECUTED`: a matching tool call starts. A tool call alone is not evidence.
   Heuristic matching requires the same check action and at least one shared
   subject token; generic overlap with the affected system area is insufficient.
4. `EVIDENCED`: every required tool result matches the tool-use binding and all
   of its allowlisted acceptance predicates. Only predicate outcomes and
   evidence digests are retained; raw tool output is not.
5. `RESOLVED`: a visible resolution binds the evidence to a conclusion.
6. `DEFERRED`: a complete bounded deferral records the reason, decision impact,
   and reopening condition.

Use these visible forms when closing an obligation:

```text
Resolved: <check>; evidence: <tool-call id or evidence digest>; conclusion: <result>.

Deferred: <check>; reason: <why>; decision impact: <what must not change>;
reopen when: <specific condition>.
```

A temporary fail-closed mitigation is allowed when it is explicitly labelled
temporary or mitigation and says the root cause remains unresolved. It does not
resolve the obligation.

## Guarded boundaries

The four independent categories cover:

- `evidence_follow_through`: stopping while a decision-relevant check is open.
- `causal_claims`: root-cause attribution or pruning a plausible branch before
  the discriminating evidence exists.
- `permanent_mutations`: permanent routing, fallback, policy, hook, profile, or
  configuration changes before the relevant evidence is resolved or deferred.
- `completion_claims`: claiming done or resolved while the evidence obligation
  remains open.

Pre-tool checks can prevent a mutation. Post-tool checks reconcile evidence and
also detect mutations a host did not expose early enough to prevent. Report mode
records the same finding without changing execution.

Findings use stable codes (`RG-EVIDENCE-OPEN-001`,
`RG-CAUSAL-AHEAD-001`, `RG-MUTATION-AHEAD-001`, and
`RG-COMPLETION-AHEAD-001`). Repeated occurrences for the same obligation are
coalesced into one finding with first/last timestamps, an occurrence count, and
bounded source-event digests. The count advances only when the obligation
reaches a new lifecycle state; repeated equivalent boundaries refresh
last-seen evidence without inflating ignored-transition telemetry. When
evidence exists but reconciliation is missing, the diagnostic says that
explicitly rather than claiming the evidence check itself is still open.
Resolution makes the finding inactive instead of deleting its audit history.

## Local report and security

Both `on` and `report` create valid local state even when no obligation is
found. `.prd_plugin/local/reason_guard.json` remains the compatibility snapshot
for the currently active session. Durable host ledgers live beside it:

- `.prd_plugin/local/claude_reasoning_guard.json`
- `.prd_plugin/local/codex_reasoning_guard.json`
- `.prd_plugin/local/opencode_reasoning_guard.json`

Each provider ledger is a schema-1 envelope with the host name, active session,
update time, and at most 20 schema-3 session reports. A new session creates a
separate record instead of overwriting the previous session; concurrent
sessions remain distinct under the same repository lock. The oldest retained
session is discarded only when that host exceeds the bound. Existing
`reason_guard.json` state is migrated into the matching host ledger on the next
event and remains readable by older status consumers.

Provider, surface, model, effort, and session are separate fields. The session report uses one
allowlisted surface value: `codex_desktop`, `codex_cli`, `claude_desktop`,
`claude_cli`, `opencode`, or `unknown`. Status and UI render readable labels
such as **Codex Desktop** and **Claude CLI**. Surface is resolved in this order:
a canonical `surface`/`client_surface` hook value or `PRD_HOOK_SURFACE`, the
host-owned `CODEX_INTERNAL_ORIGINATOR_OVERRIDE` or
`CLAUDE_CODE_ENTRYPOINT`, then trusted transcript metadata. Unknown,
unsupported, or cross-provider values stay `unknown`; missing metadata is never
treated as proof of CLI or Desktop.

The same record retains the latest trusted model identifier and normalized
effort (`minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`, `none`, or
`unknown`). Codex reads these from bounded `turn_context` or applied thread
settings; Claude reads them from allowlisted assistant-record metadata. A
canonical hook `model`/`model_id` plus `effort`/`reasoning_effort`, or
`PRD_HOOK_MODEL`/`PRD_HOOK_EFFORT`, can supply the values when the host does not.
Model strings are identifier-shaped and bounded to 128 characters; synthetic,
malformed, or unsupported effort values become `unknown`. Initial or migration
recovery inspects at most the newest 2 MiB / 4,096 complete metadata records;
after that it reads only the newly covered cursor interval.

Schema version `3.0` contains mode, session, surface, model, and effort identity, update time, host coverage,
typed obligation contracts, lifecycle history, source-event digests, tool-call
bindings, bounded observed-evidence summaries, deferral and resolution,
coalesced findings, guard actions, replay digests, diagnostics, summary counts,
clearance, aggregate classification counters, and locally measured p50/p95
latency. It also records the configured backfill mode and limit, a hashed trusted
source identity, append cursor, compact processed byte interval, completion
flags, a bounded head/cursor source anchor, selected/processed counts, and a
bounded error string. Classification
telemetry contains only numeric counts for summaries
observed, high-confidence candidates, created/reconciled obligations, ignored
summaries, and uncertain active summaries; it does not retain an additional
copy of summary text.

Supported schema `1.0` and `2.0` reports migrate atomically on the next event.
Legacy obligations become low-confidence, non-blocking records, and repeated
legacy findings are coalesced. Malformed, future, or structurally unsafe reports
recover through the existing fail-safe path rather than being guessed into
schema 3.

The `clearance` receipt is derived from the report, not asserted by an agent:

- `clear`: full visible coverage with no unresolved explicit or inferred
  obligation and no unclassified active-looking summary.
- `conditional`: no blocker, but host coverage is reduced, a low-confidence
  inferred obligation remains unresolved, or an active-looking summary could
  not be classified confidently.
- `blocked`: an open/deferred blocking obligation or its active diagnostic
  remains.

The receipt includes human-readable obligation names, limitations, mode,
coverage, a report digest, and secondary IDs for audit. Status and the UI display
the readable clearance state rather than presenting internal IDs as the primary
label.

Writes use a repository-local lock, a temporary file, `fsync`, and atomic
replacement. Retention is bounded to 128 obligations/findings/actions, 256 event
digests, and 128 latency samples. The managed `.prd_plugin/local/` ignore keeps
the report out of source control and exports.

Prompt text is not persisted. Stored excerpts are bounded and sanitized;
values whose names contain `TOKEN`, `SECRET`, `KEY`, `PASSWORD`, `PASS`, or
`CREDENTIAL`, plus matching command flags, are replaced with `[REDACTED]`.
For Codex, a hook-provided transcript path is accepted only when it resolves to
a `rollout-*.jsonl` file under the configured Codex `sessions` directory. When
Codex reports that path as null or omits it, the guard can resolve exactly one
trusted rollout from a UUID-shaped `session_id`; invalid, ambiguous, or
out-of-tree matches fail closed. In `live` mode the initial lookup reads at most
the final 2 MiB / 512 lines. `recent` and `full` make one sequential initial
scan so they can honor their history contract; `recent` retains only the
selected count while scanning. After initialization every mode seeks directly
to the saved byte cursor and reads only newly appended complete records. A
partial final JSONL record is left behind the cursor and retried after
completion. A replaced source, path change, or file shrink invalidates coverage
instead of silently resetting it.

For Claude, a hook path is accepted only when it resolves below
`CLAUDE_CONFIG_DIR/projects` (or `~/.claude/projects`), names a `.jsonl` file,
and its filename stem exactly matches the UUID-shaped session ID. When the hook
omits the path, exactly one matching file may be resolved below that trusted
tree; missing, ambiguous, out-of-tree, or symlink-escaped candidates are
rejected.

The Codex adapter allow-lists only
`response_item` → `reasoning` → `summary` → `summary_text.text`. The Claude
adapter allow-lists only top-level `assistant` records whose nested message role
is `assistant`, then only exact `{type, text}` blocks with `type: "text"`.
All other Claude block and record shapes are excluded without interpretation.
Before sanitization and hashing, allowlisted Claude text is normalized to NFC
and recognizable CP1252/Latin-1-to-UTF-8 mojibake is conservatively reversed;
ordinary Unicode and malformed-byte rejection remain unchanged.

## Provider, surface, and host coverage

Codex, Claude Code, and OpenCode use the same modes, per-session state schema,
classifier, and reconciliation engine. Host-specific adapters normalize each
provider's visible history and surface metadata into that shared kernel.

- Codex routes session, prompt, stop, pre-tool, and post-tool events. Codex hook
  payloads identify the session but may report the transcript path as null
  rather than supplying an inline reasoning summary. The guard uses the trusted
  path when present and otherwise resolves the UUID-shaped session ID below the
  Codex sessions root, then extracts only explicit visible summary records
  according to the configured `live`, `recent`, or `full` startup mode. A
  trusted session-metadata `originator` distinguishes Codex Desktop from Codex
  CLI (`codex_exec`, `codex-tui`, or canonical `codex_cli`), while the latest
  bounded turn context supplies model and effort.
- Claude Code routes the same five events and observes all tools rather than
  only Bash or Skill calls. Its trusted transcript adapter applies the same
  `live`, `recent`, and `full` history contract to visible assistant text while
  excluding thinking, signatures, user text, and tool content. Its allowlisted
  transcript `entrypoint` distinguishes `claude-desktop` from `cli`; assistant
  metadata supplies the latest real model and normalized effort.
- OpenCode routes idle/Stop plus pre-tool and post-tool events. Its idle event is
  not a vetoable Stop, so an `on` finding is reprompted into the session. Its
  bounded in-memory `message.updated` cache forwards the latest assistant
  `modelID` and allowlisted effort-like `variant`; absent or non-effort variants
  remain unknown.

Coverage is cumulative within each retained host session. Once a visible reasoning
summary, tool input, or tool result is observed, a later event cannot erase that
observation. A different session starts with fresh coverage without erasing the
older retained report. Until a visible
reasoning summary reaches the guard, the report declares `reduced coverage`.
`live_full` means complete observation from the selected live starting point,
not historical completeness. `recent_backfill` means the configured recent
window plus all later summaries. Only a successful `full` scan reports
`historically_complete`. Any incomplete, missing, replaced, or truncated
rollout forces `reduced` coverage and prevents unconditional clearance.
The guard continues with visible plans, commentary, tool inputs, tool results,
and final messages; it never substitutes unavailable channels or invents full
coverage.

## Latency

The guard uses local parsing and one atomic state update, with no network or
model call. `live` has the smallest bounded startup read; `recent` scans the
rollout once but retains only the newest configured count; `full` scans and
processes all visible-summary history. After that first event, all three modes
seek to the cursor, so steady-state work is proportional only to newly appended
records. Status and the configuration UI report observed p50/p95 latency and
the aggregate candidate, uncertain, and observed-summary counts from the active
report. Their session table also shows provider surface, model, effort, readable session time, coverage,
clearance, processed-summary count, and p95 latency for every retained session;
the system session ID remains secondary. When no samples exist, latency is shown
as unmeasured. `off` adds no guard work or report write.

The checked-in focused comparison is:

```bash
python scripts/reason_guard_benchmark.py --samples 100 --warmup 10 --json
```

It exercises the same Claude host-event path and local atomic report writes for
plain and structured events. It also replays deduplicated, labelled development
and holdout samples from a real Codex session, split into challenge and
representative-neutral strata. The report separates high-confidence candidate
precision from safety recall (a consequential summary is either a candidate or
an explicit abstention), clear specificity, abstention rate, misses, and
classification p50/p95/max latency. Ambiguous labels are excluded from headline
metrics rather than forced negative. Release requires at least 0.95 candidate
precision when candidate predictions exist (otherwise precision is unmeasured
and false obligations must be zero), at least 0.95 safety recall, at least 0.50
overall and representative-neutral clear specificity, zero required misses,
classifier p95 no greater than 1 ms,
structured hook p95 no greater than 50 ms, and a structured-minus-plain p95
delta no greater than 20 ms on the executing machine. Challenge negatives may
abstain because uncertainty is not an obligation or blocking authority. These
are regression budgets for this bounded local method, not portable latency
promises for every host or filesystem.

## Troubleshooting

- No report: confirm `hooks.enabled` is true and the global mode is `on` or
  `report`. `off` intentionally preserves the old report without touching it.
- A category did not block: inspect its effective mode in `/prd-status` or the
  configuration UI. `report` records but never blocks.
- Reduced coverage: inspect `coverage.missing`. It means no visible reasoning
  summary has reached the guard, or the configured history scan could not be
  completed safely; it does not prove that no summary or obligation exists.
  Inspect `reasoning_summary_ingestion.last_error`. For Codex, confirm the
  payload has either a trusted
  `transcript_path` or a UUID-shaped `session_id` whose unique rollout exists
  under the Codex `sessions` directory.
- Claude history unavailable: confirm the session ID is UUID-shaped and the
  transcript is uniquely resolvable below `CLAUDE_CONFIG_DIR/projects` (or
  `~/.claude/projects`). Only visible assistant text blocks count; the presence
  of `thinking`, signature, user, or tool blocks does not provide visible
  summary coverage.
- Surface unknown: inspect the canonical `surface`/`client_surface` value,
  `PRD_HOOK_SURFACE`, the matching host-owned environment marker, or the
  trusted transcript's `originator`/`entrypoint`. Unsupported values remain
  unknown by design; use a canonical explicit override when a host does not
  expose stable metadata.
- Malformed state: report mode replaces it with a valid report and diagnostic.
  On mode also fails closed at a guarded boundary so the recovered state can be
  reviewed before continuing.
- Unexpected clearance: inspect its limitations and human-readable obligations.
  `conditional` means visibility is reduced, an inferred check remains open, or
  one or more active-looking summaries were conservatively left uncertain; it
  does not mean an obligation was rejected.
- Unexpected finding: inspect the bounded obligation summary and source-event
  digest. Narrow or disable only the relevant category.
