feat(okf-v0.2): V-A7 -- a profile cannot name both timestamp and generated

Plan step 3's remaining half, and the one place steps 1-3 add behavior rather
than only characterizing it. Operator decision this session: build the gate NOW
rather than at D2, so the multi-file D2 work is developed with the invariant
already in place.

OKF section 13.1 reads `timestamp` as a legacy stand-in for `generated.at`
only while `generated` is ABSENT. A schema able to name both can therefore
describe a document with neither a valid `generated.at` nor an eligible
fallback. Refused at construction, the same shape as the reserved `verdict`
layer -- and one level below the sketch that was approved, on
`FrontmatterSchema` rather than `BundleProfile`: the schema is the policy that
names frontmatter keys, it is the exact C3 analog, and a profile must build its
schema first, so the stricter placement subsumes the other.

Stated as key names rather than as a judgement on values, deliberately: every
`generated` a schema can express today is a scalar (`_is_legal_value`) and so
malformed as a v0.2 mapping, which makes naming both exactly the hazard. It
narrows when a value model can express a well-formed `generated` -- recorded at
the constant rather than left for a reader to reconstruct.

Behavior-neutral for everything shipped, and not by assertion: DEFAULT emits
`generated` and no `timestamp`, STRICT_V1 the reverse (which is what puts the
wiki on the legacy path rather than in a defect), and the byte-exact golden
suite is green.

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-26 20:08:51 +02:00
commit 7bc366bc1a
2 changed files with 64 additions and 2 deletions

View file

@ -39,7 +39,7 @@ from llm_ingestion_okf.materialize import (
materialize_bundle,
parse_frontmatter,
)
from llm_ingestion_okf.profiles import DEFAULT, STRICT_V1, BundleProfile
from llm_ingestion_okf.profiles import DEFAULT, STRICT_V1, BundleProfile, FrontmatterSchema
from test_import_flow import StubImportGate, place, run
INGESTED_AT = "2026-07-25T12:00:00Z"
@ -277,6 +277,48 @@ def test_the_key_name_is_what_a_profile_pins(tmp_path: Path) -> None:
assert "okf_version" in STRICT_V1.index.root_frontmatter
@pytest.mark.parametrize(
"schema_kwargs",
[
pytest.param({"order": ("timestamp", "generated")}, id="both-emitted"),
pytest.param(
{"order": ("timestamp",), "allowed": frozenset({"timestamp", "generated"})},
id="one-emitted-one-admitted",
),
pytest.param(
{"order": (), "required": frozenset({"timestamp", "generated"})},
id="both-required",
),
],
)
def test_a_schema_naming_both_timestamp_and_generated_cannot_be_built(
schema_kwargs: dict[str, Any],
) -> None:
"""V-A7. OKF §13.1 grants the `timestamp` fallback only while `generated` is
ABSENT, so a schema able to name both can describe a document with neither a
valid `generated.at` nor an eligible fallback. Refused at construction, the
same shape as the reserved `verdict` layer: a profile that could reach the
combination cannot be built, let alone handed to a door.
"""
with pytest.raises(ValueError, match="timestamp"):
FrontmatterSchema(**schema_kwargs)
def test_the_two_shipped_profiles_sit_on_opposite_sides_of_the_fallback() -> None:
"""Why the gate above costs nothing: neither shipped profile is near the
combination, and they are not near it in opposite directions. `DEFAULT`
emits `generated` and no `timestamp`; `STRICT_V1` emits `timestamp` and no
`generated`, which is precisely what puts the wiki on the §13.1 legacy path
rather than in a defect.
"""
assert "generated" in DEFAULT.frontmatter.order
assert "timestamp" not in DEFAULT.frontmatter.order
assert "timestamp" in STRICT_V1.frontmatter.order
assert "generated" not in STRICT_V1.frontmatter.order
assert STRICT_V1.frontmatter.allowed is not None
assert "generated" not in STRICT_V1.frontmatter.allowed
@pytest.mark.parametrize("declared", ["0.1", "0.2", "1.0", "2026.7.3"])
def test_the_root_frontmatter_policy_expresses_any_version_value(declared: str) -> None:
"""V4. The policy judges presence and ORDER, never the value — so the day