Skip to content
19 changes: 19 additions & 0 deletions docs/guide/determinism.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@ factlog enforces freshness through two distinct mechanisms:

These two levels are complementary: the hook closes the deterministic gap; the SKILL discipline covers the narration layer where engineering enforcement is not possible.

> **An existing KB needs one `/factlog check` first.** In earlier versions the
> hook could not read Claude Code's real payload, so this deny never fired. From
> this fix on, a KB that already has `facts/accepted.dl` or `facts/query.dl`
> while `facts/logic_report.txt` is missing or older must be refreshed with
> `/factlog check` before its engine inputs can be edited.

The hook denies for **one more reason** besides staleness. It reads the target
path out of the tool payload Claude Code sends; if the call is a `Write`/`Edit`
but the payload shape has changed such that no path can be read — so the write
cannot be shown *not* to target an engine input — the hook denies rather than
letting it through. Only in that situation can you skip the check with
`FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1`. As the name says, it exempts the
unreadable-payload branch and nothing else — it does **not** release the
staleness deny. It is not something the model can do from inside the session: a hook
inherits the Claude Code process environment, so **a human** has to set it there
— in the `env` block of `settings.json`, or exported before launching Claude
Code — and then start a new session. Please also report the payload shape
upstream.

### Scale & performance

**You don't need to empty the KB for performance.** The logic-check cost depends
Expand Down
19 changes: 19 additions & 0 deletions docs/guide/determinism.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@ factlog는 두 가지 서로 다른 메커니즘으로 신선도(freshness)를
이 두 단계는 상호 보완적입니다. 훅은 결정론적 빈틈을 메우고, SKILL 규율은
엔지니어링적 강제가 불가능한 서술(narration) 계층을 담당합니다.

> **기존 KB 를 쓰고 있었다면 한 번은 `/factlog check` 가 필요합니다.** 이전
> 버전에서는 훅이 Claude Code 의 실제 페이로드를 읽지 못해 이 거부가 한 번도
> 발동하지 않았습니다. 이번 수정 이후로는, `facts/accepted.dl` 이나
> `facts/query.dl` 이 이미 있는데 `facts/logic_report.txt` 가 없거나 낡은 KB
> 에서는 `/factlog check` 로 리포트를 먼저 갱신해야 엔진 입력을 편집할 수
> 있습니다.

훅은 신선도 외에 **한 가지 사유로 더** 거부합니다. 훅은 Claude Code 가 보내는
도구 페이로드에서 대상 경로를 읽어 판정하는데, `Write`/`Edit` 호출이면서 페이로드
구조가 바뀌어 그 경로를 전혀 읽을 수 없으면 — 즉 그 쓰기가 엔진 입력을 겨냥하지
*않는다*는 것을 보일 수 없으면 — 통과시키지 않고 거부합니다. 이 상황에서만
`FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1` 로 해당 검사를 건너뛸 수 있습니다. 이름
그대로 **읽을 수 없는 페이로드** 한 갈래만 면제하며, 이 변수로 **신선도 거부는
풀리지 않습니다.** 이건 세션 안에서 모델이 스스로 할 수 있는 일이 아닙니다
— 훅은 Claude Code 프로세스의 환경을 물려받으므로, **사람이** Claude Code 환경에
설정해야 합니다. `settings.json` 의 `env` 블록에 넣거나 Claude Code 를 띄우기 전에
export 한 뒤, 새 세션을 시작하세요. 그리고 읽지 못한 페이로드 형태를 업스트림에
알려 주세요.

### 규모와 성능

**성능 때문에 KB를 비울 필요는 없습니다.** 로직 체크 비용은 총 사실 수보다
Expand Down
234 changes: 221 additions & 13 deletions hooks/gate_check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
# DENY (exit 2) otherwise, i.e. TARGET is an engine input AND NOT bootstrap
# AND (report absent OR report stale).
#
# TARGET itself is read from the hook payload; when it cannot be read at all
# the predicate above is undefined, and the narrow fail-closed rule described
# under "fail-closed branches" below decides instead.
#
# This predicate is falsifiable in both directions:
# - Bootstrap is allowed: creating facts/query.dl in a freshly `factlog init`
# KB (no logic_report.txt, no pre-existing query.dl) returns exit 0.
Expand All @@ -48,10 +52,53 @@
# That degrade is made OBSERVABLE: when Python is available but the resolver
# returns empty (package import failure), a one-line stderr note is emitted so
# the silent permissive fallback is visible to an operator (see below).
# The only true fail-closed here is the python-availability check below (which
# DENYs when no usable Python 3.11+ is present, since the predicate cannot then
# be evaluated). Target-path extraction failures for engine-input-shaped payloads
# likewise deny.
# THREE branches DENY without evaluating the freshness predicate:
# 1. The python-availability check below DENYs when no usable Python 3.11+ is
# present, since the predicate cannot then be evaluated. Escape hatch:
# FACTLOG_PYTHON (point it at a usable interpreter).
# 2. Target-path extraction DENYs only in the narrow case where the payload
# carries a `tool_input` JSON OBJECT, `tool_name` is one of the write-class
# tools this hook is registered for, and NO usable path can be read from
# either `tool_input` or the top level. That combination means the payload
# schema drifted out from under the extractor while a write was in flight,
# so the predicate cannot be evaluated for a write that may well target an
# engine input. Escape hatch: FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1,
# which only a human can set (see the deny message below). The name states
# the one branch it releases: it must NOT grow into a switch that also
# releases branch 1 or 3, or the freshness predicate itself.
# 3. _mtime() DENYs when `stat` cannot report a file's mtime. That branch has
# NO escape hatch; it is only reachable for a file this script has already
# seen pass `-f`, i.e. a race or an unreadable filesystem.
# Only branches 1 and 2 carry an escape hatch, and only branches 1 and 2 are
# about the payload/interpreter layer.
#
# Everything else fails OPEN (exit 0). Three of those branches are a check the
# gate SKIPPED because it could not read the call, and each emits a one-line
# stderr note — #323 was filed partly because such skips were silent and left
# the operator believing the gate was running (same rule as the resolver
# degrade, #244):
# - an unparseable payload;
# - an INCOMPLETE record from the extractor — the interpreter died, or wrote
# something that is not three NUL-terminated fields. Note this lands the
# OPPOSITE way from branch 2's reasoning, deliberately: with no record at
# all we cannot tell that the call is even a write, so denying would block
# every tool call in the session on evidence we do not have. Branch 2 denies
# because we positively know it IS a write we cannot evaluate.
# - a write-class `tool_name` whose `tool_input` is absent or is not a JSON
# object. The JSON parses and the record is complete, so the two notes above
# do not cover it, yet the gate is just as blind. Under the current schema
# Write/Edit always send a `tool_input` object, so this fires zero times in
# normal operation.
# Two branches stay silent, and neither is a skipped check:
# - a `tool_name` outside the write-class list (a Read is not this gate's
# business);
# - a payload with no `tool_name` at all.
# One more permissive degrade exists further down: _canon() falls back to the
# raw path string if the canonicaliser cannot run, which can only make a match
# LESS likely (i.e. more permissive). It is silent, and predates #323.
#
# A gate protecting two files in one KB must not become a global Write/Edit
# outage, so anything short of positive knowledge falls open.

set -euo pipefail

Expand Down Expand Up @@ -98,17 +145,173 @@ else
echo "[factlog GATE] note: factlog config resolver unavailable; freshness gate falling back to \${FACTLOG_ROOT:-cwd} (KB_ROOT=$KB_ROOT)" >&2
fi

# Extract the tool target from the hook payload.
# Claude Code sends the tool input as JSON on stdin.
# The relevant field is "file_path" for Write and "file_path" for Edit.
target_path="$(printf '%s' "$payload" | "${PYTHON_RUNNER[@]}" -c \
"import json,sys; d=json.load(sys.stdin); print(d.get('file_path','') or d.get('path',''))" \
2>/dev/null || true)"
# Extract the tool target from the hook payload (issue #323).
#
# Claude Code sends an ENVELOPE on stdin, not the bare tool input:
# {"session_id":..,"cwd":..,"hook_event_name":"PreToolUse","tool_name":"Write",
# "tool_input":{"file_path":..,"content":..},"tool_use_id":..}
# so the target path lives under `tool_input`, which the previous extractor
# never looked at — every real payload fell through to the fail-open branch.
#
# Key precedence: `tool_input` first, then the TOP LEVEL as a fallback. No real
# Claude Code payload puts `file_path` at the top level; that fallback exists to
# keep the flat fixture shape used by tests/test_gate_check.sh working.
# `notebook_path` is defensive only: hooks.json registers the matcher "Write|Edit",
# which Claude Code compares by exact tool name, so NotebookEdit (and MultiEdit)
# never reach this hook. It costs nothing and covers a user who widens the
# matcher in their own settings.json.
#
# The extractor pulls each field under its OWN try/except and always writes
# exactly three NUL-terminated fields, so a failure in one field cannot truncate
# the others. NUL is the separator because a path may legally contain a newline,
# and a newline separator misreads such a path (or, with a leading newline,
# denies a perfectly legal write). A JSON string CAN itself contain a NUL, which
# shifts the remaining fields; that is a wash here — a truncated engine-input
# path still matches the engine input (deny), and a NUL-prefixed one is a path
# the OS cannot write to anyway.
#
# Three fields, not two: `tool_name` is carried raw so the write-class decision
# stays a readable `case` in shell right next to hooks.json's matcher, and so
# the deny message can name the offending tool. Collapsing the tool name and the
# tool_input shape into one field is possible, but it moves that decision into
# the embedded Python where it is harder to audit.
#
# Fields are read straight off a pipe: bash command substitution silently drops
# NUL bytes, so `$(...)` cannot capture this. A shell that cannot parse process
# substitution would raise a SYNTAX error, and bash exits 2 on one — which
# PreToolUse reads as DENY, not as a fail-open. That is unreachable in practice
# (bash falls back to FIFOs where /dev/fd is missing, and macOS, Linux and Git
# Bash all support it), so the code is left as is; it is noted only so the
# failure direction is not misdescribed.
GATE_EXTRACT_PY="
import json, sys
PATH_KEYS = (\"file_path\", \"path\", \"notebook_path\")
UNPARSED = object()
try:
payload = json.load(sys.stdin)
except Exception:
payload = UNPARSED
try:
name = payload.get(\"tool_name\")
tool_name = name if isinstance(name, str) else \"\"
except Exception:
tool_name = \"\"
try:
if payload is UNPARSED:
input_kind = \"unparsed\"
elif not isinstance(payload, dict) or \"tool_input\" not in payload:
input_kind = \"absent\"
elif isinstance(payload[\"tool_input\"], dict):
input_kind = \"object\"
else:
input_kind = \"other\"
except Exception:
input_kind = \"absent\"
try:
target = \"\"
nested = payload.get(\"tool_input\") if isinstance(payload, dict) else None
for source in (nested, payload):
if not isinstance(source, dict):
continue
for key in PATH_KEYS:
value = source.get(key)
if isinstance(value, str) and value:
target = value
break
if target:
break
except Exception:
target = \"\"
sys.stdout.write(tool_name + \"\\0\" + target + \"\\0\" + input_kind + \"\\0\")
"

tool_name=""
target_path=""
tool_input_kind="absent"
if ! { IFS= read -r -d '' tool_name \
&& IFS= read -r -d '' target_path \
&& IFS= read -r -d '' tool_input_kind; } \
< <(printf '%s' "$payload" | "${PYTHON_RUNNER[@]}" -c "$GATE_EXTRACT_PY" 2>/dev/null); then
# The extractor produced no complete record (the interpreter died, or wrote
# something that is not three NUL-terminated fields). Treat it as an
# unparseable payload: fail OPEN, the pre-#323 behaviour. See the header for
# why this lands opposite to the narrow fail-closed branch below.
tool_name=""
target_path=""
tool_input_kind="incomplete"
fi

# Write-class tool names, matched EXACTLY. A user may register this hook with a
# broader matcher in their own settings.json, so the deny branch below must key
# off the tool name rather than assume only Write/Edit arrive. Anything outside
# this list — including an absent tool_name, which is what the flat test
# fixtures send — is not a write we can reason about, and falls open.
_is_write_tool() {
case "$1" in
Write|Edit) return 0 ;;
*) return 1 ;;
esac
}

# If we could not extract a path, allow the tool to proceed (fail open).
# An empty/unparseable payload cannot target an engine input, so allowing here
# does not weaken the engine-input guard below.
if [ -z "$target_path" ]; then
if [ "$tool_input_kind" = "object" ] && _is_write_tool "$tool_name"; then
# Narrow fail-closed: a write-class call carrying a structured tool_input
# from which no path key could be read. Usually that means the payload
# schema drifted; a present-but-empty file_path lands here too, and is
# denied regardless of report freshness because this branch runs before the
# engine-input match. Either way we cannot tell whether it targets an
# engine input.
#
# The escape hatch is read from THIS process's environment, which a hook
# inherits from the Claude Code process. A model cannot set it for its own
# tool calls from inside the session, so the deny message is addressed to a
# human operator and says where to set it.
if [ "${FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD:-}" = "1" ]; then
echo "[factlog GATE] note: could not read a target path from the $tool_name payload;" >&2
echo " FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 is set, so the write is allowed unchecked." >&2
exit 0
fi
echo "[factlog GATE] DENIED: could not read a target path from the $tool_name tool payload." >&2
echo " Either the tool call carried an empty path — in which case the write itself is" >&2
echo " malformed and nothing needs configuring — or the hook payload schema changed, so" >&2
echo " the freshness predicate cannot be evaluated and this write cannot be shown to" >&2
echo " miss facts/accepted.dl or facts/query.dl." >&2
echo " If it is the schema: this cannot be worked around from inside the session. Ask" >&2
echo " the operator to set FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 in the Claude Code" >&2
echo " environment (the \"env\" block of settings.json, or export it before launching" >&2
echo " Claude Code) and start a new session. That bypasses only this check — the" >&2
echo " freshness deny and the Python-availability deny still apply." >&2
echo " Please also report the payload shape upstream." >&2
exit 2
fi
# Fail OPEN for everything else. Three of those branches mean the gate SKIPPED
# a check because it could not read the call, which is exactly the "operator
# believes the gate is running" half of #323, so each emits one stderr line
# the way the resolver degrade does (#244):
# - the payload was not parseable JSON;
# - the extractor returned no complete record;
# - the call IS a write-class tool, but its `tool_input` is missing or is not
# a JSON object (a renamed key, a changed nesting). The JSON parses and the
# record is complete here, so neither note above covers it — and under the
# current schema Write/Edit always send a `tool_input` object, so this note
# fires zero times in normal operation.
# Only two branches stay silent, and neither is a skipped check: a tool outside
# the write-class list (a Read is not this gate's business) and a payload with
# no `tool_name` at all. Those are ordinary traffic; a note on each would be
# noise on every tool call.
case "$tool_input_kind" in
unparsed)
echo "[factlog GATE] note: hook payload was not parseable JSON; the freshness gate was skipped for this call (fail-open)." >&2
;;
incomplete)
echo "[factlog GATE] note: the payload extractor returned no complete record; the freshness gate was skipped for this call (fail-open)." >&2
;;
absent|other)
if _is_write_tool "$tool_name"; then
echo "[factlog GATE] note: the $tool_name payload carried no tool_input object, so no target path could be read; the freshness gate was skipped for this call (fail-open)." >&2
fi
;;
esac
exit 0
fi

Expand Down Expand Up @@ -163,6 +366,11 @@ if [ ! -f "$report" ]; then
exit 2
fi

# Fail-closed branch 3 (see header): if stat cannot report an mtime we cannot
# compare freshness, so we deny. The `exit 2` inside this command substitution
# ends the whole script under `set -e`. There is no escape hatch here — the file
# has already passed `-f` a line earlier, so reaching this needs a race or an
# unreadable filesystem, not a schema change someone has to work around.
_mtime() {
local value
if value="$(stat -c %Y "$1" 2>/dev/null)" || value="$(stat -f %m "$1" 2>/dev/null)"; then
Expand Down
Loading