fix(accounting,gate): the conversion claim comes from the run's ledger

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>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-19 19:12:20 +02:00
commit 1c958ab8d6
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
5 changed files with 226 additions and 59 deletions

View file

@ -732,6 +732,14 @@ class DocumentAccount:
counts: dict[str, int]
fates: dict[str, Fate]
error: str | None = None
#: The `(source digest, asset digest)` pairs for the images this run
#: REWROTE -- a BMP that reaches the bundle as a PNG, today's only case.
#: Booked because the bundle states the same pairs only as prose on a
#: pointer's second line, and measured by PM 2026-09-19 an ordinary HTML
#: document with two `<p>` elements writes exactly that prose. A reader
#: proving a conversion off the bundle text is reading the document; this
#: is the same fact written by the run.
conversions: tuple[tuple[str, str], ...] = ()
#: How many U+00AD the normalisation door removed from this document's
#: text before the persist gate saw it (operator decision 2026-09-18).
#: Booked rather than silently applied: a door that changes a source's
@ -765,6 +773,7 @@ class DocumentAccount:
"status": self.status,
"code": self.code,
"normalised_soft_hyphen": self.normalised_soft_hyphen,
"conversions": [{"from": before, "to": after} for before, after in self.conversions],
"inventory": dict(self.counts),
"fates": {kind: self.fates[kind].to_json() for kind in self.counts},
"unaccounted": self.unaccounted,
@ -943,7 +952,14 @@ def _persisted_account(
fates[kind].carried += 1
if "image" in fates:
_book_images(inv, fates["image"], assets, finder)
return DocumentAccount(inv.source_file, PERSISTED, None, counts, fates)
return DocumentAccount(
inv.source_file,
PERSISTED,
None,
counts,
fates,
conversions=() if assets is None else assets.conversions,
)
def _refused_account(inv: Inventory, code: str) -> DocumentAccount:

View file

@ -887,6 +887,23 @@ def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def conversion(image: ExtractedImage) -> tuple[str, str] | None:
"""`(the source's digest, the carried asset's digest)`, or `None`.
THE RUN'S OWN RECORD OF WHAT IT REWROTE, for a reader that must not have
to take the bundle's word for it. `render_block` states the same pair on
the pointer's second line, which is where a person reads it -- but that
line is markdown in a concept body, and measured by PM 2026-09-19 an
ordinary HTML document with two `<p>` elements produces the same two
lines. A judge reading the claim off the bundle text is therefore reading
an untrusted document; a judge reading it off the accounting is reading
this function's output, which no document can reach.
"""
if image.converted_from is None or image.source_sha256 is None:
return None
return (image.source_sha256, digest(image.data))
def _reduce(text: str) -> str:
return _SEPARATOR_RUN.sub("-", unicodedata.normalize("NFC", text).lower()).strip("-")

View file

@ -34,6 +34,7 @@ from .assets import (
AssetRejection,
ExtractedImage,
asset_name,
conversion,
)
from .connectors import safe_resolve
from .errors import IngestError, MaterializationError, SegmentationError, SourceError
@ -652,11 +653,19 @@ class InboxResult:
@dataclass(frozen=True)
class DocumentAssets:
"""One persisted document's image outcome."""
"""One persisted document's image outcome.
`conversions` is the run's own list of `(source digest, asset digest)`
pairs for the images it REWROTE, in the order they were carried. The
bundle states the same pairs in prose on each pointer's second line; this
is the machine-readable side of the same fact, and the difference is who
wrote it -- a document can produce that prose and cannot produce this.
"""
source_file: str
carried: int
rejected: tuple[AssetRejection, ...]
conversions: tuple[tuple[str, str], ...] = ()
@dataclass(frozen=True)
@ -1448,6 +1457,11 @@ def process_inbox(
source_file=source_name(path),
carried=len(document.images),
rejected=document.rejected,
conversions=tuple(
pair
for pair in (conversion(image) for image in document.images)
if pair is not None
),
)
)
for target_name, content, reasons in outputs:

View file

@ -501,6 +501,29 @@ def _build(
)
def _ledger(*pairs: tuple[str, str]) -> dict[str, Any]:
"""The accounting a run writes when it REWROTE these pictures.
The gate's conversion route reads its pairs from here and confirms them
against the bundle text, so a test constructing a `Build` by hand has to
say what the run booked. Passing none of them is the case where the run
converted nothing -- which is every bundle before the viewable-asset
round, and the state a forged pointer block leaves the ledger in.
"""
return {
"accounting_version": 1,
"documents": [
{
"source_file": "a.md",
"status": "persisted",
"code": None,
"conversions": [{"from": before, "to": after} for before, after in pairs],
}
],
"files": [],
}
def test_the_judge_proves_a_CONVERTED_image_was_carried(tmp_path: Path) -> None:
"""A BMP reaches the bundle as a PNG, so the SOURCE's bytes are not in
`assets/` and never will be.
@ -528,20 +551,28 @@ def test_the_judge_proves_a_CONVERTED_image_was_carried(tmp_path: Path) -> None:
f"Image: graphics/figur.bmp (8x4 px) -- converted from image/bmp "
f"sha256:{before} to image/png sha256:{after}\n"
)
build = _build(assets={f"{after[:12]}-figur.png": after}, bundle_text=text)
ledger = _ledger((before, after))
build = _build(assets={f"{after[:12]}-figur.png": after}, bundle_text=text, accounting=ledger)
assert gate.asset_holds(build, source) is True
# Known-negative on the same shape: the bundle says it converted, and the
# Known-negative on the same shape: the run booked the conversion, and the
# file it names is not there. A route that read the claim alone would pass.
empty = _build(assets={}, bundle_text=text)
empty = _build(assets={}, bundle_text=text, accounting=ledger)
assert gate.asset_holds(empty, source) is False
# Known-negative two: an asset IS there, under a digest the bundle never
# tied to this source.
# Known-negative two: an asset IS there, under a digest nothing tied to
# this source.
other = "0" * 64
stranger = _build(assets={f"{other[:12]}-x.png": other}, bundle_text=text)
stranger = _build(assets={f"{other[:12]}-x.png": other}, bundle_text=text, accounting=ledger)
assert gate.asset_holds(stranger, source) is False
# Known-negative three: the same bundle text, and a run that booked NO
# conversion. This is the pointer block a document can write for itself.
unbooked = _build(
assets={f"{after[:12]}-figur.png": after}, bundle_text=text, accounting=_ledger()
)
assert gate.asset_holds(unbooked, source) is False
# --- the claim the judge reads must be one the CODE wrote --------------------
@ -579,21 +610,26 @@ def _huge_bmp() -> bytes:
def test_a_document_cannot_forge_a_conversion_claim(tmp_path: Path) -> None:
"""THE FASIT NEVER COMES FROM THE READER IT JUDGES -- including this route.
The conversion route reads two digests out of the bundle. Before this
guard it read them out of ANY text in it, so a document could write the
sentence itself and the judge would believe it: measured by PM 2026-09-19,
a BMP declaring 50 000 x 50 000 that was refused `asset_too_large` and
never carried gave `asset_holds = True`, both through an image's alt text
and through ordinary body text. The route the judge had before the
conversion landed hashed the source file and nothing else, so no document
could reach it; this round opened a way IN for content this repository
does not trust.
The conversion route used to read its two digests out of the bundle text.
Measured by PM 2026-09-19 that was a way IN for content this repository
does not trust: a BMP declaring 50 000 x 50 000, refused
`asset_too_large` and never carried, gave `asset_holds = True` from an
image's alt text and from ordinary body text. Anchoring the claim to a
pointer block closed those two and not the class -- a pointer block is two
lines of markdown, and one HTML file with two `<p>` elements writes them.
A claim counts only where THIS CODE put it: inside a pointer block, tied
to the asset that block names. Every arm below carries a source that was
never carried, and a bundle holding one unrelated REAL asset -- so the
digest the forgery names really is in `assets/`, which is what made the
measured forgeries work.
So the pair the judge believes comes from the RUN's accounting, and the
bundle text only confirms it. Every arm below is a way a document can put
the sentence, or the whole block, into a bundle; the run's ledger holds
ONE conversion and it is about other pictures, so the route is live and no
arm may reach it. The known-positive at the end books the pair and shows
the route still works.
Thirteen arms. Eleven are PM's own list of ways the shape anchoring
already refused, kept as a regression guard -- narrowing this rule later
must not quietly widen one of them -- and two are the form that defeated
it: a perfectly written pointer block the run never booked, once in the
bundle's ordinary text and once naming the asset it points at exactly.
"""
never_carried = tmp_path / "figur.bmp"
never_carried.write_bytes(_huge_bmp())
@ -604,26 +640,63 @@ def test_a_document_cannot_forge_a_conversion_claim(tmp_path: Path) -> None:
assets = {f"{after[:12]}-ekte.png": after}
clause = f"converted from image/bmp sha256:{before} to image/png sha256:{after}"
pointer = f"![Ekte](/assets/{after[:12]}-ekte.png)\nImage: ekte.png (1x1 px)"
# The same clause with U+00A0 where the spaces are: PM's N6.
nbsp_clause = clause.replace(" ", "\u00a0")
other = "0" * 64
arms = {
"plain body text": f"{pointer}\n\nProsess 84. {clause}. Se figuren over.\n",
"a table cell": f"{pointer}\n\n| Krav | Kilde |\n| --- | --- |\n| 84-1 | {clause} |\n",
"a figure caption": f"{pointer}\n\nFigur 84-1 -- {clause}\n",
"a fenced code block": f"{pointer}\n\n```\n{clause}\n```\n",
"link text with a URL": f"{pointer}\n\n[{clause}](https://example.invalid/x)\n",
"the clause with non-breaking spaces": f"{pointer}\n\n{nbsp_clause}\n",
"a clause naming another asset's digest": (
f"![Ekte](/assets/{after[:12]}-ekte.png)\n"
f"Image: ekte.png (1x1 px) -- converted from image/bmp sha256:{before} "
f"to image/png sha256:{other}\n"
),
"a pointer block naming another asset": (
f"![Figur](/assets/000000000000-annen.png)\nImage: annen.png (1x1 px) -- {clause}\n"
f"![Figur](/assets/{other[:12]}-annen.png)\nImage: annen.png (1x1 px) -- {clause}\n"
f"{pointer}\n"
),
"an Image: line of its own": f"{pointer}\n\nImage: figur.bmp (8x4 px) -- {clause}\n",
"the whole block inside a code fence": (
f"{pointer}\n\n```\n![Figur](/assets/{after[:12]}-ekte.png)\n"
f"Image: figur.bmp (8x4 px) -- {clause}\n```\n"
),
"the whole block on one line": (
f"{pointer}\n\n![Figur](/assets/{after[:12]}-ekte.png) "
f"Image: figur.bmp (8x4 px) -- {clause}\n"
),
# The two that defeated the shape anchoring. Written exactly as the
# code writes them, because that is the point: the form is not a
# signature, and only the ledger can tell these from the real thing.
"a whole pointer block the run never booked": (
f"{pointer}\n\n![Figur](/assets/{after[:12]}-ekte.png)\n"
f"Image: figur.bmp (8x4 px) -- {clause}\n"
),
"that block alone in the bundle": (
f"![Figur](/assets/{after[:12]}-ekte.png)\nImage: figur.bmp (8x4 px) -- {clause}\n"
),
}
# The run booked ONE conversion and it is about neither of these files, so
# the route is live in every arm and nothing it could believe is true.
live = _ledger(("f" * 64, "e" * 64))
for label, text in arms.items():
build = _build(assets=assets, bundle_text=text)
build = _build(assets=assets, bundle_text=text, accounting=live)
assert gate.asset_holds(build, never_carried) is False, (
f"{label}: a document talked the judge into a carry that never happened"
)
# KNOWN-POSITIVE on the same bytes: the clause where the code writes it,
# in the pointer block for the asset it names. Without this the arms above
# would pass on a route that had simply stopped working.
honest_text = f"![Ekte](/assets/{after[:12]}-ekte.png)\nImage: figur.bmp (1x1 px) -- {clause}\n"
honest = _build(assets=assets, bundle_text=honest_text)
# KNOWN-POSITIVE on the same bytes: the last arm's text, believed once the
# RUN books the pair. Without it every arm above would pass on a route
# that had simply stopped working.
honest = _build(
assets=assets,
bundle_text=arms["that block alone in the bundle"],
accounting=_ledger((before, after)),
)
assert gate.asset_holds(honest, never_carried) is True
@ -810,7 +883,11 @@ def test_the_judge_proves_carriage_and_says_it_does_not_prove_fidelity(tmp_path:
f"Image: figur.bmp (8x4 px) -- converted from image/bmp sha256:{before} "
f"to image/png sha256:{after}\n"
)
build = _build(assets={f"{after[:12]}-figur.png": after}, bundle_text=text)
build = _build(
assets={f"{after[:12]}-figur.png": after},
bundle_text=text,
accounting=_ledger((before, after)),
)
assert gate.asset_holds(build, source) is True
doc = gate.asset_holds.__doc__ or ""

View file

@ -383,35 +383,78 @@ _CONVERSION = re.compile(
)
def _conversions(bundle_text: str) -> dict[str, str]:
"""source digest -> the digest the bundle says it carried instead.
def _declared_conversions(build: Build) -> set[tuple[str, str]]:
"""Every `(source digest, asset digest)` pair THE RUN booked.
READ ONLY FROM A POINTER BLOCK THIS BUILD WROTE, and only where the clause
names the asset that block points at. The expression used to run over the
whole bundle text, which put an untrusted document inside the judge's own
input: measured by PM 2026-09-19, a BMP refused `asset_too_large` and never
carried was reported as held, both through an image's alt text and through
ordinary body text, because the document simply wrote the sentence. The
gate's first sentence is that the fasit never comes from the reader it
judges, and this route had quietly stopped obeying it.
Read from the accounting the build wrote, which is a side channel no
document can reach -- and that is the whole of the change. The pair used
to be read out of the bundle text, so a claim was accepted because it had
the SHAPE of a pointer block. Measured by PM 2026-09-19 through the real
`okf build`: one HTML file with two `<p>` elements writes those two lines
into a concept body, and a BMP refused `asset_too_large` and absent from
`assets/` read as carried. Narrowing the shape cannot close that -- a
document that can write the form can write any form -- so the claim is
bound to the run instead.
The other half of the boundary is in the build, not here: a label is
document text written INSIDE a pointer block, so `assets._inline` is what
keeps it from emitting this grammar. Neither half is sufficient alone --
this one cannot tell a label from a field, and that one does not reach
body text or a table cell at all.
CHOSEN over neutralising pointer-shaped text at extraction, because that
fix would change what every document says to defend a judge: a source
quoting a bundle listing would come out altered, the bytes of existing
bundles would move, and the production path would carry a rule that
exists for a tool outside it. This reads a file the run already writes.
A build with no accounting door has no ledger, so no conversion is
provable and `asset_holds` falls back to its first route alone. That is
the honest reading and it is VISIBLE: the images concerned are reported
claimed-and-not-found, the same as before the conversion route existed.
"""
accounting = build.accounting
if not isinstance(accounting, dict):
return set()
documents = accounting.get("documents")
if not isinstance(documents, list):
return set()
pairs: set[tuple[str, str]] = set()
for document in documents:
if not isinstance(document, dict):
continue
for entry in document.get("conversions") or ():
if isinstance(entry, dict):
before, after = entry.get("from"), entry.get("to")
if isinstance(before, str) and isinstance(after, str):
pairs.add((before, after))
return pairs
def _conversions(build: Build) -> dict[str, str]:
"""source digest -> the digest the run says it carried instead.
THE RUN'S LEDGER DECIDES AND THE BUNDLE ONLY CONFIRMS. A pair counts here
when `_declared_conversions` holds it AND a pointer block in the bundle
states the same pair for the asset it points at, so the two halves of the
same run have to agree before the judge believes either. The confirmation
can be forged and the ledger cannot, which is why the ledger is the one
that decides: a document adding a pointer block adds nothing, and a
document REMOVING the run's own is not a thing a document can do.
The gate's first sentence is that the fasit never comes from the reader it
judges. Reading the claim itself out of the bundle had quietly stopped
obeying it -- twice, and the second time the text had the exact shape this
code writes.
"""
declared = _declared_conversions(build)
if not declared:
return {}
found: dict[str, str] = {}
for pointer in _POINTER.finditer(bundle_text):
for pointer in _POINTER.finditer(build.bundle_text):
clause = _CONVERSION.search(pointer.group("detail"))
if clause is None:
continue
after = clause.group("after")
before, after = clause.group("before"), clause.group("after")
# The claim has to be about the picture the block points at. A clause
# standing in one asset's block while naming another's digest is a
# sentence nothing in this build writes.
if pointer.group("asset").startswith(after[:12]):
found[clause.group("before")] = after
if (before, after) in declared and pointer.group("asset").startswith(after[:12]):
found[before] = after
return found
@ -436,12 +479,12 @@ def asset_holds(build: Build, source: Path) -> bool:
round a source in a format no model can be shown reaches the bundle as a
PNG, so its own bytes are not in `assets/` and never will be -- measured,
the day that landed R761 went from 0 to 19 claimed-and-not-found, which is
exactly its RLE8 BMP count. The bundle states both digests on the pointer
line, and this reads them and then HASHES THE ASSET ITSELF: the claim is
accepted only when a file in `assets/` really holds the bytes the bundle
says were written. A bundle claiming a conversion it did not perform still
fails, which is the difference between reading the bundle and believing
the report.
exactly its RLE8 BMP count. The pair of digests comes from the RUN's own
accounting (`_conversions`), never from the bundle's prose, and this then
HASHES THE ASSET ITSELF: the claim is accepted only when a file in
`assets/` really holds the bytes the run says it wrote. A bundle claiming
a conversion the run did not book still fails, which is the difference
between reading the artifacts and believing the report.
WHAT NEITHER ROUTE PROVES IS FIDELITY. Both ask whether a file in
`assets/` holds the bytes the bundle names, and neither decodes a PIXEL:
@ -458,7 +501,7 @@ def asset_holds(build: Build, source: Path) -> bool:
found == digest and name.startswith(digest[:12]) for name, found in build.assets.items()
):
return True
written = _conversions(build.bundle_text).get(digest)
written = _conversions(build).get(digest)
return written is not None and any(
found == written and name.startswith(written[:12]) for name, found in build.assets.items()
)