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: