Skip to content

The web surface

the web surface: every surface discovered from its producer rather than registered, the two reserved namespaces (`/docs` is documentation, `/swagger` is Swagger UI), what a surface declares, how to add one, how the documentation is built for its mount and served safely, and what is enforced where

Rendered from docs/WEB.md — the same Markdown GitHub shows.

majordomus serve and majordomus mcp bind one shared server per repository. What that server serves is not written down anywhere as a list. It is discovered: from the executable's own declarations, from the site's configuration, from the crate itself, and from a surface.json a producer writes beside its output. One resolution then feeds the router, the home page, the machine-readable index, the validator, the publication and the generated reference.

The decision is ADR 13; the rule that owns the invariant is project.web-surface-declared-once. Behaviour as implemented and tested; where this document and the executable disagree, the document is wrong and changes in the same commit.

flowchart TD
  subgraph producers["PRODUCERS"]
    decl["capability! declarations"]
    config["site/config.toml"]
    crate["the crate, apps/majordomus-cli"]
    surfacejson["target/web/<id>/surface.json"]
  end
  resolution["ONE RESOLUTION<br>web::discover → Topology,<br>validated, narrowed per process"]
  subgraph projections["DERIVED PROJECTIONS"]
    router["the HTTP router"]
    home["the home page at /"]
    api["/api/v1/web/surfaces"]
    webjson["docs/generated/web.json →<br>the website's route reference"]
    cli["majordomus web list / explain /<br>validate / compose"]
    bench["the benchmark targets"]
    log["the startup log"]
  end
  producers --> resolution
  resolution --> projections

The effective surface

Every path below is the mount its surface declares. Nothing in this table is typed twice: majordomus web list prints it from the same resolution, and a mount that moves moves here.

PathSurfaceWhat it isKind
/homeThis process, and everything it servesroute
/docs/docsThis repository's documentationdirectory
/rustdoc/rustdocThe Rust crate's documentation, as rustdoc renders itdirectory
/swaggerswaggerSwagger UI over the OpenAPI documentroute
/openapi.jsonopenapiThe OpenAPI document, generated from the registryroute
/api/v1/apiThe capability routesroute
/cockpitcockpitThe registry, rendered for a personroute
/mcpmcpMCP over HTTP, when this process serves a shared serverroute

Two names are reserved and may never be repurposed:

  • /docs is documentation. It used to be Swagger UI, which is how one name came to mean two things in four files. It does not any more.
  • /swagger is Swagger UI. It is a viewer for the API, and a viewer for the API is not the documentation.

The crate's rustdoc holds a reservation of the same kind: /rustdoc is the crate's rustdoc, and a copy of the reference published at that path under another id is refused as a duplicate canonical route — a surface.mount-collision while the rustdoc exists, a surface.reserved-namespace finding whether or not it does.

The reservations are data — web::discover::reserved(), each path the same constant its surface is declared with — and everything that cares reads them. majordomus web validate refuses a topology in which a reserved mount is held by anything but its owner, or in which an owner has wandered off its mount (surface.reserved-namespace); the router refuses to be built from such a topology, before it answers a request; and docs/generated/web.json publishes the map, which is what the website's reserved-names table renders. A collision would catch the two names taking each other's mount only while both surfaces exist — this catches it when one of them is gone, which is the state the repository was in.

Reservations are about what a process serves. A publication's / belongs to the site as it is deployed, and always has.

What a surface is

Surface {
    id,            // identity and selector; unique across the topology
    title,         // one line, shown on the home page and in a listing
    category,      // interface | documentation | api | protocol | report
    visibility,    // public (offered to a person) | internal (served, not advertised)
    kind,          // static (a generated directory) | native (a route) | redirect
    mount,         // where it answers; it owns this path and everything under it
    producer,      // what writes it; the home page names this when it has not run
    feature,       // the runtime capability it needs: mcp | cockpit | none
    artifact,      // the generated directory, repository-relative, for a static surface
    index,         // the file the mount itself answers with
    availability,  // both | served-only | published-only
    built_from,    // the revision its producer recorded, when it recorded one
    provenance,    // per field: where the resolved value came from
}

Two properties do most of the work.

availability is two worlds, not one flag. What a running process serves and what a publication contains are different sets. The site as it is deployed owns / of a GitHub Pages origin and is never served by this executable; the home page owns / of a process and is never published. They share a mount and are not in conflict, because they never meet — and the validator checks mount ownership per world for exactly that reason.

feature describes the effective binary. A process that answers no MCP has no MCP surface, so the home page cannot link to one and the startup log cannot name one. The narrowing is a filter over the resolved value, never a second discovery.

What every surface is rendered with

Whatever a surface is — the published site, the Cockpit, a report the executable renders, the Swagger shell — it is rendered with one design: the roles, status vocabulary, type scale, theme contract and brand declared once in share/design/tokens.yaml and projected by majordomus generate design into the sheets each surface loads. A served page carries the declaration's fingerprint (--mj-design in its stylesheet, data-design on the page) and the executable answers the same fingerprint from GET /api/v1/design, so a stale bundle is visible on the page. The pipeline, the extension flow and the gates are DESIGN_SYSTEM.md.

Adding a surface

A generated report — no code at all. Write your output into target/web/<id>/ and a surface.json beside it:

{
  "schema": "web-surface/v1",
  "id": "coverage",
  "mount": "/coverage",
  "title": "Line coverage of the crate",
  "producer": "just coverage"
}

It is now discovered, served under /coverage/, listed on the home page, present in /api/v1/web/surfaces, validated for collisions and composed into the publication by majordomus web compose. There is no registry to add it to, because there is none. It is not named in the generated reference, docs/generated/web.json: a report exists only in the checkout that ran its producer, and the committed file holds what every checkout has.

A route the executable answers — one declaration and one handler. Add the Surface to web::discover::native_all() with its mount, category and visibility, and an arm to http::surfaces::Native::of binding it to code. Those two are the whole change: a native surface declared with no handler refuses the router at construction, and the test every_native_surface_has_a_handler holds the two halves in step. Everything downstream — router, home page, route index, startup log, generated reference — follows.

A capability is not a surface. It is a capability! block with an http exposure, and it appears under /api/v1/ because the api surface owns that prefix (CAPABILITIES.md).

What the publication carries

GitHub Pages publishes the site and every other static surface the committed topology says the publication carries — today the crate's rustdoc at /rustdoc. Publication is composition, and composition reads the topology: scripts/site-build copies each surface of docs/generated/web.json that is a static directory with availability both or published-only, other than the site itself, from its artifact into the build's output at its mount, after Zola has rendered the site — site/public for the publication, and the same for the documentation build majordomus serve answers at /docs/. Nothing in the build names a surface.

A surface the topology publishes and whose artifact is absent refuses the build with exit 12, naming the producer that writes it; a mount the site itself already fills is refused with exit 10, because one path has one owner. The build records each composed surface, with the commit its producer declared, under surfaces in /build.json, and scripts/site-check (section 13m) refuses one that is missing, undeclared, declared at another id or mount, or built from another commit than the build. The site's page contracts judge the site's own pages; pages under a composed surface's mount are that surface's check's business, and a link from a site page into one is decided by scripts/ci/link-check against the composed files.

After the deploy, scripts/pages verify-rustdoc verifies the public reference against the deployed commit, as a hard step of the publication. RUSTDOC.md is the whole path of the reference, with the troubleshooting for each stage; ADR 86 is the decision.

How /docs is built and served

The documentation under /docs/ is the same Zola source GitHub Pages renders. There is no second copy and no second generator; the two builds differ in one argument.

flowchart LR
  src["site/"]
  src -->|"scripts/site-build"| public["site/public/<br>base_url https://majordomus.dev"]
  src -->|"scripts/site-build --serve"| served["target/web/docs/<br>base_url /docs"]

zola build --base-url /docs emits root-relative links (/docs/commands/), so the mount is correct behind any host, port or reverse-proxy prefix — no hostname is ever baked into the build. --serve also writes the surface.json that makes the directory discoverable, with the revision it was built from.

majordomus serve serves an already-built tree and never invokes Zola: a build per request is not a thing this executable does, and Zola is an external tool that may not be installed. A request under /docs/ when the tree is absent answers 503 naming the directory that is missing and the command that writes it, rather than a 404 that explains nothing. The home page shows the same surface as not built with that command beside it, instead of offering a link that is certain to fail.

The generated tree lives under target/web/, which is ignored: it is derived state with the same status as every other generated tree here — reproducible, never read as source, and a stale artifact is a majordomus web validate --artifacts finding rather than a diff.

How /rustdoc is declared and served

The crate's rustdoc is a surface because the crate exists, not because cargo doc ran. web::discover::rustdoc declares it whenever apps/majordomus-cli holds a Cargo.toml and a src/lib.rs — the same test the quality gates use to decide the crate is there — with every value fixed in one place: mount /rustdoc, directory target/web/rustdoc, index index.html, producer scripts/rust-check --doc, category documentation, and both worlds. A running process serves it at a top-level mount of its own; a publication carries it beside the site under the same path, nested under the application's root, which is the one nesting the validator allows. /docs/rust was refused because /docs owns its whole subtree, and /api and /reference because both already mean something else.

The producer writes a surface.json beside its output, and discovery reads one value from it: the revision it was built from. The walk of the generated root skips the directory, as it skips target/web/docs, so the reference is never discovered twice and never exists only in the checkout where somebody ran the producer. Unbuilt, /rustdoc/ answers 503 naming scripts/rust-check --doc, and the home page shows it as not built.

rustdoc writes a redirect stub for every macro named macro.<name>!.html, so ! is in the character set the file server accepts. It spells neither a separator nor ./.., no browser escapes it, and % stays refused, so an escaped traversal is still text and never a path.

Static file serving

/docs/**, /rustdoc/** and every other generated directory are served by web::files. A request path is decomposed into segments and every segment checked against a conservative character set — the characters the producers write, ! included for rustdoc's macro stubs — before the filesystem is touched; a segment that is empty, . or .. refuses the request. The resolved path is canonicalised and required to still be inside the canonical root, which closes the door a symlink inside the directory would otherwise open. There is no concatenation of untrusted text anywhere in that file, and a file whose extension is not one the surface generates is not served. Files are read as bytes, so a font or an image comes back as it was written.

A file is read once and answered from memory afterwards, up to a ceiling; documents carry Cache-Control: no-cache, because the pages are rebuilt from a working tree somebody is editing and a page one build behind is worse than a request.

Reading the topology

majordomus web list                  # every surface: id, kind, mount, category, world, source
majordomus web explain docs          # one surface, and where each of its values came from
majordomus web validate              # the invariants; exit 10 with every finding and its remedy
majordomus web validate --artifacts  # and require every producer to have run
majordomus web compose               # every published surface into one tree
majordomus web list --format json    # the same value as JSON

Over HTTP and MCP the same resolution is /api/v1/web/surfaces, the tool majordomus_web_surfaces and the resource majordomus://web. One nuance: over a served socket it answers the narrowed topology, because the router hands its capability calls a context whose topology is what that process serves; from the command line or a standalone MCP session it answers the repository's. Both read one value, resolved once for each generation of the repository a long-lived process reads (crate::live), never per request.

docs/generated/web.json is the committed projection, written by majordomus generate and checked by generate --check. It exists because the site generator runs without a Rust toolchain and reads committed artifacts; the Rust resolution is authoritative and the file is never edited by hand. It omits built_from, which is a fact of one checkout's artifacts and would make every clone report drift; the documentation and the rustdoc are declared by discovery rather than by their output, so the file is byte-identical whether or not either producer ran, and the_web_projection_is_the_same_whether_or_not_a_producer_ran holds it so. A report is the other kind of surface: known only from the surface.json its producer wrote, it exists in the checkout that ran the producer and in no other, so the file leaves it out while the process that finds it still serves and composes it (a_report_on_disk_does_not_reach_the_web_projection). A surface meant to be in the committed file — to be published by the site build on every machine — is declared by discovery from what says it exists, as the rustdoc is from the crate.

What is enforced, and where

InvariantEnforced by
Ids unique; one owner per path in each world; no nested mountmajordomus web validate, in scripts/rust-check
A native surface has a handlerthe router refuses to build; every_native_surface_has_a_handler
/docs is documentation, /swagger is Swagger UIproject.web-surface-declared-once, test/cases/89_web_surface.sh
/rustdoc is the crate's rustdoc, declared from the crate; a second claim on it is refusedweb::discover and web::validate tests (a_second_surface_at_the_rustdoc_mount_is_a_duplicate_canonical_route)
A rustdoc tree is served whole: macro stubs, styles, scripts, fontsweb::files tests, http::surfaces tests, tests/http_serve.rs over a real socket
docs/generated/web.json does not depend on which producers ranthe_web_projection_is_the_same_whether_or_not_a_producer_ran, a_report_on_disk_does_not_reach_the_web_projection
The home page covers every public served surfaceweb::home tests, and case 89 over a real socket
docs/generated/web.json currentmajordomus generate --check
No origin-absolute link forces the two builds apartscripts/site-basepath-check
Every published static surface is in the publication, built from the site's commitscripts/site-build (exit 12 naming the producer), scripts/site-check §13m
Every exported item has its rustdoc page, and the tree is HEAD'smajordomus quality rustdoc, the rustdoc gate
The public reference is the deployed commit'sscripts/pages verify-rustdoc in pages.yml, the pages-live gate
The new routes are timedSystemTarget::HttpHome, HttpSwagger, HttpIndex

Searches the titles and descriptions of every page this site publishes.