fix(okf-v0.2): V-A7 counted every naming field but nullable

The gate refuses a schema that names both `timestamp` and `generated`,
because OKF §13.1 grants the legacy fallback only while `generated` is
ABSENT. `__post_init__` built the named set from `order | required |
allowed` and left `nullable` out, so

    FrontmatterSchema(order=("timestamp", "title"),
                      nullable=frozenset({"generated"}))

constructed, and `violations()` on a document carrying both returned ().
The namespace is open by default, so a key named only by `nullable` is
admitted exactly as surely as an emitted one — the omission was a hole in
the gate, not a narrower gate.

Found by the proving consumer the same day the gate was written; their
construction is the red test case.

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 21:28:13 +02:00
commit a00355533e
2 changed files with 14 additions and 1 deletions

View file

@ -35,6 +35,10 @@ RESERVED_OKF_TYPE = "verdict"
# schema can express today is a scalar (`_is_legal_value`) and therefore
# malformed as a v0.2 mapping — naming both IS the hazard here. When a value
# model can express a well-formed `generated`, this narrows with it.
# "Naming" spans EVERY field that puts a key in the schema's namespace — `order`,
# `required`, `allowed` and `nullable` alike. A field left out of that union is a
# hole in the gate, not a narrower gate: the namespace is open by default, so a
# key named only by `nullable` is admitted just as surely as an emitted one.
_TIMESTAMP_FALLBACK_PAIR = frozenset({"timestamp", "generated"})
@ -144,7 +148,7 @@ class FrontmatterSchema:
key_pattern: re.Pattern[str] | None = None
def __post_init__(self) -> None:
named = set(self.order) | set(self.required)
named = set(self.order) | set(self.required) | set(self.nullable)
if self.allowed is not None:
named |= set(self.allowed)
if _TIMESTAMP_FALLBACK_PAIR <= named:

View file

@ -289,6 +289,10 @@ def test_the_key_name_is_what_a_profile_pins(tmp_path: Path) -> None:
{"order": (), "required": frozenset({"timestamp", "generated"})},
id="both-required",
),
pytest.param(
{"order": ("timestamp", "title"), "nullable": frozenset({"generated"})},
id="one-emitted-one-nullable",
),
],
)
def test_a_schema_naming_both_timestamp_and_generated_cannot_be_built(
@ -299,6 +303,11 @@ def test_a_schema_naming_both_timestamp_and_generated_cannot_be_built(
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.
Every judging field names keys, `nullable` included. An open namespace
already admits `generated`, so a schema that also gives it a null rule has
named it exactly as surely as one that emits it the last case is the
construction the proving consumer found the day the gate was written.
"""
with pytest.raises(ValueError, match="timestamp"):
FrontmatterSchema(**schema_kwargs)