Skip to content

Every web surface is discovered from its producer, resolved once, and projected

Every web surface is discovered from its producer, resolved once, and projected

majordomus://adr/adr-0013proposed architectureweb

it references

  • put_in_force .ai/repo/ci/pages.yaml (file)
  • put_in_force apps/majordomus-cli/src/http/router.rs (file)

referenced by

13. Every web surface is discovered from its producer, resolved once, and projected

Context

This repository exposes more over HTTP every week and knows it in more places every week. Today four surfaces are served by one process and named individually in its router — the JSON index at /, the OpenAPI document, the Swagger UI at /docs, MCP at /mcp, and now the Cockpit at /cockpit, dispatched by Cockpit::owns before the match arms. Each prefix is a constant in exactly one place, which is right; what is hand-written is the dispatch, and a fifth surface means a fifth arm. Meanwhile the published site is a separate world entirely: .ai/repo/ci/pages.yaml names one output directory, scripts/site-deploy knows how to push it, .github/workflows/pages.yml carries the paths that trigger it, and nothing in either world can name the other's surfaces.

Two more are arriving — a test report and a benchmark report — and they are the ones that decide the shape. Added the way the last four were, each would need a route in the router, an output path in a script, a path class in the gate model, a copy step in publication, a line in the documentation table and a mention in the deployment workflow. Six registrations for one directory of HTML is the definition of an abstraction that leaks, and the sixth surface would cost the same again.

The pattern the repository already applies to capabilities, kinds, skills, use cases and decisions is the answer: one canonical declaration, every other surface a projection of it (ADR 0002, ADR 0004, ADR 0005). Web surfaces are the last part of this repository that does not work that way.

Decision

A web surface is a resolved object with an identity, a kind, a mount path, a producer and provenance for every value. Three kinds cover what this repository has and expects: StaticDirectory (a generated tree mounted under a prefix), NativeRoute (a path the process answers itself) and Redirect. Nothing is named TestsSurface or CockpitRoute: those differ in data, not in behaviour.

Discovery is convention-first, from three inference sources, and none of them is a list a person maintains:

  • the capability registry for everything the executable answers itself — the routes are already declared once in capability! and projected to the CLI, MCP, OpenAPI and the reference, so the topology reads the same declarations;
  • the generated web root, target/web/<id>/, for static reports: a producer writes its own directory and a surface.json beside the artifact, stating the intent no filesystem walk can infer — the mount it wants, whether it has an index, what built it;
  • the site configuration for the Zola application, which owns / and is built into its own output rather than into anyone else's.

Explicit declaration exists only for intent that cannot be inferred safely, it sits with the producer that has the intent, and it is written by that producer rather than copied into a central manifest. A resolved manifest is generated for diagnostics and hand-off; it is disposable, and nothing reads it as truth that could not be recomputed.

One resolution feeds every consumer: the server's routing table, the static composition that publication uploads, the validator CI runs, the web list and web explain commands, and the documentation's generated topology. Route ownership is validated rather than left to insertion order: a surface owns its mount and everything under it, two surfaces may not claim one path, and a nested claim is an error unless the outer surface declares it.

Alternatives rejected

A central manifest listing every surface. It is the second registry the repository already refuses for rules, kinds and capabilities: correct on the day it is written, and wrong on the first rename that nobody propagated. Producer-local declaration keeps the fact where the change happens.

Keeping the two worlds apart — dynamic routes in the router, static publication in the scripts. It is the current state, and it is why a static report cannot be served locally and a dynamic route cannot appear in the published documentation. One model that both consume costs less than two that must be kept in step by memory.

Inferring everything, with no declaration at all. A mount path is intent: nothing in a directory of HTML says whether it belongs at /tests or /reports/tests, and a convention that derives it from the directory name silently renames the surface when somebody renames the directory. One declared field, written by the producer, is cheaper than that surprise.

A separate gateway process in front of the existing server. Another process to supervise, another port to coordinate and another place for a route to be wrong. The executable already answers HTTP and already holds the registry; the surfaces it does not answer itself are directories it can mount.

Consequences

The router stops naming surfaces: it asks the resolved topology, and a new surface is served because it was discovered, not because an arm was added. That is a change to code the Cockpit work is actively writing, so it is made in one agreed window rather than incrementally under its author.

A producer of a static report now has one obligation — write its output into its own directory under the generated web root, with a surface.json — and gets serving, validation, composition, publication and listing without touching any of them. The obligation is small enough to state in a rule and mechanical enough to check.

target/web/ becomes generated state with the same status as every other derived tree: it is reproducible, it is never read as source, and a stale artifact is a validation finding rather than a silently published page.

What the tool cannot decide stays where it belongs: whether a surface should exist at all, and what its mount ought to be, are decisions a person makes and a producer records. The validator checks that the decision is coherent — not that it was wise.

Where this comes from

canonical file
.ai/repo/adrs/0013-every-web-surface-is-discovered-from-its-producer-resolved-o.md
identity
adr-0013
also served as
MCP resource majordomus://adr/adr-0013 · majordomus entity show adr/adr-0013 · GET /api/v1/entity?uri=majordomus://adr/adr-0013
in the Cockpit
/cockpit/objects/adr/adr-0013

This page is generated from the file above by scripts/generate-site-data; editing the page changes nothing, editing the file changes every surface. The relations above are edges of docs/generated/graph.json, which the executable writes from the same index.

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