feat(inbox): Door B provenance render + filename slug (Phase 2 step 2)

Second guard-independent step of Phase 2: the pure functions that turn a
dropped file's name and extracted text into an OKF concept file, with the
§7-analogous honesty marker. No runtime dependency, no guard call, no model
call — the persist gate is still steps 4–5.

- `inbox_slug(filename)` reduces a name to the Phase 1 id grammar
  (`[a-z0-9][a-z0-9-]*`): drop the final extension, lowercase, collapse every
  run of non-grammar characters to one `-`, strip the ends. A name that
  reduces to nothing fails fast rather than inventing an `untitled` fallback
  that would silently collide across unrelated files.
- `inbox_filename(slug)` namespaces it `inbox-{slug}.md`, disjoint from
  `index.md`, Door A's `ingest-*`, and `promoted-verdict-*` for every slug the
  grammar admits (asserted over a hostile-name table, not one example).
- `render_inbox_concept(...)` emits the six-key layer in fixed order — `type`,
  `title`, `source_file`, `source_sha256`, `ingested_at`, `generated: true` —
  with the digest taken over the ORIGINAL dropped bytes, never the extracted
  text, so provenance stays re-verifiable against the operator's file. Body is
  normalised to LF-only with exactly one trailing newline (dropped files
  legitimately arrive with CRLF; Door A can validate instead because it renders
  its own bodies).

Slugging normalises to NFC first. macOS (APFS) hands filenames over
decomposed, so `é` arrives as `e` + combining acute: without normalising, the
combining mark alone becomes a separator and the base letter survives
(`cafe`), where the composed form is one non-grammar character (`caf`) — one
visual filename, two slugs, depending on where the string came from. The test
pins both forms with explicit escapes so it cannot depend on the test file's
own encoding. No transliteration, deliberately: mapping non-ASCII to ASCII is
a semantic claim the slugger cannot make (Norwegian `møte`/meeting would
become `mote`/fashion). The readable name survives verbatim in `title`.

Fail-fast gates, all typed: reserved `verdict` layer refused at this door too
(the promotion gate stays the only path in); a title that is multi-line or
contains `[`/`]` refused, matching Door A's manifest rule, because it renders
verbatim into `- [title](target)`; a multi-line `source_file` refused because
line-oriented frontmatter would take the injected lines. Four new stable
MaterializationError codes, each with its entry in the test_error_codes.py
registry: `inbox_slug_empty`, `inbox_title_invalid`,
`inbox_source_file_invalid`, `okf_type_reserved`.

`ingested_at` validation moves to a shared `validate_ingested_at` in
materialize.py and is called by both doors — one rule in one place is what
keeps the determinism contract from drifting apart per door.

TDD: tests/test_inbox.py precedes the implementation. 325 tests green;
mypy --strict, ruff check/format, and the sanitize|quarantine|lexicon
boundary grep-gate clean; runtime dependencies still exactly none; the Phase 1
golden suite still passes byte-for-byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2aKJxLejT9S8jYwoZ9fut
This commit is contained in:
Kjell Tore Guttormsen 2026-07-25 06:10:11 +02:00
commit 6b9b21c602
5 changed files with 465 additions and 6 deletions

View file

@ -28,6 +28,7 @@ from llm_ingestion_okf.errors import (
SourceError,
)
from llm_ingestion_okf.extract import extract_text
from llm_ingestion_okf.inbox import inbox_slug, render_inbox_concept
from llm_ingestion_okf.manifest import load_manifest, load_manifest_bytes
from llm_ingestion_okf.materialize import materialize_bundle
from llm_ingestion_okf.render import sql_value_to_text
@ -35,6 +36,19 @@ from llm_ingestion_okf.render import sql_value_to_text
INGESTED_AT = "2026-07-17T12:00:00Z"
def inbox_concept(**overrides: Any) -> str:
"""A valid inbox concept render, one field at a time made invalid."""
kwargs: dict[str, Any] = {
"okf_type": "note",
"title": "Note",
"source_file": "note.md",
"source_bytes": b"x",
"ingested_at": INGESTED_AT,
}
kwargs.update(overrides)
return render_inbox_concept("body", **kwargs)
def manifest_data(**overrides: Any) -> dict[str, Any]:
data: dict[str, Any] = {
"manifest_version": 1,
@ -343,6 +357,31 @@ def test_collision_unstamped(tmp_path: Path) -> None:
assert code_of(excinfo) == "collision_unstamped"
def test_inbox_slug_empty() -> None:
with pytest.raises(MaterializationError) as excinfo:
inbox_slug("!!!.md")
assert code_of(excinfo) == "inbox_slug_empty"
def test_inbox_title_invalid() -> None:
with pytest.raises(MaterializationError) as excinfo:
inbox_concept(title="broken [link]")
assert code_of(excinfo) == "inbox_title_invalid"
def test_inbox_source_file_invalid() -> None:
with pytest.raises(MaterializationError) as excinfo:
inbox_concept(source_file="two\nlines.md")
assert code_of(excinfo) == "inbox_source_file_invalid"
def test_okf_type_reserved_at_the_inbox_door() -> None:
# Same reserved layer as the manifest code above, enforced at Door B.
with pytest.raises(MaterializationError) as excinfo:
inbox_concept(okf_type="verdict")
assert code_of(excinfo) == "okf_type_reserved"
# --- NetworkGateError codes ---