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:
parent
b7f5ce3800
commit
d812a839be
6 changed files with 763 additions and 18 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue