Skip to content

feat(schemas): add a fail-closed consumer schema registry - #154

Merged
forkwright merged 3 commits into
mainfrom
fix/60-consumer-schema-registry
Aug 16, 2026
Merged

feat(schemas): add a fail-closed consumer schema registry#154
forkwright merged 3 commits into
mainfrom
fix/60-consumer-schema-registry

Conversation

@forkwright

@forkwright forkwright commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Finding

typikon-validate recognized only six built-in content classes. Any file whose frontmatter set a template typikon does not ship — or whose path a consumer wanted to route independently — fell through classify() to page.schema.json, whose extra object accepted any additional field without checking its type. A typo such as kanon_ci = "false" (string, not boolean) was schema-valid and became truthy wherever a consumer template did bool(...) on it.

Evidence

  • bin/typikon-validate (pre-fix) classify(): TEMPLATE_SCHEMA_MAP covered only faq.html/sizing-guide.html; every other custom template fell through path-based routing to page.
  • schemas/page.schema.json and schemas/section.schema.json (pre-fix): extra.additionalProperties: true — any field beyond the named ones passed unchecked.
  • docs/AGENTIC.md:20 (unchanged by this PR): "Do not invent fields. If a frontmatter field is not in the schema, do not write it." — the permissive extra directly contradicted typikon's own stated contract.

Desired correction (delivered)

  • schemas/page.core.schema.json, schemas/section.core.schema.json: open building blocks (no additionalProperties/unevaluatedProperties anywhere) holding every field the closed wrappers declare.
  • schemas/page.schema.json, schemas/section.schema.json: rewritten as thin closed wrappers — allOf: [{$ref: <core>}] plus a second allOf+$ref on extra, each closed once with unevaluatedProperties: false.
  • schemas/journal-entry.schema.json, schemas/product.schema.json: extra.additionalProperties flipped truefalse directly (no consumer needs to extend these yet).
  • bin/typikon-validate: load_consumer_registry() reads <consumer-root>/schemas/registry.toml; classify() raises UnregisteredTemplateError for a template neither typikon's own nor registered.
  • 15 cases in ci/check-consumer-schema-registry.py, each invoking the real CLI as a subprocess against a throwaway consumer tree.
  • docs/SCHEMAS.md, docs/AGENTIC.md, CLAUDE.md (+ _llm/decisions.toml, _llm/glossary.toml): document the registry and the composition pattern.

Adversarial review found this incomplete. Three findings, addressed below — all three, no scope reduction.

Finding 1 — the issue's own Done-when was unverified: no real consumer was ever run through the mechanism

Correct. The original PR built the registry, closed the schemas, and proved it against synthetic fixtures — but never ran the actual validator against either real typikon consumer's actual content. That gap is now closed, against both real consumers, with the evidence a reviewer needs to disprove it:

forkwright/ardent-site (Ardent Leatherworks) — no custom templates, so it needs no registry, but closing page/section/journal-entry/product is a real behavior change for it. Running this branch's validator against a fresh clone of its real content, pre-fix:

{"file": "content/_index.md", "schema": "section", "pointer": "/extra", "error": "Unevaluated properties are not allowed ('template' was unexpected)"}
{"file": "content/sizing/_index.md", "schema": "section", "pointer": "/extra", "error": "Unevaluated properties are not allowed ('seo_title' was unexpected)"}
{"checked": 23, "passed": 21, "failed": 2}

Both are real, previously-undetectable bugs this PR's closure surfaces — not this repo's content to fix silently:

  1. content/_index.md had template = "index.html" nested inside [extra] by mistake (a TOML authoring error — the real top-level template field was therefore unset, silently falling back to a section default, while the stray extra.template did nothing). Fixed in forkwright/ardent-site#35.
  2. section.core.schema.json never declared seo_title, even though templates/section.html, templates/index.html, and templates/journal-section.html (typikon's own theme, not consumer-specific — verified by reading all three) all read section.extra.seo_title | default(value=section.title), the same override page.core already declares. Fixed here in this PR (schemas/section.core.schema.json) — a real omission in the schema this PR itself introduces, not a pre-existing issue.

With both fixed, this branch's validator against ardent-site's real content:

{"checked": 23, "passed": 23, "failed": 0}

ardent-tools/ardent-tools-site (Ardent Tools — the consumer issue #60 names by its five templates: home, consulting, evidence, systems index, system dossier; the reviewer correctly found no such repo in the forkwright org, because it lives under the separate ardent-tools org, not forkwright — confirmed real via gh api repos/ardent-tools/ardent-tools-site and its actual templates/consulting.html, templates/evidence.html, templates/system.html, templates/systems.html). It had zero schema registration for any of its six custom templates. Running this branch's validator against its real content, pre-fix:

{"file": "content/about.md", "pointer": "/template", "error": "custom template 'about.html' has no schema registration..."}
... (20 of 26 real content files failed the same way — every home/about/consulting/evidence/systems-index/system-dossier page)
{"checked": 26, "passed": 6, "failed": 20}

ardent-tools/ardent-tools-site#146 adds schemas/registry.toml plus one schema per custom template, each composing this PR's page.core.schema.json/section.core.schema.json and closed with unevaluatedProperties: false — field shapes derived by parsing every real content file with tomllib, not by inspection (schemas/system-dossier.schema.json's extra.demo reflects the actual union of fields across all 7 real system pages, including the planned-recording-vs-published-cast field split, and repo/kanon_ci/license are optional because the real content/systems/kanon.md omits all three). With that PR's schemas in place, this branch's validator against the real content:

{"checked": 26, "passed": 18, "failed": 8}

The remaining 8 are a real, honestly-disclosed limitation, not swept under the rug. content/writing/*.md sets no explicit template; it relies on Zola's page_template cascade (content/writing/_index.md sets page_template = "journal-entry.html"), which classify() does not simulate — a gap independent of the registry, pre-existing, and newly exposed (not created) by this PR's closure of page.schema.json. Filed as #159 with full repro; a reviewer can confirm it's real and confirm it's out of #60's scope (registering known discriminators, not resolving Zola's template-inheritance semantics before that lookup starts).

Every existing fixture (examples/sample-blog, examples/sample-shop) still validates clean and unchanged — {"checked": 5, "passed": 5} / {"checked": 9, "passed": 9}.

Finding 2 — classify() let a registry entry's extends silently disagree with what a file structurally is

Correct, and reproduced. A path_prefix (or template) entry registered extends = "page" unconditionally overrode section classification for any _index.md a path also matched — base = "section" was computed, then discarded on any registry hit, with no check that the entry's extends agreed with what the file structurally is.

Reproduced against the pre-fix committed classifier (git show HEAD~2:bin/typikon-validate before this PR's fix commits) with a path_prefix = "systems/" (extends="page") entry and a content/systems/_index.md shaped to coincidentally satisfy the page-extending schema (title + extra.ardent.repo_url, nothing section-specific):

$ python3 <pre-fix bin/typikon-validate> <fixture>
{"checked": 1, "passed": 1, "failed": 0}

Silently validated clean — a section file checked against the wrong schema, with nothing surfacing the mismatch, exactly as the finding describes. A second fixture (no section-only fields, just sort_by) instead produced a misleading "'sort_by' was unexpected" error — technically a failure, but for the wrong reason, pointing a maintainer at deleting a real field rather than at the actual defect (the registry entry itself).

Fix: classify() now computes the file's structural kind (section for _index.md, page for everything else — Zola's own distinction) before either match path, and raises MismatchedExtendsError when a template or path_prefix hit's extends disagrees with it. validate_file() converts that to the same JSONL-failure shape as UnregisteredTemplateError.

Negative fixtures, wired into ci/check-consumer-schema-registry.py (now 15 cases, run every CI pass):

  • "path_prefix registered extends=page silently matching a _index.md (structurally section) — fails closed, not a silent pass" — the exact reproduction above.
  • "registered template applied to a _index.md (structurally section) — mismatched extends fails closed" — the template-discriminator analog (the finding's parenthetical "(or template)").
$ python3 ci/check-consumer-schema-registry.py
{"checked": 15, "passed": 15, "failed": 0}

Both new cases fail if the extends-check is reverted — verified by running them against the pre-fix classifier above.

Finding 3 — untagged comments

Correct, and fixed: bin/typikon-validate's COMPOSABLE_CORE_SLUGS rationale block now opens # WHY:; ci/check-consumer-schema-registry.py:128's case-format comment is now # NOTE:. (The reviewer noted this was pre-existing noncompliance elsewhere in the file, not introduced by this diff, and non-blocking — fixed anyway since it was cheap and in a file this PR is already touching.)


What a reviewer would have to disprove

  • bin/typikon-validate examples/sample-blog and examples/sample-shop still exit 0 with {"failed": 0}.
  • python3 ci/check-consumer-schema-registry.py genuinely exercises the CLI and all 15 assertions hold, including the two new MismatchedExtendsError cases (revert the extends-check in classify() and cases 14–15 fail; that's the test proving the fix, not just documenting it).
  • python3 ci/check-triad-schema.py still passes 4/4.
  • Running this branch's bin/typikon-validate against a fresh clone of forkwright/ardent-site (main + ardent-site#35) returns {"checked": 23, "passed": 23, "failed": 0}.
  • Running this branch's bin/typikon-validate against a fresh clone of ardent-tools/ardent-tools-site (main + ardent-tools-site#146) returns {"checked": 26, "passed": 18, "failed": 8}, and every one of the 8 failures is under content/writing/, matching typikon#159 exactly (not the six templates Add a fail-closed consumer schema registry #60 is about).
  • That section.core.schema.json's new seo_title field is genuinely read by typikon's own theme (grep -n "extra\.seo_title" templates/section.html templates/index.html templates/journal-section.html), not a speculative addition.
  • That ardent-site#35's one-line move (template out of [extra]) doesn't change rendering: grep -rn "extra\.template\b" themes/typikon/templates/ in that repo returns no matches, so nothing read the stray field being removed.

Companion PRs (both needed for the "both real consumers pass" claim to be checkable independently of this repo)

  • forkwright/ardent-site#35 — homepage template frontmatter placement fix.
  • ardent-tools/ardent-tools-site#146 — registers all six custom templates against this PR's registry mechanism.
  • forkwright/typikon#159 — the page_template-cascade gap this verification pass found and disclosed rather than silently leaving unhandled or hidden.

Closes #60

forkwright added 3 commits August 15, 2026 21:24
Custom-templated consumer content (a page type typikon does not ship)
fell through classify() to page/section.schema.json, whose `extra`
object accepted any additional field via additionalProperties: true.
A typo like kanon_ci = "false" was schema-valid and became truthy
wherever a template did bool(...) on it.

- schemas/page.core.schema.json, section.core.schema.json: open
  building blocks (no closure keyword) split out of page/section
  schema.json so a consumer schema can compose them via allOf + $ref
  without inheriting a closed schema's inability to be extended.
- page.schema.json, section.schema.json: now thin wrappers that close
  their core once, standalone, with unevaluatedProperties: false
  (additionalProperties does not compose across allOf — verified
  failure mode, not style).
- journal-entry.schema.json, product.schema.json: extra.
  additionalProperties flipped true -> false directly; no consumer
  has needed to extend these yet, so no core split for them.
- bin/typikon-validate: loads <consumer-root>/schemas/registry.toml
  (optional; absent = unchanged behavior). A `template` entry or a
  `path_prefix` entry maps to a consumer-owned schema, loaded into a
  shared referencing.Registry so its $ref to typikon's core resolves.
  A custom `template` that is neither a typikon-shipped template nor
  registered fails validation naming the exact missing registration,
  instead of silently inheriting page's shape.
- ci/check-consumer-schema-registry.py: 13 cases exercising the real
  CLI end-to-end (registered template, path_prefix, the exact #60
  typo, unregistered template, and every registry-load-time failure).
- ci/check-triad-schema.py: loads section.schema.json via
  bin/typikon-validate's load_schemas() instead of a bare
  Draft202012Validator(json.load(...)) — the schema now $refs its
  core, which needs the same registry to resolve.
- docs/SCHEMAS.md, docs/AGENTIC.md, CLAUDE.md, _llm/: document the
  registry, the composition pattern, and why it must reference the
  open core rather than another closed schema.
…with the matched file's structural kind

Review finding: a registry.toml `path_prefix` (or `template`) entry
registered with `extends = "page"` silently overrode section
classification for any _index.md file the prefix also textually
matched -- `base = "section"` was computed then unconditionally
discarded on a registry hit, with no check that the matched entry's
`extends` agreed with what the path-based classifier determined the
file structurally is. Reproduced against the pre-fix classify(): a
`path_prefix = "systems/"` entry (extends="page") matched against a
_index.md shaped to coincidentally satisfy the page-extending schema
validated clean -- {"checked": 1, "passed": 1, "failed": 0} -- a
section file silently checked against the wrong schema with nothing
surfacing the mismatch.

classify() now computes the file's structural kind (section for
_index.md, page for everything else -- Zola's own distinction, not
the registry's) before either match path, and raises
MismatchedExtendsError when a `template` or `path_prefix` hit's
`extends` disagrees with it. validate_file() converts that into the
same JSONL-failure shape as UnregisteredTemplateError, naming the
discriminator, its registered extends, and the file's actual
structural kind.

Two negative fixtures in ci/check-consumer-schema-registry.py prove
the fix against the reproduced pre-fix behavior: one path_prefix case
(the exact silently-passing shape above) and one template case (a
`template` value registered extends="page" applied to a _index.md).
15/15 cases pass; both new cases fail (correctly) if the extends check
is removed.

docs/SCHEMAS.md and the module docstring document the invariant.
… section.core never declared it

Found verifying this PR's closed schemas end-to-end against
forkwright/ardent-site's real content (part of closing #60's
Done-when, both real consumers passing): content/sizing/_index.md
sets extra.seo_title and, once section.schema.json closes its extra
object, failed with "'seo_title' was unexpected".

templates/section.html, templates/index.html, and
templates/journal-section.html all read
`section.extra.seo_title | default(value=section.title)` -- the same
override page.html reads via page.extra.seo_title, already declared
on page.core.schema.json. section.core.schema.json simply never
carried it: not a new field, an omission in the schema this PR
itself closes.
@forkwright
forkwright force-pushed the fix/60-consumer-schema-registry branch from 9737630 to 6ecdeb2 Compare August 16, 2026 02:24
@forkwright
forkwright merged commit 3d9432a into main Aug 16, 2026
4 checks passed
@forkwright
forkwright deleted the fix/60-consumer-schema-registry branch August 16, 2026 02:33
forkwright added a commit that referenced this pull request Aug 16, 2026
#153)

Closes #64

Not closing #136 — see "Notes for the reviewer" below for why.

## Session update (rebase + genuine CI failure + comment-tag gap)

This branch was cut before four PRs landed on `main` (#141 favicon
config, #150
asset-provenance label fix, #152 home-page heading, #154 consumer schema
registry). Rebased onto `origin/main` (3d9432a). One real conflict, in
`ci/run-fixtures.sh` — main added four new check invocations
(`check-favicon-path.py`, `check-init-favicon-path.py`,
`check-home-heading.py`,
`check-fixture-corpus-exemption.sh`) in the same region this branch adds
two
(`check-interactive-contrast.py`,
`check-interactive-contrast-selftest.py`).
Both sides were right; composed rather than picked one — every check
from both
branches now runs (`ci/run-fixtures.sh:8-24`).

`gate / full-gate-build` was failing on this branch (run 31921507701)
with a
genuine defect, not a stale base:
`interactive-state-contrast-selftest.spec.ts`
imported `assertContrast`/`contrastRatio`/`textContrastFloor` directly
from
`interactive-state-contrast.spec.ts`. Playwright refuses to load a test
file
that another test file imports and fails the entire collection, not just
the
importing file:

```
Error: test file "smoke/interactive-state-contrast-selftest.spec.ts" should not import test file "smoke/interactive-state-contrast.spec.ts"
Listing tests:
Total: 0 tests in 0 files
```

That's what
`{"stage":"playwright-smoke","status":"fail","duration_ms":1050,...}`
in the CI log was — a collection-time failure before any test ran, which
is
why the log carried no further detail. (The `cargo` exit-101 also
visible in
that run's log is unrelated and pre-existing: `Swatinem/rust-cache`'s
`cargo metadata` probe fails on every typikon run because this repo has
no
`Cargo.toml` — documented and treated as non-fatal in
`.github/workflows/gate-attestation.yml`'s own header comment. It logs
an
error but the job continues past it; the actual failure is the
playwright
stage above.)

Fixed by extracting the pure contrast/threshold math into a new non-spec
module, `ci/smoke/interactive-contrast.ts`, and pointing both spec files
at
it instead of one importing the other
(`ci/smoke/interactive-contrast.ts`
new; `ci/smoke/interactive-state-contrast.spec.ts:26-33` +
`ci/smoke/interactive-state-contrast-selftest.spec.ts:24` updated).
`readEffectiveStyle` stays in the browser-driven spec — it needs a
Playwright
`Locator` and only that file uses it.

**Negative fixture:
`ci/smoke/interactive-state-contrast-selftest.spec.ts` —
watched failing by `npx playwright test --config ci/playwright.config.ts
--list`, which produced exactly the "should not import test file" /
"Total: 0 tests in 0 files" error above before the fix, and now lists 7
tests across 3 files (verified again against the real
`examples/sample-blog`
build: `npx playwright test` — 16/16 passed, including the
browser-driven
interactive-state assertions on every real route).** This isn't a check
I
added — it's Playwright's own test-collection step, which already runs
on
every `bin/typikon-check`/CI invocation with no additional wiring; the
defect was that this branch's own new file tripped it.

While rebasing I re-audited `check-interactive-contrast.py` against its
own
established comment-tag convention (the earlier review round tagged two
blocks — FAQ deep-link anchor, home-triad-mark — that had rationale
prose
with no `WHY:`/`NOTE:`/etc. tag). Two more blocks in the same file had
the
identical gap and were missed by that fix: the `.nav-links
a:hover::after`
"why no separate entry" note
(`ci/check-interactive-contrast.py:216-219`) and
the home-nav-secondary-nav fallthrough note (`:221-224`), plus the
`NOT_TEXT_CONTRAST` dict's own rationale (`:337-343`). Tagged all three
`WHY:`,
matching the file's own convention. Also tagged
`ci/smoke/interactive-state-
contrast.spec.ts`'s `TARGETS` comment (`:56-63`) — same defect class,
same
file inconsistency (the file's other rationale comment,
`readEffectiveStyle`'s, already carried `WHY:`).

## Addressed in the prior independent adversarial review

An independent adversarial review returned `must_fix` on three findings
against an earlier version of this PR. All three were fixed at that time
and
verified intact after this session's rebase:

1. **Correctness — the property regex had no left-boundary anchor.**
   `_prop_var_re()` (`ci/check-interactive-contrast.py:118-126`) built
`(?:{prop_alt})\s*:\s*...` with nothing anchoring the start, unlike the
sibling `COLOR_AFFECTING_RE` two lines above it, which uses
`(?:^|;)\s*(...)`.
   `prop_alt="color"` matched the substring `color:` inside
`background-color:`/`text-decoration-color:`/etc., so a selector with
both
properties on the same rule could silently resolve to the WRONG
property's
value with no error. Fixed by mirroring `COLOR_AFFECTING_RE`'s `(?:^|;)`
boundary in both `_prop_var_re()` and its sibling `_prop_inherit_re()`
(`ci/check-interactive-contrast.py:118-136`). Reproduce the pre-fix bug:
   ```
   python3 -c "
   import sys; sys.path.insert(0, 'ci')
   import importlib.util
spec = importlib.util.spec_from_file_location('m',
'ci/check-interactive-contrast.py')
   m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
print(m.find_declared_var('.widget { background-color: var(--danger);
}', '.widget', 'color'))
   "
   ```
   prints `None` now; printed `danger` before the fix. Re-verified this
session: `python3 ci/check-interactive-contrast.py` still passes clean
   post-rebase.

2. **Missing negative-case fixture for the checker's own logic.** Fixed
by
   committing `ci/check-interactive-contrast-selftest.py`, wired into
`ci/run-fixtures.sh` immediately after the checker it tests —
function-level
synthetic-CSS assertions (including the reviewer's own two repro cases)
plus
an end-to-end mutation of the real `static/css/style.css` (reverts the
#64
fix, asserts the exact WCAG-floor failure, restores byte-identical,
confirms
a second uncovered-rule mutation fails closed too). The Playwright side
got
the same treatment via
`ci/smoke/interactive-state-contrast-selftest.spec.ts`.
   Re-run this session: both pass clean (`python3
   ci/check-interactive-contrast-selftest.py`; `npx playwright test
   ci/smoke/interactive-state-contrast-selftest.spec.ts` — 5/5).

3. **Two comment blocks broke the closed comment-tag set.**
`ci/check-interactive-contrast.py:295-299` (FAQ deep-link anchor) and
the
home-triad-mark block were tagged `WHY:`. (This session found and fixed
two
   more instances of the same gap — see above.)

## Enumerated acceptance criteria

Issue #64's `Done when:` (restated identically in the reopening
comment):
**"a matrix covering every token/background/usage combination,
assertions
across the interactive states, and a gate stage that goes red when any
of
them regresses."**

| Bullet | Satisfied by | Evidence |
|---|---|---|
| a matrix covering every token/background/usage combination | `MATRIX`
in `ci/check-interactive-contrast.py:200-335` — 41 entries, each
resolved from the live CSS source (not hand-typed), covering every
selector this theme renders in a non-default interactive state that
touches `color`, a text-bearing `background`, or `opacity` |
`ci/check-interactive-contrast.py:426-461` walks each entry via
`resolve_chain()`; run output lists all 41 with their resolved
token/ratio |
| assertions across the interactive states | Static: `resolve_chain()` +
WCAG floor check per `MATRIX` entry
(`ci/check-interactive-contrast.py:426-461`). Browser:
`ci/smoke/interactive-state-contrast.spec.ts:104-146` drives real
Chromium through default → focus → hover → active per element via
`getComputedStyle`. `:visited` covered by source-absence check (browser
cannot observe it — history-sniffing protection, documented
`ci/smoke/interactive-state-contrast.spec.ts:14-20`) | Real run against
`examples/sample-blog`: 16/16 Playwright tests pass across every route
in the build's sitemap |
| a gate stage that goes red when any of them regresses | Coverage scan
`ci/check-interactive-contrast.py:488-512` fails closed on any
new/unreviewed state rule; `ci/run-fixtures.sh:8-24` runs both the
static and selftest checks on every `bin/typikon-check`/CI invocation;
`ci/smoke/` specs run in the same `npx playwright test` CI already
invokes | Negative fixtures (this PR's own selftest files) prove the red
path — see "Addressed" #2 above and the negative-fixture line above for
this session's fix |

Issue #136's `Done when:` — **not this PR's job**: its specific defect
(`--rule` used as a form-control border) already merged in #140
(`ci/check-control-contrast.py` verifies `.purchase-box select` /
`.buttondown-form input[type="email"]` both clear 3:1 — confirmed still
passing post-rebase: `python3 ci/check-control-contrast.py`). The one
thing
that kept #136 open — the home page's missing `<h1>` — was split into
#142
and landed on `main` as #152 before this branch rebased past it
(`ci/check-home-heading.py`, now composed into `ci/run-fixtures.sh:16`
alongside this PR's own additions). See "Notes for the reviewer" below.

## Evidence (from the original submission, still accurate)

- `ci/check-interactive-contrast.py:200-335` (`MATRIX`) — every selector
this
theme renders in a non-default interactive state that touches `color`, a
text-bearing `background`, or `opacity`, each entry resolved from the
live
  CSS source rather than hand-typed.
- `ci/check-interactive-contrast.py:139-160` (`find_declared_var`,
left-boundary-anchored) + `:163-177` (`resolve_chain`) — walks an
explicit
  selector chain exactly mirroring real CSS cascade/inheritance.
- `ci/check-interactive-contrast.py:208-209` —
  `.nav-links a:nth-child(3):hover`, resolved from source, must equal
  `--aporia-interactive` (4.91:1 against `--bg`) — the exact rule #64
  originally fixed.
- `ci/check-interactive-contrast.py:488-512` (coverage scan) — parses
every
`:hover`/`:focus`/`:focus-visible`/`:active`/`:visited`/`:disabled` rule
touching `color`/`background`/`opacity` and fails if it isn't in
`MATRIX`,
  `NOT_TEXT_CONTRAST`, or the opacity special-case.
- `ci/contrast.py` — the WCAG luminance/contrast formula and `:root`
parser,
extracted from `ci/check-control-contrast.py` (#136) so both checks
share
  one formula.
- `ci/smoke/interactive-state-contrast.spec.ts:128-148`
(`readEffectiveStyle`)
  drives real Chromium and reads `getComputedStyle`'s actual composited
  output, catching a cascade bug a static parse structurally cannot see.
- `:visited` is explicitly out of the Playwright spec's reach
(`ci/smoke/interactive-state-contrast.spec.ts:14-20`): every major
browser
  engine reports `:visited` computed style as if unvisited, to block
history-sniffing. `ci/check-interactive-contrast.py` covers it instead
by
parsing the CSS source directly and confirming no `:visited` rule
exists.
- `ci/run-fixtures.sh:8-24` + `.kanon-ci.toml` — both checks and their
  negative-case fixtures wired into the gate.

## What a reviewer would have to disprove

- That any `MATRIX` entry's resolved token is wrong — reproducible by
running `python3 ci/check-interactive-contrast.py` and reading the
printed
  line for that entry against `static/css/style.css` directly.
- That the coverage scan misses a real state-changing rule —
reproducible by
adding any `:hover`/`:focus`/`:active`/`:visited`/`:disabled` color rule
anywhere in `style.css` and confirming the script fails before making it
  pass by adding a matching entry.
- That `find_declared_var()`'s property regex can still cross-match a
wrong
  property — reproducible by running
`python3 ci/check-interactive-contrast-selftest.py`, which fails closed
on
  that exact class today.
- That the Playwright cross-import defect from this session is still
  present — reproducible by running `npx playwright test --config
ci/playwright.config.ts --list` and confirming it lists tests rather
than
  erroring.

## Notes for the reviewer

- Issue #136 is not closed by this PR and doesn't need to be — its
form-control-border-contrast defect already merged in #140, and the one
  thing keeping #136 open (the home page's missing `<h1>`) was correctly
  split into #142, landed as #152, and is out of this PR's scope.
- No CSS colors changed. `git diff origin/main...HEAD --
static/css/style.css`
  is empty by design.
- `gate / full-gate-build` and `gate / gate` are green on this branch's
current tip (run 31923043711, post-rebase + post-fix) — mergeable,
clean.
- `verda-build` (the fleet's local-gate box) is currently unusable for
this
repo's `fixtures-gate` stage for two reasons unrelated to this branch:
its
disk is at 100% (286M free on 2.4T — tracked in
`metis-ops/collab/OPERATOR.md`),
  and separately it has no `pip`/`jsonschema` installed at all (CI's own
  `check_cmd` installs `jsonschema`/`fonttools`/`Brotli` via pip before
  running `ci/run-fixtures.sh`; verda's gate script does not). Both are
  environment gaps on that box, not code defects — confirmed by running
every `ci/run-fixtures.sh` script individually on this branch, plus the
  real Playwright suite against a real `zola build`, all green. GitHub
Actions' `gate / full-gate-build` is the authoritative signal here and
is
  green.

---------

Co-authored-by: forkwright <cody@forkwright.com>
forkwright pushed a commit that referenced this pull request Aug 16, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.4.0](v0.3.0...v0.4.0)
(2026-08-16)


### Features

* **schemas:** add a fail-closed consumer schema registry
([#154](#154))
([3d9432a](3d9432a))
* **templates:** make the favicon path configurable like every sibling
brand asset ([#141](#141))
([729ce2e](729ce2e))


### Bug Fixes

* **ci:** stop exempting the gate's own fixture corpus from
full-gate-build
([#151](#151))
([8690c82](8690c82))
* **templates:** give the home page a genuine top-level heading
([#152](#152))
([880f711](880f711))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
forkwright added a commit that referenced this pull request Aug 16, 2026
…ify() (#164)

## Finding

`bin/typikon-validate`'s `classify()` read only a file's own frontmatter
`template` field. It did not simulate Zola's `page_template` cascade: a
section's `_index.md` can set `page_template = "..."` and Zola applies
that template to every descendant page that sets no `template` of its
own — recursively through nested sections, with the nearest ancestor
that sets one winning, and a page's own `template` always taking
priority over any ancestor's. `classify()` had no notion of any of this
and fell through to path-based classification instead.

Verified against Zola's own documentation before implementing (not
guessed — the issue explicitly warns that a guessed precedence "produces
a validator that is confidently wrong"). Fetched and grepped the raw
HTML of https://www.getzola.org/documentation/content/section/ directly
(not summarized secondhand) — the section docs read verbatim:

> "Template to use to render this section page. template =
'section.html' # The given template is applied to ALL pages below the
section, recursively. # If you have several nested sections, each with a
page_template set, the page # will always use the closest to itself. #
However, a page's own `template` variable will always have priority."

## Evidence

- `bin/typikon-validate:183-238` (pre-fix, `origin/main`): `classify()`
reads `frontmatter.get("template")` only — no ancestor walk, no
`page_template` reference anywhere in the file.
- Reproduced the issue's own report directly against `origin/main`'s
`bin/typikon-validate`, with an explicit `template =
"journal-entry.html"` on the leaf (isolating the cascade question from
the template's OWN reachability — see the follow-up issue below):
  ```
$ python3 bin/typikon-validate <fixture: content/writing/_index.md sets
page_template="journal-entry.html">
{"file": "content/writing/foo.md", "schema": "page", "pointer":
"/extra", "error": "Unevaluated properties are not allowed
('components', 'words' were unexpected)"}
  ```
- Fix: `bin/typikon-validate` — `build_page_template_cascade()` (new,
pre-scans every `content/**/_index.md`), `_cascade_lookup()` (new,
nearest-ancestor-wins walk), `classify()` (now resolves an effective
`template` from the cascade when the page sets none —
`bin/typikon-validate:311-313`), `main()` (builds the cascade once
before the per-file walk — `bin/typikon-validate:619-622`).

## Why this matters

`page_template` is a first-class, documented field on
`section.core.schema.json` — the validator shipped a schema that
describes the cascade without implementing it. Any consumer using the
pattern typikon's own theme ships as its canonical example
(`examples/sample-blog/content/journal/_index.md:6` sets `page_template
= "journal-entry.html"`, consumed per `templates/journal-entry.html:7`'s
own setup comment: "content/journal/_index.md -- transparent +
page_template default makes children inherit") for a NON-canonical
section directory got silently wrong validation for every child page,
and #154's closure of `page.schema.json`'s `extra` turned "silently
wrong" into "silently broken" the moment a consumer bumps its submodule
pin.

## Desired correction

`classify()` now resolves a file's effective template by walking up to
the nearest ancestor `_index.md` that actually sets `page_template`,
when the file sets no `template` of its own — mirroring Zola's cascade
exactly, including a child's own `template` overriding it, and INCLUDING
a nested section without its own `page_template` not breaking the
inheritance chain from further up. The resolution is fed into the
*existing* `TEMPLATE_SCHEMA_MAP` / registry / fail-closed pipeline
unchanged, so a cascade-resolved template is indistinguishable from an
explicitly-set one — this is the literal reading of the issue's "Done
when."

`main()`'s file-walk ordering, not just `classify()`'s signature,
changes: `build_page_template_cascade` is a full separate pre-pass over
`content/**/_index.md`, run before any file is validated — see the code
comment for why an incremental single-pass build would be wrong
(`Path.rglob` sorts `_index.md` below any letter but ABOVE ascii digits,
so a numerically-prefixed subdirectory would be visited before its own
section's index file).

### Acceptance criteria (from #159)

**Done when:** *"a leaf page with no template of its own, under a
section whose _index.md sets page_template, classifies (and validates)
as if it had explicitly set that template — verified by a case in
ci/check-consumer-schema-registry.py (or a new script) that fails on the
un-fixed classifier and passes once the cascade is implemented."*

- Leaf page with no template, under a section setting `page_template`,
classifies as if it had explicitly set it:
`bin/typikon-validate:311-313` (`_cascade_lookup` result flows into the
same `template` variable the explicit-frontmatter path uses). Verified
by `ci/check-page-template-cascade.py` case 1 (REQUIRED negative
fixture, see below) and case 8 (registry-routed template, not just
`TEMPLATE_SCHEMA_MAP`).
- New script, since #159's own text scopes this "distinct from the #60
consumer registry": `ci/check-page-template-cascade.py` (new file),
wired into `ci/run-fixtures.sh:10`.
- Fails on the un-fixed classifier, passes once implemented: verified
directly — ran `ci/check-page-template-cascade.py`'s Section A against
`origin/main`'s pre-fix `bin/typikon-validate`; 5 of 8 subprocess cases
fail (the 3 that pass in both states are deliberate regression guards —
own-template-wins, section-itself-unaffected,
malformed-ancestor-doesn't-crash — not cascade-dependent). All 13 cases
(8 subprocess + 5 unit) pass against the shipped fix.

One nuance stated plainly, not implied: "as if it had explicitly set
that template" is an *equivalence* claim, not "always resolves to the
intuitively-right schema." `TEMPLATE_SCHEMA_MAP` has no entry for
`journal-entry.html` — that's true whether the template arrives by
cascade or by explicit frontmatter, both before and after this fix
(verified above, and unchanged by this PR). Making
`journal-entry.html`-templated content off the canonical
`content/journal/` path fully green requires that *and* extending
`journal-entry.schema.json`'s `extra` with fields (`figure`,
`figure_alt`, `tier`) it doesn't recognize yet — two separate, narrower
gaps, filed as #163 rather than folded in here (mixing
"cascade resolution" with "template-map completeness" would have made
this PR's own negative fixture untestable against a template with
well-defined explicit-set behavior, and #159's text explicitly scopes
registry/routing completeness out).

## Negative fixture

Negative fixture: `ci/check-page-template-cascade.py` (case 1, "leaf
page with no template, under a section with page_template=faq.html,
cascades to faq.schema.json") — watched failing by `python3
bin/typikon-validate <tmpdir>` against the pre-fix
`bin/typikon-validate` (`git show origin/main:bin/typikon-validate`, run
from a `bin/` + `schemas/` shim so `THEME_ROOT` resolves correctly),
which produced:
```
{"file": "content/guides/shipping.md", "schema": "page", "pointer": "/extra", "error": "Unevaluated properties are not allowed ('questions' was unexpected)"}
{"checked": 2, "passed": 1, "failed": 1}
exit=1
```
before the fix, and `{"checked": 2, "passed": 2, "failed": 0}` / exit 0
after. `questions` is `faq.schema.json`'s own required field —
`page.schema.json`'s `extra` has never heard of it, which is exactly the
"confidently wrong schema" the issue reports. Also stress-tested the
walk's own arithmetic (Section B, `_cascade_lookup` in isolation):
deliberately broke it to skip the immediate ancestor (an off-by-one in
the exact shape the issue warns about — "guessing the precedence
produces a validator that is confidently wrong") and confirmed 2 of 5
unit assertions catch it (`nearest-wins`, `root-section-checked`);
reverted before committing.

## Verification run locally

`kanon lint bin/typikon-validate --precision low` / `kanon lint
ci/check-page-template-cascade.py --precision low`: clean, 0 findings
(both exit 0). `python3 ci/check-consumer-schema-registry.py` (15/15)
and `python3 ci/check-triad-schema.py` (4/4): unaffected, no
regressions. Real fixture corpora unaffected: `bin/typikon-validate
examples/sample-blog` (5/5) and `examples/sample-shop` (9/9) both still
pass clean. Full `ci/run-fixtures.sh` run locally (zola/lychee present
locally): every stage passes including `zola-build`, `csp-enforce`,
`asset-provenance`, `lychee`, `playwright-smoke`, `playwright-coverage`;
the only failure is `pa11y` (`pa11y-ci not on PATH`), a pre-existing
local-environment gap unrelated to this diff (no templates/CSS/HTML
touched) — CI has it installed and will grade it for real.

Closes #159

## Review round 2 (addressed)

- **Comment tags** (`ci/check-page-template-cascade.py`): every
explanatory comment block now leads with `WHY:` or `NOTE:`, matching the
sibling scripts' convention (`ci/check-triad-schema.py`,
`ci/check-consumer-schema-registry.py`) — no freeform comments remain.
`python3 ci/check-page-template-cascade.py` still reports `{"checked":
13, "passed": 13, "failed": 0}` after the edit (comment-only change, no
logic touched).
- **`docs/AGENTIC.md` citation** ("Why this matters", above): corrected.
That file has zero occurrences of `page_template` — it was a wrong
citation carried over from #159's own text without re-verification. The
real canonical example is
`examples/sample-blog/content/journal/_index.md:6` +
`templates/journal-entry.html:7`, now cited directly. The underlying
cascade-precedence claim itself (nearest-set-ancestor wins, own
`template` always wins, applies to descendant pages not the section's
own `_index.md`) was independently re-verified against a fresh fetch of
https://www.getzola.org/documentation/content/section/ and against the
shipped implementation
(`_cascade_lookup`/`build_page_template_cascade`/`classify` in
`bin/typikon-validate:196-313`) — unchanged and accurate.

---------

Co-authored-by: forkwright <cody@forkwright.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a fail-closed consumer schema registry

1 participant