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

@ -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()
)