Implements AGENTS.md section 7 front-matter schema as a single-file, standard-library-only Python validator; no third-party YAML dependency and no framework. Adds the `make validate` gate, a 20-case fixture set and docs/content-schema.md. DoD satisfied: - make validate runs and exits 0 on an empty/clean content/ tree (missing dir and empty dir both exit 0; fixture ok-empty covers it). - Every violation class has a fixture under test/fixtures/fail-*; each exits non-zero with one line per violation (file:line: field: rule). `make test` runs all 20 cases: 20/20 pass. - Dangling artefact reference is fatal (fail-dangling-artefact; artefacts[].url must resolve to an existing in-repo L3 stub or asset). - Unknown front-matter field is fatal (fail-unknown-field), including unknown keys nested inside artefacts[]/metrics[]. - docs/content-schema.md committed; states the frozen schema, links to AGENTS.md section 7, and records that any later schema change is a breaking change requiring Sam's approval. - Scope respected: no content authored, no rendering, no site code. Files: Makefile, tools/validate_content.py, test/fixtures/**, docs/content-schema.md, .gitignore (__pycache__).
9.1 KiB
Content schema (frozen)
This document states the front-matter schema that every file in content/ must carry, and the rules the validator enforces. The schema is the one in AGENTS.md §7. Read that section first; this page is the exact, testable restatement of it.
The schema is frozen. Any change to the fields, their types, their enum values or the artefact rules is a breaking change: it invalidates every existing file in content/ and both renderers. A change requires Sam's written approval before it is made. Do not widen or narrow the schema to make a document validate — fix the document.
1. How to validate
make validate # the gate: exits 0 only on a clean tree
make test # validator self-test, 20 fixture cases
make validate is the command CI and deploy.sh call in later phases. Run it before every content commit.
Both targets invoke:
python3 tools/validate_content.py [--root DIR] [--content DIR] [--quiet]
Exit codes: 0 clean (also when content/ does not exist yet), 1 at least one violation, 2 usage or internal error.
2. Document shape
- Every
.mdfile undercontent/is a document and is validated. Other file types undercontent/(images, SVG, PDF) are assets and are not validated — they exist to be referenced. - A document starts with a front-matter block on line 1:
---
<fields>
---
- The closing delimiter is
---(or...). Everything after it is the Markdown body and is not validated. - One document per file. One front-matter block per file.
content/is organised by area:
content/
ai/ platform/ edge/ products/ history/ about/ # documents, plus l3/ below each
assets/ # diagrams, screenshots, logos
3. Fields
All thirteen fields are required on every document, including L3 stubs (which use empty lists). Order does not matter.
| Field | Type | Rule |
|---|---|---|
id |
string | Lowercase kebab-case: a-z, 0-9, single hyphens between parts. Must equal the file name without .md. Unique across all of content/. |
area |
enum | One of ai, platform, edge, products, history, about. |
level |
integer | 1, 2 or 3 (depth level: L1 landing, L2 capability, L3 artefact). |
title |
string | Non-empty. Corporate capability framing (see AGENTS.md §7); product names are evidence, not headings. |
claim |
string | Non-empty. One sentence a hiring manager can verify. |
confidence |
enum | One of delivered, running, in-progress, abandoned, historic. Displayed on the page. Never promote a value to make a page read better. |
period |
string | Non-empty. Free text, e.g. "2023–", "2019–2024", "2000–2014". |
tech |
list of strings | Non-empty strings. May be []. Product and technology names belong here, not in title. |
metrics |
list of maps | May be []. Each item is exactly {label, value}. Both non-empty. value may be a number (8) or a string ("50+"). Every value must trace to the evidence notes — the validator checks shape, not truth. |
artefacts |
list of maps | May be []. Each item is exactly {type, label, url}. See §4. |
public |
boolean | true or false. false keeps the document out of the published output. |
last_verified |
date | YYYY-MM-DD and a real calendar date. |
tags |
list of strings | Non-empty strings. May be []. |
Unknown fields are rejected — at the top level and inside metrics[] and artefacts[]. There is no extension point. A field that an author thinks is useful is a schema change (§6).
The one exception to "all required": nothing. tech: [], metrics: [], artefacts: [] and tags: [] are the correct way to say "this document has none". An omitted field is a violation; an empty list is not.
4. Artefact links — dangling references are fatal
Every artefacts[].url must resolve to a file that exists in this repository. This is the rule that keeps every claim one step away from its proof.
url form |
Resolves against | Example |
|---|---|---|
content/... or any path without a leading ./ or ../ |
the repository root | content/edge/l3/audio-architecture.md |
./... or ../... |
the directory of the document | ./l3/audio-architecture.md |
Rules:
- The target must be an existing file — an L3 stub document (
.md) or an asset (.svg,.png,.pdf, …). A directory is a violation. - External URLs (
https://…) are not allowed. If the artefact lives outside the repository (a Gitea repo, a video), create an in-repo L3 stub document that describes and links it, and pointurlat that stub. This keeps every drill-down verifiable offline and keeps Mode A free of third-party dependencies. - Root-absolute paths (
/content/...) are not allowed — they do not survive a release directory move. - A path that escapes the repository is a violation.
type is one of repo, image, video, doc and describes the artefact behind the link, not the file extension of the stub.
5. Cross-document rules
idis unique acrosscontent/. Two files with the sameidare a violation on the second file.idmatches the file name.content/edge/snapcast.mdmust haveid: snapcast; an L3 stubcontent/edge/l3/snapcast-code.mdmust haveid: snapcast-code.
6. Changing the schema
Breaking (requires Sam's written approval, then a migration of every file):
- adding, removing or renaming a field
- changing a field's type
- adding or removing an enum value (
area,level,confidence, artefacttype) - relaxing or tightening a rule (e.g. allowing external artefact URLs)
Not a schema change: adding documents, filling empty lists, adding tags or metrics within the existing shape.
A change that supersedes this document must update, in one commit: AGENTS.md §7, this file, tools/validate_content.py, and the fixtures under test/fixtures/.
7. Error output
One line per violation, on stdout:
<path>:<line>: <field>: <rule broken>
Examples:
content/edge/audio.md:14: artefacts[0].url: dangling reference: 'content/edge/l3/not-written-yet.md' does not exist (create the L3 stub or asset)
content/edge/audio.md:16: author: unknown field (the schema is frozen; see docs/content-schema.md)
content/edge/audio.md:3: area: must be one of ai, platform, edge, products, history, about
content/edge/audio.md:1: claim: missing required field
A missing field is reported on line 1, because there is no line that carries it. A malformed front-matter block is reported as <front-matter>: invalid YAML: … and stops validation of that document only.
The final line is either FAIL: N violation(s) in M document(s) or OK: M document(s) validated, 0 violations.
8. Front-matter dialect the parser accepts
The validator contains its own small YAML-subset parser so the repository needs no third-party dependency (AGENTS.md §4: the stack is frozen, and the .27 build host has no Go toolchain yet). It accepts:
- block mappings, block sequences, and flow collections:
tech: [a, b],metrics: [{label: "endpoints", value: 8}] - single- and double-quoted scalars, plain scalars
- literal (
|) and folded (>) block scalars, including|-/>- #comments outside quoted strings- blank lines
It does not accept: anchors/aliases (&, *), explicit tags (!!str), multi-document files, complex keys, tab indentation. Use two or four spaces, consistently within a block.
Two consequences worth knowing:
- A quoted string must be closed.
claim: "text # not a commentis an error, not a comment. #after a space ends a plain scalar, as in YAML. Quote values that contain#.
9. What the validator does not check
Enforced elsewhere in Phase 1, and reviewed by Sam:
- that a
metricvalue appears in the evidence notes (no invented numbers) - that
confidencematches reality — the validator cannot know; it only forces an honest label to be present - the redaction pass (no family names, address, phone, credentials)
- that every L1 claim has a metric and at least one L3 artefact
- corporate framing of headings
10. Implementation and tests
| Path | Purpose |
|---|---|
tools/validate_content.py |
The validator. One file, Python 3 standard library only, no framework. |
Makefile |
validate and test targets. |
test/fixtures/<case>/content/ |
Fixture trees. ok-* cases must pass, fail-* cases must fail. |
test/fixtures/<case>/expect.txt |
Substrings that fail-* output must contain, one per line. |
Fixture cases cover: missing field, area enum, level enum, confidence enum, last_verified format and calendar validity, empty metrics[].label, artefact type enum, artefact missing url, dangling artefact, external artefact URL, duplicate id, id/filename mismatch, unknown top-level field, unknown nested artefact field, missing front-matter, malformed YAML. ok-empty proves an empty content/ exits 0; ok-block-scalars proves the block-scalar and block-sequence dialects.
Fixtures live under test/, never under content/.