fix(accounting): a document refused whole is never clean, and the run says how many (H1)

The gate gains a FIFTH column, `Unit.refused`, and it is the only one
that is not a defect in the report: the elements of a document the
build read and persisted nothing of. Their fate is declared honestly,
so `unaccounted` and `double_booked` both stay 0 -- which is exactly
why nothing else could see the loss. `refused_whole` asks its question
only for a corpus that persisted NOTHING, so one refused source beside
an accepted one, the ordinary case on a heterogeneous corpus, reached
row 3 as clean.

Row 3's reason now carries `N element(s) lost with R of D document(s)
refused whole`, and each unclean unit's detail line carries
`refused=` beside u, d, unverified and invalid, with the document's
own rejection code in the note.

On the build side `Accounting.refused` is written into the JSON and
into the `**Accounting**` bullet of `log.md` as `R of D document(s)
refused whole`. The exit code is NOT moved: it belongs to the whole
run, and a corpus holding one unreadable file among many is ordinary,
so the order's other half -- state it in the accounting -- is the one
taken. `okf build` still exits 1 when it persisted nothing at all.

`test_a_corpus_refused_whole_under_the_default_gate_is_red` kept its
point and lost its premise: the numbers still balance, and that is now
asserted as u = 0 and d = 0 rather than as a clean unit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-19 05:48:57 +02:00
commit d27ca503c8
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
4 changed files with 51 additions and 6 deletions

View file

@ -21,6 +21,7 @@ THE DOOR THE CAPABILITY MUST OPEN (the contract this gate reads). `okf build`
accepts `--accounting PATH` and writes one JSON object there:
{"accounting_version": 1,
"refused": <documents the build read and persisted nothing of>,
"documents": [
{"source_file": "<inbox-relative path>",
"status": "persisted" | "rejected", "code": "<rejection code>" | null,
@ -479,6 +480,11 @@ class Unit:
`invalid` are about the bundle and the declaration themselves. A booking
the gate could not verify is never clean, and `verified`/`unverifiable`
carry the denominator behind that word.
`refused` is the fifth and the only one that is not a defect in the
REPORT: the elements of a document the build refused whole. Their fate is
honestly declared and their content is not in the bundle, so the numbers
balance and nothing else here can see the loss (H1).
"""
name: str
@ -489,11 +495,14 @@ class Unit:
invalid: int = 0
verified: int = 0
unverifiable: int = 0
refused: int = 0
notes: list[str] = field(default_factory=list)
@property
def clean(self) -> bool:
return not (self.unaccounted or self.double or self.unverified or self.invalid)
return not (
self.unaccounted or self.double or self.unverified or self.invalid or self.refused
)
def _conservation_held(build: Build) -> bool:
@ -644,6 +653,18 @@ def _document_unit(
if persisted and total > 0 and booked_carried == 0:
invalid += 1
notes.append("the build persisted this document and the report carries nothing from it")
# H1: a document refused whole balances by construction -- every element
# is a coded rejection, so u = 0 and d = 0 -- and `refused_whole` below
# asks its question only for a corpus that persisted NOTHING. One refused
# source beside an accepted one is the ordinary case on a heterogeneous
# corpus, and it read CLEAN with the content gone.
refused = 0
if status == "rejected" and not persisted and total > 0:
refused = total
notes.append(
f"refused whole: {total} element(s) declared rejected `{code}`, "
"and the bundle holds nothing from this document"
)
return Unit(
name,
"document",
@ -653,6 +674,7 @@ def _document_unit(
invalid=invalid,
verified=verified,
unverifiable=unverifiable,
refused=refused,
notes=notes,
)
@ -803,15 +825,19 @@ def row3(units: list[Unit], door: bool) -> Row:
d_total = sum(u.double for u in units)
unverified = sum(u.unverified for u in units)
invalid = sum(u.invalid for u in units)
refused = sum(u.refused for u in units)
documents = sum(1 for u in units if u.kind == "document")
refused_docs = sum(1 for u in units if u.refused)
reason = (
f"u = {u_total} unaccounted, d = {d_total} double-booked, "
f"{unverified} booked carried and not in the bundle, {invalid} declaration(s) the gate refuses"
f"{unverified} booked carried and not in the bundle, {invalid} declaration(s) the gate refuses, "
f"{refused} element(s) lost with {refused_docs} of {documents} document(s) refused whole"
)
if not door:
reason += f"; no `{ACCOUNTING_FLAG}` door, so no element has a declared fate"
details = [_tally(units)] + [
f"{u.kind} {u.name}: u={u.unaccounted} d={u.double} "
f"unverified={u.unverified} invalid={u.invalid}"
f"unverified={u.unverified} invalid={u.invalid} refused={u.refused}"
+ (f" ({'; '.join(u.notes)})" if u.notes else "")
for u in units
if not u.clean
@ -1060,6 +1086,7 @@ def row6(r761: Path | None, n200: Path | None, ci: bool) -> Row:
details.append(
f" {unit.name}: u={unit.unaccounted} d={unit.double} "
f"unverified={unit.unverified} invalid={unit.invalid} "
f"refused={unit.refused} "
f"({'; '.join(unit.notes)})"
)
doubled = [u for u in units if u.kind == "file" and u.double]