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 asurface.jsonbeside 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.