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:
parent
db93de4aef
commit
6b9b21c602
5 changed files with 465 additions and 6 deletions
|
|
@ -96,6 +96,14 @@ class MaterializationError(IngestError):
|
|||
- `ingested_at_invalid` — ingested_at is not ISO-8601 UTC with a Z suffix
|
||||
- `collision_unstamped` — the §3 collision gate: a generated name is
|
||||
occupied by a file without the ingest stamp
|
||||
- `inbox_slug_empty` — a dropped file's name reduces to an empty slug
|
||||
under the id grammar (Door B; never an invented fallback name)
|
||||
- `inbox_title_invalid` — an inbox title is multi-line or contains `[`/`]`,
|
||||
either of which would break frontmatter or an index link
|
||||
- `inbox_source_file_invalid` — an inbox `source_file` is multi-line and
|
||||
would inject frontmatter lines
|
||||
- `okf_type_reserved` — an inbox concept claims the reserved 'verdict'
|
||||
layer (the same reservation ManifestError enforces at Door A)
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
128
src/llm_ingestion_okf/inbox.py
Normal file
128
src/llm_ingestion_okf/inbox.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Door B inbox provenance rendering and filename slugging (Phase 2 step 2).
|
||||
|
||||
Pure functions on top of the Phase 1 primitives: a dropped file's name is
|
||||
reduced to the Phase 1 id grammar and namespaced `inbox-{slug}.md` — disjoint
|
||||
from `index.md`, Door A's `ingest-*`, and `promoted-verdict-*` — and the
|
||||
concept body is framed with the §7-analogous honesty marker (`type`, `title`,
|
||||
`source_file`, `source_sha256`, `ingested_at`, `generated: true`).
|
||||
|
||||
`source_sha256` is taken over the ORIGINAL dropped bytes, never over the
|
||||
extracted text, so provenance stays re-verifiable against the operator's file.
|
||||
`ingested_at` is explicit and validated by the same rule as Door A: no
|
||||
wall-clock anywhere. Output is LF-only with exactly one trailing newline.
|
||||
|
||||
No guard call and no model call in this module — the persist gate arrives with
|
||||
the inbox flow (step 3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import MaterializationError
|
||||
from .materialize import validate_ingested_at
|
||||
|
||||
_RESERVED_OKF_TYPE = "verdict"
|
||||
|
||||
# Every character outside the Phase 1 id grammar (`[a-z0-9][a-z0-9-]*`) is a
|
||||
# separator. Deliberately NOT a transliteration: mapping non-ASCII letters to
|
||||
# ASCII ones would be a semantic claim the slugger cannot make — Norwegian
|
||||
# `møte` (meeting) would become `mote` (fashion). The readable name survives
|
||||
# verbatim in the `title` field; the slug is an identifier, not a label.
|
||||
_SEPARATOR_RUN_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def inbox_slug(source_filename: str) -> str:
|
||||
"""Reduce a dropped file's name to the Phase 1 id grammar.
|
||||
|
||||
The final extension is dropped, the rest is lowercased, and every run of
|
||||
non-grammar characters collapses to a single `-` (stripped at both ends).
|
||||
A name that reduces to nothing fails fast: never an invented fallback like
|
||||
`untitled`, which would silently collide across unrelated files.
|
||||
"""
|
||||
# NFC first: macOS (APFS/HFS+) hands filenames over DECOMPOSED, so an
|
||||
# `é` arrives as `e` + combining acute. Without normalising, the same
|
||||
# visual filename slugs differently depending on where it came from — the
|
||||
# combining mark alone becomes a separator and the base letter survives
|
||||
# (`cafe`), where a composed `é` is one non-grammar character (`caf`).
|
||||
# Composing first makes the whole letter one unit, so non-ASCII is
|
||||
# uniformly a separator and the slug is stable across both forms.
|
||||
stem = unicodedata.normalize("NFC", Path(source_filename).stem)
|
||||
slug = _SEPARATOR_RUN_RE.sub("-", stem.lower()).strip("-")
|
||||
if not slug:
|
||||
raise MaterializationError(
|
||||
f"inbox filename {source_filename!r} reduces to an empty slug under the "
|
||||
"id grammar ([a-z0-9][a-z0-9-]*) — refusing to invent a filename",
|
||||
code="inbox_slug_empty",
|
||||
)
|
||||
return slug
|
||||
|
||||
|
||||
def inbox_filename(slug: str) -> str:
|
||||
"""The concept filename for an inbox file.
|
||||
|
||||
The `inbox-` prefix keeps the namespace disjoint from `index.md`, Door A's
|
||||
`ingest-*`, and `promoted-verdict-*` for every slug the grammar admits.
|
||||
"""
|
||||
return f"inbox-{slug}.md"
|
||||
|
||||
|
||||
def _normalize_body(text: str) -> str:
|
||||
# LF-only with exactly one trailing newline is a byte-level guarantee, and
|
||||
# dropped files legitimately arrive with CRLF — normalising is the
|
||||
# deterministic answer here, where Door A can validate instead because it
|
||||
# renders its own bodies.
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n") + "\n"
|
||||
|
||||
|
||||
def render_inbox_concept(
|
||||
text: str,
|
||||
*,
|
||||
okf_type: str,
|
||||
title: str,
|
||||
source_file: str,
|
||||
source_bytes: bytes,
|
||||
ingested_at: str,
|
||||
) -> str:
|
||||
"""Frame extracted text as an inbox concept file with its provenance layer.
|
||||
|
||||
`source_bytes` are the ORIGINAL dropped bytes — hashed here so the marker
|
||||
cannot drift onto the extracted text. Fail-fast on an invalid
|
||||
`ingested_at`, on the reserved verdict layer, and on a title or
|
||||
`source_file` that would break an index link or inject frontmatter lines.
|
||||
"""
|
||||
validate_ingested_at(ingested_at)
|
||||
|
||||
# The verdict layer is RESERVED: the promotion gate is the only path into
|
||||
# it, at this door exactly as at Door A's manifest validation.
|
||||
if okf_type.lower() == _RESERVED_OKF_TYPE:
|
||||
raise MaterializationError(
|
||||
f"okf_type must not be {_RESERVED_OKF_TYPE!r} (reserved layer)",
|
||||
code="okf_type_reserved",
|
||||
)
|
||||
# The title is rendered verbatim into `- [title](target)` and into
|
||||
# line-oriented frontmatter — met by fail-fast validation, never repair.
|
||||
if any(char in title for char in "\n\r[]"):
|
||||
raise MaterializationError(
|
||||
f"title must be single-line and must not contain '[' or ']', got {title!r}",
|
||||
code="inbox_title_invalid",
|
||||
)
|
||||
if "\n" in source_file or "\r" in source_file:
|
||||
raise MaterializationError(
|
||||
f"source_file must be single-line, got {source_file!r}",
|
||||
code="inbox_source_file_invalid",
|
||||
)
|
||||
|
||||
frontmatter = {
|
||||
"type": okf_type,
|
||||
"title": title,
|
||||
"source_file": source_file,
|
||||
"source_sha256": hashlib.sha256(source_bytes).hexdigest(),
|
||||
"ingested_at": ingested_at,
|
||||
"generated": "true",
|
||||
}
|
||||
rendered = "\n".join(f"{key}: {value}" for key, value in frontmatter.items())
|
||||
return f"---\n{rendered}\n---\n\n{_normalize_body(text)}"
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue