feat(import): Door C flow against an injected import gate (Phase 2 step 5)
Reads an external OKF bundle as {bundle-relative path -> document text},
hands it WHOLE to an injected gate over the guard's okf.import_bundle (a
bundle-level call: it resolves the cross-link graph across concepts), and
merges only concepts clearing the non-blocking floor. Same injection pattern
as Door B, so the core stays dependency-free while the CI channel for the
real guard is settled.
Two constraints shaped the design and are pinned by tests:
- A merged concept is written VERBATIM. Stamping provenance into it would
require round-tripping its frontmatter through this library's line-oriented
parser, which cannot represent the block lists the guard's parser accepts --
silent data loss -- and would persist bytes the guard never screened.
- Ownership is therefore proven by content identity: identical bytes at the
target name are a no-op re-merge (re-import of an unchanged bundle is
idempotent), and anything else at the name is refused. Curated content and
an updated concept are refused alike; refusing is what never destroys.
The floor is fail-closed beyond the plan's "no error" wording: an error, an
unrecognised disposition, and a concept the gate returned no verdict for are
all refusals. quarantine_review stays its own bucket, as at Door B.
origin/channel are validated against the guard's pinned vocabulary -- it
derives trust from origin by enum identity, so an unrecognised string would be
silently downgraded rather than caught.
Three primitives promoted for reuse rather than duplicated:
reduce_to_id_grammar and check_filename_length to materialize.py, and
extract.decode_text. Door C slugs the WHOLE concept path, so tables/users.md
and views/users.md stay distinct. Concept discovery folds case explicitly
rather than globbing *.md, whose case-sensitivity follows the filesystem and
would import the same bundle differently on APFS and ext4.
README's "what is gated today" section corrected: it claimed nothing is gated,
which is no longer true, but the honest statement is narrower than "the doors
are gated" -- the library cannot verify that an injected adapter is a real
guard, and a permissive stub is believed.
405 tests green; ruff, ruff format and mypy --strict clean.
This commit is contained in:
parent
d812a839be
commit
f10fc60de2
11 changed files with 1250 additions and 61 deletions
|
|
@ -20,7 +20,6 @@ supplies the verdict and this module only obeys it.
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import unicodedata
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -30,28 +29,16 @@ from .errors import IngestError, MaterializationError, SourceError
|
|||
from .extract import extract_text
|
||||
from .materialize import (
|
||||
INDEX_NAME,
|
||||
check_filename_length,
|
||||
link_in_index,
|
||||
parse_frontmatter,
|
||||
reduce_to_id_grammar,
|
||||
validate_ingested_at,
|
||||
write_bytes,
|
||||
)
|
||||
|
||||
_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]+")
|
||||
|
||||
# NAME_MAX: the per-component limit on every filesystem this library targets
|
||||
# (APFS, ext4, NTFS all cap at 255). Checked here rather than caught at the
|
||||
# write, because the OS signals it as an OSError whose errno differs per
|
||||
# platform (63 on macOS, 36 on Linux) — an untyped, unportable failure at the
|
||||
# very moment the caller needs a typed per-file outcome. Verified empirically
|
||||
# on APFS 2026-07-25: a 255-byte name writes, a 258-byte one raises errno 63.
|
||||
_MAX_FILENAME_BYTES = 255
|
||||
_FILENAME_PREFIX = "inbox-"
|
||||
_FILENAME_SUFFIX = ".md"
|
||||
|
||||
|
|
@ -64,15 +51,7 @@ def inbox_slug(source_filename: str) -> str:
|
|||
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("-")
|
||||
slug = reduce_to_id_grammar(Path(source_filename).stem)
|
||||
if not slug:
|
||||
raise MaterializationError(
|
||||
f"inbox filename {source_filename!r} reduces to an empty slug under the "
|
||||
|
|
@ -88,27 +67,14 @@ def inbox_filename(slug: str) -> str:
|
|||
The `inbox-` prefix keeps the namespace disjoint from `index.md`, Door A's
|
||||
`ingest-*`, and `promoted-verdict-*` for every slug the grammar admits.
|
||||
|
||||
A name the filesystem cannot hold fails fast rather than being truncated:
|
||||
truncation is lossy AND collision-prone (two long names sharing a prefix
|
||||
would reduce to one filename, and the second write would silently claim
|
||||
the first file). Refusing keeps the same posture as `inbox_slug_empty` —
|
||||
the library never invents a filename the operator did not give it. The
|
||||
operator's fix is to rename the dropped file, so the message carries both
|
||||
the actual size and the limit.
|
||||
A name the filesystem cannot hold fails fast rather than being truncated
|
||||
(see :func:`check_filename_length`). Refusing keeps the same posture as
|
||||
`inbox_slug_empty` — the library never invents a filename the operator did
|
||||
not give it.
|
||||
"""
|
||||
name = f"{_FILENAME_PREFIX}{slug}{_FILENAME_SUFFIX}"
|
||||
# The slug is ASCII by construction (the id grammar admits nothing else),
|
||||
# so len() in characters and in bytes agree — encoding here anyway keeps
|
||||
# the check honest if the grammar is ever widened.
|
||||
size = len(name.encode("utf-8"))
|
||||
if size > _MAX_FILENAME_BYTES:
|
||||
raise MaterializationError(
|
||||
f"inbox filename for slug {slug!r} would be {size} bytes, over the "
|
||||
f"{_MAX_FILENAME_BYTES}-byte filesystem limit — rename the dropped "
|
||||
"file; refusing to truncate (lossy and collision-prone)",
|
||||
code="inbox_slug_too_long",
|
||||
)
|
||||
return name
|
||||
return check_filename_length(
|
||||
f"{_FILENAME_PREFIX}{slug}{_FILENAME_SUFFIX}", code="inbox_slug_too_long"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_body(text: str) -> str:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue