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.
This commit is contained in:
Kjell Tore Guttormsen 2026-07-25 06:35:55 +02:00
commit d812a839be
6 changed files with 763 additions and 18 deletions

View file

@ -30,7 +30,7 @@ from .render import render_fenced_block, render_table
_LOGGER = logging.getLogger(__name__)
_INDEX_NAME = "index.md"
INDEX_NAME = "index.md"
_INGESTED_AT_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
# One managed index line: `- [<label>](<target>)`. Anchored full-line —
@ -82,7 +82,7 @@ def _render_frontmatter(frontmatter: dict[str, str]) -> str:
)
def _parse_frontmatter(path: Path) -> dict[str, str]:
def parse_frontmatter(path: Path) -> dict[str, str]:
lines = path.read_text(encoding="utf-8").splitlines()
if not lines or lines[0].strip() != "---":
return {}
@ -108,7 +108,7 @@ def _is_ingest_owned(path: Path, manifest_stem: str) -> bool:
# the same manifest wrote, while a DIFFERENT manifest sharing the bundle
# keeps its own. rsplit strips the trailing `@{sha}`, so a stem that itself
# contains `@` still compares correctly.
frontmatter = _parse_frontmatter(path)
frontmatter = parse_frontmatter(path)
if frontmatter.get("generated") != "true":
return False
reference = frontmatter.get("ingest_manifest")
@ -133,7 +133,7 @@ def _render_concept_file(
return f"---\n{_render_frontmatter(frontmatter)}\n---\n\n{body}"
def _write_bytes(bundle_dir: Path, name: str, content: str) -> Path:
def write_bytes(bundle_dir: Path, name: str, content: str) -> Path:
# LF-only + exactly one trailing newline are byte-level guarantees (§5),
# so the write is raw bytes — never write_text, whose platform newline
# translation would break golden byte-determinism.
@ -172,14 +172,17 @@ def _update_index_lines(
index_path.write_bytes("".join(updated).encode("utf-8"))
def _link_in_index(bundle_dir: Path, target_name: str, label: str) -> None:
def link_in_index(bundle_dir: Path, target_name: str, label: str) -> None:
# §6: idempotent by target — a link whose target is already present in
# the index is never added twice.
index_path = safe_resolve(bundle_dir, _INDEX_NAME)
index_path = safe_resolve(bundle_dir, INDEX_NAME)
body = index_path.read_bytes().decode("utf-8")
if f"]({target_name})" in body:
return
prefix = body if body.endswith("\n") else body + "\n"
# An empty index needs no separator: Door A always seeds its index with
# bundle_summary first, but Door B has no summary to invent, so its index
# starts empty and must not open with a blank line.
prefix = body if (body == "" or body.endswith("\n")) else body + "\n"
index_path.write_bytes(f"{prefix}- [{label}]({target_name})\n".encode())
@ -278,7 +281,7 @@ def materialize_bundle(
owned = {
path.name
for path in sorted(bundle.glob("*.md"))
if path.name != _INDEX_NAME and _is_ingest_owned(path, manifest_file.stem)
if path.name != INDEX_NAME and _is_ingest_owned(path, manifest_file.stem)
}
# §3 collision gate — BEFORE any mutation: a staged filename occupied by
# a file WITHOUT the stamp is curated content; never overwrite it.
@ -293,20 +296,20 @@ def materialize_bundle(
# §5 replacement: remove every stamped file, then write the new set.
for name in sorted(owned):
(bundle / name).unlink()
written = tuple(_write_bytes(bundle, name, content) for name, content in staged)
written = tuple(write_bytes(bundle, name, content) for name, content in staged)
# §6 index generation — the last disk mutation. A fresh index gets
# bundle_summary as its body; links are appended in extraction order.
index_path = bundle / _INDEX_NAME
index_path = bundle / INDEX_NAME
labels_by_target = {
generated_filename(extraction.id): extraction.title for extraction in manifest.extractions
}
if not index_path.is_file():
_write_bytes(bundle, _INDEX_NAME, manifest.bundle_summary + "\n")
write_bytes(bundle, INDEX_NAME, manifest.bundle_summary + "\n")
else:
# Links whose target is an ingest-owned file removed this run MUST be
# removed; all other links — curated and promoted — are preserved.
_update_index_lines(index_path, owned - staged_names, labels_by_target)
for extraction in manifest.extractions:
_link_in_index(bundle, generated_filename(extraction.id), extraction.title)
link_in_index(bundle, generated_filename(extraction.id), extraction.title)
return IngestResult(written=written, stamp=stamp)