A snapshot: read at commit 707a04c7ce8 on 2026-10-18T00:06:01Z by majordomus 0.29.0. Nothing on this page is read again. See it live
docs/GITHUB_PAGES_ARCHITECTURE.md
GitHub Pages architecture
What it is
- URI
majordomus://document/docs/GITHUB_PAGES_ARCHITECTURE.md- Kind
document- Identity
docs/GITHUB_PAGES_ARCHITECTURE.md- Path
docs/GITHUB_PAGES_ARCHITECTURE.md- Provenance
- declared
- Media type
text/markdown- As a capability
- document.docs/GITHUB_PAGES_ARCHITECTURE.md
Front matter
{}Content
# GitHub Pages architecture
The website at `https://majordomus.dev/` is a projection of this
repository. It is built the way Majordomus asks projects to build their own instruction
files: one canonical source, generated outputs, and a check that fails when the two drift.
## Purpose
Let a visitor understand in about two minutes what Majordomus is, what it guarantees, what
is only advisory, what is not built, and how to start, without any sentence on the site
being maintained separately from the repository that backs it.
## Canonical sources
| layer | owns | lives in | edited by hand |
|---|---|---|---|
| product truth | policy schema, profiles, the worker instructions, the shell CLI | `share/skeleton/**`, `bin/majordomus`, `lib/**`, `share/commands.yaml` | yes |
| the Rust executable | every capability with its schemas, exposures, stability, provenance, benchmark and cache policy; the modules; the command line with the examples of every command | `capability!` and `module!` declarations under `apps/majordomus-cli/src/capability/builtin/`, `apps/majordomus-cli/src/cli.rs` (clap for the structure, `cli::EXAMPLES` for the examples) | yes |
| the executable's narrative | what the crate is, owns and refuses; discovery, transports, side effects, architecture | `apps/majordomus-cli/README.md`, `docs/CAPABILITIES.md`, `docs/MCP.md` | yes |
| benchmark evidence | the regression policy and the accepted baselines | `.ai/repo/benchmarks/rust/policy.yaml`, `.ai/repo/benchmarks/rust/baseline.<platform>.json` | policy yes; a baseline by `majordomus bench baseline update` |
| registry projections | the OpenAPI document, the registry manifest, the capability, module and benchmark references, the command line, and the manifest of the whole generated set — each document in every encoding it is committed in | `docs/generated/**` (`artifacts.md` is the generated index of it) | never — `majordomus generate` |
| registry dataset | everything the site renders about the executable: descriptors, modules, command line, MCP and HTTP surfaces, benchmarks, the index | `site/data/registry/registry.json` | never — `majordomus generate site` |
| public narrative | what it is, why, how, what it refuses | `README.md`, `docs/*.md` | yes |
| claims | every capability with status, source, implementation, test | `docs/CLAIMS.yaml` | yes |
| marketing copy | the hero's positioning sentences, section leads, button labels; no capability claims, no numbers, 80-line budget | `site/data/marketing.toml` | yes |
| navigation | the intents, their dropdown items and hrefs, and the routes that moved (`[[redirects]]`, rendered as Zola aliases) | `site/data/nav.toml` | yes |
| claim detail | what each claim means, how it works, how to see it, what it does not cover, why it exists | `docs/claims/<id>.md` | yes |
| product features | one file per feature: the references naming what it is made of, and the editorial decisions nothing can infer; every surface, count and route beside it is derived (`docs/PRODUCT.md`, ADR 23) | `.ai/repo/features/<id>.md` | yes |
| case studies | the recognition moments, each with its homepage hook in front matter | `site/content-src/why/*.md` | yes |
| skills | the repository's skills, one directory each; the site reads the catalogue `lib/skills.sh` derives from the source class `skill` | `.ai/repo/skills/<id>/SKILL.md` | yes |
| rendering reference | representative Markdown for visual validation | `site/content-src/render-test.md` | yes |
| derived data | stable JSON the templates read | `site/data/generated/*.json` | never |
| the layer's directory contracts | every directory of `.ai/`, the contract it carries or the one that exempts it, and the counts | `site/data/generated/context.json` (from `majordomus context list --json`) | never |
| derived release artifact | the claims matrix as Markdown | `docs/SITE_CLAIMS.md` | never |
| derived content | canonical Markdown with generated front matter; one page per profile, claim, status, responsibility, command, doctrine, use case, application, milestone, issue, case study, skill, module and capability | `site/content/{docs,profiles,guarantees,supervises,commands,doctrines,use-cases,applications,plan,why,registry,skills}/`, `render-test.md`, `architecture.md` | never |
| derived routes and links for the executable | one route per module and per capability, the executable's pages, the API anchor, the source on GitHub, the claims attached to each surface | `site/data/generated/executable.json` | never |
| the native command line, as the site renders it | the command tree flattened, each command with its route, usage, arguments, children and executed examples | `site/data/generated/cli.json` (from `docs/generated/cli.json`, via `scripts/lib/cli-site.jq`) | never |
| build provenance | the commit and its cleanliness, the site's input hash, the registry and index fingerprints, and each composed surface with the commit its producer declared | `site/data/build.json`, served as `/build.json` | never — `scripts/site-build`, not committed |
| the crate's reference | the rustdoc pages of `apps/majordomus-cli`, composed at `/rustdoc` ([`RUSTDOC.md`](RUSTDOC.md)) | `target/web/rustdoc/`, with the `surface.json` that declares what it was built from | never — `scripts/rust-check --doc`, not committed |
| presentation | Zola templates and Tera 2 components | `site/templates/**` | yes |
| styling entry | Tailwind v4 + Flowbite v4 directives | `site/tailwind.css` | yes |
| behaviour | theme toggle, the diagram viewer, the chart component | `site/theme.js`, `site/diagrams.js`, `site/charts.js` | yes |
| output | the static site | `site/public/**` | never |
## What the publishing clone must be able to answer
The site links to the repository on its forge, and `scripts/ci/link-check` decides those
links **against git rather than over the network**: `commit/<sha>` and `compare/a...b` are
resolved in the history, `releases/tag/<t>` and `tree/<tag>` in the tags. A clone that was
not given those cannot decide such a link. It must then refuse rather than guess — and
`scripts/site-check` counts a refusal as a failure, so the publication stops.
That is not a hypothetical cost. Between **2026-09-14 18:34 and 2026-09-15 00:0x** the
deploy checked out at depth 1, which truncates the history *and* fetches no tags. Six links
to `v0.3.1`, `v0.5.0` and `v0.6.0` — tags that exist and carry published releases — were
reported as non-existent, 2302 commit links were undecidable, `publish` was skipped on every
run, and the live site stayed at `8cf457000` for seven hours while master moved three merges
ahead. Every gate was green throughout, because nothing asked whether the site was current.
So the requirement is **declared, not written into the workflow**:
```yaml
# .ai/repo/ci/pages.yaml
deploy:
checkout:
history: full # renders as fetch-depth: 0
tags: required # actions/checkout fetches none at a shallow depth
```
| what | where |
|------|-------|
| the requirement | `deploy.checkout` in `.ai/repo/ci/pages.yaml` |
| the depth it implies | `scripts/pages checkout` — prints `0` |
| the clone a run was given | `scripts/pages checkout --verify` — exit 10 when it cannot decide a ref |
| the workflow held to the model | `test/cases/97_pages_fast_path.sh` |
The workflow is an adapter over that, the same way its `paths:` block is an adapter over
`scripts/pages paths`. **Changing what the publisher needs from its clone is an edit to the
model and nowhere else**: the depth, the verification and the test all read it. A test that
asserted `fetch-depth: 0` literally would hold one patch in place; the case compares the two
and fails when they disagree, whichever of them moved.
`--verify` runs in the deploy job immediately after checkout, so a runner that does not
honour the declaration fails there, naming the depth — rather than three steps later inside
link-check, where a checkout problem presents as a link defect.
## When a publication fails: the scheduled retry
A push-triggered run that fails leaves the site where it was, and nothing asks again. On
2026-09-14 the site stood seven hours while every run failed. So `.github/workflows/pages.yml`
also runs on a clock, `trigger.schedule` in `.ai/repo/ci/pages.yaml`, every thirty minutes —
the window `scripts/ci/pages-check` judges by (`OWED_AFTER`, 1800 s); a schedule slower than the
window cannot notice what the window calls owed. `test/cases/97_pages_fast_path.sh` holds the
workflow's cron to the model's.
A scheduled run asks before it works, in a small job of its own (`owed`):
| state | remedy | run ends |
|-------|--------|----------|
| the site is current | none; nothing else runs | green |
| gh-pages does not carry the trunk's site | the `rustdoc` and `deploy` jobs run as on a push | red |
| gh-pages is right, GitHub's own build errored | `scripts/pages rebuild` | red |
Red is deliberate: the run repaired a fault the push-triggered run should not have left, and
healing without reporting would hide a fault that recurs every thirty minutes. The
`intervention` job says what was owed and what was done.
A rebuild is a different action from a redeploy. Deploying an identical tree changes nothing
when GitHub's builder is what failed, and a request for a build **preempts a build in flight**:
asking while GitHub is building kills that build. `scripts/pages rebuild` therefore asks only
when the build of the commit errored *and* nothing is building, reads that state through
`scripts/pages built --porcelain` (the one reader of it), exits 12 without asking when the state
cannot be read or a build is in flight, and reads the state again after asking.
"gh-pages is right" is measured, not assumed: the `owed` job chooses the rebuild only when the
top-level `commit` of gh-pages' `build.json` is the trunk commit the run checked out. An errored
build of an older tree — a push deploy that failed before publishing, over a build that had
errored too — is deployed, because rebuilding it would republish the stale site on every run.
`test/cases/625_a_rebuild_is_asked_only_when_it_helps.sh` holds each of those, the last by
running the workflow's own remedy step against both trees.
## Projection pipeline
Two generators, one graph. The Rust executable projects its own registry; the site
generator projects the canonical files and consumes the executable's projections through
one machine-readable boundary, `docs/generated/registry.json` (and `openapi.json`); Zola
renders templates over what both wrote. `scripts/derive` runs the stages in order,
`scripts/derive-check` proves the committed result, and no stage reads its own output.
```mermaid
flowchart TB
subgraph canonical
R[capability! and module! declarations, src/cli.rs with cli::EXAMPLES]
K[share/kinds.yaml, schemas, providers, policy]
D[README, docs/*.md, docs/claims, CLAIMS.yaml, RESPONSIBILITIES.yaml]
C[share/commands.yaml, use-cases.yaml, applications.yaml]
P[.ai/repo/project, rules, benchmarks]
end
subgraph stageA[stage A · majordomus generate, code only]
GA[docs/generated: openapi, registry, capabilities, cli, modules/*.md — json+yaml+md as each document is committed · share/allow/*.txt · AGENTS.md, CLAUDE.md]
end
subgraph stageB[stage B · scripts/generate-site-data]
GB[site/data/generated/*.json · site/content/** · docs/SITE_CLAIMS.md, PLAN_STATUS.md]
end
subgraph stageC[stage C · majordomus generate, over the index]
GC[site/data/registry/registry.json · docs/generated/benchmarks.* · docs/generated/artifacts.*]
end
subgraph build[scripts/site-build]
Z[zola + Tailwind → site/public · build.json]
end
R --> GA
K --> GA
D --> GB
C --> GB
P --> GB
GA -->|registry.json, openapi.json, cli.json| GB
R --> GC
P --> GC
GB -->|the derived documents are objects of the index| GC
GB --> Z
GC --> Z
```
| stage | generator | reads | writes | checked by |
|---|---|---|---|---|
| A | `majordomus generate` | the Rust declarations, `share/`, the policy and the provider templates | `docs/generated/{openapi,registry,cli}.{json,yaml}`, `docs/generated/{capabilities,cli}.md`, `docs/generated/modules/*.md`, `share/allow/*.txt`, the provider bootstraps | `majordomus generate --check` |
| B | `scripts/generate-site-data` | every canonical file (`--inputs` lists them) and stage A's `registry.json` and `openapi.json` | `site/data/generated/*.json`, `site/content/**`, `docs/SITE_CLAIMS.md`, `docs/PLAN_STATUS.md` | `scripts/generate-site-data --check` (by input hash) |
| C | `majordomus generate` | the Rust declarations and the index of the layer, which now holds the documents stage B wrote | `site/data/registry/registry.json`, `docs/generated/benchmarks.{md,json,yaml}`, `docs/generated/artifacts.{json,yaml,md}` | `majordomus generate --check` |
| build | `scripts/site-build` | stages B and C, the templates, the assets, and the artifact of every published static surface `docs/generated/web.json` names | `site/public/**` (with each such surface composed at its mount), `site/data/build.json`, `site/static/build.json` | `scripts/site-check`, `scripts/site-probe` |
The boundary between the executable and the site is data, in both directions of reading:
the site generator reads the registry manifest for the ids it turns into routes and never
parses Rust; the templates read the registry dataset for every fact about a capability and
never restate one; the executable knows nothing of Zola. The manifest is a projection used
as an interface between stages, not a second truth: a capability exists in one place, and
`generate --check` refuses a manifest that no longer says what the declarations say.
Why stage C exists: the dataset lists every object of the layer with its size, and the
derived documents stage B writes are objects of the layer, so the dataset is derived after
them. Stage C's outputs are read by nothing but the site build, so the graph has no cycle
and a second `scripts/derive` on a clean tree changes nothing — `git status` stays empty,
which `test/cases/51_derived_artifacts_committed.sh` and the release criterion require.
`scripts/generate-site-data` reads every canonical input, normalises it once, and writes:
| file | from | what |
|---|---|---|
| `project.json` | `bin/majordomus`, `README.md`, `LICENSE` | name, version, tagline, licence, commands, exit codes |
| `profiles.json` | `share/skeleton/profiles/*.yaml` | every profile, every field |
| `policy.json` | `share/skeleton/policy.yaml` | the policy as structure, plus the raw text |
| `capabilities.json` | `docs/CLAIMS.yaml` | every claim; the generator fails on a missing path or an untested guaranteed claim |
| `commands.json` | `share/commands.yaml`, `docs/CLI.md`, the test cases | every shell command with its semantics, narrative and evidence |
| `catalogue.json` | `.ai/repo/use-cases/`, `.ai/repo/applications/` | the use cases and applications, cross-referenced |
| `doctrines.json` | the rule packages | every doctrine with its enforcement chain |
| `plan.json` | `.ai/repo/project/` | milestones, issues, the dependency graph, derived status |
| `openapi.json` | `docs/generated/openapi.json` | the HTTP API in the shape `api.html` and `api-tag.html` render, with the one page every operation and schema is published on (`scripts/lib/openapi-site.jq`) |
| `artifacts.json` | `docs/generated/artifacts.json` | the generator's own manifest — declarations only, carrying `schema: 1` like every other file of that directory with the manifest's own schema id under `manifest_schema`, moved under `site/data/` so `registry-artifacts.html` can load it; nothing is added and no file is named in the template. Sizes and hashes are dropped: two of the manifest's subjects are written by stage C, so a copy carrying them could never converge with stage B |
| `executable.json` | `docs/generated/registry.json`, `capabilities.json` | one route per module and per capability, the executable's pages, API anchors, sources on GitHub, the claims attached to each surface by the path of their implementation (`scripts/lib/executable-site.jq`) |
| `lifecycle.json` | `lib/finish.sh`, `share/skeleton/ai/repo/workflows/task-lifecycle.md`, `share/standard/majordomus/` | outcome vocabulary, divergence labels, lifecycle steps, the ten principles (the rules tagged `principle`) |
| `diagrams.json` | the files above | Mermaid source projected from data |
| `readme.json` | `README.md` | the sections the homepage renders, by heading; a renamed heading fails the build |
| `docs.json` | `docs/README.md` | the documentation index |
| `source.json` | the inputs | version, input hash, generator version, input list; no commit (see build provenance) |
`site/content/docs/*.md` is written from `docs/*.md` with a generated front matter, links
rewritten to site routes, and the whole body wrapped in a Tera `raw` block so that Tera never
templates canonical Markdown. The tag itself cannot be written out here: a literal closing
`raw` tag in a canonical document would end the wrapper the projection puts around it.
`site/content/registry/executable.md` is `apps/majordomus-cli/README.md` through the same
projection.
## Derived files, classified
| class | what | examples | rule |
|---|---|---|---|
| canonical | authored, the only place a fact lives | the Rust declarations, `share/*.yaml`, `docs/*.md`, `docs/CLAIMS.yaml`, `.ai/repo/**`, templates, `nav.toml`, `marketing.toml` | edited by hand |
| derived, committed | a projection reviewers see on GitHub and CI compares with its sources | `docs/generated/**`, `share/allow/**`, `AGENTS.md`, `CLAUDE.md`, `site/data/generated/**`, `site/data/registry/**`, `docs/SITE_CLAIMS.md`, `docs/PLAN_STATUS.md` | regenerated with `scripts/derive`, committed with the change that moved it; `scripts/derive-check` refuses drift |
| derived, build only | rebuilt on every build, never committed | `site/content/{docs,registry,commands,...}/`, `site/static/{app.css,js/,openapi.json,build.json}`, `site/data/build.json`, `site/public/**`, `target/web/rustdoc/` | gitignored; `scripts/site-check` proves the build's own guards (`13e`, `13a`) and that every composed surface is this build's (`13m`) |
| local, ephemeral | this checkout's state | `.ai/local/**`, `apps/majordomus-cli/target/` | never a source of anything the site shows |
### The generator is an input, and it is not in the table
Every row above classifies a *file*. The classification is incomplete in one way that has
already cost this repository a near-miss: the derived files are a projection of the canonical
sources **as read by one executable**, and that executable is not a file in the tree. Two
builds of the same declared version can project different registries.
So an executable borrowed from another worktree — through `MAJORDOMUS_BIN`, which is how a
session avoids a ten-minute rebuild — is a third input, and an unsuitable one fails in the
worst available direction: it reports the *tree* stale, printing a remedy that regenerates,
and regenerating rewrites each document from a model the tree no longer holds. On 2026-09-10
that removed about 9,700 lines from `docs/generated/changelog.json` and exited zero.
Before deriving with an executable you did not just build:
```sh
env -u MAJORDOMUS_SHARE "$MAJORDOMUS_BIN" generate --check # must say: in sync
```
and after any derivation, before committing:
```sh
git diff --stat -- docs/generated site/data # mass deletion is corruption
```
`project.a-tool-that-cannot-match-the-tree-refuses` is the rule; the guard that enforces it
lives in the executable, so it holds for every caller rather than for one script.
## Where do I edit this?
| I want to change | edit | never edit |
|---|---|---|
| a Rust capability's title, description, schema, stability, tags, benchmark or cache policy | its `capability!` declaration under `apps/majordomus-cli/src/capability/builtin/` | `docs/generated/**`, `site/data/registry/registry.json`, any `/registry/` page |
| how a capability is exposed (MCP tool, resource, HTTP route, CLI path) | the `exposure` of the same declaration | `docs/generated/openapi.json`, `/docs/api/`, `/registry/mcp/` |
| a module's title, description or stability | its `module!` declaration | `docs/generated/modules/*.md`, `/registry/modules/` |
| the native command line: a command, an argument, a default, a value set | the clap declaration in `apps/majordomus-cli/src/cli.rs` | `docs/generated/cli.md`, `docs/generated/cli.json`, `site/data/generated/cli.json`, `/registry/cli/`, `/docs/cli/**` |
| an example of a native command, or adding a command that has none | `cli::EXAMPLES` in the same file, beside the declaration | an example written into a template, a page or `cli.md`: the pages render the argument vectors the crate's tests execute |
| the benchmark regression thresholds | `.ai/repo/benchmarks/rust/policy.yaml` | the policy table on `/registry/benchmarks/` |
| an accepted baseline | `majordomus bench baseline update` on the platform it measures | the baseline JSON by hand |
| what the executable is, owns, refuses; discovery; transports; side effects | `apps/majordomus-cli/README.md` | `/registry/executable/` |
| a shell command's category, stage, reads, writes, syntax, exit codes | `share/commands.yaml` (semantics) and `docs/CLI.md` (narrative) | `commands.json`, `/commands/<name>/` |
| a use case or an application | `.ai/repo/use-cases/`, `.ai/repo/applications/` | `catalogue.json`, its route |
| a claim | `docs/CLAIMS.yaml` and `docs/claims/<id>.md` | `capabilities.json`, `docs/SITE_CLAIMS.md`, `/guarantees/` |
| a doctrine | the rule file under `.ai/repo/rules/` or the vendored package | `doctrines.json`, `/doctrines/` |
| a milestone or an issue | `.ai/repo/project/**` | `plan.json`, `docs/PLAN_STATUS.md`, `/plan/` |
| a profile, the policy's schema | `share/skeleton/**` | `profiles.json`, `policy.json` |
| a kind or a schema | `share/kinds.yaml`, `share/schemas/`, or `.ai/repo/knowledge/` for this repository | `share/allow/*.txt`, the kinds table |
| the provider bootstraps | `.ai/repo/policy.yaml`, the provider templates | `AGENTS.md`, `CLAUDE.md`, `GEMINI.md` |
| how a page looks | `site/templates/**`, `site/tailwind.css` | `site/data/generated/**` to make a template's life easier |
| which pages the executable's section has, or how claims attach to its surfaces | `scripts/generate-site-data`, `scripts/lib/executable-site.jq` | a route list in a template or in `nav.toml` |
| the editorial navigation | `site/data/nav.toml` — intents and their fixed pages | an entry per module or capability: those routes are enumerated by the generator, and `site-check` refuses one typed here |
| the homepage's words | `site/data/marketing.toml` | a number, a claim word |
After any edit in the left column: `just derive`, review the diff of the derived files,
`just derive-check`, commit both together.
## Markdown rendering
Canonical Markdown must stay readable on GitHub, so the site uses only syntax GitHub renders
natively. `scripts/lib/project-markdown.awk` projects three GitHub-native constructs into site
components, on the derived copy only:
| in the canonical file | on the site |
|---|---|
| ` ```mermaid ` fence | `<pre class="mermaid">`, named, linked and given its controls by the build, drawn client-side in the viewer ([`DIAGRAMS.md`](DIAGRAMS.md)); the source stays visible without JavaScript |
| `> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, `[!CAUTION]` | Flowbite alert |
| a pipe table | wrapped in `overflow-x-auto` so wide tables scroll inside their container |
Everything else is Zola's Markdown renderer inside a Flowbite Typography container
(`format dark:format-invert`): headings with anchors, lists, task lists, footnotes,
blockquotes, and class-based syntax highlighting (`[markdown.highlighting] style = "class"`,
themes `ayu-light` and `ayu-dark`). Zola writes the two theme stylesheets into `site/public/`;
`scripts/site-build` scopes the dark one under `.dark` so class-based dark mode selects it.
`site/content-src/render-test.md` exercises every construct and is linked from nowhere.
## Mermaid
Client-side, from the pinned npm package, vendored to `static/js/mermaid.min.js` at build.
Chosen over build-time SVG because the Mermaid CLI needs a headless browser in CI and the
site must stay reproducible from bash, Node and Zola alone. No page loads the build with a
script tag: `site/diagrams.js` is deferred and fetches it once the page has been parsed.
It initialises with `securityLevel: 'strict'`, takes its colours from the page's design
tokens, and draws again when the theme changes. Four diagrams are projected from data, never
drawn by hand: the task lifecycle from the outcome vocabulary, the policy-to-instruction-file
graph from the policy's projections, the site pipeline from the generator's input list, and
the core model from the README. A failed diagram leaves its source visible and never breaks
the page.
After Zola, `scripts/site-build` runs `scripts/lib/diagrams.mjs` over the built pages: every
diagram gets an id (`diagram-<name>`), the list of its nodes, the viewer's toolbar and a
list of the links inferred from the identities in `site/data/generated`. The viewer, the
address scheme and the chart component, which draws a table the page already renders with
the vendored Apache ECharts build, are described in [`DIAGRAMS.md`](DIAGRAMS.md)
(ADR 0152).
## Zola structure
```
site/
config.toml base_url, class-based highlighting, heading anchors, footnotes
content/ _index and one stub per page; docs/ and render-test.md are generated
content-src/ hand-written pages that get the same projection as docs
templates/
base.html head, theme-before-paint, navbar, footer, Flowbite init, Alpine
components.html Tera 2 components: badge, code, card, section_head, diagram
index.html homepage: the business hero, then the sections, all from generated data
page.html standalone page with breadcrumb and Typography body
docs-section.html /docs/ index from the section's pages
docs-page.html document with sidebar navigation
profiles.html policy.html guarantees.html getting-started.html architecture.html
404.html
partials/ navbar, footer, theme-toggle, breadcrumb, profile-cards, mermaid
data/marketing.toml the only hand-written prose
data/generated/ written by generate-site-data, committed, checked
tailwind.css theme.js diagrams.js
static/ images copied from assets/; app.css and js/ written at build
public/ output
```
Zola 0.23 runs Tera 2: components replace macros, `import` no longer exists, array indexing
is `x[1]`, undefined variables are errors, `filter` and `slice` are gone in favour of list
comprehensions and Python-style slicing.
## Tailwind, Flowbite, Alpine
`site/tailwind.css` follows the Flowbite quickstart for Tailwind v4: `@import "tailwindcss"`,
`@import "flowbite/src/themes/default"`, `@plugin "flowbite/plugin"`,
`@plugin "flowbite-typography"`, `@source` for `node_modules/flowbite` and the templates, and
`@custom-variant dark (&:where(.dark, .dark *))` from the dark-mode guide. Flowbite v4's
semantic tokens (`bg-neutral-primary-soft`, `text-heading`, `border-default`, `rounded-base`)
are used throughout. The theme names Inter first; it is not loaded, so the stack falls to the
system font on purpose.
Flowbite's JavaScript is not loaded. The only two things the site used it for, the navbar's
mobile collapse (`data-collapse-toggle`) and its dropdowns (`data-dropdown-toggle`), are
`site/nav.js`, about 2 KB, which `base.html` loads deferred and site-check requires exactly
once per page. Removing the 134 KB bundle took the homepage from 338555 to 207140 bytes of
scripts and stylesheets. Flowbite's styles stay; they are CSS, compiled into `app.css`.
The homepage is a projection of `site/data/registry/product.json`, which the executable
writes from the features of the layer, the capability registry and the web topology. Every
chapter, mark, count, provider, kind and rule on it is read from that dataset; the template
names none of them, and `scripts/site-check` fails a template, a generator or a navigation
file that names a feature route or a provider's own file. The hero's second column is the
answer `majordomus product matrix` prints, rendered as a table rather than padded text so
the columns align at every width and a screen reader reads a table rather than a picture of
one. The words around it — the title, the mantra, the section leads and the button labels —
are `marketing.toml`'s, which carries no number and no capability claim and is held to a
sixty-line budget.
The one thing a person decides is which features are chapters and in what order, and that
decision lives in the feature files themselves (`featured` and `weight`), not here: a
feature becomes a chapter by declaring itself one. `docs/PRODUCT.md` is the contract, ADR 23
is the decision, and `project.product-surface-derived` is the rule.
Routes that moved are declared once, in `nav.toml` under `[[redirects]]`, beside the routes
that did not. The generator turns each into a Zola alias on the page it points at, so the
old address answers with a redirect; a redirect whose target the generator did not produce
fails the build rather than publishing a link into nothing. An alias page is a redirect and
not a page, so `scripts/lib/site-pages.awk` exempts it from the contract a page is held to.
Alpine.js is vendored and used for two things: the copy button on code snippets (`x-data`,
`x-on:click`, `x-text`) and the raw-policy disclosure on `/policy/`. Both degrade: the code
and the policy are in the HTML; only the control disappears without JavaScript.
Custom CSS: one rule, `[x-cloak]{display:none!important}`, inline in `base.html`, required by
Alpine's documentation. Inline `style=""` attributes are forbidden and `scripts/site-check`
fails on one.
## Flowbite guidance consulted
The Flowbite LLM entry point (`flowbite.com/docs/getting-started/llm/`) points at `llms.txt`,
which is an index. The pages read and followed, on 2026-09-04: getting-started/quickstart
(Tailwind v4 directives), getting-started/javascript (`initFlowbite`), customize/dark-mode
(`@custom-variant`, toggle markup and script, reproduced in `theme.js`), components/navbar,
components/card, components/badge, components/footer, components/typography. The repository's
installed `flowbite` is 4.0.2, matching the documented token vocabulary.
## Routes
Every tile on the homepage is a link to a page with its own title, description, canonical URL
and Open Graph metadata. The route classes and their sources:
| route | source | template |
|---|---|---|
| `/` | `site/data/registry/product.json` and `product-graph.json` (chapters, interfaces, matrix, providers, kinds, doctrine), `marketing.toml` (the words), `readme.json` (the finish contract), `distribution.json` (the install command), the `why` section | `index.html` |
| `/why/`, `/why/<slug>/` | `site/content-src/why/*.md` | `why-section.html`, `why.html` |
| `/features/`, `/features/<id>/`, `/features/matrix/` | `site/data/registry/product.json` and `product-graph.json`, with each page's prose read from the feature's own file | `features-section.html`, `feature.html`, `features-matrix.html` |
| `/getting-started/` | `project.json`, `policy.json`, `lifecycle.json` | `getting-started.html` |
| `/supervises/`, `/supervises/<slug>/` | `readme.json` (What it does rows, cross-linked to commands and claims by keyword) | `supervises-section.html`, `responsibility.html` |
| `/commands/`, `/commands/<name>/` | `commands.json` (each command's section of `docs/CLI.md`) | `commands-section.html`, `command.html` |
| `/profiles/`, `/profiles/<slug>/` | `profiles.json` | `profiles.html`, `profile.html` |
| `/policy/` | `policy.json` including the raw file | `policy.html` |
| `/guarantees/`, `/guarantees/<status>/`, `/guarantees/<id>/` | `capabilities.json`, `docs/claims/<id>.md` | `guarantees.html`, `status.html`, `claim.html` |
| `/limitations/`, `/roadmap/` | `readme.json` sections by heading | `readme-section.html` |
| `/architecture/` | `site/content-src/architecture.md`, `source.json`, `diagrams.json` | `architecture.html` |
| `/docs/`, `/docs/<doc>/` | `docs/*.md` listed in `docs/README.md` | `docs-section.html`, `docs-page.html` |
| `/docs/api/`, `/docs/api/<tag>/`, `/docs/api/shared/`, `/openapi.json` | `openapi.json` (from `docs/generated/openapi.json`) | `api.html` (the index), `api-tag.html` (a page per tag, and shared types) |
| `/docs/cli/`, `/docs/cli/<command path>/` | `cli.json` (from `docs/generated/cli.json`, itself the clap declaration and `cli::EXAMPLES`) | `docs-cli.html`, `docs-cli-group.html`, `docs-cli-command.html` |
| `/doctrines/`, `/doctrines/<slug>/` | `doctrines.json` | `doctrines-section.html`, `doctrine.html` |
| `/use-cases/`, `/use-cases/<id>/`, `/applications/`, `/applications/<id>/` | `catalogue.json` | `use-cases-section.html`, `use-case.html`, `applications-section.html`, `application.html` |
| `/plan/`, `/plan/dag/`, `/plan/<id>/` | `plan.json` | `plan-section.html`, `dag.html`, `milestone.html`, `issue.html` |
| `/evidence/`, `/map/` | `claims-graph.json`, `architecture.json` | `evidence.html`, `map.html` |
| `/registry/` | `site/data/registry/registry.json`, `executable.json` | `registry.html` |
| `/registry/executable/` | `apps/majordomus-cli/README.md`, `executable.json` (claims) | `registry-executable.html` |
| `/registry/modules/`, `/registry/modules/<id>/` | the dataset's modules, `executable.json` | `registry-modules.html`, `registry-module.html` |
| `/registry/capabilities/`, `/registry/capabilities/<slug>/` | the dataset's descriptors, benchmarks and coverage, `executable.json` (module, API anchor, source, claims, tests) | `registry-capabilities.html`, `registry-capability.html` |
| `/registry/cli/` | the dataset's `cli` (the clap declaration) | `registry-cli.html` |
| `/registry/mcp/` | the dataset's `mcp` (tools, resources, protocol) | `registry-mcp.html` |
| `/registry/benchmarks/` | the dataset's `benchmarks` (targets, coverage, policy, baselines) | `registry-benchmarks.html` |
| `/registry/artifacts/` | `artifacts.json` (the generator's own manifest) | `registry-artifacts.html` |
| `/rustdoc/`, and every page rustdoc writes beneath it | `target/web/rustdoc/`, written by `scripts/rust-check --doc` and composed by `scripts/site-build` from `docs/generated/web.json` | — (rustdoc's own pages; `index.html` is the producer's landing page) |
| `/build.json` | `scripts/site-build` | — (served raw) |
| `/render-test/` | `site/content-src/render-test.md`, `noindex` | `docs-page.html` |
A capability's route slug is its id with `.` and `_` replaced by `-` (`objects.search` →
`/registry/capabilities/objects-search/`, `repository.scope_classify` →
`/registry/capabilities/repository-scope-classify/`): Zola slugifies every path that way,
so the dataset names the route Zola actually serves. The API reference's operation anchors
are HTML ids Zola never touches, so they replace only the `.` (`#op-objects-search`,
`#op-repository-scope_classify`); `executable.json` carries both spellings (`slug`,
`api_anchor`), so the two link each other without a table.
A native command's route is its path under `/docs/cli/`, one segment per word, with a
trailing slash: `["majordomus", "bench", "baseline", "update"]` is
`/docs/cli/bench/baseline/update/`. The rule is one function in the executable
(`cli::route`), the route it produced is carried in `docs/generated/cli.json`, and every
consumer downstream — the site generator, the templates, `site-check`, `site-probe` — reads
that field rather than deriving one of its own. A command that has commands under it is a
Zola section so that its children have routes beneath it, and a command that has none is a
page; which it is comes from the data, so a command that grows a subcommand changes shape by
itself. `site-check` compares the declared routes and the built ones as sets, both ways: a
command with no page fails, and a page whose command the executable no longer has fails as an
orphan.
`/docs/cli/` belongs to the *native* command line, the Rust executable's. `docs/CLI.md`
specifies the **shell** tool `bin/majordomus` — a different program, whose structured
reference is `/commands/` — and renders at `/docs/cli-specification/`. The rule that turns a
document into a route is one function in the site generator (`doc_slug`), and the route it
produced is recorded in `docs.json`, so the link rewriting, the pages and `site-check` read
it instead of each computing a slug. Do not describe `share/commands.yaml` or `docs/CLI.md`
as sources for the Rust executable: they are the shell tool's, and the two command surfaces
are documented apart on purpose.
Navigation is `site/data/nav.toml`: a handful of intents (Why, Get started, Concepts, Plan,
Trust, Executable, Reference), rendered as Flowbite dropdowns on desktop and as labelled flat
lists inside the collapsed menu on phones. It holds editorial intent — which fixed pages an
intent leads to — and never an enumeration: the modules and capabilities are listed by the
generator, and `scripts/site-check` refuses a module or capability route typed into it.
`scripts/site-check` verifies every navigation and homepage link resolves, every tile class
has its page, for the executable's section that every module and capability of the dataset
has its page and no page exists without its entry, and for the command line that the routes
under `/docs/cli/` are exactly the commands the executable declares, that each page renders
its own usage, arguments and example anchors and links up and down, and that no command
route is typed by hand into a template, the generator or the navigation. `scripts/site-probe`
then fetches every declared command route from a running server and reads the deepest command
page back.
## Responsive strategy
Mobile-first: base classes describe the phone layout, `sm:`/`md:`/`lg:`/`xl:` add columns.
Every `<pre>` and `<table>` sits in a scroll container, either an explicit `overflow-x-auto`
wrapper or a Typography container carrying `[&_pre]:overflow-x-auto` and
`[&_table]:overflow-x-auto`. Grid items that hold code carry `min-w-0`. Validation is
measured, not eyeballed: `scripts/site-probe` builds a copy under the deployment prefix, serves
it locally, loads every route in headless Chrome inside iframes of 320, 390 and 1280 px and
asserts `document.scrollWidth == clientWidth`, then exercises the mobile menu, a navigation
dropdown and the theme toggle and counts rendered Mermaid diagrams. It runs in the Pages
workflow and skips with a notice where no Chrome exists. `test/cases/09_site_mobile_first.sh`
lints the built HTML for the structural causes of overflow without a browser.
## Accessibility
One `h1` per page, `<main>`, `<nav aria-label>`, a skip link, `aria-current` on active
navigation, `aria-expanded`/`aria-controls` on the menu toggle, `aria-label` on icon-only
controls, `role="note"` on callouts, visible focus rings (`focus:ring-*`, never removed),
Flowbite's contrast tokens in both themes. Everything except the copy button and the
disclosure works without JavaScript.
## GitHub Pages deployment
The site is published by `.github/workflows/pages.yml`, which triggers directly on a push to
master whose paths can change the site and runs beside `validate.yml` rather than after it.
Its deploy job proves the committed `site/data/generated` is current for the tree by comparing
the tree's canonical input hash with the one `source.json` carries
(`scripts/generate-site-data --fingerprint`), renders it (`scripts/pages build`), runs every
static check over the output (`scripts/pages check`), and pushes `gh-pages`. A tree whose
committed derived data is stale is refused rather than regenerated.
The crate's rustdoc reference is part of those bytes and is not committed, so a `rustdoc` job
of its own, which the deploy job needs, produces it from the same commit through the composite
action `.github/actions/rustdoc` — the one the validation workflow uses too — and hands it over
as an artifact of the run. The deploy job fetches that tree before it renders,
`scripts/site-build` composes it at `/rustdoc` from `docs/generated/web.json`, and
`scripts/site-check` refuses a composed surface that is missing or was built from another
commit (section 13m). Once the site serves the deployed commit, `scripts/pages verify-rustdoc`
checks the public reference against it, and that step is a hard one: a deployment whose public
reference is absent, broken or stale fails the run. [`RUSTDOC.md`](RUSTDOC.md) has the whole
path and ADR 86 the decision.
Everything that decides whether a change may *merge* — the behavioural suite, the crate's
gates, coverage, the macOS suite, the benchmark check and the browser probe — runs on the same
commit in `validate.yml` and gates the `ci` status. The publication path carries what can make
the published bytes wrong and nothing else; the reasoning, the budgets and the measurements
are [`GITHUB_PAGES_PERFORMANCE.md`](GITHUB_PAGES_PERFORMANCE.md), and [`CI.md`](CI.md) has the
validation shape. The `rust` job runs `majordomus generate --check` and the `site` job
`scripts/generate-site-data --check`; locally `scripts/derive-check` (`just derive-check`)
runs both, and `scripts/derive` (`just derive`) regenerates every derived artifact in
dependency order.
GitHub Pages serves the `gh-pages` branch (source: branch, path `/`), and whoever pushes
that branch deploys. One script does it, `scripts/site-deploy`: it refuses a dirty tree and a
commit `origin/master` does not contain, runs the gate (`generate-site-data --check`,
`majordomus generate --check`, `site-build`, `site-check`, `--probe` on request), copies
`site/public` into a worktree of `gh-pages` with `.nojekyll`, commits it with the source
commit in the message, and pushes. GitHub's own "pages build and deployment" then serves it
in under a minute; the footer of every page names the commit it was built from, so the
deploy is verifiable with `curl`. Unchanged output is not pushed twice, and a `--dry-run`
shows the commit it would push. `test/cases/96_site_deploy.sh` runs the whole path against a
local bare remote: every refusal, the first deploy, the no-op redeploy, and the check that
`site/public` was built from HEAD.
The job of `.github/workflows/pages.yml` is the gated form of the same command: it builds and
checks the commit and then runs `scripts/site-deploy --skip-build`. A person runs
`scripts/site-deploy` (or `just site-deploy`) when the operator wants the site live without
waiting for the Actions queue, and that path — no queue in front of it at all — is why the
branch source is kept instead of the native Pages artifact flow, which would make a workflow
run the only way to publish. The procedure is `.ai/repo/skills/deploy-site/SKILL.md`. Pointing
Pages at the branch is a one-time `scripts/site-deploy --configure-pages`.
### The documentation a deploy publishes
The build writes `/docs/index.json` last, with `scripts/lib/docs-index.mjs`. It reads the
pages the build just produced, the composed surfaces' among them (the crate's reference at
`/rustdoc`), so it lists no route of its own. Each page has its route, its
title and its description; an entity page is also joined to its object, and the index
carries the `/build.json` identity of the build. The build fails when a route that
`site/data/publication.toml` publishes, or an entity page, was not built. After the site
serves the pushed commit, the workflow runs `scripts/pages verify-docs --commit <sha>`. The
public index must name that commit, and each of the following must answer 200: the
documentation root, every route the catalogue publishes a kind at, one page of every entity
kind, `/openapi.json` and `/build.json`. Run the same command by hand against any deployed
commit. `test/cases/500_docs_index.sh` holds the generator and the probe to their refusals
against a served fixture.
### The UI a deploy publishes
The site carries `majordomus ui` at `/ui/` as a snapshot read at the published commit (ADR
0141, `docs/MAJORDOMUS_UI.md`). The workflow's `ui-snapshot` job builds the executable from
that commit and runs `majordomus web report ui-snapshot`; it runs beside the `rustdoc` job,
and the deploy job waits for both. After `scripts/pages check` and before the push, the deploy
job places the snapshot at the mount its own `surface.json` declares. It refuses a snapshot
that is absent, one read at another commit, and a mount the site itself already built.
The Cockpit is published the same way at `/cockpit/` (ADR 0147). The same job runs
`majordomus web report cockpit-snapshot` after the UI's, because the Cockpit's snapshot reads
first what the UI's links to and then leads those links to its own pages; both directories
are uploaded once that step is over. The deploy job places every snapshot the model declares
at the mount the topology gives it, and after the push
`scripts/pages verify-ui --surface cockpit` asks the public site for it.
The snapshot is a generated surface, known only where its producer ran, so it is not in the
committed topology and `scripts/site-build` does not compose it: a local build of the site has
no `/ui/` unless that step is repeated by hand. The site's checks do not judge its pages.
That a publication holds it is declared once, in `.ai/repo/ci/pages.yaml` under
`deploy.snapshots`: the served surface it is a snapshot of, where the producer writes it, the
producer, and the attribute its pages state their commit with. The mount is the topology's
mount of that surface, and `scripts/pages snapshots` prints the resolved rows. Three readers
hold a publication to that entry (`project.the-published-ui-is-the-trunks`):
- `scripts/site-deploy` refuses a site whose tree has no snapshot at the mount, one read at
another commit than `HEAD`, or one that declares another mount, and says how to place it.
A deploy from a terminal therefore cannot publish the site without `/ui/`.
`--release-metadata` changes two files of what is already served and is not asked.
- `scripts/pages verify-ui` asks the public site, once it serves the deployed commit:
`/ui/surface.json` names that commit, the landing page's banner states it, `/ui/snapshot.json`
records it and says what ended the reading, and every page the record lists answers 200. It
prints how many pages it asked for, how many `--max-pages` left unasked, how many the
snapshot itself left out and what stopped it. Exit 10 is a UI that is missing, another
commit's or not whole; 12 is a site that never answered. The deploy's `the UI it publishes`
step runs it and is not `continue-on-error`.
- The `pages-live` gate asks the same probe of the published commit (section 2c of
`scripts/ci/pages-check`), so the live `/ui/` is held to the trunk within the window the
site is.
`test/cases/2983_the_published_ui_is_the_trunks.sh` holds all three against a served fixture.
### The release the site offers
Which release is the latest is one fact with three public surfaces: the homepage (the navbar
badge, the hero and the trust strip's `data-release`), `/releases/latest.json`, which the
installer resolves, and the newest GitHub release. A release reaches them in that order — GitHub,
then `latest.json` from the release's publish job (`scripts/site-deploy --release-metadata`,
ADR 0131), then the pages from the build of master that job asks for — and the record that
archives it lands later still, through its own pull request.
So the pages do not read the record. `scripts/site-build` derives the release the site offers as
the newer of the record's projection (`site/data/registry/distribution.json`) and the
`releases/latest.json` that `origin/gh-pages` serves, read from git and named in `build.json`
(`release.source`, `release.published_from`); `base.html` sets `offered` from it and every page
reads `offered`. When the published release is the newer, the build serves its two documents, so
the metadata beside the pages names what they offer. `scripts/site-build --release` prints the
derivation alone.
It is checked three times: `scripts/site-check` (section 17b) refuses a build whose homepage,
`/build.json` and `/releases/latest.json` disagree, or whose two committed projections of the latest release do;
the deploy's `the release it offers` step runs `scripts/pages release-live` once the site serves
the new commit; and `scripts/ci/pages-check` asks the same probe as section 5 of `pages-live`, on
the schedule and at `finish`. A disagreement younger than `deploy.release_window`
(`.ai/repo/ci/pages.yaml`) after GitHub's release is a publication in flight; older, or a site
offering a version GitHub has not published, is a failure. The rule is
`project.the-site-offers-the-release-it-published`.
### When a deploy does not happen
Publication is two steps owned by two parties, and either can be missing while everything
visible stays green. This repository pushes `gh-pages`; GitHub's own "pages build and
deployment" turns that branch into the served site, and it has its own status, which can be
`errored` for a branch that was pushed perfectly — it was, on 2026-09-10 at 17:35, and the
site served the previous commit for 27 minutes with nothing red anywhere.
The workflow's own conclusion does not answer the question either. `pages.yml` serialises its
runs and never cancels the one in flight (`cancel-in-progress: false`; cancelling it starved
publication on 2026-09-12, when every superseding run was itself superseded), but GitHub drops
an older *pending* run for a newer one, and a cancelled run is not a failure: on 2026-09-09/10,
while a push still cancelled the run in flight, fourteen of seventeen cancelled runs had already
pushed `gh-pages` before they died, and no reader could tell them from the three that had not.
So the run says which it was — an annotation and a line in its summary — and cancellation
itself cannot strand the site, because a pending run is only ever dropped for a newer run of
the same workflow, and the run in flight always finishes: the chain ends in a run that
publishes or in one that goes red.
What *is* left stale is a run that never published and whose successor then failed to
build. Nothing in the deploy can report that, because by then there is no deploy.
The reader is the `pages-live` gate (`scripts/ci/pages-check`), which runs on every full
validation and asks four things: the published commit is on master, master has not moved past
it beyond the deploy window (30 minutes, sized from a 25-minute Actions queue observed on
2026-09-10), the live site serves what `gh-pages` published, and GitHub's own build of that
branch did not error. A section of its own asks a fifth: the reference at `/rustdoc/` is
whole and was built from the commit the site serves, by the same `scripts/pages verify-rustdoc`
the deploy runs. Another asks the same of the UI's snapshot at `/ui/`, by
`scripts/pages verify-ui`. The trigger paths it uses to decide whether a publication is *owed* are
`scripts/pages paths` — the same list that is the workflow's own `paths:` — so a commit that
cannot change the site does not owe a deploy.
The site answers at its own domain, and one file keeps it there. `site/static/CNAME` names the
host; the build copies it into `site/public` like any other static file and `scripts/site-deploy`
publishes it with the rest. GitHub Pages reads that file on every deploy and unsets the custom
domain the moment a published tree stops carrying it — which would take the advertised install
command down with the site, so `scripts/site-check` refuses a build whose `CNAME` and `base_url`
disagree. Those are the two places the host is written, and there is no third: `base_url` is what
every generated link and every check resolves against, `CNAME` is what GitHub reads. The project
address the site was published at before, under `korczis.github.io`, redirects here.
## Sync guarantee
`scripts/derive-check` is the one read-only gate; it composes the generators' own checks
and names every stale artifact before it exits:
- `majordomus generate --check` derives every projection of the registry, the schemas, the
policy and the index again and compares byte for byte: `docs/generated/**`,
`share/allow/**`, the provider bootstraps, `site/data/registry/registry.json`.
- `scripts/generate-site-data --check` regenerates into a temporary directory and diffs every
generated file, `docs/SITE_CLAIMS.md` and `docs/PLAN_STATUS.md` against the committed ones;
`source.json` is compared by its input hash, which covers every canonical file the
generator reads, `docs/generated/registry.json` and `docs/generated/openapi.json` among
them, so a change to the executable's code moves the site's hash.
The chain is tested edge by edge. `apps/majordomus-cli/tests/projections.rs` proves that a
descriptor changed in code moves the OpenAPI document, the reference, the manifest and the
site dataset, and only the sections that project it (an exposure removed moves the routes
and the coverage, not the tools; a description moves the descriptor everywhere), and that
the dataset's tools are the MCP surface's and its routes the document's.
`test/cases/95_executable_reference.sh` proves that a capability added to the manifest gains
its route, its index entries and its links from the site generator alone, that one removed
loses them with no orphan left, and that a manifest naming an unknown module, a missing
source file or another schema is refused. `scripts/site-check` proves the rendered side:
every module and capability of the dataset has its page, no page exists without its entry,
the CLI, MCP and benchmark pages render every command, tool, target and baseline, every
capability page links its module and its operation, and no template, generator or
navigation file names a capability by hand. `test/cases/11_site_derivation.sh` changes the
version, a profile, a principle, a policy value, a claim and a document heading in a
scratch copy and asserts each change appears in the derived data; `test/cases/10_site_data.sh`
proves a missing path or an untested guaranteed claim fails the generator;
`test/cases/51_derived_artifacts_committed.sh` proves the committed derived files match
their sources and that none embeds the commit it lands in.
The commit a page was built from is not a derived file's business: `scripts/site-build`
writes `site/data/build.json` (for the footer and the architecture page) and
`site/static/build.json`, served at `/build.json`, with the commit, whether the tree was
dirty, the site's input hash, the registry and index fingerprints, and under `surfaces` each
composed surface with the commit its producer declared; `scripts/site-check` proves the served
file names HEAD and the data the site was built from, and that every composed surface was built
from that same commit, so a deployment can be verified from outside against the commit that was
meant to deploy.
## Local development
```
npm ci # Tailwind, Flowbite, Alpine, Mermaid — pinned
brew install zola # or the release binary; CI pins 0.23.4
just derive # every committed derived artifact, in order (scripts/derive)
just derive-check # is every committed derived artifact current? writes nothing
just test # the gates CI would run for this working tree (scripts/ci/run-plan)
scripts/site-serve # generate, build, serve the built pages at http://127.0.0.1:1111/
scripts/rust-check --doc # the crate's reference, target/web/rustdoc — site-build composes it and refuses without it
scripts/site-build # production build into site/public/
scripts/site-check # the static checks CI runs
scripts/site-probe # the browser-measured checks (needs Chrome; --quick for one page per section)
SITE_PROBE_JOBS=4 scripts/site-probe # as CI runs it: four routes at a time
```
The one workflow after a change to anything canonical — a Rust declaration, a document, a
claim, a command's semantics — is: `just derive`, review the diff of the derived files (it
is the change, seen from every projection), `just derive-check`, commit both. Forgetting
the first step is caught by the third, and by CI.
## Adding new canonical data
Add the field to the file that owns it, read it in `scripts/generate-site-data`, render it in
a template, and extend `test/cases/11_site_derivation.sh` with the change-and-assert pair.
Commit the regenerated `site/data/generated/`. Do not add a field to the JSON by hand.
## Adding new site components
Define a Tera 2 component in `site/templates/components.html`, using Flowbite markup from the
component's documentation page, with mobile classes first. Call it as `{{ <name attr="…" /> }}`.
If it needs a class Tailwind cannot see in the templates, it does not belong here.
## What must never be edited manually
`docs/generated/**`, `share/allow/**`, `site/data/registry/**`, `site/data/generated/**`,
`site/content/{docs,profiles,guarantees,supervises,commands,doctrines,use-cases,applications,plan,why,registry}/**`,
`site/content/render-test.md`, `docs/SITE_CLAIMS.md`, `docs/PLAN_STATUS.md`,
`site/static/app.css`, `site/static/js/**`, `site/static/images/**`, `site/static/openapi.json`,
`site/static/build.json`, `site/data/build.json`, `site/public/**`, `CLAUDE.md`, `AGENTS.md`.
Change the canonical file; `just derive`; rebuild.