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

@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Fixed
- **`okf build` now runs a real guard, and the bundle says which one (F1).**
From the day the command was packaged until 2026-09-15, `corpus.measure`
wired an unconditional approve-everything stub into `process_inbox` and no
`add_argument` call anywhere in the package named a gate -- so the only 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. Reported from outside by `claude-code-llm-wiki`
and reproduced here before anything moved.
**`--gate` takes `guard-trusted-source` (the new default),
`guard-user-upload` or `none`**, and the name is written into the bundle's
section 9 `log.md` either way, so a consumer holding a bundle can tell a
screened one from an unscreened one. An unknown name is refused rather than
resolved to the stub: falling back would reproduce the defect with an extra
step. `okf project` owns no flag that moves a bundle's bytes and takes the
default; the corpus harness carries the same flag and the same default,
because a test holds the two paths byte-equal.
**The default was chosen on a measurement, not on caution.** Over the 453
concept bodies of the pinned reference bundle, `PRESET_TRUSTED_SOURCE`
returns the persist disposition on **453 of 453** while `PRESET_USER_UPLOAD`
holds **1**, taking one of the 39 source documents out. Neither tier waves
anything through: an invisible carrier and a CRITICAL finding fail secure at
both, measured against guard 1.4.0. Door B's own library default is
unchanged at `PRESET_USER_UPLOAD` -- an inbox drop is an untrusted upload,
an operator pointing `okf build` at their own folder is not. The second tier
ships as `guard_adapter.inbox_gate_trusted_source`, the three-line adapter
that module's own docstring describes, rather than as a preset parameter.
**The composition the README recommends is now tested.** Before this change
`grep -rl inbox_gate tests/` gave ONE file with 0 occurrences of `segment`,
while the nine files passing `segmentation=` all injected a local warn-stub:
no test ran a real guard verdict and a segmentation plan in the same call.
- **A fenced code block no longer declares structure (F2).** The proposer read
every line of the extracted text with the same grammars, so `# Use the
opus[1m] alias` inside a ```bash fence became a level-1 ATX heading. Two
effects, and the smaller one was the visible one: the document was REFUSED
entirely when the line carried `[` or `]` (Door B validates a title fail-fast
and never repairs one) -- 5 of 191 pages of the reporter's corpus -- and the
concept TITLE was silently taken from somebody's shell session everywhere
else, on **62 of 191 pages (32.5 %)**.
No rule in `find_candidates` reads a fenced line now: not ATX, not the
numbered grammar, not a table row, not a bold title, and not Arm D's outline
run, which selects from the whole line list and would otherwise let a fenced
install listing decide which run wins. Backtick and tilde fences, up to three
leading spaces, a closing fence at least as long as its opener, and
CommonMark's rule that a backtick fence's info string may not contain a
backtick -- that last one is what keeps a line holding only `` `okf build` ``
from silencing the rest of a document.
**It lands unconditionally rather than behind a flag, and the exposure is
measured on the bytes**: 0 of 865 concept files in the pinned default bundle
and 0 of the shipped fixtures and goldens that reach the proposer carry a
fence of either kind, so a rule that can only fire INSIDE a fence cannot have
moved anything this repository has measured. It is a defect, not a default
move.
### Added ### Added
- **`okf quality <bundle> --fasit <json>` -- boundary recall against the - **`okf quality <bundle> --fasit <json>` -- boundary recall against the

View file

@ -205,6 +205,69 @@ one boundary rule:
proven by **content identity** — an occupied target name is re-used only proven by **content identity** — an occupied target name is re-used only
when the bytes there are already identical, never overwritten otherwise. when the bytes there are already identical, never overwritten otherwise.
**`okf build` RUNS a real guard and NAMES it in the bundle (F1, 2026-09-15).**
From the day the command was packaged until then, `corpus.measure` wired an
unconditional approve-everything stub into `process_inbox` and **0 of 90**
`add_argument` calls in the package 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. Reported from outside by `claude-code-llm-wiki`, reproduced here first.
`--gate` takes `guard-trusted-source` (default), `guard-user-upload` or `none`,
`corpus.resolve_gate` is the ONE name→callable map (guard imported lazily, so
importing the package still does not pull the dependency in), and an unknown
name RAISES (`gate_invalid`) rather than falling back — a fallback reproduces
the defect with an extra step. **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 (1 of 39) — and neither tier waves anything
through, an invisible carrier and a CRITICAL finding are `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 is
`guard_adapter.inbox_gate_trusted_source`, the three-line adapter that module's
docstring already described — never a preset parameter. **The gate's NAME is
written into the §9 `log.md`**, because a stub is only dangerous when nothing
downstream can see it; `--gate none` renders `NOTHING WAS SCREENED`. The corpus
harness carries the same flag and the SAME default (a test holds the two paths
byte-equal); `okf project` takes none, owning no flag that moves bytes. The
composition `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`, which is how the defect survived.
**A FENCED CODE BLOCK DECLARES NO STRUCTURE (F2, 2026-09-15).** The proposer
read every line with the same grammars, so `# Use the opus[1m] alias` inside a
```` ```bash ```` fence became a level-1 ATX heading. Two effects and the
SMALLER one was visible: the document was REFUSED entirely when the line carried
`[` or `]` (**5 of 191** pages of the reporter's corpus, `inbox_title_invalid`),
and the concept TITLE was silently taken 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: not `_ATX`, not the numbered grammar, not a table
row, not `--bold-title`, and not Arm D's outline RUN, which selects from the
whole line list (filtering only at admission would let a fenced install listing
decide which run wins). Four CommonMark § 4.5 details are load-bearing, each a
way to remove REAL boundaries: three leading spaces still open a fence; a
backtick fence's info string may not contain a backtick (or a line holding only
`` `okf build` `` silences the document); a closing fence must be at least as
long as its opener; an unclosed fence runs to the end. **It lands
unconditionally, not as an eleventh flag, and the exposure is measured on the
bytes:** **0 of 865** concept files in the pinned default bundle and **0** of the
shipped fixtures and goldens reaching the proposer carry a fence of either kind,
so a rule that can only fire INSIDE one cannot have moved anything measured
here. It is a defect, not a default move.
**BOTH CHANGES TOGETHER MOVE ONE LINE, AND IT IS MEASURED ON THE BYTES:** 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 added `**Gate**:` bullet -- **865 concept files on both sides, every
concept byte-identical**. The same run found something this work did NOT cause:
the pinned artifact `K2-bundle-default-20260912` was written **2026-09-09
21:38**, two days before `ed0418f` (K3-22) changed `title:` quoting, so it
differs from what HEAD produces on **42 concept files** -- and
`tests/test_default_bundle_pin.py` stays green because it pins the count and
the hit@8 ranks, not the bytes. Re-pinning it is the OPERATOR's. Report:
`docs/2026-09-15-f1-f2-gaten-og-kodefencen.md`.
**Boundary rule (non-negotiable, zero overlap):** `llm-ingestion-guard` **Boundary rule (non-negotiable, zero overlap):** `llm-ingestion-guard`
(pinned `>=1.2,<2.0`) answers "is this content safe to persist?" — (pinned `>=1.2,<2.0`) answers "is this content safe to persist?" —
scan/sanitize/quarantine/fail-secure/provenance-stamp. This library is scan/sanitize/quarantine/fail-secure/provenance-stamp. This library is

View file

@ -889,6 +889,29 @@ No security functionality is reimplemented here.
and (at Door C) a concept the gate returned no verdict for. What it cannot and (at Door C) a concept the gate returned no verdict for. What it cannot
do is check that your adapter is a real guard: a permissive stub approves do is check that your adapter is a real guard: a permissive stub approves
everything, and the flow will believe it. everything, and the flow will believe it.
- **`okf build` runs a real guard by default, and names it in the bundle.**
`--gate` takes `guard-trusted-source` (the default), `guard-user-upload` or
`none`, and the name is written into the bundle's `log.md` either way, so a
consumer holding a bundle can tell a screened one from an unscreened one
without asking. `okf project` has no such flag and takes the default.
That paragraph is new, and the sentence above it was true of our own command
until 2026-09-15: `okf build` injected a permissive stub and no argument
anywhere in the package named a gate, so the only path most people use
screened nothing while the guard sat in `pyproject.toml` as a mandatory
runtime dependency. It was reported from outside, reproduced here, and the
cost of each tier was measured before the default was chosen — over the 453
concept bodies of the pinned reference bundle, `guard-trusted-source` returns
the persist disposition on 453 of 453 and `guard-user-upload` holds 1 of
them. Neither waves anything through: an invisible carrier and a CRITICAL
finding fail secure at both.
<!-- cli-default-gate: guard-trusted-source -->
- **`--gate none` is still reachable, by name.** The corpus harness reproduces
published numbers with it, and a caller measuring segmentation alone has a
legitimate reason to take the gate out of the picture. What changed is that
asking for it is an act, and the log says `NOTHING WAS SCREENED`.
`llm_ingestion_okf.guard_adapter` is the adapter over the real guard, and the `llm_ingestion_okf.guard_adapter` is the adapter over the real guard, and the
only module here that imports it — importing the package itself does not: only module here that imports it — importing the package itself does not:

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.

View file

@ -74,7 +74,14 @@ from collections.abc import Mapping, Sequence
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from .corpus import LOG_NAME, CorpusReport, load_plans, measure from .corpus import (
GATE_GUARD_TRUSTED_SOURCE,
GATE_NAMES,
LOG_NAME,
CorpusReport,
load_plans,
measure,
)
from .errors import IngestError from .errors import IngestError
from .extract import declared_identity from .extract import declared_identity
from .inbox import validate_concept_frontmatter, walk_inbox from .inbox import validate_concept_frontmatter, walk_inbox
@ -198,6 +205,32 @@ DEFAULT_CLOSE_SPAN_GAPS = True
#: every consumer and not to this one. Turning it on is its own decision with #: every consumer and not to this one. Turning it on is its own decision with
#: its own denominator, and this line is not it. #: its own denominator, and this line is not it.
DEFAULT_SHELL_PARENT = False DEFAULT_SHELL_PARENT = False
#: `okf build`'s persist gate, by name. Set by the operator 2026-09-15, on a
#: measurement and not on caution.
#:
#: Until then `corpus.measure` wired an unconditional approve-everything stub
#: into every run and no argument anywhere named a gate, so the one path people
#: use screened nothing while the guard sat in `pyproject.toml` as a mandatory
#: runtime dependency and the README recommended a composition that was not
#: reachable from the command line. Reported from outside (F1,
#: `claude-code-llm-wiki`, 2026-09-15) and reproduced here before anything moved.
#:
#: `guard-trusted-source` rather than Door B's own `PRESET_USER_UPLOAD`, because
#: the two are answering different questions -- an inbox drop is an untrusted
#: upload, an operator pointing this command at their own folder is not -- and
#: because the cost of each is measured rather than assumed: over the 453
#: concept bodies of the pinned default bundle, the trusted tier returns the
#: persist disposition on 453 of 453 while the upload tier holds 1, taking one
#: of the 39 source documents out of the bundle. Neither tier waves anything
#: through: an invisible carrier and a CRITICAL finding fail secure at both.
#:
#: This is the ONE default in this module that is not about segmentation, and
#: it is the only one whose opt-out (`--gate none`) turns a screen OFF rather
#: than a rule. The stub stays reachable by name because the corpus harness
#: reproduces published numbers with it; what changed is that reaching it is an
#: act, and `log.md` records which gate screened the bundle either way.
DEFAULT_GATE = GATE_GUARD_TRUSTED_SOURCE
#: Round 9. Clause 1 asked whether a title ENDED in a number, which is a #: Round 9. Clause 1 asked whether a title ENDED in a number, which is a
#: question about the number rather than about the title: a drawing's dimension #: question about the number rather than about the title: a drawing's dimension
#: chain, a P&ID's schematic labels, a door schedule and a borehole log's #: chain, a P&ID's schematic labels, a door schedule and a borehole log's
@ -461,6 +494,7 @@ def build(
pdf_outline: bool = DEFAULT_PDF_OUTLINE, pdf_outline: bool = DEFAULT_PDF_OUTLINE,
shell_parent: bool = DEFAULT_SHELL_PARENT, shell_parent: bool = DEFAULT_SHELL_PARENT,
frontmatter: Mapping[str, str] | None = None, frontmatter: Mapping[str, str] | None = None,
gate: str = DEFAULT_GATE,
) -> CorpusReport: ) -> CorpusReport:
"""Folder in, bundle out. The whole command, minus argument parsing. """Folder in, bundle out. The whole command, minus argument parsing.
@ -500,6 +534,7 @@ def build(
heading_reserve=reserve, heading_reserve=reserve,
ocr=ocr, ocr=ocr,
concept_frontmatter_values=concept_values, concept_frontmatter_values=concept_values,
gate=gate,
) )
_write_log(bundle, report, profile=STRUCTURED_V1) _write_log(bundle, report, profile=STRUCTURED_V1)
return report return report
@ -559,6 +594,7 @@ def build(
heading_reserve=reserve, heading_reserve=reserve,
ocr=ocr, ocr=ocr,
concept_frontmatter_values=concept_values, concept_frontmatter_values=concept_values,
gate=gate,
) )
_write_log(bundle, report, profile=SEGMENTED_OKF_V0_2) _write_log(bundle, report, profile=SEGMENTED_OKF_V0_2)
return report return report
@ -861,6 +897,19 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
"pre-2026-09-11 spans byte for byte" "pre-2026-09-11 spans byte for byte"
), ),
) )
build_parser.add_argument(
"--gate",
choices=GATE_NAMES,
default=DEFAULT_GATE,
help=(
"the persist gate every concept body passes before it is written. "
"`guard-trusted-source` (default) and `guard-user-upload` are the "
"real llm-ingestion-guard at its two presets; `none` is a "
"permissive stub that approves EVERYTHING and screens nothing. "
"The name is written into the bundle's log.md either way, so a "
"consumer can tell a screened bundle from an unscreened one"
),
)
build_parser.add_argument( build_parser.add_argument(
"--shell-parent", "--shell-parent",
action="store_true", action="store_true",
@ -1034,6 +1083,7 @@ def main(argv: list[str] | None = None) -> int:
ocr=args.ocr, ocr=args.ocr,
pdf_outline=args.pdf_outline, pdf_outline=args.pdf_outline,
shell_parent=args.shell_parent, shell_parent=args.shell_parent,
gate=args.gate,
frontmatter=frontmatter_from_flags(args.frontmatter or ()), frontmatter=frontmatter_from_flags(args.frontmatter or ()),
) )
except (IngestError, OSError, ValueError) as exc: except (IngestError, OSError, ValueError) as exc:

View file

@ -43,6 +43,7 @@ from pathlib import Path
from .errors import IngestError from .errors import IngestError
from .extract import extract_text from .extract import extract_text
from .inbox import ( from .inbox import (
Gate,
GateDecision, GateDecision,
InboxResult, InboxResult,
process_inbox, process_inbox,
@ -53,6 +54,10 @@ from .profiles import SEGMENTED_OKF_V0_2, STRUCTURED_V1, BundleProfile
from .segmentation import SegmentationPlan, parse_segmentation_plan from .segmentation import SegmentationPlan, parse_segmentation_plan
__all__ = [ __all__ = [
"GATE_GUARD_TRUSTED_SOURCE",
"GATE_GUARD_USER_UPLOAD",
"GATE_NAMES",
"GATE_NONE",
"CorpusReport", "CorpusReport",
"converter_identity", "converter_identity",
"load_plans", "load_plans",
@ -60,6 +65,7 @@ __all__ = [
"main", "main",
"measure", "measure",
"replace", "replace",
"resolve_gate",
"unaccounted_names", "unaccounted_names",
] ]
@ -72,6 +78,69 @@ LOG_NAME = "log.md"
LOG_TITLE = "Corpus run history" LOG_TITLE = "Corpus run history"
#: The permissive stub. It approves EVERYTHING, and it is named rather than
#: anonymous because that is the whole lesson of F1: from the day `okf build`
#: was packaged until 2026-09-15 this function was wired unconditionally into
#: every run, so the only path anyone used screened nothing while the guard sat
#: in `pyproject.toml` as a mandatory runtime dependency. A consumer had no way
#: to see it. It survives because the corpus harness reproduces published
#: numbers with it and a caller measuring segmentation alone has a legitimate
#: reason to take the gate out of the picture -- but reaching it is now an act,
#: and the bundle's own log says so.
GATE_NONE = "none"
#: `okf build`'s default, set by the operator 2026-09-15 on a measurement:
#: over the 453 concept bodies of the pinned default bundle, this tier returns
#: the persist disposition on 453 of 453, so the move costs the pinned artifact
#: nothing while the scanner actually runs. Pointing the command at your own
#: folder is the trusted-source case.
GATE_GUARD_TRUSTED_SOURCE = "guard-trusted-source"
#: Door B's own library default, exposed for a folder that IS an untrusted
#: drop. Stricter, and the cost is measured rather than implied: on the same
#: 453 bodies it holds 1, which takes one of the 39 source documents out.
GATE_GUARD_USER_UPLOAD = "guard-user-upload"
#: Every gate a run may declare. A name outside this tuple is refused rather
#: than resolved to the stub: falling back would reproduce F1 with an extra
#: step -- the caller believes they asked for the guard, and the run approves
#: everything.
GATE_NAMES = (GATE_GUARD_TRUSTED_SOURCE, GATE_GUARD_USER_UPLOAD, GATE_NONE)
_GATE_DESCRIPTIONS = {
GATE_GUARD_TRUSTED_SOURCE: "llm-ingestion-guard, PRESET_TRUSTED_SOURCE",
GATE_GUARD_USER_UPLOAD: "llm-ingestion-guard, PRESET_USER_UPLOAD",
GATE_NONE: "permissive stub -- NOTHING WAS SCREENED",
}
def _gate(text: str) -> GateDecision:
return GateDecision(sanitized_text=text, disposition="warn")
def resolve_gate(name: str) -> Gate:
"""One place maps a gate NAME to the callable, and it is this one.
The guard is imported lazily, inside the branch that needs it, because
`guard_adapter` is the only module in this library allowed to import the
dependency and importing the package must not pull it in.
"""
if name == GATE_NONE:
return _gate
if name == GATE_GUARD_TRUSTED_SOURCE:
from .guard_adapter import inbox_gate_trusted_source
return inbox_gate_trusted_source
if name == GATE_GUARD_USER_UPLOAD:
from .guard_adapter import inbox_gate
return inbox_gate
raise IngestError(
f"unknown gate {name!r}; one of {', '.join(GATE_NAMES)} -- a gate name that "
"resolved to the permissive stub would approve everything while the caller "
"believed they had asked for the guard",
code="gate_invalid",
)
def is_degenerate(text: str) -> bool: def is_degenerate(text: str) -> bool:
"""Zero characters after stripping whitespace. The whole rule, in one line. """Zero characters after stripping whitespace. The whole rule, in one line.
@ -127,6 +196,10 @@ class CorpusReport:
converter_version: str converter_version: str
codes: tuple[tuple[str, int], ...] codes: tuple[tuple[str, int], ...]
unaccounted: tuple[str, ...] unaccounted: tuple[str, ...]
#: The gate that screened this run, by NAME. Defaulted LAST and to the
#: stub so every existing construction site stays source-compatible; the
#: two commands that build bundles both pass it explicitly.
gate: str = GATE_NONE
@property @property
def merged(self) -> int: def merged(self) -> int:
@ -215,6 +288,9 @@ class CorpusReport:
f"{self.merged} + {self.rejected} = {self.merged + self.rejected}; " f"{self.merged} + {self.rejected} = {self.merged + self.rejected}; "
f"N = {self.n}. The run exits non-zero when these differ.", f"N = {self.n}. The run exits non-zero when these differ.",
f"* **Converter**: {self.converter_path}, version {self.converter_version}.", f"* **Converter**: {self.converter_path}, version {self.converter_version}.",
f"* **Gate**: {self.gate} "
f"({_GATE_DESCRIPTIONS.get(self.gate, 'unrecognised gate name')}). "
"Every persisted byte of this bundle passed it.",
] ]
if self.unaccounted: if self.unaccounted:
lines.append("* **Unaccounted**: " + ", ".join(self.unaccounted) + " — K1b FAILED.") lines.append("* **Unaccounted**: " + ", ".join(self.unaccounted) + " — K1b FAILED.")
@ -247,10 +323,6 @@ def load_plans(plans_dir: Path) -> dict[str, SegmentationPlan]:
} }
def _gate(text: str) -> GateDecision:
return GateDecision(sanitized_text=text, disposition="warn")
def _split_merges(corpus: Path, result: InboxResult) -> tuple[int, int]: def _split_merges(corpus: Path, result: InboxResult) -> tuple[int, int]:
"""Merged files split into substantive and degenerate, by the stated rule. """Merged files split into substantive and degenerate, by the stated rule.
@ -284,6 +356,7 @@ def measure(
heading_reserve: Callable[[str], bool] | None = None, heading_reserve: Callable[[str], bool] | None = None,
ocr: bool = False, ocr: bool = False,
concept_frontmatter_values: Mapping[str, str] | None = None, concept_frontmatter_values: Mapping[str, str] | None = None,
gate: str = GATE_NONE,
) -> CorpusReport: ) -> CorpusReport:
"""Run the corpus through the door and count what happened. """Run the corpus through the door and count what happened.
@ -301,7 +374,7 @@ def measure(
bundle, bundle,
ingested_at, ingested_at,
okf_type="reference", okf_type="reference",
gate=_gate, gate=resolve_gate(gate),
profile=profile, profile=profile,
root_frontmatter_values=root_frontmatter_values, root_frontmatter_values=root_frontmatter_values,
segmentations=plans, segmentations=plans,
@ -342,6 +415,7 @@ def measure(
converter_version=version, converter_version=version,
codes=tuple(sorted(counts.items())), codes=tuple(sorted(counts.items())),
unaccounted=unaccounted_names(dropped=dropped, merged=merged_names, coded=coded_names), unaccounted=unaccounted_names(dropped=dropped, merged=merged_names, coded=coded_names),
gate=gate,
) )
@ -371,6 +445,17 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
default=None, default=None,
help="required with --plans-dir: what a consumer joins the bundle's concepts on", help="required with --plans-dir: what a consumer joins the bundle's concepts on",
) )
parser.add_argument(
"--gate",
choices=GATE_NAMES,
default=GATE_GUARD_TRUSTED_SOURCE,
help=(
"the persist gate every concept body passes before it is written. The "
"same name and the same default as `okf build --gate`, deliberately: the "
"two paths are held byte-equal by a test, and two different defaults "
"would make that equality depend on which command you ran"
),
)
parser.add_argument( parser.add_argument(
"--okf-version", "--okf-version",
default=None, default=None,
@ -451,6 +536,7 @@ def main(argv: list[str] | None = None) -> int:
plans=plans, plans=plans,
profile=profile, profile=profile,
root_frontmatter_values=root_values, root_frontmatter_values=root_values,
gate=args.gate,
) )
args.report.parent.mkdir(parents=True, exist_ok=True) args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(report.render(), encoding="utf-8", newline="") args.report.write_text(report.render(), encoding="utf-8", newline="")

View file

@ -48,14 +48,14 @@ own output, not a second opinion on the guard's scan.
from __future__ import annotations from __future__ import annotations
from llm_ingestion_guard import PRESET_USER_UPLOAD, screen_output from llm_ingestion_guard import PRESET_TRUSTED_SOURCE, PRESET_USER_UPLOAD, screen_output
from llm_ingestion_guard import okf as guard_okf from llm_ingestion_guard import okf as guard_okf
from .errors import MaterializationError from .errors import MaterializationError
from .importer import BundleDecision, ImportDecision from .importer import BundleDecision, ImportDecision
from .inbox import GateDecision from .inbox import GateDecision
__all__ = ["import_gate", "inbox_gate"] __all__ = ["import_gate", "inbox_gate", "inbox_gate_trusted_source"]
def inbox_gate(text: str) -> GateDecision: def inbox_gate(text: str) -> GateDecision:
@ -73,6 +73,32 @@ def inbox_gate(text: str) -> GateDecision:
) )
def inbox_gate_trusted_source(text: str) -> GateDecision:
"""The same gate at `PRESET_TRUSTED_SOURCE` (a `Gate`).
This IS the three-line adapter the module docstring above describes, shipped
rather than left for each caller to write, because `okf build` needs a
second tier and the alternative was a CLI reaching past the seam into the
guard's presets. Nothing else differs: the same one-directional translation,
the same `screen_output` on the exact bytes that will be persisted.
The tier is a statement about WHERE the bytes came from, and the two doors
are answering different questions. An inbox drop is an untrusted upload, so
`inbox_gate` stays on `PRESET_USER_UPLOAD` and remains Door B's default.
`okf build <folder>` is an operator pointing at their own directory, which
is the trusted-source case. Nothing is waved through either way: an any-tier
finding -- an invisible carrier, a CRITICAL hit -- fails secure at both
tiers, measured against guard 1.4.0 on the fixtures in
`tests/test_cli_gate.py`.
"""
decision = screen_output(text, PRESET_TRUSTED_SOURCE)
return GateDecision(
sanitized_text=text,
disposition=str(decision.disposition.value),
reasons=tuple(str(reason) for reason in decision.reasons),
)
def import_gate(bundle: dict[str, str], *, origin: str, channel: str) -> BundleDecision: def import_gate(bundle: dict[str, str], *, origin: str, channel: str) -> BundleDecision:
"""Door C's persist gate over `okf.import_bundle` (an `ImportGate`). """Door C's persist gate over `okf.import_bundle` (an `ImportGate`).

View file

@ -237,6 +237,25 @@ STOP_WORDS = frozenset(
# list items, quantities and page furniture. The gate is what makes the signal # list items, quantities and page furniture. The gate is what makes the signal
# a signal. # a signal.
_ATX = re.compile(r"^(?P<hashes>#{1,6})\s+(?P<title>\S.*?)\s*$") _ATX = re.compile(r"^(?P<hashes>#{1,6})\s+(?P<title>\S.*?)\s*$")
# A FENCED CODE BLOCK, and it is the one construct in markdown that says "the
# lines inside me are not markdown". Every grammar above reads lines, so
# without this a shell comment in a ```bash block was a level-1 heading --
# reported from outside 2026-09-15 and reproduced before anything moved. Two
# effects, and the smaller one is the visible one: the document is REFUSED
# entirely when the line carries `[` or `]` (Door B validates a title fail-fast
# and never repairs one, 5 of 191 pages of the reporter's corpus), and the
# concept TITLE is silently taken from somebody's shell session everywhere else
# (62 of 191, 32.5 %).
#
# Three details of CommonMark SS 4.5 are load-bearing here, and each one 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 is
# the ordinary case in technical documentation); 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); and a closing fence must be at least
# as long as the opening one (or a four-backtick block quoting a three-backtick
# example closes on the quoted line).
_FENCE = re.compile(r"^ {0,3}(?P<marker>`{3,}|~{3,})(?P<info>.*)$")
_NUMBERED = re.compile(r"^(?P<number>\d+(?:\.\d+)+)\s+(?P<title>\S.*?)\s*$") _NUMBERED = re.compile(r"^(?P<number>\d+(?:\.\d+)+)\s+(?P<title>\S.*?)\s*$")
_TABLE_ROW = re.compile(r"^\s*\|.*\|\s*$") _TABLE_ROW = re.compile(r"^\s*\|.*\|\s*$")
# Arm E's grammar: a pandoc GRID-table rule line. The converter separates a grid # Arm E's grammar: a pandoc GRID-table rule line. The converter separates a grid
@ -400,6 +419,49 @@ def outline_lines(text: str) -> list[tuple[int, int, str]]:
return found return found
def fenced_lines(lines: Sequence[str]) -> set[int]:
"""Every line index inside a fenced code block, fence lines included.
A whole-text decision, computed before the scan for the same reason the
outline run is: whether a line is inside a fence is a property of the lines
ABOVE it, and every rule in `find_candidates` has to agree about it or two
of them will read the same line differently.
The fence lines themselves are in the set. They are not candidates under
any grammar here, and leaving them out would only invite a later rule to
read them.
An UNCLOSED fence runs to the end of the document, which is CommonMark's
own rule. The alternative -- treating an unterminated opener as ordinary
text -- reads a truncated code listing as a document full of headings,
which is this defect in its worst form rather than a repair of it.
"""
fenced: set[int] = set()
marker: str | None = None
for index, line in enumerate(lines):
match = _FENCE.match(line)
if marker is None:
if match is None:
continue
opening = match.group("marker")
if opening[0] == "`" and "`" in match.group("info"):
continue
marker = opening
fenced.add(index)
continue
fenced.add(index)
if match is None:
continue
closing = match.group("marker")
if (
closing[0] == marker[0]
and len(closing) >= len(marker)
and not match.group("info").strip()
):
marker = None
return fenced
def heading_reserve_applies(text: str, *, outline_run: int) -> bool: def heading_reserve_applies(text: str, *, outline_run: int) -> bool:
"""Whether this text needs a SECOND heading source, having no run of its own. """Whether this text needs a SECOND heading source, having no run of its own.
@ -779,6 +841,12 @@ def find_candidates(
position += len(line) position += len(line)
end_of_text = position end_of_text = position
# The fenced lines, and NOTHING below reads one. A fence is the one
# construct that declares its own contents not to be markdown, so every
# grammar here has to agree about it -- including the two whole-text passes
# below, which select from the line list rather than from the loop.
fenced = fenced_lines(lines)
# Computed BEFORE the loop, and that is a correctness requirement rather # Computed BEFORE the loop, and that is a correctness requirement rather
# than a style choice: run selection is a whole-text decision (the LAST # than a style choice: run selection is a whole-text decision (the LAST
# maximal run wins, because a contents listing precedes the body it lists), # maximal run wins, because a contents listing precedes the body it lists),
@ -789,7 +857,11 @@ def find_candidates(
# silently. Silent loss, not a raise: nothing would announce it. # silently. Silent loss, not a raise: nothing would announce it.
admitted: dict[int, str] = {} admitted: dict[int, str] = {}
if outline_run > 0: if outline_run > 0:
runs = outline_runs(outline_lines(text), outline_run) # Filtered HERE and not at admission: run selection is a property of
# the whole text, so a fenced install listing left in the input would
# decide WHICH run wins and move a boundary in prose it never touched.
unfenced = [entry for entry in outline_lines(text) if entry[0] not in fenced]
runs = outline_runs(unfenced, outline_run)
if runs: if runs:
# LAST run, not longest and not first. Measured against both: # LAST run, not longest and not first. Measured against both:
# first-run opens segments inside the table of contents on 14/39 # first-run opens segments inside the table of contents on 14/39
@ -835,7 +907,15 @@ def find_candidates(
# D3's input, and the same whole-text reasoning as `admitted` above: a run # D3's input, and the same whole-text reasoning as `admitted` above: a run
# is a property of the line list, not of a line. # is a property of the line list, not of a line.
sections = _sheet_section_rows(lines) if sheet_section_rows else {} sections = (
{
index: section
for index, section in _sheet_section_rows(lines).items()
if index not in fenced
}
if sheet_section_rows
else {}
)
marked: list[tuple[int, Candidate]] = [] marked: list[tuple[int, Candidate]] = []
in_table = False in_table = False
@ -850,6 +930,14 @@ def find_candidates(
open_block: int | None = None open_block: int | None = None
joined: set[int] = set() joined: set[int] = set()
for index, line in enumerate(lines): for index, line in enumerate(lines):
if index in fenced:
# The same state the fall-through below clears for any other line
# that is not a table row: a fenced block interrupts a table, and
# the fence's own lines must not reopen one.
in_table = False
rule_pending = False
open_block = None
continue
if _TABLE_ROW.match(line): if _TABLE_ROW.match(line):
section = sections.get(index) section = sections.get(index)
if section is not None: if section is not None:

329
tests/test_cli_gate.py Normal file
View file

@ -0,0 +1,329 @@
"""`okf build` runs a NAMED gate, and the bundle says which one (F1).
Reported from outside on 2026-09-15 by `claude-code-llm-wiki`, verified here
before a line moved: `corpus.measure` wired an unconditional approve-everything
stub (`corpus._gate`, returning the persist disposition for any text) into
`process_inbox`, and no `add_argument` call anywhere in the package named a
gate. So the only path people actually use -- `okf build`, and `okf project`
through it -- screened nothing, while `pyproject.toml` makes the guard a
MANDATORY runtime dependency and the README recommends exactly the composition
that was never reachable. A permissive stub that approves everything is the
shape this repository's own README names as the danger; it was on our own door.
Three separate things are pinned, because they fail in three different ways:
- **the gate runs.** A document the real guard refuses is not persisted. Under
the stub it was, which is the defect itself; `CARRIER` is measured to fail
secure in BOTH presets (an invisible carrier is an any-tier finding), so this
test cannot pass by picking a lenient tier.
- **the gate is named in the artifact.** SS 9's `log.md` already carries `N`
because it is the one fact about a run the bundle cannot otherwise recover.
Which gate screened the bytes is the same class of fact: without it a bundle
built with `--gate none` is indistinguishable from one the guard cleared, and
that indistinguishability is the whole trust trap, not the stub alone.
- **the seam composes with segmentation.** Measured before the fix:
`grep -rl inbox_gate tests/` gave ONE file (`tests/test_guard_adapter.py`)
and `grep -c segment` in it gave 0, while the nine files passing
`segmentation=`/`segmentations=` all injected a local warn-stub. The
composition the README recommends -- `process_inbox(segmentations=...,
gate=inbox_gate)` -- had no test at all, which is how the defect survived.
The default is `guard-trusted-source`, set by the operator 2026-09-15 on a
measurement rather than on caution: over the 453 concept bodies of the pinned
default bundle, `PRESET_TRUSTED_SOURCE` returns the persist disposition on
453 of 453 (the artifact survives the move), while `PRESET_USER_UPLOAD` holds
1 of them and would take one of the 39 source documents out of the bundle.
Pointing `okf build` at your own folder is not an untrusted upload; Door B's
library default stays `PRESET_USER_UPLOAD` because an inbox drop is.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from llm_ingestion_okf import cli, corpus
from llm_ingestion_okf.inbox import process_inbox
from llm_ingestion_okf.profiles import SEGMENTED_OKF_V0_2
from llm_ingestion_okf.propose import build_plan
from llm_ingestion_okf.segmentation import parse_segmentation_plan
BUNDLE_ID = "cli-gate-fixture"
OKF_VERSION = "0.2"
INGESTED_AT = "2026-09-15T00:00:00Z"
# Measured against the pinned guard 1.4.0, both presets, before this suite was
# written: `fail_secure` under `PRESET_TRUSTED_SOURCE` AND under
# `PRESET_USER_UPLOAD` ("any-tier: invisible carrier"). A fixture only one tier
# refuses would make this suite pass for the wrong reason.
CARRIER = "# Kostnader\n\nEn merknad med et nullbreddetegn i seg.\n"
BENIGN = "# Kostnader\n\nKvartalstall for plattformgruppen, uten funn.\n"
def _inbox(root: Path, documents: dict[str, str]) -> Path:
inbox = root / "inbox"
inbox.mkdir(parents=True, exist_ok=True)
for name, body in documents.items():
(inbox / name).write_text(body, encoding="utf-8", newline="")
return inbox
def _build(inbox: Path, bundle: Path, *extra: str) -> int:
return cli.main(
[
"build",
str(inbox),
"--bundle",
str(bundle),
"--bundle-id",
BUNDLE_ID,
"--okf-version",
OKF_VERSION,
*extra,
]
)
def _concepts(bundle: Path) -> list[Path]:
return [
path
for path in sorted(bundle.rglob("*.md"))
if path.name not in {"index.md", corpus.LOG_NAME}
]
# --- the gate runs ---------------------------------------------------------
def test_build_refuses_a_document_the_real_guard_refuses(tmp_path: Path) -> None:
"""The defect itself, as a red test: the stub persisted this document.
`CARRIER` carries a zero-width space inside a word. The guard refuses it in
any trust tier, so a build that persists it has not consulted the guard --
whatever the dependency table says.
"""
pytest.importorskip("llm_ingestion_guard")
inbox = _inbox(tmp_path, {"carrier.md": CARRIER})
bundle = tmp_path / "bundle"
assert _build(inbox, bundle) == 0
assert _concepts(bundle) == [], "a document the guard refuses must not reach the bundle"
def test_build_persists_a_document_the_real_guard_clears(tmp_path: Path) -> None:
"""The known-positive beside it: the gate must not simply refuse everything.
A gate that blocked every document would pass the test above and destroy
the command. Both halves are needed, on the same default, in the same run.
"""
pytest.importorskip("llm_ingestion_guard")
inbox = _inbox(tmp_path, {"benign.md": BENIGN})
bundle = tmp_path / "bundle"
assert _build(inbox, bundle) == 0
assert _concepts(bundle), "a clean document must still reach the bundle"
def test_gate_none_is_reachable_and_still_permissive(tmp_path: Path) -> None:
"""`--gate none` keeps the old behaviour, deliberately and by name.
The stub is not deleted -- the corpus harness reproduces published numbers
with it, and a caller measuring segmentation alone has a legitimate reason
to take the gate out of the picture. What changes is that asking for it is
now an act, and the bundle records it.
"""
inbox = _inbox(tmp_path, {"carrier.md": CARRIER})
bundle = tmp_path / "bundle"
assert _build(inbox, bundle, "--gate", "none") == 0
assert _concepts(bundle), "--gate none is the documented permissive path"
def test_user_upload_preset_is_reachable(tmp_path: Path) -> None:
"""The stricter tier is a flag value, not a fork of the adapter.
Door B's own default stays `PRESET_USER_UPLOAD`; the CLI exposes it so a
caller whose folder IS an untrusted drop does not have to write their own
entry point to reach the tier this library already ships.
"""
pytest.importorskip("llm_ingestion_guard")
inbox = _inbox(tmp_path, {"benign.md": BENIGN})
bundle = tmp_path / "bundle"
assert _build(inbox, bundle, "--gate", "guard-user-upload") == 0
assert _concepts(bundle)
def test_an_unknown_gate_name_is_refused(tmp_path: Path) -> None:
"""A misspelled tier must not fall back to the permissive one.
Falling back would reproduce the defect with an extra step: the caller
believes they asked for the guard, and the run approves everything.
"""
inbox = _inbox(tmp_path, {"benign.md": BENIGN})
bundle = tmp_path / "bundle"
with pytest.raises(SystemExit):
_build(inbox, bundle, "--gate", "guard-trusted")
assert not bundle.exists() or not _concepts(bundle)
# --- the gate is named in the artifact -------------------------------------
def test_the_log_names_the_gate_that_screened_the_bundle(tmp_path: Path) -> None:
"""SS 9's log carries the gate for the same reason it carries `N`.
A consumer holding a bundle cannot otherwise tell a screened one from an
unscreened one, and that is the trust trap in its pure form -- a stub is
only dangerous because nothing downstream can see it.
"""
pytest.importorskip("llm_ingestion_guard")
inbox = _inbox(tmp_path, {"benign.md": BENIGN})
bundle = tmp_path / "bundle"
assert _build(inbox, bundle) == 0
log = (bundle / corpus.LOG_NAME).read_text(encoding="utf-8")
assert corpus.GATE_GUARD_TRUSTED_SOURCE in log
def test_the_log_says_when_nothing_was_screened(tmp_path: Path) -> None:
"""`--gate none` must be legible as "nothing was screened", not as silence.
An omitted line reads as "no gate section in this log format"; a present
line naming the permissive stub reads as what it is.
"""
inbox = _inbox(tmp_path, {"benign.md": BENIGN})
bundle = tmp_path / "bundle"
assert _build(inbox, bundle, "--gate", "none") == 0
log = (bundle / corpus.LOG_NAME).read_text(encoding="utf-8")
assert corpus.GATE_NONE in log
assert "NOTHING WAS SCREENED" in log
def test_every_gate_name_renders_a_log_line() -> None:
"""No gate name may render a log without a gate line.
A name added later without a description would produce a bundle that
declares its gate as the empty string, which reads as an absent field.
"""
for name in corpus.GATE_NAMES:
report = corpus.CorpusReport(
corpus="corpus",
ingested_at=INGESTED_AT,
n=0,
extracted=0,
gated=0,
persisted=0,
substantive=0,
degenerate=0,
rejected=0,
seconds_total=0.0,
converter_path="none",
converter_version="none",
codes=(),
unaccounted=(),
gate=name,
)
assert f"**Gate**: {name}" in report.render_log()
# --- the seam composes with segmentation -----------------------------------
def test_segmentation_and_the_real_guard_compose(tmp_path: Path) -> None:
"""`process_inbox(segmentations=..., gate=inbox_gate)`, the README's own form.
Measured before this test existed: no test in the repository ran a real
guard verdict and a segmentation plan in the same call. The composition is
what the README recommends to consumers, so leaving it untested left the
recommendation unverified -- and it is the exact call `okf build` now makes.
"""
guard_adapter = pytest.importorskip("llm_ingestion_okf.guard_adapter")
document = (
"# 1 Innledning\n\nDette dokumentet beskriver krav til seksjonering.\n\n"
"# 2 Omfang\n\nOmfanget er hele anlegget og alle tilhoerende systemer.\n"
)
inbox = _inbox(tmp_path, {"alpha.md": document})
bundle = tmp_path / "bundle"
source = inbox / "alpha.md"
plans = {
"alpha.json": parse_segmentation_plan(
build_plan(
source,
document,
source.read_bytes(),
okf_type="reference",
proposed_at=INGESTED_AT,
path_prefix="alpha",
)
)
}
result = process_inbox(
inbox,
bundle,
INGESTED_AT,
okf_type="reference",
gate=guard_adapter.inbox_gate,
profile=SEGMENTED_OKF_V0_2,
root_frontmatter_values={"okf_version": OKF_VERSION, "bundle_id": BUNDLE_ID},
segmentations=plans,
)
assert len(result.persisted) == 1
assert not result.rejected and not result.quarantined
assert len(_concepts(bundle)) > 1, "the plan's segments must be what the guard screened"
def test_segmentation_and_the_real_guard_refuse_together(tmp_path: Path) -> None:
"""The known-negative for the composition above, on the same call shape.
One refused segment body refuses the whole file, which is Door B's stated
rule; without this half the test above would pass against a gate that never
said no.
"""
guard_adapter = pytest.importorskip("llm_ingestion_okf.guard_adapter")
document = (
"# 1 Innledning\n\nDette dokumentet beskriver krav til seksjonering.\n\n"
"# 2 Omfang\n\nEn merknad med et nullbreddetegn i seg.\n"
)
inbox = _inbox(tmp_path, {"alpha.md": document})
bundle = tmp_path / "bundle"
source = inbox / "alpha.md"
plans = {
"alpha.json": parse_segmentation_plan(
build_plan(
source,
document,
source.read_bytes(),
okf_type="reference",
proposed_at=INGESTED_AT,
path_prefix="alpha",
)
)
}
result = process_inbox(
inbox,
bundle,
INGESTED_AT,
okf_type="reference",
gate=guard_adapter.inbox_gate,
profile=SEGMENTED_OKF_V0_2,
root_frontmatter_values={"okf_version": OKF_VERSION, "bundle_id": BUNDLE_ID},
segmentations=plans,
)
assert not result.persisted
assert result.rejected, "a carrier in one segment refuses the file"
# --- the adapter's second tier ---------------------------------------------
def test_the_trusted_source_adapter_is_the_same_shape(tmp_path: Path) -> None:
"""The second tier is a second three-line adapter, not a parameter.
`guard_adapter`'s own docstring says a caller needing another tier writes
their own three-line adapter; this is that adapter, shipped, so the CLI
does not have to reach past the seam into the guard's presets.
"""
guard_adapter = pytest.importorskip("llm_ingestion_okf.guard_adapter")
clean = guard_adapter.inbox_gate_trusted_source(BENIGN)
assert clean.disposition == "warn"
assert clean.sanitized_text == BENIGN
held = guard_adapter.inbox_gate_trusted_source(CARRIER)
assert held.disposition != "warn", "an any-tier finding is refused at every tier"
assert held.reasons, "the guard's audit trail is carried across verbatim"

241
tests/test_code_fence.py Normal file
View file

@ -0,0 +1,241 @@
"""A fenced code block declares no structure (F2).
Reported from outside on 2026-09-15 by `claude-code-llm-wiki` and reproduced
here on `b6da09c` before a line moved. The proposer read every line of the
extracted text with the same grammars, so a shell comment inside a
```` ```bash ```` fence -- `# Use the opus[1m] alias` -- became a level-1 ATX
heading. Two effects, and the SMALLER one is the visible one:
- **the document is refused entirely** when the fenced line carries `[` or `]`,
because Door B validates a title fail-fast (`inbox.py`, `inbox_title_invalid`)
and never repairs one. Measured on the reporter's corpus: 5 of 191 pages.
- **the TITLE is poisoned** on every document where such a line survives
validation. Measured: 62 of 191 pages (32.5 %) carry `#` lines inside fences.
That is the larger effect and it is silent -- the bundle builds, and the
concept is filed under a line of somebody's shell session.
The fix is in the PROPOSER and not in Door B's title rule: the title rule is
right, and a heading that was never a heading is what has to stop being
proposed. A fenced block is the one construct in markdown that says "the lines
inside me are not markdown", so no rule may read them -- not ATX, not the
numbered grammar, not a table row, not a bold title, not an outline run.
**Exposure, measured on the bytes before the change:** 0 of 865 concept files
in the pinned default bundle (`K2-bundle-default-20260912`) and 0 of the
shipped fixtures and goldens that reach the proposer contain a fence of either
kind. A rule that only ever fires INSIDE a fence therefore cannot move a byte
in anything this repository has measured, which is why it lands unconditionally
rather than behind an eleventh flag: it is not a default move, it is a defect.
"""
from __future__ import annotations
from pathlib import Path
from llm_ingestion_okf import cli
from llm_ingestion_okf.propose import RULE_HEADING, find_candidates
BUNDLE_ID = "code-fence-fixture"
OKF_VERSION = "0.2"
# The reporter's minimal repro, verbatim in shape: a real heading, a real
# subheading, and one fenced shell comment carrying brackets.
REPRO = (
"# Tittel\n"
"Source: https://example.invalid/x\n"
"\n"
"Broedtekst.\n"
"\n"
"## Seksjon\n"
"\n"
"```bash\n"
"# Use the opus[1m] alias\n"
"claude --model opus\n"
"```\n"
"\n"
"Mer tekst.\n"
)
def _titles(text: str, **flags: object) -> list[str]:
return [candidate.title for candidate in find_candidates(text, **flags)] # type: ignore[arg-type]
# --- the defect ------------------------------------------------------------
def test_a_fenced_hash_line_is_not_a_heading() -> None:
"""The defect itself: the fenced comment proposed a boundary."""
titles = _titles(REPRO)
assert "Use the opus[1m] alias" not in titles
assert titles == ["Tittel", "Seksjon"]
def test_the_document_still_builds_a_bundle(tmp_path: Path) -> None:
"""End to end, on the reporter's own numbers: 0/1 substantive -> 1/1.
The proposer test above would pass against a fix that dropped the fenced
line and also broke the door; this is the one that says the document
arrives.
"""
inbox = tmp_path / "inbox"
inbox.mkdir()
(inbox / "doc.md").write_text(REPRO, encoding="utf-8", newline="")
bundle = tmp_path / "bundle"
assert (
cli.main(
[
"build",
str(inbox),
"--bundle",
str(bundle),
"--bundle-id",
BUNDLE_ID,
"--okf-version",
OKF_VERSION,
]
)
== 0
)
concepts = [
path for path in sorted(bundle.rglob("*.md")) if path.name not in {"index.md", "log.md"}
]
assert concepts, "the document was refused entirely before the fix"
def test_the_discriminating_control_still_holds() -> None:
"""The reporter's own control: brackets fell the door, the fence proposed it.
With `1m` instead of `[1m]` the document built before the fix, because the
title validated -- and the concept was still filed under a shell comment.
Both readings must end at the same place now: the line is not a heading.
"""
control = REPRO.replace("[1m]", "1m")
assert "Use the opus 1m alias" not in _titles(control)
assert _titles(control) == ["Tittel", "Seksjon"]
# --- the fence grammar -----------------------------------------------------
def test_a_tilde_fence_closes_the_same_way() -> None:
"""`~~~` is a fence in CommonMark and reaches this library the same way.
Reading only backticks would leave the same defect behind a second spelling
nothing here measures.
"""
text = "# Tittel\n\nTekst.\n\n~~~\n# ikke en overskrift\n~~~\n\nMer tekst.\n"
assert _titles(text) == ["Tittel"]
def test_a_longer_fence_is_not_closed_by_a_shorter_one() -> None:
"""A closing fence must be at least as long as the opening one.
Otherwise a four-backtick block quoting a three-backtick example closes on
the quoted line, and every line after it is read as markdown again.
"""
text = "# Tittel\n\n````\n```\n# ikke en overskrift\n```\n````\n\nTekst under.\n"
assert _titles(text) == ["Tittel"]
def test_an_unclosed_fence_runs_to_the_end_of_the_document() -> None:
"""An unterminated fence swallows the rest, which is CommonMark's own rule.
The alternative -- treating the opener as ordinary text -- would read a
truncated code listing as a document full of headings, which is the defect
in its worst form rather than a repair of it.
"""
text = "# Tittel\n\nTekst.\n\n```\n# ikke en overskrift\n## heller ikke denne\n"
assert _titles(text) == ["Tittel"]
def test_an_indented_fence_marker_still_opens_a_fence() -> None:
"""Up to three leading spaces still open a fence in CommonMark.
A nested list holding a code block is the ordinary way this appears in
technical documentation, so a column-0-only rule would miss the common case.
"""
text = "# Tittel\n\nTekst.\n\n ```\n # ikke en overskrift\n ```\n\nSlutt.\n"
assert _titles(text) == ["Tittel"]
def test_a_fence_marker_inside_prose_does_not_open_a_fence() -> None:
"""The known-negative: a line must BEGIN with the marker to be a fence.
Without this, a sentence mentioning ``` would silence every heading after
it -- a rule that removes real boundaries instead of false ones.
"""
text = "# Tittel\n\nSkriv ``` for en kodeblokk.\n\n## Seksjon\n\nTekst.\n"
assert _titles(text) == ["Tittel", "Seksjon"]
def test_the_info_string_may_not_contain_a_backtick() -> None:
"""CommonMark forbids a backtick in a backtick fence's info string.
A line of inline code alone on a line (`` `okf build` ``) otherwise reads as
an opening fence and silences the rest of the document.
"""
text = "# Tittel\n\n`okf build`\n\n## Seksjon\n\nTekst.\n"
assert _titles(text) == ["Tittel", "Seksjon"]
# --- every other rule is fenced off too ------------------------------------
def test_a_fenced_numbered_line_is_not_a_boundary() -> None:
"""The numbered grammar reads the same lines and must stop at the fence."""
text = "# Tittel\n\nTekst her.\n\n```\n1.2 Installer pakken foerst\n```\n\nSlutt.\n"
assert _titles(text) == ["Tittel"]
def test_a_fenced_numbered_line_does_not_feed_an_outline_run() -> None:
"""Arm D selects its run from the whole text, so the fence binds there too.
Filtering only at admission would leave a fenced listing deciding WHICH run
wins -- a boundary moved by lines that declare nothing.
"""
text = (
"# Tittel\n"
"\n"
"Innledende tekst om emnet.\n"
"\n"
"```\n"
"1 Installer\n"
"2 Konfigurer\n"
"3 Kjoer\n"
"```\n"
"\n"
"Avsluttende tekst.\n"
)
assert _titles(text, outline_run=3) == ["Tittel"]
def test_a_fenced_pipe_line_does_not_open_a_table_block() -> None:
"""A piped line inside a fence is shell syntax, not a table row."""
text = "# Tittel\n\nTekst her.\n\n```\nokf build | tee log\nokf check | wc -l\n```\n\nSlutt.\n"
assert _titles(text) == ["Tittel"]
def test_a_fenced_bold_line_is_not_a_bold_title() -> None:
"""Round 10's rule reads the same lines and stops at the fence too."""
text = "```\n**ikke en tittel**\n```\n\nTekst som staar under.\n"
assert _titles(text, bold_title=True) == []
# --- the exposure control --------------------------------------------------
def test_a_document_without_a_fence_is_untouched() -> None:
"""The control that says the rule fires only inside a fence.
This is the property the exposure measurement rests on: 0 of 865 pinned
concept files carry a fence, so a rule that cannot fire outside one cannot
have moved them.
"""
text = (
"# 1 Innledning\n\nDette dokumentet beskriver krav til seksjonering.\n\n"
"## 1.1 Omfang\n\nOmfanget er hele anlegget og alle systemer.\n"
)
candidates = find_candidates(text)
assert [candidate.title for candidate in candidates] == ["1 Innledning", "1.1 Omfang"]
assert all(candidate.rule == RULE_HEADING for candidate in candidates)

View file

@ -280,3 +280,40 @@ def test_the_threshold_document_carries_the_boundary_bar_and_its_single_corpus()
assert f"`{BOUNDARY_THRESHOLD.metric}` | **2 759/2 761**" in text assert f"`{BOUNDARY_THRESHOLD.metric}` | **2 759/2 761**" in text
assert "1 corpus" in text assert "1 corpus" in text
assert BOUNDARY_THRESHOLD.corpora == 1 assert BOUNDARY_THRESHOLD.corpora == 1
# --- the CLI's default persist gate ----------------------------------------
#
# F1 (reported 2026-09-15): `okf build` injected a permissive stub and no
# argument in the package named a gate, so the command screened nothing while
# the guard was a mandatory runtime dependency and the README recommended a
# composition the command line could not reach. What made that survivable for
# months is that nothing tied the README's claim to the code's behaviour. This
# does. A published promise is a test obligation.
_GATE_LINE = re.compile(r"^<!-- cli-default-gate: (.+) -->$", re.MULTILINE)
def test_the_readme_names_the_gate_the_build_command_actually_defaults_to() -> None:
from llm_ingestion_okf import cli
match = _GATE_LINE.search(README.read_text(encoding="utf-8"))
assert match is not None, (
"README.md carries no `<!-- cli-default-gate: ... -->` marker; without it "
"the documented default can drift from the one the command applies, which "
"is exactly how F1 survived"
)
assert match.group(1).strip() == cli.DEFAULT_GATE
def test_the_readme_names_every_gate_the_command_accepts() -> None:
"""A tier reachable but undocumented is a tier nobody can choose.
The stricter one matters most: a caller whose folder IS an untrusted drop
has to be able to find `guard-user-upload` without reading the source.
"""
from llm_ingestion_okf.corpus import GATE_NAMES
text = README.read_text(encoding="utf-8")
for name in GATE_NAMES:
assert f"`{name}`" in text, f"README does not name the gate {name}"