# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added - **One normalisation door in front of the persist gate: U+00AD is removed and COUNTED** (operator decision 2026-09-18). `llm-ingestion-guard` 1.4.0 keeps the soft hyphen in `_ZERO_WIDTH_CPS`, and `output:zero-width-present` is an any-tier carrier — `fail_secure` at every trust level, no sanitisation, no exception. R761 Prosesskoden:2025 carries 71 of them and 0 of the four real zero-width characters; all 71 are Norwegian hyphenation points inside words, so a 701-page process code was unreadable for the whole chain over typography. `extract.normalise_extracted` removes that one character from every extracted text; `ExtractedDocument.soft_hyphens`, `InboxResult.normalised` and the accounting's `normalised_soft_hyphen` carry the number per document and per run, and `log.md` gains a `**Normalisation**` bullet. The guard is untouched, the other four characters and U+00A0 NBSP are untouched, and a real zero-width character is still `fail_secure`. Reach, measured: **0 of the 78** readable documents of the reference corpus carry any of the six, so no bundle measured here moves. - **`refused` in the accounting: a partial refusal is never silent.** The report and `log.md` now say how many of M documents the run persisted nothing of. The exit code is unchanged — it belongs to the whole run. ### Fixed - **The content-accounting gate: a document refused whole is never clean.** Its elements are all booked as coded rejections, so u = 0 and d = 0, and `refused_whole` asks its question only for a corpus that persisted NOTHING — one refused source beside an accepted one read as clean with the content gone. `Unit.refused` is that loss with its own column and the document's code in the note. - **The STS JSON role map reads the publisher's own tags.** `count_sts_json` compared the raw tag string where the XML witness has always used `_local`, so `mml:math` reached `tag == "math"` on nothing — 74 formulas in N200 Vegbygging:2024 counted as 0. And the publisher's JSON writes a figure's caption as `figcaption` under the `graphic`, not as the `fig/caption` NISO-STS writes — 430 of them over the eight deliveries measured. No other count moves, measured role by role over those eight and the committed twins. - **The mutation harness is a gate.** A surviving mutant now exits 1; the run ended `2 if errors else 0`, so `killed 0 of 1` exited 0. PM's X2 mutant — a report may declare a document rejected while the bundle holds it — is in the set and is killed by a new test driven from both sides. - **The skipped-row guard measures the machine, not the argument.** Row 6 is SKIPPED exactly when the corpora the arguments name are absent, so asking the arguments made the branch unreachable. - **Row 6 says when a corpus measures no element class at all.** On N200 the build proposes 0 plans and exits 2 before the accounting door, so 16 549 elements land as unaccounted with no declared fate — a finding about the run that read as a finding about the build. ## [0.10.1] — 2026-09-18 ### Added - **`okf build --accounting PATH`: content accounting per element.** Before extraction, every source document is inventoried in a per-format element vocabulary: headings, paragraphs, tables, cells, images, and so on. After the run, every element gets exactly one fate: `carried`, `pointer` or a coded rejection. The fates are written as JSON to PATH and summarised in `log.md`. The build then exits 1 when any element is unaccounted or booked twice. - **`carried` is checked, not declared.** A persisted document's element is carried when all of its text is found in the concept bodies written for that document (letters and digits, case-folded). A document the gate refused books every element as rejected with the gate's code, and its `log.md` line says what the source held. - **The judge is `tools/okf_accounting_gate.py`**, written red first against an independent witness (`tools/okf_witness.py`, which imports nothing from this package). At this change it is green on all six rows, including R761 Prosesskoden:2025: 110 of 110 units under both the default gate and `--gate none`. - **Opt-in, measured.** On the 43-document reference corpus the build took +744 s (+19 %) and +0.53 GB peak RSS. - **The reference corpus fails the check, with 24 real losses:** 22 images on PDF pages without a text layer, which the reader drops together with the page, and 2 docx Title paragraphs, which the converter moves into metadata. A default-on door would therefore fail builds that pass today. Report: `docs/2026-09-17-innholdsregnskapet-bygget.md`. - **Opt-in by operator decision (2026-09-17)**, until the losses it reports on the reference corpus are fixed. Of the three exceptions the gate proposes, the operator approved the PDF one only; approving it moves no number, because no witness counts a heading in a PDF. - **Limit, measured:** the check proves that a string is present, not where. Short elements such as a section label or a one-word title are often found elsewhere in the same document. With R761's concept text cut to half, 4 823 paragraphs and 3 621 sections were reported lost, but only 3 titles and 16 labels. ### Changed - **`okf build` exits 1 when it extracted at least one document and persisted none.** Until now such a run exited 0, because every refusal was coded and the conservation identity held. The bundle was nonetheless empty. Measured case: guard 1.4.0 refuses R761 Prosesskoden:2025 whole, because of its 71 soft hyphens (U+00AD). Door B's library function (`process_inbox`) and `corpus.measure` are unchanged; for a hostile inbox, "all rejected" is a correct outcome. - In this repository, one test relied on exit 0: `tests/test_cli_gate.py::test_build_refuses_a_document_the_real_guard_refuses`. - No script here does. `okf project` calls the build as a function and is unaffected. - **A file carried through a document is no longer also a coded rejection.** Since 0.10.0, an image beside a document was carried into `assets/` through that document and was ALSO counted as `extractor_unknown`, so one file had two fates. On R761 under `--gate none` that was 50 files. - The conservation identity is now `merged + files carried through a document + coded rejections = N`. - `log.md` writes the middle term only when it is non-zero, so a corpus with no such files keeps its line byte for byte. - The carried files are the references the reader actually resolved and carried (`ExtractedDocument.files`), never a byte match. A byte match would credit R761's 7 unpointed duplicates. - An unpointed file beside a document stays a coded rejection. - **`log.md`'s `Images: C carried of F found`**: with `--accounting`, F is what the SOURCES declare. A refused document's pictures therefore no longer read as "0 of 0 found". ### Security - **A remote image reference is no longer a live markdown image link (0.10.1).** New in 0.10.0: before it, no reader read an `` attribute at all. A document could put `![fig](https://collect.example.net/p.gif?u=...)` into a persisted concept, with the address and query string chosen by whoever wrote the document. This package opens no socket, but a consumer that renders the bundle — or an agent that fetches what it renders — does, which turns "this bundle was opened" into a beacon, and a server-side consumer into an SSRF. The guard refuses such a line at `guard-user-upload` and the build's default tier does not, so the same bytes were persisted under the default and refused one tier up. A remote reference is now inert text with the address in ONE code span, and a property test over the readers asserts that no reference produces a markdown image link outside `assets/`. Found by an independent review of 0.10.0 before it was pushed. - The first fix wrote the address **twice** — once in a code span and once bare — and a GFM/linkify renderer autolinks a bare URL into ``. It takes a click rather than a render, so it is weaker than an image link, but "inert" was half true. The address is now written once. - The first fix also **dropped the caption**: `label` stayed in the signature of the line that says what is missing, and no branch read it, so the alt text or figure caption of an image the bundle does not carry was lost — a regression against 0.10.0 and against that line's own reason for existing. It is written again, in the same `--