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

@ -11,7 +11,8 @@ 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.
passed to a door. The `timestamp`/`generated` pair is refused the same way, and
for the same reason: both are things a profile must not be able to express.
Profiles are constructed in code. Config-file loading and inheritance chains
are extension points, not v1 (settled with the operator at phase start).
@ -27,6 +28,15 @@ from dataclasses import dataclass, field
# the only path into it. Compared case-insensitively, as both doors already do.
RESERVED_OKF_TYPE = "verdict"
# The one key PAIR no profile may name (OKF §13.1): `timestamp` is readable as
# a legacy stand-in for `generated.at` only while `generated` is ABSENT, so a
# schema able to name both can describe a document that has neither. Stated as
# key names rather than as a judgement on values because every `generated` a
# 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.
_TIMESTAMP_FALLBACK_PAIR = frozenset({"timestamp", "generated"})
@dataclass(frozen=True)
class TypeRejection:
@ -134,6 +144,16 @@ class FrontmatterSchema:
key_pattern: re.Pattern[str] | None = None
def __post_init__(self) -> None:
named = set(self.order) | set(self.required)
if self.allowed is not None:
named |= set(self.allowed)
if _TIMESTAMP_FALLBACK_PAIR <= named:
raise ValueError(
"a profile must not name both 'timestamp' and 'generated' (OKF "
"§13.1 grants the timestamp fallback only while `generated` is "
"ABSENT, so a schema naming both can describe a document with "
"neither a valid `generated.at` nor an eligible fallback)"
)
if self.allowed is None:
return
for label, keys in (("required", self.required), ("nullable", self.nullable)):