refactor(phase-3): the bundle contract becomes a profile object
Phase 3 step 1. What Phases 1 and 2 hard-coded about a valid bundle now lives on one frozen `BundleProfile`, and `DEFAULT` states exactly the ingest-spec v1 + Phase 2 contract. Nothing observable changes: the 425 existing tests are unmodified and green, and `git diff --stat examples/` is empty, which is assumption C1's whole proof. Both oracles are real rather than nominal — the golden suite compares `read_bytes()`, and Door B pins its frontmatter block as an exact string. Moved onto the profile, each one previously a constant with a reader: the index name and its managed-link shape (template plus pattern, kept honest by a round-trip test), the three filename namespaces (`ingest-`/`inbox-`/`import-` and `.md`), the reserved `verdict` layer (duplicated in `manifest` and `inbox` before this), the frontmatter key order, and the `source_query` whitespace collapse. Two details are worth naming. The DEFAULT key order spans both doors: Door A's seven keys and Door B's six are subsequences of one nine-key order, so a single canonical order reproduces both doors byte-for-byte. And emission follows commons decision D1 — ordered prefix, then any remaining keys sorted — which is the mechanism the proving consumer's hash registry needs; under DEFAULT the tail is always empty. `TypePolicy` refuses the reserved layer at CONSTRUCTION (assumption C3), so a profile admitting `verdict` cannot be built, let alone passed to a door. It reports refusals rather than raising them, because Door A refuses with `ManifestError` and Door B with `MaterializationError` for the same type — the wording and the stable code come from the policy, the exception class stays each door's own. Deliberately NOT moved, each for a stated reason: the required and allowlisted key sets the proving consumer needs (no code reads them until the STRICT_V1 validator exists, and a field nothing reads is a claim nothing tests), the id grammar (a pattern the slugger derives a separator class from, not a flat name), `NAME_MAX_BYTES` (a filesystem fact, not a contract choice), and every disposition/origin/channel vocabulary (guard territory, always). The profile is also not exported from the package root yet — the optional `profile` argument on the flows is step 3's plumbing change. 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
08ca68fee2
commit
6f42c10608
6 changed files with 470 additions and 77 deletions
|
|
@ -28,7 +28,6 @@ from pathlib import Path
|
|||
from .errors import IngestError, MaterializationError, SourceError
|
||||
from .extract import extract_text
|
||||
from .materialize import (
|
||||
INDEX_NAME,
|
||||
check_filename_length,
|
||||
link_in_index,
|
||||
parse_frontmatter,
|
||||
|
|
@ -36,11 +35,7 @@ from .materialize import (
|
|||
validate_ingested_at,
|
||||
write_bytes,
|
||||
)
|
||||
|
||||
_RESERVED_OKF_TYPE = "verdict"
|
||||
|
||||
_FILENAME_PREFIX = "inbox-"
|
||||
_FILENAME_SUFFIX = ".md"
|
||||
from .profiles import DEFAULT
|
||||
|
||||
|
||||
def inbox_slug(source_filename: str) -> str:
|
||||
|
|
@ -73,7 +68,8 @@ def inbox_filename(slug: str) -> str:
|
|||
not give it.
|
||||
"""
|
||||
return check_filename_length(
|
||||
f"{_FILENAME_PREFIX}{slug}{_FILENAME_SUFFIX}", code="inbox_slug_too_long"
|
||||
f"{DEFAULT.paths.inbox_prefix}{slug}{DEFAULT.paths.concept_suffix}",
|
||||
code="inbox_slug_too_long",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -104,12 +100,11 @@ def render_inbox_concept(
|
|||
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",
|
||||
)
|
||||
# it, at this door exactly as at Door A's manifest validation — the same
|
||||
# profile decides, each door raises its own typed error.
|
||||
rejection = DEFAULT.types.rejection(okf_type)
|
||||
if rejection is not None:
|
||||
raise MaterializationError(f"okf_type {rejection.reason}", code=rejection.code)
|
||||
# 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[]"):
|
||||
|
|
@ -131,8 +126,7 @@ def render_inbox_concept(
|
|||
"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)}"
|
||||
return f"---\n{DEFAULT.frontmatter.emit(frontmatter)}\n---\n\n{_normalize_body(text)}"
|
||||
|
||||
|
||||
# --- the guard seam -------------------------------------------------------
|
||||
|
|
@ -248,11 +242,9 @@ def process_inbox(
|
|||
a reserved `okf_type`, and a missing inbox directory.
|
||||
"""
|
||||
validate_ingested_at(ingested_at)
|
||||
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",
|
||||
)
|
||||
run_rejection = DEFAULT.types.rejection(okf_type)
|
||||
if run_rejection is not None:
|
||||
raise MaterializationError(f"okf_type {run_rejection.reason}", code=run_rejection.code)
|
||||
inbox = Path(inbox_dir)
|
||||
if not inbox.is_dir():
|
||||
raise SourceError(f"inbox directory does not exist: {inbox}", code="source_root_missing")
|
||||
|
|
@ -299,7 +291,11 @@ def process_inbox(
|
|||
# pre-existing curated content by a later file's check.
|
||||
bundle = Path(bundle_dir)
|
||||
pre_existing = (
|
||||
{path.name for path in bundle.glob("*.md") if path.name != INDEX_NAME}
|
||||
{
|
||||
path.name
|
||||
for path in bundle.glob(f"*{DEFAULT.paths.concept_suffix}")
|
||||
if path.name != DEFAULT.index.name
|
||||
}
|
||||
if bundle.is_dir()
|
||||
else set()
|
||||
)
|
||||
|
|
@ -369,9 +365,9 @@ def process_inbox(
|
|||
|
||||
# §6 index — the last disk mutation, and only when something was written.
|
||||
if persisted:
|
||||
index_path = bundle / INDEX_NAME
|
||||
index_path = bundle / DEFAULT.index.name
|
||||
if not index_path.is_file():
|
||||
write_bytes(bundle, INDEX_NAME, "")
|
||||
write_bytes(bundle, DEFAULT.index.name, "")
|
||||
for entry in persisted:
|
||||
link_in_index(
|
||||
bundle, entry.path.name, unicodedata.normalize("NFC", Path(entry.source_file).stem)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue