docs← Back to article

Markdown for LLMs

Logs, audit trail, and decision journals

The source Markdown for this article. Copy it into your assistant or download it as a text file.

Download this articlePlain text ↗
# Logs, audit trail, and decision journals

## Goal

Separate the three records the deployment keeps — technical call log,
evaluation audit trail, operator decision journal — and say for each
what it holds, who reads it, and what happens when writing fails.

## Scope

Component `law-mcp` over HTTP (stdio journals nothing, real).
Scenario: a served `tools/call` and its afterlife in the logs. All
call ids, hashes, and package names below are synthetic examples.

## Applies to

| Branch | Coverage in this article |
|---|---|
| MCP (`law-mcp-server --http`) | call journal, audit fields, redaction list |
| `law serve` | decision records live in article 02 (journal mode), [Upgrades and compatibility](/operate/upgrades-compatibility/), [Rollback](/operate/rollback/) — shared journal, per-record pins |

## Prerequisites

- The journal switch state: enabled by default, `LAW_MCP_LOG=0`
  disables recording (real, `main.rs`).
- A journald socket at `/run/systemd/journal/socket` if native fields
  are wanted; otherwise records fall back to stderr JSON lines
  (real, `journal.rs`). `LAW_MCP_LOG_SOCKET` overrides the socket
  path (real, test seam).
- `journalctl` access on the host to read native records
  (operator-policy-example reader; the server ships no reader).

## Steps

1. Distinguish the three records before configuring anything:
   - Technical call log: one journal record per call or refusal,
     written by the transport (`reqlog.rs` + `journal.rs`).
   - Evaluation audit trail: inside the answer itself — statuses,
     applied rules, provenance hashes, `replayControl`; replays via
     `law_explain` / `law_inspect`, not via the log.
   - Operator decision journal: human entries (who approved what
     publication, which retention ran). The engine keeps none; the
     operator keeps it as a file or ticket trail
     (operator-policy-example, no code writes it).
2. Read a call record. Real fields (`reqlog.rs`, `journal.rs`,
   `trace.rs`): `MESSAGE` (`http <method> <tool> ok|<code> <ms>ms`),
   `PRIORITY` (`6` ok / `3` failed / `4` refused or overrun-finished),
   `SYSLOG_IDENTIFIER=law-mcp`, `MCP_TRANSPORT=http`,
   `MCP_PROFILE`, `MCP_CALL_ID` (32 hex), `MCP_PORT`,
   `MCP_CLIENT`, `MCP_METHOD`, `MCP_TOOL`, `MCP_OK`,
   `MCP_CODE`, `MCP_MS`, `MCP_BYTES_IN`, `MCP_BYTES_OUT`,
   `MCP_RPC_ID`, hashes `MCP_PROGRAM_HASH`, `MCP_CASE_HASH`,
   `MCP_RESULT_HASH`, `MCP_CODE_HASH`, stages `MCP_STAGES` and
   `MCP_MS_<STAGE>`, bodies `MCP_REQUEST`/`MCP_RESPONSE`, and flags
   `MCP_TRUNCATED` / `MCP_REDACTED`. `law_report` additionally
   spreads `MCP_REPORT_PACKAGE`, `MCP_REPORT_CATEGORY`,
   `MCP_REPORT_FRAGMENT`, `MCP_REPORT_PREDICATE`,
   `MCP_REPORT_CALL_ID` (real).
3. Know what enters the bodies. `MCP_REQUEST`/`MCP_RESPONSE` carry the
   raw JSON-RPC bytes capped at 64 KiB per side by default
   (`LAW_MCP_LOG_MAX_BODY`, `0` uncaps, real). Case facts ARE in the
   logged bytes when they fit — the journal is not a facts-free
   zone. Truncation is marked (`MCP_TRUNCATED=request,response`,
   real).
4. Know what is redacted. Exactly three terminal keys are replaced
   with `[REDACTED]`: `revokeSecret`, `LAW_ANSWERS_TOKEN`,
   `ANSWERS_INTERNAL_TOKEN`, and the record gains
   `MCP_REDACTED=answers-secret` (real, `reqlog.rs` `redact`). Same-named
   keys in a `tools/list` schema keep their descriptions; only
   terminal values hide. Nothing else is redacted — notably,
   `LAW_NEURAL_TOKEN` is *not* on the redaction list, so keep it out
   of logged tool arguments (current gap, not a policy choice).
5. Replay from the audit trail, not the log. The journal's hashes
   (`MCP_RESULT_HASH`, `MCP_CODE_HASH`) plus the original question
   re-run through `law_explain` reproduce the proof chain; the
   server re-executes and refuses on hash mismatch (reference
   behavior of `law_explain`). `GET /healthz` leaves no journal
   record (real): health polls are invisible by design.
6. Record operator decisions separately. Created-example entry:
   `2026-10-03T09:15Z op:salim prepared p_9f2 (24h), publish only
   after counsel sign-off; journal archived to w42`. The format is
   the operator's; the engine never reads it.

## Expected result

- Every served call and every transport refusal has exactly one
  journal record, correlated across journald, OTLP spans, and the
  `X-Law-Call-Id` response header by the 32-hex call id (real,
  `otlp.rs`). An edge `X-Request-Id` is accepted into the record
  only if short (≤64) and alphanumeric (real, `journal.rs`).
- A timed-out computation that later finishes emits a second record
  with `MCP_CODE=OVERRUN_FINISHED` and priority `4` (real,
  `http.rs`).
- The answer bytes never depend on logging: disabling the journal
  changes no answer (real invariant, `journal.rs` header).

## Result check

- `LAW_MCP_LOG_SOCKET=<created-example test socket>` plus one call:
  the receiver gets a real datagram decodable field-by-field (real
  seam, covered by `journal.rs` tests).
- A `law_revoke_answer` call journal line contains `[REDACTED]` and
  `MCP_REDACTED=answers-secret`, never the secret (real).
- `journalctl --namespace=law-mcp -u law-mcp@8722 MCP_TOOL=law_ask
  -o json --all` (operator-policy-example unit name; field names
  real) lists only `law_ask` calls.

## Failures and diagnostics

- Journal write failure never fails the call: send fails → resend
  without bodies with `MCP_TRUNCATED=oversize`; no socket → stderr
  JSON line (real, `Journal::emit`). Silence in journald with lines
  on stderr means the socket path is wrong or journald is down.
- OTLP receiver down or slow: the call never waits; spans queue
  (256, real) then drop, and the drop counter prints to stderr once
  per hundred (real, `otlp.rs`).
- Missing `MCP_RESULT_HASH` on catalog/search answers is by
  construction (real, `reqlog.rs`): those tools carry no
  reproducibility provenance.

## Support boundaries

- Access rights to records are the host's: journald ACLs, file modes
  on the stderr capture, OTLP receiver auth. The server enforces no
  read policy on logs it already wrote.
- With HTTP journaling on (default), bodies persist — so retention,
  backup, and cleanup of journal records follow the same case-data
  policy as the case directory itself (article 08): journald
  `MaxRetentionSec`/rotation, who can read the stderr capture and
  the OTLP store, what the backups include, and how records are
  purged. Turning journaling off stops future copies; it does not
  erase records already collected.
- Integrity: records carry no signature or hash chain in code. Tamper
  evidence is the host's (journald sealing/forwarding,
  append-only storage) — an operator-policy-example layer, not an
  engine guarantee. "Verified" in this article means only: hashes in
  the answer re-execute to the same bytes via `law_explain`.
- The journal is not a backup (bodies truncate) and not the audit
  trail (replay needs the question + `replayControl`).

## Next step

- Article 11 ([Resource limits and cancellation](/operate/resource-limits-cancellation/)) for the timeout/overrun mechanics behind
  `OVERRUN_FINISHED`; article 12 ([Health and observability](/operate/health-observability/)) for OTLP span contents and the
  health endpoint.