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

@ -15,6 +15,11 @@ what it is given. The guard-calling persist gates arrive with Doors B and C.
Door A (spec-based ingestion) public surface: materialize_bundle plus the
typed error hierarchy rooted in IngestError.
Door B (bundle inbox) public surface: process_inbox plus its result types.
Its persist gate is INJECTED -- the caller supplies a Gate adapter over
llm-ingestion-guard and process_inbox obeys the verdict; the library imports
no guard function itself and makes no security decision of its own.
"""
from .errors import (
@ -27,6 +32,15 @@ from .errors import (
SourceError,
)
from .extract import extract_text
from .inbox import (
BlockedFile,
FailedFile,
Gate,
GateDecision,
InboxResult,
PersistedFile,
process_inbox,
)
from .manifest import (
Extraction,
FileSource,
@ -40,20 +54,27 @@ from .materialize import IngestResult, materialize_bundle
__version__ = "0.3.2"
__all__ = [
"BlockedFile",
"Extraction",
"ExtractionError",
"FailedFile",
"FileSource",
"Gate",
"GateDecision",
"HttpSource",
"InboxResult",
"IngestError",
"IngestResult",
"Manifest",
"ManifestError",
"MaterializationError",
"NetworkGateError",
"PersistedFile",
"RenderError",
"SourceError",
"SqlSource",
"extract_text",
"load_manifest",
"materialize_bundle",
"process_inbox",
]