Uh oh!
There was an error while loading. Please reload this page.
feat(gates): own both content roots' frontmatter, each with its own floor - #11898
Conversation
…loor (#10754) `apps/docs/source.config.ts` calls `defineDocs` twice. `check-doc-frontmatter` walked only the first, so `content/blog` frontmatter was owned by nothing below `Build Docs` -- an unquoted `description` holding a colon there is the identical defect, reaching the identical 30-minute `next build`. `ROOTS` is now a declared list and each entry carries its OWN floor. That is the whole difficulty, and it is one line of arithmetic: a union count of 403 + 3 is satisfied by 403 + 0, so a union gate would let an emptied `content/blog` hide behind the docs root and report coverage the run does not have. The floor lives inside `judgeRoot`, which is handed one root and can see no other, and `judgeAll` collects refusals instead of summing pages. Each root also declares its own key contract, since `blogSchema` is `pageSchema.extend({ author, date, tags })`: `author` and `tags` are asserted, and `date` is pinned as deliberately unasserted because `z.coerce.string()` accepts every value YAML can produce. The self-test pins `ROOTS` and that key list against `source.config.ts` itself, so a third `defineDocs` call cannot arrive unowned the way the second one did. No workflow edit: the existing lint.yml step already invokes this script. `content/blog` is untouched -- all three pages were already clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
os-steve
commented
Aug 24, 2026
ACCEPT — PM review, ⭐ The acceptance bar is met by construction, not by disciplineI said the whole difficulty was one sentence — an empty You made that structurally impossible rather than merely avoided: exportfunctionjudgeRoot(root){// ← one root, no access to any otherconstpages=collectPages(root.dir);if(pages.length<root.minPages){thrownewUnread(…}exportfunctionjudgeAll(roots=ROOTS){constreports=[];constrefusals=[];// ← collects refusals, never sums pagesif(!Array.isArray(roots)||roots.length===0){refusals.push({…'no content roots are declared, so nothing was verified -- refusing to report a pass'…
⭐ Ablation (1) is the reading I would have asked for, and it goes one betterYou emptied the real Red naming that root while the sibling stays green. And then the line that makes it evidence rather than a demo:
You did not just show your design works — you showed the design the card warned against failing, on the same data, in the same run. That is the difference between a passing test and a measurement. Ablation (3) is the necessary companion: rewriting Both ⛔ fences heldNo The judgment I want on the record⭐ The self-test pins And the
Flipping to ready; arming once every check run completes green. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10754
apps/docs/source.config.tscallsdefineDocstwice, andcheck-doc-frontmatterwalked only the first. Socontent/blog/**frontmatter was parsed bynext build— samefumadocs-coreextractor, sameyaml@2.9.0,blogSchema = pageSchema.extend({ author, date, tags })— and owned by nothing belowBuild Docs. An unquoteddescriptionholding a colon in a blog post is the identical defect on the identical 30-minute path.One file changed. No workflow edit: the existing
lint.ymlstep already invokes this script, so widening it kept out of the repo's busiest file (which had two other open PRs touching it).The acceptance bar, and why it is structural rather than intended
The bar was per-root non-empty assertion, and the trap is one line of arithmetic:
A two-root gate that counts globally is strictly worse than no second root, because it reports coverage it does not have. So the floor is not a rule this code follows — it is a shape it cannot break:
judgeRoot(root)is handed one root and is given no way to see another. It cannot let a populated root satisfy an empty root's floor because it is never offered the other root's count.judgeAll(roots)collects refusals instead of summing pages, and a refusal on one root does not suppress another root's verdict.ROOTSis itself a refusal.scripts/check-doc-authoring.mjsreached the same conclusion from the other side and states it in one sentence worth reusing: "A total floor is held up by whichever root still has files while another empties." Same invariant, same house shape — the refusal names the root.The floor is 1, deliberately, and is not a ratchet against a high-water mark: a legitimate deletion must not become an argument with a number. It answers exactly one question, per root: was this root read at all.
Proof: the ablation, against the real tree
Emptying
content/blogwhilecontent/docskeeps its 403 pages — the state a union gate is green in:Exit 1, naming the root, while the sibling root still prints its own clean verdict. Two more ablations, same trap-guarded harness, each restored byte-identically (
sha256fingerprint of the tree matched before/after on all three):content/blog.mdxcount 3 → 0blogroot; docs root still green at 403frontmatter does not parse,YAMLParseError [BLOCK_AS_IMPLICIT_KEY]at the FILE linetags: ai, architecture(blog-only key)`tags` parses to a string, not an array of stringsThe self-test pins the first of these permanently, with the populated root deliberately given 12 pages so the run is observed refusing while the global count is positive — the one observation a naive union cannot reproduce. Both declaration orders are asserted, so the result cannot come from a fold's accumulator.
Each root declares its own key contract
blogSchematypes five keys, not two. Asserting only the inherited pair would leave three of five unowned on a root this gate claims to cover — a smaller copy of the same coverage-it-does-not-have failure. So:titlez.string()descriptionz.string().optional()authorz.string().optional()tagsz.array(z.string()).optional()datez.coerce.string().optional()dateis a recorded decision, not a gap. Measured against the zod the docs app resolves (zod@4.4.3),z.coerce.string()accepts every value YAML can produce —42,true,null, a mapping, a sequence, a Date. There is no frontmatterdatethe build rejects, so any assertion would be ours rather than the build's — the same line this gate already draws by not requiringdescriptionto be present. It is pinned as declared-and-deliberately-unasserted, so a fourth key landing inblogSchemafails the battery instead of arriving unowned.tagsis not decoration:tags: [a, b]is two items andtags: a, bis one string — an ordinary prose mistake, exactly like the colon.A third root cannot arrive unowned
This gap existed because a
defineDocscall was added and nothing downstream noticed. Fixing the instance without the class would leave the next one in the same position, so the self-test parsesapps/docs/source.config.tsand asserts set equality between thedir:of everydefineDocscall there and every entry inROOTS, plus a count cross-check — a call spelled in a way the parser cannot see fails the battery instead of reading as zero extra roots. TheblogSchema.extend({...})key set is pinned the same way.Card premises, re-derived at
e75e34381(not trusted)defineDocscalls,blogSchemaextendspageSchema— confirmed, verbatim..mdx, no backlog — confirmed.content/blogis untouched; all three pages already carried a stringtitleanddescription, and the gate is green on them as written.affected-docs.mjsstill walkscontent/docsonly (roots at lines 627–628).check-doc-anchors.mjsreadscontent/**, blog included, but blanks frontmatter by design.check-doc-authoring.mjsreads those three files but scans fenced TypeScript. Not on the card:packages/lint/scripts/check-doc-formula-expressions.mjsalso readscontent/blog/**(its header names a blog post as one of the two defects it was written for) — but it scans fenced code for formula expressions and has no notion of frontmatter either (0 occurrences offrontmatterin that file, against 19 ofROOTSas a positive control). The premise holds; one more reader exists than the card listed, and it changes nothing.Verification
Everything below at
f4b6f3c72, clean tree:node scripts/check-doc-frontmatter.mjs2 content root(s) verified, each against its own floor — content/docs 403, content/blog 3node scripts/check-doc-frontmatter.mjs --self-testpnpm lint(whole repo,eslint . --no-inline-config)pnpm check:nul-bytespnpm check:entry-guardpnpm check:parse-guardpnpm check:agent-test-spellingpnpm check:cross-package-test-inputspnpm check:pnpm-filter-targetsnode scripts/check-cross-package-test-inputs.mjsGate families re-derived from the real changeset with
node scripts/pm/dispatch-gates.mjs(no paths passed,objectstack-ai/objectstackate75e34381): 7 families matched, all run above.No changeset — this is a CI gate script; it publishes nothing. Same shape as the PR that added this gate, which shipped scripts + workflow with none. Labelled
skip-changeset.Generated by Claude Code