`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.
434 lines
16 KiB
Python
434 lines
16 KiB
Python
"""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()
|