Chose the side channel over neutralising pointer-shaped document text,
because the second fix changes what every document SAYS in order to
defend a tool outside the build: a source quoting a bundle listing would
come out altered and existing bundles would move bytes. This reads a
file the run already writes.
`assets.conversion` names the pair, `DocumentAssets.conversions` carries
it out of the run, `DocumentAccount.conversions` books it, and the
accounting JSON states it per document. `_declared_conversions` reads it;
`_conversions` now believes a pair only when the RUN booked it AND a
pointer block confirms it for the asset it names. The confirmation can be
forged and the ledger cannot, which is why the ledger decides.
Measured through the real `okf build`: the three arms PM reproduced
(two `<p>`, one `<p>` with `<br>`, a markdown note beside the carrier)
go forged -> refused, 3 of 3, with the known-positive True in all three.
The text-level regression guard goes 3 arms to 13, the two new ones being
a perfectly written pointer block the run never booked.
R761, rebuilt: 25 BMP sources, 19 held, 19 of 19 conversions confirmed
against 19 declared, 50 assets (29 JPEG + 21 PNG, 0 BMP), SHY 71,
u = 0, d = 0, exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chose the stated provenance over a second, independent counter, because a
second counter would be a number built to disagree. The door acts on the
EXTRACTED text; a counter inside `accounting.py` would have to re-read the
SOURCE bytes, and for every type extraction does not carry verbatim (pdf,
xml, html, docx) those are counts over two different strings. The gate would
then have to decide which difference is a loss and which is extraction doing
its job -- inventing a discrepancy where there is none. `accounting.py` does
not hold the source bytes at that point either; being a second reader of a
document is what `tools/okf_witness.py` exists for, outside the package.
Where an independent denominator DOES exist the tests already pin it from
outside the door: the fixture writes its own five, and R761's 71 is PM's
count over the delivery.
The `**Normalisation**` bullet now ends: "The count is the door's own, read
off the run and not recounted from the source."
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Operator decision 2026-09-18, and the whole of it: okf removes the soft
hyphen, the guard is untouched, and the number is BOOKED.
`normalise_extracted(text) -> (text, removed)` in `extract`, applied
once at the end of `extract_document`, so both entry points and both
sides of a plan's `text_sha256` see one string. `_pdf_units` measures
its page offsets through the same door, because that table is
CHARACTER offsets rebuilt from the pages while the text it indexes
comes back normalised -- two readings of one document, and a table
built against the other names the wrong page with full confidence.
Removing a character removes no newline, so every line-indexed rule
downstream is unmoved by construction.
`ExtractedDocument.soft_hyphens` carries the count out;
`InboxResult.normalised` carries it per document; `account_run` reads
it off the RUN rather than recounting the source, because a second
count would be a second reader. It reaches the accounting JSON as
`normalised_soft_hyphen` at both levels and `log.md` as a
`**Normalisation**` bullet naming the count, the documents touched and
that no other character is moved.
EXPOSURE, measured and not assumed: 0 of the 78 readable documents of
the K2 reference corpus carry U+00AD or any of the four real
zero-width characters (the 8 unreadable ones raise before extraction),
0 in the pinned K2 bundle's concept bodies, and 0 across
`tests/fixtures`, `examples`, `skills`, `docs`, `src`, README and
CHANGELOG. The door cannot have moved a byte anyone here has measured.
Suite 2179 passed, 1 skipped; ruff and `mypy --strict src/` clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gate gains a FIFTH column, `Unit.refused`, and it is the only one
that is not a defect in the report: the elements of a document the
build read and persisted nothing of. Their fate is declared honestly,
so `unaccounted` and `double_booked` both stay 0 -- which is exactly
why nothing else could see the loss. `refused_whole` asks its question
only for a corpus that persisted NOTHING, so one refused source beside
an accepted one, the ordinary case on a heterogeneous corpus, reached
row 3 as clean.
Row 3's reason now carries `N element(s) lost with R of D document(s)
refused whole`, and each unclean unit's detail line carries
`refused=` beside u, d, unverified and invalid, with the document's
own rejection code in the note.
On the build side `Accounting.refused` is written into the JSON and
into the `**Accounting**` bullet of `log.md` as `R of D document(s)
refused whole`. The exit code is NOT moved: it belongs to the whole
run, and a corpus holding one unreadable file among many is ordinary,
so the order's other half -- state it in the accounting -- is the one
taken. `okf build` still exits 1 when it persisted nothing at all.
`test_a_corpus_refused_whole_under_the_default_gate_is_red` kept its
point and lost its premise: the numbers still balance, and that is now
asserted as u = 0 and d = 0 rather than as a clean unit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
okf build --accounting PATH inventories every source document before
extraction, in the gate's per-format vocabulary, and after the run gives
each element one fate (carried / pointer / coded rejection), written as
JSON and summarised in log.md. "carried" is checked against the written
concept bodies, so a gate that drops a line is found (test). Exit 1 on
anything unaccounted or double-booked. Opt-in: +744 s (+19 %) on the
43-document reference corpus, and that corpus fails the check on 24 real
losses (22 images on text-less PDF pages, 2 docx Title paragraphs).
Changed without the flag:
- okf build exits 1 when it extracted documents and persisted none.
Door B and corpus.measure are unchanged. One test relied on exit 0.
- An image file carried through a persisted document is its own K1b
column, no longer also extractor_unknown. The set is what the resolver
actually carried (ExtractedDocument.files), never a byte match.
tools/okf_accounting_gate.py (checks untouched) is green on all six rows,
R761 110 of 110 under both gates.
Report: docs/2026-09-17-innholdsregnskapet-bygget.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>