Skip to content

Every object of the layer has a derived address, both directions of every relation, and a declared place of publication

Every object of the layer has a derived address, both directions of every relation, and a declared place of publication

majordomus://adr/adr-0056proposed governanceroutingweb

56. Every object of the layer has a derived address, both directions of every relation, and a declared place of publication

Extends ADR 4 and ADR 13.

Context

ADR 2 made every interface of the executable a projection of one capability registry, and ADR 4 made one canonical declaration the only place a capability is written down. The declarative half of the repository — every file under .ai/, indexed as a typed object with a kind, an identity and a majordomus:// URI — was already read that way: one relation table resolves every reference an object declares, graph.compose draws it, and every object is an MCP resource.

Three things were missing, and each was the kind of gap that looks like polish until a reader meets it.

An identity is not an address. An object could be read in the Cockpit at /cockpit/object?uri=majordomus%3A%2F%2Frule%2Fproject.x%401 — a query parameter carrying a percent-encoded URI. Nothing linked to it, nothing was bookmarkable, no index of a kind existed, and the composed graph's nodes pointed at that address, so the one place the relations were drawn sent every click into a query string.

The relation table was read forward only. A rule declared what it depends on; nothing answered what depends on this, although the edge that answers it was already in the graph pointing the other way. The same was true of a decision and what it put in force, of a claim and its implementation, of an application and the use cases that serve it.

Nothing said where a kind is published. The public site had editorial sections for skills, commands, use cases, features, claims, sessions, profiles and the Why catalogue — and no page at all for a decision or for this repository's own 78 rules. Which kinds reach the site was implicit in twenty lines of a shell generator, so a kind could be added to the layer and reach no public page with nothing anywhere saying so. That is the silent discovery failure this repository has already met twice, in a glob that matched no ADR and in a site-check whose subject set was empty.

Decision

An address is derived, never assigned. entity::slug reduces an identity to one path segment — lowercase, every run of anything else a single -, trimmed — and the address is the kind and that segment. There is no table mapping objects to routes, because a table can be incomplete and a function cannot. A function can, however, map two identities of one kind onto one segment; that is a collision, entity::collisions finds every one, and scripts/ci/entity-check refuses the tree while one exists. A route that answers with one of two objects is worse than no route, because the reader cannot tell which one they were given.

One capability answers for an entity, on every surface. entity.show returns the identity, the address, the provenance, the front matter, the content, the outgoing edges, the incoming edges, the surfaces that answer for the object and what can be said about the executable artefacts it names. It is projected to MCP as majordomus_entity, to HTTP as GET /api/v1/entity, to the command line as majordomus entity show, and it is what the Cockpit's entity page lays out. entity.kinds is the same for the catalogue of kinds.

Backlinks are derived from the forward declaration. entity::edges reads graph::RELATIONS — the one table the composed graph and the dangling-reference check already share — over every object, and keeps the edges whose either end is the subject. A relation added to that table becomes a forward edge on one page and a backlink on the other, with nothing declared twice. Nothing in the layer declares a reverse reference, and nothing may: half a relationship is the failure mode this forecloses.

Every kind declares where it is published. site/data/publication.toml carries one entry per kind of the index: published as entity pages at a route, published by an editorial section already, or not published with the reason written down. scripts/ci/entity-check reads that file and majordomus entity kinds and refuses both directions — a kind the index holds that the declaration does not mention, and a kind the declaration names that the index does not hold.

The public entity pages are one projection, not one per kind. Two templates — entity.html and entities-section.html — serve every kind that publishes entity pages, and neither names a kind. The gate refuses a kind name appearing in either, because a template that named one would make adding a kind an edit to a consumer.

The documentation hub reads the same two declarations, and the index is the build's. Added 2026-09-24. The top of /docs/ lists every kind with its count and where it is published, or the reason it is not. Those rows are the catalogue of entities.json: the publication declaration joined to the index. Beside it are the interface counts (capabilities, command-line operations, HTTP routes, MCP tools and resources, modules), which are the registry's own summary. /docs/index.json is written by scripts/lib/docs-index.mjs after the build: it walks the pages Zola produced, not a list. So it holds every page a visitor can open, joins each entity page to its object, carries the /build.json identity of the build that wrote it, and refuses the build (exit 10) when a route the catalogue publishes was not built. The hub's search reads that file, so a search result can only be a page that exists. scripts/pages verify-docs asks the public site for the same file, checks that it names the deployed commit, and probes every route it publishes. It runs as the step of the Pages workflow after the site serves the commit. Case 500 holds all four.

Alternatives rejected

A route table in the Cockpit. The obvious shape, and the one the mandate that produced this work explicitly forbade: it is an inventory, it goes stale silently, and it makes adding a rule an edit to the router.

Publishing every kind. 1309 objects across 29 kinds. Most already have an editorial page — an issue on /plan/, a claim on /guarantees/, a session on /sessions/ — and a second address for the same object is the duplication the whole subsystem removes. Some are facts about a checkout rather than about the product: a mesh identity, a scope declaration, a prompt. The decision is not "publish everything" but "decide about everything, in writing, where a check can read it".

Declaring publication in share/kinds.yaml. That file is the tool distribution's, shared with every supervised repository; where this website publishes a kind is not a property of the kind everywhere. It belongs to the site, and it sits with the site's other declaration, nav.toml.

A second dataset for the site. docs/generated/graph.json already carries every node and every edge, is generated by the executable and is drift-checked. The site's entities.json is a projection of it, not a second reading of the layer.

A search service, or Zola's built-in search index. A remote service is a dependency a static site does not need. Zola's index covers every word of every page, adds a runtime library, and would be a second listing of the pages beside the one this build already writes. A filter over the titles and descriptions of /docs/index.json answers "where is it", which is the question a documentation index is for.

ExDoc. The mission that asked for the hub named it. Majordomus has no Elixir code, so running ExDoc would mean running a toolchain to imitate its own output. The properties that make ExDoc useful are an index by kind, a stable page per entity, source links, cross references, search and deterministic output, and each of those is a projection this repository already had or has here. The crate's own API reference is cargo doc, which another change publishes.

Consequences

  • A file added under .ai/ gets an index row, an address, a page, its place in every other page's backlinks, an MCP resource, an HTTP answer and a command-line answer, with no edit to any consumer. The acceptance test writes one rule and asserts exactly that.
  • A kind added to the layer fails entity-check until somebody decides, in writing, whether the site publishes it. That is deliberate friction in the one place silence was expensive.
  • graph::RELATIONS becomes load-bearing for a user-facing page rather than only for a drawing and a check. A relation declared there is now visible in both directions immediately, which is the point, and a relation left out of it is invisible in both.
  • The published site grows one page per decision and per rule. They are generated from the objects' own files, so the prose has one home and the pages cannot drift from it.
  • An entity page may say what an object names as its executable artefacts and whether the tree holds them. It may not say the object is enforced: resolved is a statement about paths, and whether any of them ever ran is rules.show's question, which the page links to rather than answering.
  • /docs/ counts nothing of its own, and /docs/index.json lists nothing of its own. A kind, a page or an interface added to the repository appears in both because it was added. A declared route that fails to build fails the build, and a deployed commit whose documentation does not answer fails the Pages run.

Where this comes from

canonical file
.ai/repo/adrs/0056-every-object-of-the-layer-has-a-derived-address-both-directi.md
identity
adr-0056
also served as
MCP resource majordomus://adr/adr-0056 · majordomus entity show adr/adr-0056 · GET /api/v1/entity?uri=majordomus://adr/adr-0056
in the Cockpit
/cockpit/objects/adr/adr-0056

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.