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,
)

330
tests/test_strict_v1.py Normal file
View file

@ -0,0 +1,330 @@
"""The proving consumer's contract as a profile (Phase 3, step 3).
`STRICT_V1` is the `claude-code-llm-wiki` bundle contract expressed in the same
object `DEFAULT` uses. Everything pinned here was measured in their repository
on 2026-07-25 and settled in `docs/phase-3-split-table.md`; the eight-key
required set is their operator's ratification, deliberately stricter than the
four-key constant their own validator enforces.
These tests are also the reader that earns the new schema fields. A profile
field no code reads is a claim no test covers, so `required`, `allowed`,
`nullable` and `key_pattern` land together with `FrontmatterSchema.violations`,
which is what reads them.
What is NOT here, and why: `violations` takes an already-parsed mapping. This
library's line-oriented parser cannot represent the block lists and nulls the
consumer's frontmatter admits — that is precisely why Door C writes imported
concepts verbatim so parsing strict-v1 frontmatter is a separate concern from
judging it, and lands separately.
"""
from __future__ import annotations
import pytest
from llm_ingestion_okf.profiles import (
DEFAULT,
STRICT_V1,
FrontmatterSchema,
)
# The eight keys the consumer's operator ratified on 2026-07-25, measured
# present on 522/522 of their documents.
RATIFIED_REQUIRED = frozenset(
{
"type",
"title",
"description",
"timestamp",
"layer",
"source_tier",
"source_url",
"source_sha",
}
)
# `FRONTMATTER_KEY_ORDER` in their `bundle.py`: the eight above plus three
# layer-specific keys, doubling as canonical emission order.
RATIFIED_ORDER = (
"type",
"title",
"description",
"timestamp",
"layer",
"source_tier",
"source_url",
"source_sha",
"version",
"date",
"summary",
)
def wiki_document() -> dict[str, object]:
"""A conforming `concepts/` page, every allowlisted key present."""
return {
"type": "Concept",
"title": "Hooks",
"description": "Event-driven automation in Claude Code.",
"timestamp": "2026-07-25T12:00:00Z",
"layer": "concepts",
"source_tier": "official-docs",
"source_url": "https://docs.claude.com/en/docs/claude-code/hooks",
"source_sha": "0" * 64,
"version": "2.1.183",
"date": "2026-07-25",
"summary": "What hooks are and when they fire.",
}
def codes(schema: FrontmatterSchema, values: dict[str, object]) -> list[str]:
return [violation.code for violation in schema.violations(values)]
# --- the profile itself ---------------------------------------------------
def test_strict_v1_closes_the_type_enum_to_the_four_document_classes() -> None:
"""Their four classes exactly — an open set would admit anything."""
assert STRICT_V1.types.allowed == frozenset({"Concept", "Guide", "Reference", "Release"})
def test_strict_v1_requires_the_eight_ratified_keys() -> None:
"""Eight, not the four their `REQUIRED_KEYS` constant spells.
Their constant is the emit-path minimum; the contract their operator
ratified is all eight. This is the one place the profile is deliberately
stricter than the consumer's own validator, and the agreed resolution if a
document class ever legitimately lacks `source_sha` is to bump the profile,
not to bend the bundle.
"""
assert STRICT_V1.frontmatter.required == RATIFIED_REQUIRED
def test_strict_v1_allowlist_is_eleven_keys_and_doubles_as_emission_order() -> None:
"""The allowlist and the order are one list, not two that can drift apart.
Their `BUNDLE_HASH_REGISTRY` gate depends on regeneration being
byte-identical, which is what the fixed order buys.
"""
assert STRICT_V1.frontmatter.order == RATIFIED_ORDER
assert STRICT_V1.frontmatter.allowed == frozenset(RATIFIED_ORDER)
def test_the_three_layer_specific_keys_are_optional_not_required() -> None:
"""`version`/`date` sit on 351/522 documents and `summary` on 349/522.
Requiring them would reject pages the consumer ships today.
"""
allowed = STRICT_V1.frontmatter.allowed
assert allowed is not None
for key in ("version", "date", "summary"):
assert key in allowed
assert key not in STRICT_V1.frontmatter.required
def test_summary_is_the_only_nullable_key() -> None:
"""The single deliberate escape from strings-only frontmatter."""
assert STRICT_V1.frontmatter.nullable == frozenset({"summary"})
# --- the reader: a conforming document -----------------------------------
def test_a_conforming_wiki_document_has_no_violations() -> None:
assert STRICT_V1.frontmatter.violations(wiki_document()) == ()
def test_the_optional_keys_may_simply_be_absent() -> None:
"""A `concepts/` page carries neither `version` nor `date`."""
document = wiki_document()
for key in ("version", "date", "summary"):
del document[key]
assert STRICT_V1.frontmatter.violations(document) == ()
# --- the reader: cross-profile rejection ---------------------------------
def test_a_door_a_ingest_document_is_rejected_under_strict_v1() -> None:
"""The plan's cross-profile test, frontmatter half.
A bundle this library generates under `DEFAULT` carries the ingest-spec
layer, which shares only `type` and `title` with the consumer's contract:
six required keys are missing and five of its own keys are off-allowlist.
"""
door_a = {
"type": "dataset",
"title": "Orders",
"source_system": "golden-catalogue",
"source_query": "orders.csv",
"ingested_at": "2026-07-16T12:00:00Z",
"ingest_manifest": "manifest@37674ac20059e788",
"generated": "true",
}
violations = STRICT_V1.frontmatter.violations(door_a)
assert violations != ()
missing = {v.key for v in violations if v.code == "frontmatter_key_missing"}
assert missing == RATIFIED_REQUIRED - {"type", "title"}
off_allowlist = {v.key for v in violations if v.code == "frontmatter_key_not_allowed"}
assert off_allowlist == {
"source_system",
"source_query",
"ingested_at",
"ingest_manifest",
"generated",
}
def test_default_judges_nothing_about_keys_so_both_doors_stay_valid() -> None:
"""`DEFAULT` must NOT grow a required set — measured, not assumed.
Door A emits seven keys, Door B six, and Door C writes an imported concept
verbatim with whatever frontmatter the sender wrote. A required set on
`DEFAULT` would therefore declare invalid the very bundles this library
produces. `DEFAULT` keeps an open namespace; the rejection of a wiki-shaped
bundle under `DEFAULT` comes from the index and path shape instead.
"""
assert DEFAULT.frontmatter.required == frozenset()
assert DEFAULT.frontmatter.allowed is None
assert DEFAULT.frontmatter.violations(wiki_document()) == ()
assert DEFAULT.frontmatter.violations({"type": "dataset", "title": "Orders"}) == ()
# --- the reader: each check ----------------------------------------------
def test_a_missing_required_key_is_named_individually() -> None:
document = wiki_document()
del document["source_sha"]
violations = STRICT_V1.frontmatter.violations(document)
assert [(v.key, v.code) for v in violations] == [("source_sha", "frontmatter_key_missing")]
def test_a_key_outside_the_allowlist_is_refused() -> None:
"""A closed allowlist is what makes the contract enumerable at all."""
document = wiki_document()
document["author"] = "someone"
assert codes(STRICT_V1.frontmatter, document) == ["frontmatter_key_not_allowed"]
def test_a_key_name_outside_the_grammar_is_refused() -> None:
"""`^[a-z_]+$`: a key that reaches the allowlist check malformed would
otherwise be reported only as 'not allowed', which hides the real defect."""
schema = FrontmatterSchema(order=("type",), key_pattern=STRICT_V1.frontmatter.key_pattern)
violations = schema.violations({"Source-URL": "x"})
assert [(v.key, v.code) for v in violations] == [("Source-URL", "frontmatter_key_malformed")]
def test_summary_may_be_null_and_no_other_key_may() -> None:
document = wiki_document()
document["summary"] = None
assert STRICT_V1.frontmatter.violations(document) == ()
document["description"] = None
assert [(v.key, v.code) for v in STRICT_V1.frontmatter.violations(document)] == [
("description", "frontmatter_value_null")
]
def test_a_non_empty_list_of_strings_is_a_legal_value() -> None:
"""The block-list shape their `Release` pages carry.
A profile that assumed scalars-only would reject the consumer's own pages —
and a parser that assumed scalars-only would silently destroy them, which
is why Door C writes imported concepts verbatim.
"""
document = wiki_document()
document["source_url"] = ["https://a.example/one", "https://a.example/two"]
assert STRICT_V1.frontmatter.violations(document) == ()
@pytest.mark.parametrize(
"value",
[[], ["ok", 3], {"nested": "map"}, 7, True],
ids=["empty-list", "list-with-non-string", "mapping", "int", "bool"],
)
def test_a_value_outside_the_scalar_or_string_list_shape_is_refused(value: object) -> None:
document = wiki_document()
document["title"] = value
assert [(v.key, v.code) for v in STRICT_V1.frontmatter.violations(document)] == [
("title", "frontmatter_value_shape")
]
# --- determinism and construction-time invariants -------------------------
def test_violations_come_back_in_a_deterministic_order() -> None:
"""Two runs over the same document must report identically ordered.
Determinism is bit-exact in this library; a violation list that depended on
mapping insertion order would make a report irreproducible.
"""
document = wiki_document()
del document["source_sha"]
del document["layer"]
document["zzz"] = "off-allowlist"
document["aaa"] = "off-allowlist"
reported = [(v.key, v.code) for v in STRICT_V1.frontmatter.violations(document)]
assert reported == sorted(reported)
reversed_document = dict(reversed(list(document.items())))
assert [
(v.key, v.code) for v in STRICT_V1.frontmatter.violations(reversed_document)
] == reported
def test_a_required_key_outside_the_allowlist_cannot_be_constructed() -> None:
"""An unsatisfiable schema is a defect in the profile, not in a document."""
with pytest.raises(ValueError, match="required"):
FrontmatterSchema(
order=("type", "title"),
required=frozenset({"type", "absent_from_allowlist"}),
allowed=frozenset({"type", "title"}),
)
def test_a_nullable_key_outside_the_allowlist_cannot_be_constructed() -> None:
with pytest.raises(ValueError, match="nullable"):
FrontmatterSchema(
order=("type",),
allowed=frozenset({"type"}),
nullable=frozenset({"summary"}),
)
def test_strict_v1_path_namespaces_are_the_spec_namespaces() -> None:
"""The door filename prefixes are an ingest-spec invariant, not a preference.
They describe what Doors A/B/C write, so they are the same under any
profile; the consumer's own files are written by their pipeline, not by a
door, and are unaffected either way.
"""
assert STRICT_V1.paths == DEFAULT.paths
def test_strict_v1_index_policy_is_still_the_default_shape() -> None:
"""A known gap, pinned so it cannot drift silently.
The consumer's index shape — one index per directory, entries
`* [Title](link) - description`, exactly one `# ` heading, and a root index
carrying okf_version/bundle_profile/okf_spec_commit is not expressible by
`IndexPolicy` as it stands: neither per-directory indexes nor a description
on an entry has a field. It lands with the reader that judges it. Until
then, nothing consumes a profile's index policy, so this assertion is the
honest statement of where the port has reached.
"""
assert STRICT_V1.index == DEFAULT.index
assert "description" not in STRICT_V1.index.link_template
def test_strict_v1_emission_is_the_ordered_prefix_then_the_sorted_tail() -> None:
"""Commons decision D1, unchanged by the stricter namespace."""
emitted = STRICT_V1.frontmatter.emit(
{"title": "Hooks", "type": "Concept", "description": "d", "timestamp": "t"}
)
assert emitted == "type: Concept\ntitle: Hooks\ndescription: d\ntimestamp: t"