llm-ingestion-okf/tests/test_inbox_flow.py
Kjell Tore Guttormsen d812a839be feat(inbox): Door B flow against an injected guard gate (Phase 2 step 3)
`process_inbox(inbox_dir, bundle_dir, ingested_at, *, okf_type, gate)`:
per dropped file, bytes -> extract_text -> guard gate -> render -> collision
gate -> write -> index link. Returns an InboxResult whose four buckets
(persisted / quarantined / rejected / failed) are disjoint and complete, so a
file that vanished shows up as a missing entry rather than as nothing.

The guard is INJECTED rather than imported. The library calls no guard
function and makes no security decision: the Gate adapter returns a
GateDecision carrying the guard's own disposition value and the sanitized
text, and the flow only branches on it. That keeps the core dependency-free
while B2 (the guard's CI channel) is still open, and lets the persist and
refuse branches run deterministically against a test double.

Decisions taken with the operator this session:

- What is persisted is the gate's SANITIZED text, not the extracted text.
  Screening one string and writing another would make the verdict a statement
  about bytes nobody kept. `sanitize` is exported by the guard precisely for
  callers composing the checklist themselves, so this is sanctioned API, not
  a reimplementation. Door B has no model call, so the fenced text the
  bookends produce for a transform is never persisted.
- Quarantine is reported apart from rejection. QUARANTINE_REVIEW means "hold
  for human review" — an operator queue — where FAIL_SECURE is a decision.
  No quarantine directory in v1; that stays an extension point.

Fails closed by construction: only the guard's non-blocking floor (`warn`)
persists, so a renamed member, a future disposition or an adapter typo lands
in `rejected` rather than being guessed safe.

One bad file never aborts the run. Only three conditions fail the whole run,
and each is wrong for every file at once: an invalid `ingested_at`, a reserved
`okf_type`, and a missing inbox directory.

New code `inbox_slug_collision`: two dropped files reducing to one generated
name are BOTH refused. Persisting one would let iteration order decide the
winner, and overwriting would lose the other's content.

Phase 1 primitives are reused, never duplicated, so four helpers become
package-internal names (write_bytes, link_in_index, parse_frontmatter,
INDEX_NAME) and link_in_index learns to append to an empty index — Door A
always seeds its index with bundle_summary, but Door B has no summary to
invent and must not open with a blank line.
2026-07-25 06:35:55 +02:00

434 lines
16 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Door B inbox flow against a stub guard (Phase 2 step 3).
The stub stands in for the pinned `llm-ingestion-guard` surface so the persist,
quarantine and reject branches run deterministically without the dependency
installed. It is a TEST DOUBLE and lives here on purpose — never in `src/`.
What the flow owes the caller, and what this suite pins:
- the guard decides; the library only branches on the verdict and never
re-derives one (an unrecognised disposition must fail CLOSED);
- what is persisted is the guard's SANITIZED text, so the bytes screened at
the gate are exactly the bytes written;
- one bad file never aborts the run — every per-file failure is reported and
the remaining files still process;
- the collision gate runs before any mutation and never overwrites curated
content, exactly as at Door A.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import pytest
from llm_ingestion_okf.inbox import GateDecision, InboxResult, process_inbox
INGESTED_AT = "2026-07-25T12:00:00Z"
# The guard's Disposition is a `str, Enum`; these are its VALUES, restated here
# independently of the library constant so a drift in either is visible.
WARN = "warn"
QUARANTINE_REVIEW = "quarantine_review"
FAIL_SECURE = "fail_secure"
# --- the stub guard -------------------------------------------------------
@dataclass
class StubGuard:
"""A gate whose verdict is scripted per source text.
`sanitize` models the guard stripping carriers: the default implementation
removes zero-width characters, so a test can assert that what landed on
disk is the sanitized text and not the extracted text.
"""
disposition: str = WARN
reasons: tuple[str, ...] = ()
calls: list[str] | None = None
def __call__(self, text: str) -> GateDecision:
if self.calls is not None:
self.calls.append(text)
return GateDecision(
sanitized_text=text.replace("", ""),
disposition=self.disposition,
reasons=self.reasons,
)
@dataclass
class PerFileGuard:
"""A gate that returns a different disposition per source text fragment."""
by_marker: dict[str, str]
def __call__(self, text: str) -> GateDecision:
for marker, disposition in self.by_marker.items():
if marker in text:
return GateDecision(
sanitized_text=text, disposition=disposition, reasons=(f"matched {marker}",)
)
return GateDecision(sanitized_text=text, disposition=WARN, reasons=())
def drop(inbox: Path, name: str, content: str) -> None:
inbox.mkdir(parents=True, exist_ok=True)
(inbox / name).write_text(content, encoding="utf-8", newline="")
def run(
tmp_path: Path, gate: object, *, okf_type: str = "note", ingested_at: str = INGESTED_AT
) -> tuple[InboxResult, Path]:
bundle = tmp_path / "bundle"
result = process_inbox(
tmp_path / "inbox",
bundle,
ingested_at,
okf_type=okf_type,
gate=gate, # type: ignore[arg-type]
)
return result, bundle
# --- the persist branch ---------------------------------------------------
def test_a_benign_file_is_persisted_as_an_inbox_concept(tmp_path: Path) -> None:
drop(tmp_path / "inbox", "My Notes.md", "Body text\n")
result, bundle = run(tmp_path, StubGuard())
assert [entry.source_file for entry in result.persisted] == ["My Notes.md"]
written = bundle / "inbox-my-notes.md"
assert written.is_file()
body = written.read_text(encoding="utf-8")
assert body.startswith("---\ntype: note\ntitle: My Notes\nsource_file: My Notes.md\n")
assert "generated: true\n" in body
assert body.endswith("Body text\n")
def test_the_persisted_body_is_the_guards_sanitized_text(tmp_path: Path) -> None:
"""The gate screens the sanitized text, so the sanitized text is what must
land on disk — screening one string and persisting another would make the
gate's verdict a statement about bytes nobody kept.
"""
drop(tmp_path / "inbox", "note.md", "cleanbody\n")
_, bundle = run(tmp_path, StubGuard())
written = (bundle / "inbox-note.md").read_text(encoding="utf-8")
assert "cleanbody" in written
assert "" not in written
def test_the_index_gets_a_link_labelled_with_the_readable_name(tmp_path: Path) -> None:
drop(tmp_path / "inbox", "My Notes.md", "Body\n")
_, bundle = run(tmp_path, StubGuard())
index = (bundle / "index.md").read_text(encoding="utf-8")
assert "- [My Notes](inbox-my-notes.md)\n" in index
assert not index.startswith("\n")
def test_a_re_run_is_idempotent(tmp_path: Path) -> None:
"""Same bytes + same ingested_at + same verdict => same bundle. The inbox
file is this door's to replace, and the index link is not duplicated.
"""
drop(tmp_path / "inbox", "note.md", "Body\n")
_, bundle = run(tmp_path, StubGuard())
first = sorted((path.name, path.read_bytes()) for path in bundle.glob("*.md"))
_, bundle = run(tmp_path, StubGuard())
second = sorted((path.name, path.read_bytes()) for path in bundle.glob("*.md"))
assert first == second
def test_files_are_processed_in_sorted_order(tmp_path: Path) -> None:
for name in ("c.md", "a.md", "b.md"):
drop(tmp_path / "inbox", name, f"body of {name}\n")
calls: list[str] = []
result, _ = run(tmp_path, StubGuard(calls=calls))
assert [entry.source_file for entry in result.persisted] == ["a.md", "b.md", "c.md"]
assert calls == ["body of a.md\n", "body of b.md\n", "body of c.md\n"]
# --- the blocking branches ------------------------------------------------
def test_quarantine_review_is_not_persisted_and_is_reported_separately(tmp_path: Path) -> None:
drop(tmp_path / "inbox", "suspect.md", "Body\n")
result, bundle = run(tmp_path, StubGuard(disposition=QUARANTINE_REVIEW, reasons=("held",)))
assert result.persisted == ()
assert [entry.source_file for entry in result.quarantined] == ["suspect.md"]
assert result.quarantined[0].reasons == ("held",)
assert result.rejected == ()
assert not (bundle / "inbox-suspect.md").exists()
def test_fail_secure_is_not_persisted_and_is_reported_as_rejected(tmp_path: Path) -> None:
drop(tmp_path / "inbox", "poisoned.md", "Body\n")
result, bundle = run(tmp_path, StubGuard(disposition=FAIL_SECURE, reasons=("critical",)))
assert result.persisted == ()
assert [entry.source_file for entry in result.rejected] == ["poisoned.md"]
assert result.quarantined == ()
assert not (bundle / "inbox-poisoned.md").exists()
def test_a_blocked_file_leaves_the_bundle_byte_identical(tmp_path: Path) -> None:
"""The persist-gate proof: a blocked file produces ZERO new files and does
not touch the ones already there — not even the index.
"""
drop(tmp_path / "inbox", "ok.md", "Body\n")
result, bundle = run(tmp_path, StubGuard())
assert result.persisted != ()
before = sorted((path.name, path.read_bytes()) for path in bundle.iterdir())
(tmp_path / "inbox" / "ok.md").unlink()
drop(tmp_path / "inbox", "poisoned.md", "Body\n")
run(tmp_path, StubGuard(disposition=FAIL_SECURE))
after = sorted((path.name, path.read_bytes()) for path in bundle.iterdir())
assert after == before
@pytest.mark.parametrize("disposition", ["", "pass", "ok", "PERSIST", "warn "])
def test_an_unrecognised_disposition_fails_closed(tmp_path: Path, disposition: str) -> None:
"""Only the guard's non-blocking floor persists. Anything the library does
not recognise — a renamed member, a future disposition, a typo in an
adapter — must refuse to persist rather than guess it is safe.
"""
drop(tmp_path / "inbox", "note.md", "Body\n")
result, bundle = run(tmp_path, StubGuard(disposition=disposition))
assert result.persisted == ()
assert [entry.source_file for entry in result.rejected] == ["note.md"]
assert list(bundle.glob("*.md")) == []
# --- one bad file never aborts the run ------------------------------------
def test_an_extraction_failure_does_not_abort_the_run(tmp_path: Path) -> None:
drop(tmp_path / "inbox", "good.md", "Body\n")
drop(tmp_path / "inbox", "archive.zip", "not text\n")
result, bundle = run(tmp_path, StubGuard())
assert [entry.source_file for entry in result.persisted] == ["good.md"]
assert [entry.source_file for entry in result.failed] == ["archive.zip"]
assert result.failed[0].error.code == "extractor_unknown"
assert (bundle / "inbox-good.md").is_file()
def test_a_blocking_verdict_on_one_file_does_not_abort_the_run(tmp_path: Path) -> None:
drop(tmp_path / "inbox", "good.md", "harmless\n")
drop(tmp_path / "inbox", "bad.md", "poison\n")
result, bundle = run(tmp_path, PerFileGuard({"poison": FAIL_SECURE}))
assert [entry.source_file for entry in result.persisted] == ["good.md"]
assert [entry.source_file for entry in result.rejected] == ["bad.md"]
assert (bundle / "inbox-good.md").is_file()
assert not (bundle / "inbox-bad.md").exists()
def test_an_unusable_filename_is_a_per_file_failure(tmp_path: Path) -> None:
drop(tmp_path / "inbox", "!!!.md", "Body\n")
drop(tmp_path / "inbox", "good.md", "Body\n")
result, _ = run(tmp_path, StubGuard())
assert [entry.source_file for entry in result.persisted] == ["good.md"]
assert [entry.source_file for entry in result.failed] == ["!!!.md"]
assert result.failed[0].error.code == "inbox_slug_empty"
def test_an_over_long_filename_is_a_per_file_failure(tmp_path: Path) -> None:
"""The reachable window is narrow but real: the dropped name itself cannot
exceed 255 bytes (the filesystem refuses to create it), so the longest stem
that can arrive is 252 characters — and `inbox-` + 252 + `.md` is 261, over
the limit. Any stem from 247 to 252 triggers the gate.
"""
drop(tmp_path / "inbox", "x" * 252 + ".md", "Body\n")
drop(tmp_path / "inbox", "good.md", "Body\n")
result, _ = run(tmp_path, StubGuard())
assert [entry.source_file for entry in result.persisted] == ["good.md"]
assert [entry.error.code for entry in result.failed] == ["inbox_slug_too_long"]
def test_a_title_that_would_break_an_index_link_is_a_per_file_failure(tmp_path: Path) -> None:
# The title is the readable filename stem, so a bracket in the dropped name
# reaches the same gate Door A applies to a manifest title.
drop(tmp_path / "inbox", "report [v2].md", "Body\n")
result, _ = run(tmp_path, StubGuard())
assert result.persisted == ()
assert [entry.error.code for entry in result.failed] == ["inbox_title_invalid"]
# --- the collision gate ---------------------------------------------------
def test_an_unstamped_collision_is_refused_without_overwriting(tmp_path: Path) -> None:
"""Curated content occupying a generated name is never overwritten — the
same section 3 rule Door A enforces, applied per file so the rest of the
run still completes.
"""
bundle = tmp_path / "bundle"
bundle.mkdir()
curated = bundle / "inbox-note.md"
curated.write_text("curated, no marker\n", encoding="utf-8")
drop(tmp_path / "inbox", "note.md", "Body\n")
drop(tmp_path / "inbox", "other.md", "Body\n")
result, _ = run(tmp_path, StubGuard())
assert curated.read_text(encoding="utf-8") == "curated, no marker\n"
assert [entry.error.code for entry in result.failed] == ["collision_unstamped"]
assert [entry.source_file for entry in result.persisted] == ["other.md"]
def test_the_collision_gate_is_evaluated_before_any_write(tmp_path: Path) -> None:
"""Pre-mutation: ownership is judged against the bundle as it was BEFORE
this run. A file written earlier in the same run must never be mistaken for
pre-existing curated content by a later file's check.
"""
bundle = tmp_path / "bundle"
bundle.mkdir()
(bundle / "inbox-zzz.md").write_text("curated\n", encoding="utf-8")
drop(tmp_path / "inbox", "aaa.md", "Body\n")
drop(tmp_path / "inbox", "zzz.md", "Body\n")
result, _ = run(tmp_path, StubGuard())
# The colliding file is refused; the non-colliding one still lands.
assert [entry.error.code for entry in result.failed] == ["collision_unstamped"]
assert [entry.source_file for entry in result.persisted] == ["aaa.md"]
assert (bundle / "inbox-zzz.md").read_text(encoding="utf-8") == "curated\n"
def test_two_dropped_files_that_slug_alike_are_both_refused(tmp_path: Path) -> None:
"""`note.md` and `note.txt` both reduce to `inbox-note.md`. Persisting one
and silently dropping the other would make the outcome depend on iteration
order, and overwriting would lose the first file's content. Neither is
acceptable, and the library will not pick a winner: both are refused and
the operator renames one.
"""
drop(tmp_path / "inbox", "note.md", "from markdown\n")
drop(tmp_path / "inbox", "note.txt", "from text\n")
drop(tmp_path / "inbox", "other.md", "Body\n")
result, bundle = run(tmp_path, StubGuard())
assert [entry.source_file for entry in result.failed] == ["note.md", "note.txt"]
assert {entry.error.code for entry in result.failed} == {"inbox_slug_collision"}
assert not (bundle / "inbox-note.md").exists()
assert [entry.source_file for entry in result.persisted] == ["other.md"]
def test_a_previous_inbox_file_is_this_doors_to_replace(tmp_path: Path) -> None:
drop(tmp_path / "inbox", "note.md", "first\n")
run(tmp_path, StubGuard())
drop(tmp_path / "inbox", "note.md", "second\n")
result, bundle = run(tmp_path, StubGuard())
assert [entry.source_file for entry in result.persisted] == ["note.md"]
assert (bundle / "inbox-note.md").read_text(encoding="utf-8").endswith("second\n")
def test_a_door_a_concept_is_not_this_doors_to_replace(tmp_path: Path) -> None:
"""Namespace disjointness is the first defence, but the ownership rule is
the second: a file carrying Door A's stamp is never claimed here.
"""
bundle = tmp_path / "bundle"
bundle.mkdir()
stamped = bundle / "inbox-note.md"
stamped.write_text(
"---\ntype: dataset\ningest_manifest: m@abc\ngenerated: true\n---\n\nbody\n",
encoding="utf-8",
)
drop(tmp_path / "inbox", "note.md", "Body\n")
result, _ = run(tmp_path, StubGuard())
assert [entry.error.code for entry in result.failed] == ["collision_unstamped"]
assert "ingest_manifest: m@abc" in stamped.read_text(encoding="utf-8")
# --- run-level fail-fast --------------------------------------------------
def test_an_invalid_ingested_at_fails_the_whole_run(tmp_path: Path) -> None:
"""Not a per-file problem: a bad timestamp would stamp every file wrongly,
so it is refused before any file is read.
"""
from llm_ingestion_okf import MaterializationError
drop(tmp_path / "inbox", "note.md", "Body\n")
with pytest.raises(MaterializationError) as excinfo:
run(tmp_path, StubGuard(), ingested_at="2026-07-25")
assert excinfo.value.code == "ingested_at_invalid"
def test_a_reserved_okf_type_fails_the_whole_run(tmp_path: Path) -> None:
from llm_ingestion_okf import MaterializationError
drop(tmp_path / "inbox", "note.md", "Body\n")
with pytest.raises(MaterializationError) as excinfo:
run(tmp_path, StubGuard(), okf_type="verdict")
assert excinfo.value.code == "okf_type_reserved"
def test_a_missing_inbox_directory_is_refused(tmp_path: Path) -> None:
from llm_ingestion_okf import SourceError
with pytest.raises(SourceError) as excinfo:
process_inbox(
tmp_path / "nope",
tmp_path / "bundle",
INGESTED_AT,
okf_type="note",
gate=StubGuard(), # type: ignore[arg-type]
)
assert excinfo.value.code == "source_root_missing"
def test_an_empty_inbox_writes_nothing(tmp_path: Path) -> None:
(tmp_path / "inbox").mkdir()
result, bundle = run(tmp_path, StubGuard())
assert result == InboxResult(persisted=(), quarantined=(), rejected=(), failed=())
assert not bundle.exists() or list(bundle.iterdir()) == []
def test_subdirectories_are_not_walked(tmp_path: Path) -> None:
"""Top-level only, like every other inbox in this ecosystem: a nested tree
is the operator's structure, not ours to flatten into one namespace.
"""
drop(tmp_path / "inbox" / "nested", "deep.md", "Body\n")
drop(tmp_path / "inbox", "top.md", "Body\n")
result, bundle = run(tmp_path, StubGuard())
assert [entry.source_file for entry in result.persisted] == ["top.md"]
assert not (bundle / "inbox-deep.md").exists()