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:
Kjell Tore Guttormsen 2026-07-25 15:09:14 +02:00
commit 6f42c10608
6 changed files with 470 additions and 77 deletions

View file

@ -0,0 +1,203 @@
"""The bundle contract as configuration (Phase 3).
What a valid bundle looks like which concept types exist, which frontmatter
keys are emitted and in which order, which filename namespaces the doors own,
and what an index line looks like is a profile, not a set of constants
scattered across the doors. `DEFAULT` is exactly the ingest-spec v1 + Phase 2
contract, so nothing observable changes for a caller that never mentions a
profile; the golden fixtures are the byte-level proof.
Two things deliberately do NOT live here. Security is the guard's, always: no
disposition, origin or channel vocabulary belongs on a profile. And the
reserved `verdict` layer is a spec invariant rather than profile config it is
refused at construction, so a profile admitting it cannot be built, let alone
passed to a door.
Profiles are constructed in code. Config-file loading and inheritance chains
are extension points, not v1 (settled with the operator at phase start).
"""
from __future__ import annotations
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
# The one layer no profile may admit (ingest-spec §3): the promotion gate is
# the only path into it. Compared case-insensitively, as both doors already do.
RESERVED_OKF_TYPE = "verdict"
@dataclass(frozen=True)
class TypeRejection:
"""Why a profile refuses an `okf_type`, for the door to frame and raise.
The policy does not raise: Door A refuses with `ManifestError` and Door B
with `MaterializationError`, so the refusal has to be reported rather than
thrown. `reason` completes the sentence "<label>okf_type ..." and `code` is
the stable `IngestError.code` the caller asserts on.
"""
reason: str
code: str
@dataclass(frozen=True)
class TypePolicy:
"""Which `okf_type` values a bundle admits.
`allowed` is `None` for an open set Phase 1/2 accept any type the
operator names or a closed enum. Either way the reserved layer is
excluded, and a closed set that names it fails at construction.
"""
allowed: frozenset[str] | None = None
def __post_init__(self) -> None:
if self.allowed is None:
return
named = sorted(value for value in self.allowed if value.lower() == RESERVED_OKF_TYPE)
if named:
raise ValueError(
f"a profile must not admit the reserved {RESERVED_OKF_TYPE!r} layer "
f"(got {', '.join(repr(value) for value in named)}) — the promotion "
"gate is the only path into it (ingest-spec §3)"
)
def rejection(self, okf_type: str) -> TypeRejection | None:
"""The refusal for `okf_type`, or `None` when the profile admits it."""
if okf_type.lower() == RESERVED_OKF_TYPE:
return TypeRejection(
reason=f"must not be {RESERVED_OKF_TYPE!r} (reserved layer)",
code="okf_type_reserved",
)
if self.allowed is not None and okf_type not in self.allowed:
return TypeRejection(
reason=f"must be one of {', '.join(sorted(self.allowed))}, got {okf_type!r}",
code="okf_type_not_allowed",
)
return None
@dataclass(frozen=True)
class FrontmatterSchema:
"""The frontmatter key namespace and how it is emitted.
`order` is the canonical emission order and `collapsed_keys` names the keys
whose whitespace runs collapse to single spaces on the way out. The DEFAULT
order spans both doors' key sets: Door A emits seven of these keys and Door
B six, and each door's subset comes out in exactly the order it wrote by
hand in Phases 1 and 2.
The required/allowlisted key sets the proving consumer needs (see
`docs/phase-3-split-table.md`) are not here yet: no code reads them until
the STRICT_V1 validator exists, and a field nothing reads is a claim
nothing tests.
"""
order: tuple[str, ...]
collapsed_keys: frozenset[str] = field(default_factory=frozenset)
def emit(self, values: Mapping[str, str]) -> str:
"""Render `values` as line-oriented `key: value`, one line per key.
Commons decision D1: the keys named in `order` come first, in that
order, followed by any remaining keys SORTED. Ordering the tail rather
than trusting insertion order is what makes a regeneration over the
same data byte-identical.
Returns the lines only the caller owns the `---` fences.
"""
named = [key for key in self.order if key in values]
tail = sorted(key for key in values if key not in self.order)
return "\n".join(f"{key}: {self._render(key, values[key])}" for key in [*named, *tail])
def _render(self, key: str, value: str) -> str:
# §5 mandates whitespace-run collapse for `source_query` only
# (ingest-spec.md:140-141), where a legitimately multi-line SQL SELECT
# must render on one line. Every other value is validated single-line
# at load and emitted verbatim — validation, not repair — so
# operator-supplied bytes (e.g. a title's internal double space)
# survive.
return " ".join(value.split()) if key in self.collapsed_keys else value
@dataclass(frozen=True)
class PathPolicy:
"""The filename namespaces the three doors write into.
Each prefix keeps its door's generated names disjoint from `index.md`, from
the other doors, and from `promoted-verdict-*`, for every id the grammar
admits. The id grammar itself stays in `materialize`: it is a pattern the
slugger derives a separator class from, not a name to configure.
"""
concept_suffix: str
ingest_prefix: str
inbox_prefix: str
import_prefix: str
@dataclass(frozen=True)
class IndexPolicy:
"""The index file and the shape of the links this library manages in it.
`link_template` renders a managed line and `link_pattern` recognises one.
Both are carried because §6 does both append on write, rewrite on
maintenance and a round-trip test is what keeps the pair honest. The
pattern is anchored to the whole line by construction: removal keys on this
exact shape, never a bare substring, so curated prose that mentions a
target inline survives verbatim.
"""
name: str
link_template: str
link_pattern: re.Pattern[str]
def render_link(self, label: str, target: str) -> str:
return self.link_template.format(label=label, target=target)
@dataclass(frozen=True)
class BundleProfile:
"""One bundle contract: types, frontmatter, filenames, index."""
types: TypePolicy
frontmatter: FrontmatterSchema
paths: PathPolicy
index: IndexPolicy
# The ingest-spec v1 + Phase 2 contract, unchanged. Every value here was a
# constant in `manifest`, `materialize`, `inbox` or `importer` before this
# module existed; the golden suite is what proves the move changed no bytes.
DEFAULT = BundleProfile(
types=TypePolicy(allowed=None),
frontmatter=FrontmatterSchema(
# Door A's seven keys and Door B's six, merged into one order that
# contains both as subsequences — neither door's output moves.
order=(
"type",
"title",
"source_system",
"source_query",
"source_file",
"source_sha256",
"ingested_at",
"ingest_manifest",
"generated",
),
collapsed_keys=frozenset({"source_query"}),
),
paths=PathPolicy(
concept_suffix=".md",
ingest_prefix="ingest-",
inbox_prefix="inbox-",
import_prefix="import-",
),
index=IndexPolicy(
name="index.md",
link_template="- [{label}]({target})",
link_pattern=re.compile(r"^- \[(?P<label>[^\]]*)\]\((?P<target>[^)]+)\)$"),
),
)