fix(gate,propose): okf build runs a real guard; a code fence declares no structure

Two defects reported from outside by claude-code-llm-wiki (order
20260915T202332Z-228694739), both reproduced against this tree before
anything moved.

F1 -- the packaged CLI never ran the guard. corpus.measure wired an
unconditional approve-everything stub into process_inbox and 0 of 90
add_argument calls named a gate, so the one path most people use screened
nothing while pyproject.toml made the guard a mandatory runtime dependency
and the README recommended a composition the command line could not reach.

  --gate takes guard-trusted-source (default), guard-user-upload or none.
  corpus.resolve_gate is the one name->callable map, with the guard imported
  lazily so importing the package still does not pull it in; an unknown name
  RAISES rather than falling back, because a fallback reproduces the defect
  with an extra step. The gate's NAME goes into the section 9 log.md -- a
  stub is only dangerous when nothing downstream can see it -- and --gate
  none renders NOTHING WAS SCREENED.

  The default was chosen on a measurement: over the 453 concept bodies of
  the pinned reference bundle, PRESET_TRUSTED_SOURCE persists 453 of 453 and
  PRESET_USER_UPLOAD holds 1, costing that concept's whole source document.
  Neither tier waves anything through -- an invisible carrier and a CRITICAL
  finding fail secure at both. Door B's library default is UNCHANGED at
  PRESET_USER_UPLOAD: an inbox drop is an untrusted upload, an operator
  pointing this command at their own folder is not. The second tier ships as
  guard_adapter.inbox_gate_trusted_source, the three-line adapter that
  module's docstring already described, never a preset parameter.

  process_inbox(segmentations=..., gate=inbox_gate) now has a test. Before
  this, `grep -rl inbox_gate tests/` gave 1 file with 0 occurrences of
  `segment` -- the recommended composition was untested, which is how the
  defect survived.

F2 -- a fenced code block declared structure. `# Use the opus[1m] alias`
inside a ```bash fence became a level-1 ATX heading: the document was
refused entirely where the line carried [ or ] (5 of 191 pages of the
reporter's corpus), and the concept TITLE came from somebody's shell session
on 62 of 191 (32.5 %). The fix is in the proposer and never in Door B's
title rule -- that rule is right, and a heading that was never a heading is
what has to stop being proposed. propose.fenced_lines is computed once per
text and no rule reads a fenced line, including Arm D's outline RUN, which
selects from the whole line list. Backtick and tilde fences, three leading
spaces, a closing fence at least as long as its opener, and no backtick in a
backtick fence's info string -- that last one keeps a line holding only
`okf build` from silencing a document.

MEASURED ON THE BYTES, and this is the number that decides: the 43-document
reference corpus built at b6da09c (from git archive, never the editable
tree) and rebuilt at the shipped defaults differ in log.md alone, by the one
added bullet. 865 concept files on both sides, every concept byte-identical.

Found by that control and NOT caused by this work: the pinned artifact
K2-bundle-default-20260912 was written 2026-09-09, two days before ed0418f
changed title: quoting, so it differs from what HEAD produces on 42 concept
files. test_default_bundle_pin stays green because it pins the count and the
hit@8 ranks, not the bytes. Re-pinning is the operator's call.

Suite 1896 passed / 1 skipped (+27 from 1869). ruff, ruff format and mypy
--strict clean. No version bump, no tag, no push.

Report: docs/2026-09-15-f1-f2-gaten-og-kodefencen.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-16 00:19:50 +02:00
commit 332961a19c
11 changed files with 1194 additions and 10 deletions

View file

@ -0,0 +1,186 @@
# F1 and F2: the gate the CLI never ran, and the fence the proposer never saw
**Date:** 2026-09-15
**Order:** `20260915T202332Z-228694739-from-claude-code-llm-wiki`
**Base:** `b6da09c` (v0.9.0 + one unpushed commit)
Two defects, both reported from outside by `claude-code-llm-wiki` after a
rebuild spike over 20 documents through okf v0.9.0 with guard v1.4.0, and both
reproduced here against this repository's own code before a line moved. Neither
was touched from outside; the reporter's full numbers live in their own repo at
`e3107a4`, `docs/gjenoppbygging-2026-09-16.md` (private).
This document records what was measured, in the order it was measured, names
the control that decided it, and names what the measurement does NOT cover.
## 0. The reporter's premises, re-measured here first
An incoming claim is a premise, not a fact, and both of F1's load-bearing ones
were checked against this tree before anything was designed.
| Claim | Command | Result |
| --- | --- | --- |
| The stub is wired in unconditionally | read `corpus.py:250`, `:304` | confirmed; `measure()` took no gate parameter at all |
| No CLI path reaches the real guard | `grep` every `add_argument` | confirmed; 0 named a gate |
| One test file names `inbox_gate` | `grep -rl inbox_gate tests/` | **1** file, `tests/test_guard_adapter.py` |
| That file never segments | `grep -c segment` on it | **0** |
The third and fourth are the ones that explain how F1 survived: the composition
the README recommends — `process_inbox(segmentations=..., gate=inbox_gate)`
had no test anywhere in the suite.
## 1. F1: the exposure, measured before the default was chosen
The question that decides the fix is not "should the guard run" but "what does
running it cost", and that is a number, not an opinion. It was taken over the
453 concept bodies of the pinned reference bundle
(`~/corpora/okf-telling-20260829/K2-bundle-default-20260912`, 39 source
documents) — the exact granularity at which Door B gates in the segmented path.
| Preset | Persist (`warn`) | Held | Documents lost |
| --- | --- | --- | --- |
| `PRESET_TRUSTED_SOURCE` | **453 of 453** | 0 | 0 of 39 |
| `PRESET_USER_UPLOAD` | 452 of 453 | 1 (`quarantine_review`, MEDIUM under low-trust) | 1 of 39 |
One refused segment body refuses the whole file, which is Door B's stated rule,
so the held concept costs its whole source document.
**The operator chose `guard-trusted-source` as `okf build`'s default on that
table**, 2026-09-15. The reasoning is not that the stricter tier is wrong but
that the two tiers answer different questions: an inbox drop is an untrusted
upload and Door B's library default stays `PRESET_USER_UPLOAD`; an operator
pointing `okf build` at their own folder is the trusted-source case. Neither
tier waves anything through — measured against guard 1.4.0, an invisible
carrier and a CRITICAL finding are `fail_secure` at **both**.
## 2. F1: what shipped
- `guard_adapter.inbox_gate_trusted_source`, the three-line second adapter that
module's own docstring already describes. **Not** a preset parameter on
`inbox_gate` — the reporter explicitly did not ask for one, and the seam is
what the injected gate exists for.
- `corpus.GATE_NAMES` / `corpus.resolve_gate`: ONE place maps a name to a
callable, with the guard imported lazily inside the branch so importing the
package still does not pull the dependency in. **An unknown name raises**
(`gate_invalid`) rather than resolving to the stub: a fallback would
reproduce F1 with an extra step, the caller believing they had asked for the
guard while the run approved everything.
- `CorpusReport.gate` and a `**Gate**:` bullet in the section 9 `log.md`. This
is the half of the defect that is not about the stub at all. A stub is only
dangerous because nothing downstream can see it; the log already carries `N`
precisely because it is the one fact about a run the bundle cannot otherwise
recover, and which gate screened the bytes is the same class of fact.
`--gate none` renders `NOTHING WAS SCREENED` in the artifact.
- `okf build --gate` and the corpus harness's `--gate`, with the **same
default**, deliberately: a test holds the two paths byte-equal, and two
different defaults would make that equality depend on which command you ran.
- `okf project` takes no `--gate`. It owns no flag that moves a bundle's bytes.
## 3. F2: the fence, reproduced and bounded
The reporter's 12-line repro reproduces exactly, at both levels:
find_candidates(REPRO)
-> 'Tittel', 'Seksjon', 'Use the opus[1m] alias' <- three, the last fenced
okf build ... -> substantive 0/1, inbox_title_invalid 1/1
The discriminating control is theirs and it holds: with `1m` for `[1m]` the
document builds — and the concept is still filed under a line of somebody's
shell session. **The brackets are why the document is refused; the fence is why
the line was read as a heading at all**, and the second is the defect. Their
blast radius on the Claude Code documentation: **62 of 191 pages (32.5 %)**
carry `#` lines inside fences and get poisoned titles, **5 of 191** are refused
outright.
**The fix is in the proposer, never in Door B's title rule.** The title rule is
right — a title is rendered verbatim into `- [title](target)` and into
line-oriented frontmatter, so `[` and `]` are met fail-fast and never repaired.
What has to stop is proposing a heading that was never a heading.
Four details of CommonMark § 4.5 are load-bearing, and each is a way to get
this wrong in the direction that removes REAL boundaries:
- up to three leading spaces still open a fence (a code block inside a list);
- a backtick fence's info string may not contain a backtick, or a line holding
only `` `okf build` `` opens a fence and silences the rest of the document;
- a closing fence must be at least as long as its opener, or a four-backtick
block quoting a three-backtick example closes on the quoted line;
- an unclosed fence runs to the end, which is CommonMark's own rule — the
alternative reads a truncated listing as a document full of headings.
No rule reads a fenced line now, including Arm D's outline run, which selects
from the whole line list: filtering only at admission would leave a fenced
install listing deciding WHICH run wins, moving a boundary in prose it never
touched.
## 4. F2: exposure, on the bytes
| Set | Fences (``` or ~~~) |
| --- | --- |
| Pinned default bundle, 865 concept files | **0** |
| `examples/`, `tests/fixtures/`, `skills/` reaching the proposer | **0** |
A rule that can only fire INSIDE a fence cannot have moved anything this
repository has measured. That is why it lands unconditionally rather than as an
eleventh flag: it is a defect, not a default move.
## 5. The control: both changes, on the 43-document reference corpus
The exposure numbers above are predictions from a scan. This is the measurement
on the bytes, and it is the one that decides. Two full builds of the pinned
reference corpus (`~/corpora/okf-telling-20260829/K2/trinn1`, N = 43,
39 persisted / 4 coded rejections on both):
- **BASE**, commit `b6da09c`, built from `git archive` into a clean tree with
`PYTHONPATH` — never the editable checkout, which reads `src/` live and would
have measured the "before" run against the "after" code.
- **AFTER**, this work, at the shipped defaults, no flag.
diff -rq K2-base K2-rebuild-gate
-> Files K2-base/log.md and K2-rebuild-gate/log.md differ
(nothing else)
diff -r ... | grep '^[<>]'
-> * **Gate**: guard-trusted-source (llm-ingestion-guard, PRESET_TRUSTED_SOURCE). ...
(one line, the added bullet)
**865 concept files on both sides, and every concept byte-identical.** The two
changes together move exactly one line in one file, and it is the line they
were meant to add. The 453-of-453 prediction held.
## 6. An unrelated finding, found by this control
The same run says something about the pinned artifact itself, and it is NOT
caused by this work. Rebuilding the corpus at HEAD and diffing against
`~/corpora/okf-telling-20260829/K2-bundle-default-20260912` gives **43 differing
files**: 42 concept files differing ONLY in `title:` quoting
(`title: **Avvik nr. 1**` against `title: "**Avvik nr. 1**"`), plus `log.md`.
That quoting is K3-22's, from `ed0418f` (2026-09-11 11:09). Every file in the
pinned artifact was written **2026-09-09 21:38** — two days earlier, and not on
the date its directory is named for. **The pinned artifact no longer equals what
HEAD produces**, and `tests/test_default_bundle_pin.py` stays green because it
pins the concept count and the per-row hit@8 ranks, not the bytes. Neither
number moved, so nothing went red.
This is the operator's call, not this order's: re-pin the artifact at HEAD, or
leave it and say in the pin what it is a pin OF. Recorded here rather than
acted on.
## 7. What this does NOT cover
- **The trusted-source default is measured on ONE corpus, N = 1.** 453 of 453
is a fact about this reference bundle, not a property of the tier. A folder
whose documents carry findings the reference corpus does not will lose them,
and that is the gate working, not a regression.
- **F2's exposure denominator is ours, not the reporter's.** 0 of 865 says the
fix cannot have moved OUR pinned artifact. It says nothing about how many
boundaries the fix RECOVERS on a markdown corpus; that number belongs to the
reporter's 191-page corpus and has not been re-measured here.
- **`--gate` compares what a run DECLARES, not what a bundle contains.** A
`log.md` naming `guard-trusted-source` is this library's statement about the
run that produced the bundle. A consumer who did not run it takes that on the
same trust as `N`.
- **The reporter's `--unit-fold` finding is untouched, as they asked.** Their
boundary recall was 42/799 with it on and 792/799 with it off; the default is
tuned for tender documents, it is documented, and the flag works.