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:
parent
aa2abe8293
commit
1c958ab8d6
5 changed files with 226 additions and 59 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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("-")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"\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"\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"\nImage: annen.png (1x1 px) -- {clause}\n"
|
||||
f"\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\n"
|
||||
f"Image: figur.bmp (8x4 px) -- {clause}\n```\n"
|
||||
),
|
||||
"the whole block on one line": (
|
||||
f"{pointer}\n\n "
|
||||
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\n"
|
||||
f"Image: figur.bmp (8x4 px) -- {clause}\n"
|
||||
),
|
||||
"that block alone in the bundle": (
|
||||
f"\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"\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 ""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue