feat(phase-3): STRICT_V1's type enum and frontmatter schema, with the reader

Phase 3 step 3, frontmatter half. FrontmatterSchema grows the four judging
fields the proving consumer's contract needs — required, allowed, nullable,
key_pattern — and `violations` is what reads them, so no field lands as an
untested claim.

STRICT_V1 requires the eight keys their operator ratified on 2026-07-25 inside
the eleven-key allowlist that doubles as emission order, both derived from one
tuple so they cannot drift; `summary` is the single nullable key; a value is a
string scalar or a NON-EMPTY LIST OF STRINGS, because their Release pages carry
block lists and a scalars-only schema would reject pages they ship today.

Measured, not assumed: DEFAULT must NOT grow a required set. Door A emits seven
keys, Door B six, and Door C writes an imported concept verbatim with whatever
the sender wrote, so any required set on DEFAULT would declare invalid the
bundles this library itself produces. The plan's other cross-profile direction —
a wiki bundle rejected under DEFAULT — therefore has to come from index and path
shape, not from frontmatter. Recorded here because the plan implied otherwise.

Violations are reported, not raised, like TypeRejection, and sorted by
(key, code): a report that depended on mapping order would not be reproducible.
An unsatisfiable schema — a required or nullable key outside its own allowlist —
fails at construction.

Two boundaries kept explicit. `violations` judges an already-parsed mapping;
parsing strict frontmatter needs a parser this library does not have, which is
the same reason Door C writes verbatim. And STRICT_V1's index policy is still
DEFAULT's, pinned by a test as a known gap: per-directory indexes and an entry
description have no field on IndexPolicy yet, and nothing consumes a profile's
index policy today.

C1 re-proven: 443 existing tests unmodified and green (468 total), and
`git diff --stat examples/` empty.
This commit is contained in:
Kjell Tore Guttormsen 2026-07-25 15:29:26 +02:00
commit 848e3953fc
2 changed files with 480 additions and 5 deletions

View file

@ -79,9 +79,38 @@ class TypePolicy:
return None
@dataclass(frozen=True)
class SchemaViolation:
"""One way a document's frontmatter departs from the schema.
Reported rather than raised, like `TypeRejection`: a document can depart in
several ways at once, and the caller decides whether that is a refusal, a
report line, or a migration to-do. `code` is the stable machine-readable
part; `reason` completes the sentence "<key> ...".
"""
key: str
reason: str
code: str
def _is_legal_value(value: object) -> bool:
"""A string scalar, or a non-empty list of strings — nothing else.
The list shape is real, not hypothetical: the proving consumer's `Release`
pages carry block lists. A schema that assumed scalars-only would reject
pages they ship today.
"""
if isinstance(value, str):
return True
if isinstance(value, list):
return bool(value) and all(isinstance(item, str) for item in value)
return False
@dataclass(frozen=True)
class FrontmatterSchema:
"""The frontmatter key namespace and how it is emitted.
"""The frontmatter key namespace, what it admits, 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
@ -89,14 +118,81 @@ class FrontmatterSchema:
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.
The judging fields `required`, `allowed`, `nullable`, `key_pattern` are
read by `violations`. `allowed` is `None` for an open namespace, which is
what DEFAULT keeps: Door A emits seven keys, Door B six, and Door C writes
an imported concept verbatim with whatever the sender wrote, so a closed
namespace or a required set on DEFAULT would declare invalid the very
bundles this library produces.
"""
order: tuple[str, ...]
collapsed_keys: frozenset[str] = field(default_factory=frozenset)
required: frozenset[str] = field(default_factory=frozenset)
allowed: frozenset[str] | None = None
nullable: frozenset[str] = field(default_factory=frozenset)
key_pattern: re.Pattern[str] | None = None
def __post_init__(self) -> None:
if self.allowed is None:
return
for label, keys in (("required", self.required), ("nullable", self.nullable)):
stray = sorted(keys - self.allowed)
if stray:
raise ValueError(
f"{label} keys must be inside the allowlist "
f"(got {', '.join(repr(key) for key in stray)}) — a schema that "
"demands a key it also forbids can never be satisfied"
)
def violations(self, values: Mapping[str, object]) -> tuple[SchemaViolation, ...]:
"""Every way `values` departs from this schema, deterministically ordered.
Sorted by `(key, code)` rather than reported in mapping order: two runs
over the same document must produce the same report, and mapping order
is an accident of how the document was parsed.
Takes an already-parsed mapping. Parsing strict frontmatter is a
separate concern this library's line-oriented parser cannot represent
the block lists and nulls a strict schema admits, which is why Door C
writes imported concepts verbatim rather than round-tripping them.
"""
found: list[SchemaViolation] = []
for key in self.required - set(values):
found.append(SchemaViolation(key, "is required and absent", "frontmatter_key_missing"))
for key, value in values.items():
if self.key_pattern is not None and not self.key_pattern.fullmatch(key):
found.append(
SchemaViolation(
key,
f"is not a legal key name (must match {self.key_pattern.pattern})",
"frontmatter_key_malformed",
)
)
continue
if self.allowed is not None and key not in self.allowed:
found.append(
SchemaViolation(
key, "is not on the key allowlist", "frontmatter_key_not_allowed"
)
)
continue
if value is None:
if key not in self.nullable:
found.append(SchemaViolation(key, "must not be null", "frontmatter_value_null"))
continue
if not _is_legal_value(value):
found.append(
SchemaViolation(
key,
"must be a string or a non-empty list of strings",
"frontmatter_value_shape",
)
)
return tuple(sorted(found, key=lambda violation: (violation.key, violation.code)))
def emit(self, values: Mapping[str, str]) -> str:
"""Render `values` as line-oriented `key: value`, one line per key.
@ -201,3 +297,52 @@ DEFAULT = BundleProfile(
link_pattern=re.compile(r"^- \[(?P<label>[^\]]*)\]\((?P<target>[^)]+)\)$"),
),
)
# `FRONTMATTER_KEY_ORDER` in the proving consumer's `bundle.py`: an eleven-key
# allowlist that doubles as canonical emission order. The first eight are the
# required set their operator ratified on 2026-07-25 (measured present on
# 522/522 documents); the last three are layer-specific and stay optional.
# Deriving both sets from this one tuple is what keeps them from drifting apart
# — their BUNDLE_HASH_REGISTRY gate depends on the order.
_STRICT_V1_KEY_ORDER = (
"type",
"title",
"description",
"timestamp",
"layer",
"source_tier",
"source_url",
"source_sha",
"version",
"date",
"summary",
)
# The `claude-code-llm-wiki` contract, from `docs/phase-3-split-table.md`.
#
# Deliberately stricter than the consumer's own validator: their REQUIRED_KEYS
# constant is the first four keys, which is their emit-path minimum rather than
# their contract. If they ever ship a document class that legitimately lacks
# `source_sha`, the agreed resolution is to bump this profile — they send the
# requirement before shipping the class, not after.
#
# `paths` and `index` are DEFAULT's, and only `paths` is the whole truth: the
# filename namespaces are an ingest-spec invariant about what the doors write,
# not a consumer preference. The consumer's index shape — one index per
# directory, `* [Title](link) - description` entries, a root index carrying
# okf_version/bundle_profile/okf_spec_commit — is NOT expressible by IndexPolicy
# yet and lands with the reader that judges it. Nothing consumes a profile's
# index policy today, so the gap is pinned by a test rather than left to drift.
STRICT_V1 = BundleProfile(
types=TypePolicy(allowed=frozenset({"Concept", "Guide", "Reference", "Release"})),
frontmatter=FrontmatterSchema(
order=_STRICT_V1_KEY_ORDER,
required=frozenset(_STRICT_V1_KEY_ORDER[:8]),
allowed=frozenset(_STRICT_V1_KEY_ORDER),
nullable=frozenset({"summary"}),
key_pattern=re.compile(r"^[a-z_]+$"),
),
paths=DEFAULT.paths,
index=DEFAULT.index,
)