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

@ -49,6 +49,21 @@ class IngestResult:
stamp: str
def validate_ingested_at(ingested_at: str) -> None:
"""Refuse an `ingested_at` that is not ISO-8601 UTC with a Z suffix.
Shared by every door: the value is stamped verbatim into frontmatter, so
one rule in one place is what keeps the determinism contract from drifting
between Door A and the inbox.
"""
if not _INGESTED_AT_RE.match(ingested_at):
raise MaterializationError(
"ingested_at must be ISO-8601 UTC with a Z suffix "
f"(e.g. 2026-07-03T12:00:00Z), got {ingested_at!r}",
code="ingested_at_invalid",
)
def _collapse_whitespace(value: str) -> str:
# §5 mandates whitespace-run collapse for ONE field only: `source_query`
# (ingest-spec.md:140-141), where a legitimately multi-line SQL SELECT
@ -187,12 +202,7 @@ def materialize_bundle(
so tests run socket-free (§11). Source calls are logged per §8 (which
source, when, row count) never cell contents, never secrets.
"""
if not _INGESTED_AT_RE.match(ingested_at):
raise MaterializationError(
"ingested_at must be ISO-8601 UTC with a Z suffix "
f"(e.g. 2026-07-03T12:00:00Z), got {ingested_at!r}",
code="ingested_at_invalid",
)
validate_ingested_at(ingested_at)
manifest_file = Path(manifest_path)
try:
raw = manifest_file.read_bytes()