feat(schemas): add a fail-closed consumer schema registry - #154
Merged
Conversation
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
force-pushed
the
fix/60-consumer-schema-registry
branch
from
August 16, 2026 02:24
9737630 to
6ecdeb2
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Finding
typikon-validaterecognized only six built-in content classes. Any file whose frontmatter set atemplatetypikon does not ship — or whose path a consumer wanted to route independently — fell throughclassify()topage.schema.json, whoseextraobject accepted any additional field without checking its type. A typo such askanon_ci = "false"(string, not boolean) was schema-valid and became truthy wherever a consumer template didbool(...)on it.Evidence
bin/typikon-validate(pre-fix)classify():TEMPLATE_SCHEMA_MAPcovered onlyfaq.html/sizing-guide.html; every other customtemplatefell through path-based routing topage.schemas/page.schema.jsonandschemas/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 permissiveextradirectly contradicted typikon's own stated contract.Desired correction (delivered)
schemas/page.core.schema.json,schemas/section.core.schema.json: open building blocks (noadditionalProperties/unevaluatedPropertiesanywhere) 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 secondallOf+$refonextra, each closed once withunevaluatedProperties: false.schemas/journal-entry.schema.json,schemas/product.schema.json:extra.additionalPropertiesflippedtrue→falsedirectly (no consumer needs to extend these yet).bin/typikon-validate:load_consumer_registry()reads<consumer-root>/schemas/registry.toml;classify()raisesUnregisteredTemplateErrorfor atemplateneither typikon's own nor registered.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 closingpage/section/journal-entry/productis a real behavior change for it. Running this branch's validator against a fresh clone of its real content, pre-fix:Both are real, previously-undetectable bugs this PR's closure surfaces — not this repo's content to fix silently:
content/_index.mdhadtemplate = "index.html"nested inside[extra]by mistake (a TOML authoring error — the real top-leveltemplatefield was therefore unset, silently falling back to a section default, while the strayextra.templatedid nothing). Fixed in forkwright/ardent-site#35.section.core.schema.jsonnever declaredseo_title, even thoughtemplates/section.html,templates/index.html, andtemplates/journal-section.html(typikon's own theme, not consumer-specific — verified by reading all three) all readsection.extra.seo_title | default(value=section.title), the same overridepage.corealready 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: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 theforkwrightorg, because it lives under the separateardent-toolsorg, notforkwright— confirmed real viagh api repos/ardent-tools/ardent-tools-siteand its actualtemplates/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:ardent-tools/ardent-tools-site#146addsschemas/registry.tomlplus one schema per custom template, each composing this PR'spage.core.schema.json/section.core.schema.jsonand closed withunevaluatedProperties: false— field shapes derived by parsing every real content file withtomllib, not by inspection (schemas/system-dossier.schema.json'sextra.demoreflects the actual union of fields across all 7 real system pages, including the planned-recording-vs-published-cast field split, andrepo/kanon_ci/licenseare optional because the realcontent/systems/kanon.mdomits all three). With that PR's schemas in place, this branch's validator against the real content:The remaining 8 are a real, honestly-disclosed limitation, not swept under the rug.
content/writing/*.mdsets no explicittemplate; it relies on Zola'spage_templatecascade (content/writing/_index.mdsetspage_template = "journal-entry.html"), whichclassify()does not simulate — a gap independent of the registry, pre-existing, and newly exposed (not created) by this PR's closure ofpage.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'sextendssilently disagree with what a file structurally isCorrect, and reproduced. A
path_prefix(ortemplate) entry registeredextends = "page"unconditionally overrode section classification for any_index.mda path also matched —base = "section"was computed, then discarded on any registry hit, with no check that the entry'sextendsagreed with what the file structurally is.Reproduced against the pre-fix committed classifier (
git show HEAD~2:bin/typikon-validatebefore this PR's fix commits) with apath_prefix = "systems/"(extends="page") entry and acontent/systems/_index.mdshaped to coincidentally satisfy the page-extending schema (title+extra.ardent.repo_url, nothing section-specific):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 (sectionfor_index.md,pagefor everything else — Zola's own distinction) before either match path, and raisesMismatchedExtendsErrorwhen atemplateorpath_prefixhit'sextendsdisagrees with it.validate_file()converts that to the same JSONL-failure shape asUnregisteredTemplateError.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"— thetemplate-discriminator analog (the finding's parenthetical "(or template)").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'sCOMPOSABLE_CORE_SLUGSrationale 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-blogandexamples/sample-shopstill exit 0 with{"failed": 0}.python3 ci/check-consumer-schema-registry.pygenuinely exercises the CLI and all 15 assertions hold, including the two newMismatchedExtendsErrorcases (revert theextends-check inclassify()and cases 14–15 fail; that's the test proving the fix, not just documenting it).python3 ci/check-triad-schema.pystill passes 4/4.bin/typikon-validateagainst a fresh clone offorkwright/ardent-site(main+ardent-site#35) returns{"checked": 23, "passed": 23, "failed": 0}.bin/typikon-validateagainst a fresh clone ofardent-tools/ardent-tools-site(main+ardent-tools-site#146) returns{"checked": 26, "passed": 18, "failed": 8}, and every one of the 8 failures is undercontent/writing/, matchingtypikon#159exactly (not the six templates Add a fail-closed consumer schema registry #60 is about).section.core.schema.json's newseo_titlefield 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.ardent-site#35's one-line move (templateout 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— homepagetemplatefrontmatter placement fix.ardent-tools/ardent-tools-site#146— registers all six custom templates against this PR's registry mechanism.forkwright/typikon#159— thepage_template-cascade gap this verification pass found and disclosed rather than silently leaving unhandled or hidden.Closes #60