Skip to content

The Cockpit

the Cockpit: the registry rendered as pages for a person, what makes it a projection rather than a dashboard, the graph and health models, the browser layer and what happens without it, the security decisions, the asset pipeline

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

majordomus serve and majordomus mcp bind one shared server per repository. That server already answers machines: MCP tools and resources, HTTP routes under /api/v1/, an OpenAPI document and a Swagger UI over it. The Cockpit is the same server answering a person, at /cockpit.

It is a projection, in this repository's sense of the word: one more thing derived from the canonical capability declarations and the index, holding no model, no catalogue and no verdict of its own. The decision is ADR 12; the rules it is bound by are project.interfaces-are-projections, project.rust-canonical-declaration, project.rust-benchmark-coverage and project.rust-hot-path. Behaviour as implemented and tested; where this document and the executable disagree, the document is wrong and changes in the same commit.

flowchart TD
  decl["ONE CANONICAL DECLARATION<br>capability! { id, title, description, input,<br>output, stability, exposure, tags, cache?, handler }"]
  reg["CAPABILITY REGISTRY<br>+ every declarative object of the layer"]
  proj["DERIVED PROJECTIONS"]
  decl --> reg --> proj
  proj --> mcp["MCP"]
  proj --> http["HTTP"]
  proj --> openapi["OpenAPI"] --> swagger["Swagger UI"]
  proj --> cli["CLI"]
  proj --> bench["benchmark targets"]
  proj --> generated["docs/generated/*"]
  proj --> registry["the website's /registry/ pages"]
  proj --> cockpit["THE COCKPIT<br>pages, navigation, search,<br>palette, runner, graphs"]

Getting there

majordomus serve                 # or `majordomus mcp`, which binds the same shared server

The server logs the URL it bound. Open that URL: a browser is redirected to /cockpit, and a client that does not ask for HTML gets the JSON index it has always got. Both are on the same port; there is no second process and no second command.

Nothing needs building for it to work. If share/cockpit/ has no compiled stylesheet the pages still render, say so, and remain fully usable.

What is on it

RouteWhat it showsDerived from
/cockpitthe four questions first (is it healthy, what changed, what is broken, what needs action), then repository identity, git state, index state, the registry counted, every diagnostic, the health summarydashboard.overview, repository.info, health.report
/cockpit/capabilitiesevery capability, filtered by module, kind, source or textthe registry
/cockpit/capabilities/<id>one descriptor in full: schemas, projections, cache and benchmark policy, provenance, examples, and a form that runs itthe descriptor and its BenchmarkCases
/cockpit/commandsevery command this repository offers, from whichever program offers it, and where each one is projectedcommands.list
/cockpit/executionswhat this process has run and is running, with the counts beside it; follows the live channel and updates itselfexecutions.list
/cockpit/executions/<id>one execution: its state, steps, progress, diagnostics, live output, output or error, and the input as it was stored; a stable URL a reload restores fromexecutions.get, executions.events
/cockpit/objectsthe declarative objects of the layer, by kindobjects.list
/cockpit/objects/<kind>one kind's index, at the kind's own addressentity.kinds, objects.list
/cockpit/objects/<kind>/<slug>one entity: what it is and where the public site publishes it, what can be said about the artefacts it names, the references it declares and the references that resolve to it, the surfaces that answer for it, its front matter, and the file as it isentity.show
/cockpit/object?uri=the address an object had before it had one of its own; still resolves, so no published link breaksobjects.get
/cockpit/directoriesthe layer's directory contracts: the hierarchy, what each directory owes, and for one directory its local contract beside the chain that actually appliesdirectories.list
/cockpit/graphsevery graph this executable derivesgraph.list
/cockpit/graphs/<id>one graph: the drawing, the vocabularies, and every node and edge as tablesgraph.get
/cockpit/graphs/topologythe registry graph in three dimensions — optionalgraph.get (registry)
/cockpit/continuitywhat this checkout's lifecycle is holding, and what the subsystem around it is doing: the open episode the briefing is about, the active task, the records that resolve here with their labels, the blockers, the other machines — this device, the store against its remote, and the handovers other devices published that could be resumed here — then every open episode of the store, this process against the repository, recovery, the providers, and the tracked recordscontinuity.state, continuity.status, lifecycle.episodes, lifecycle.runtime, lifecycle.recovery, lifecycle.providers, lifecycle.closed
/cockpit/intentsevery intent with its derived stage, the criteria with current evidence, the work and providers realising it, the units of work serving no intent beside the empty records and the work from before the first intent (neither of which is orphan work, ADR 0075), and the findings — closed work the evidence contradicts among themintent_realization.work
/cockpit/intents/<id>one intent: how far reality is from it, each criterion with its evidence state, a link to the test object it names, the issues serving it and the command that reproduces it; the milestones; every task, session record and peer claim realising it with the provenance of its link, its providers and handovers; and why it stands where it standsintent_realization.explain
/cockpit/planthe milestones in derived order with the one that is now and the one after it, the issue the plan hands out next, and every issue filtered by milestone, status or waveplan.roadmap, plan.next, plan.issues
/cockpit/plan/milestones/<id>one milestone as a graph of work: its outcome, every issue with its readiness and wave, the partitions, the critical blockers, what may run at the same time, and every findingdevtask.milestone
/cockpit/plan/issues/<id>one issue as a task: its intent, acceptance criteria and scope, its readiness and what blocks it, its branches and sessions, the external issue it synchronises with, every value with where it came from — and the moves plan.transition makes, each started as an execution and each with the command that makes itdevtask.issue, plan.transition, executions.start, executions.get
/cockpit/healthone check per dimension, each with the engine that decided it and the command that reproduces ithealth.report
/cockpit/qualitythe crate's own public surface as the rules hold it, every number and finding from one executionquality.report
/cockpit/artifactswhat the generator writes: every document with the encodings it is committed in, every file with its contract and its state against the working treeartifacts.list
/cockpit/releasewhat the public contract did since the last release, and the smallest version it allowsrelease.analysis
/cockpit/designthe design system as this executable carries it: every role with its light and dark value, the statuses, the type scale, the layout values and the theme contractdesign.system, design.tokens
/cockpit/meshthe discovered nodes of this process's runtime, the providers that heard them, and why the mesh is or is not runningmesh.status, mesh.nodes
/cockpit/monitoreverything that happens around the repository as one live list, newest first: mesh sessions, claims, handovers and reviews, the executor's outcomes and GitHub's pull requests and runs, with what names the reader (?for=) first, the noise folded away counted, and every source's state (ADR 0133)monitor.stream
/cockpit/modelsthe vendors and models share/models.yaml declares, with each vendor's credential presence (never a value), and their routingmodels.list
/cockpit/apievery HTTP route the registry projects, and the projection's ownthe registry
/cockpit/mcpthe MCP projection: server and protocol versions, transports with the sessions attached now, the methods served and what is not, the tools that write the repository, every tool with its effect (narrowed by ?effect=), the client configurations and where each stands here, and where each executable capability is reachable (command line, HTTP, MCP tool, MCP resource)mcp.projection, capabilities.projections
/cockpit/searchcapabilities and objects matching one querythe registry, objects.search
/cockpit/worktreesthe branch-to-worktree topology: container, trunk, every worktree with its standing, uncommitted work and diagnostics, every branch without a worktree, the migration plan with the command that applies it; reloads itself when the topology changesworktree.topology, worktree.migration_plan
/cockpit/peersevery worker of this repository, on every checkout's board and every linked machine, with where it was found, whether it is here, its client, its intent and its claims (the exclusive and the conflicted ones marked); the claims that meet, the open reviews and untaken handovers, the typed relations, every source with its state and reason, and the checkouts whose board could not be read; follows the blackboard's snapshot frames, is read again whenever the blackboard changes, and says whether it is live, reconnecting or offline (ADR 0176)blackboard.snapshot
/cockpit/integrationthe pull-request integration queue and its executor; with nothing observed, says so and still rendersintegration.queue
/cockpit/trainsthe trains of stacked pull requests, drawn as an interactive graph in the manner of the Neo4j Browser (master as the anchor, links coloured by disposition, STACKED_ON / DEPENDS_ON arrows, a property panel, filters, search, fit and zoom, keyboard navigation) and listed as one table per train that needs no JavaScript; polls its route once a minute (ADR 0138)integration.trains
/cockpit/reasoningthe optional advisors, and the open task's uncertainties and conclusions; with no advisor, only the local review pathreasoning.advisors, reasoning.status
/cockpit/economicstoken economics, measuredeconomics.summary
/cockpit/activitythis process's counters and phases, and a running plot of themperf.counters
/cockpit/assets/<file>the stylesheet, the scripts, the vendored librariesshare/cockpit/

This table is checked against the dispatcher, not kept beside it. Every plain route cockpit::static_routes() names has a row here, and no row names a plain route the dispatcher does not answer; cockpit::tests::the_documented_route_table_is_the_dispatchers fails the build otherwise. Editing a row's description is free. The routes are derived from the navigation: adding an area to nav::areas() adds its route, a page no area names is added to PAGES_WITHOUT_AN_AREA beside the dispatcher's match, and every_plain_arm_of_the_dispatcher_is_a_static_route holds the match to both.

Every route is a deep link: the filters are query parameters, a refresh loses nothing, and a page can be sent to somebody.

Neither the kind nor the entity in the last three rows is named in the router. The address of an object is derived from its kind and its identity — majordomus://rule/project.x@1 becomes /cockpit/objects/rule/project-x-1 — so a file added under .ai/ gets an index row, a page, and its place in every other page's backlinks without an edit anywhere in the Cockpit. Two identities of one kind that reduce to one address would be a collision rather than a tie-break; majordomus entity kinds reports every one and scripts/ci/entity-check refuses the tree while one exists. The rule is project.entities-are-routable.

An entity page also names the object's public documentation page — "Published at https://majordomus.dev/adrs/adr-0056/" — or the reason its kind is not published. The page derives no address: it renders the documentation field of entity.show, which reads site/data/publication.toml and the site's base_url at request time, the same declaration scripts/generate-site-data writes the site's pages from. A repository with no such declaration has no public page to name, and the page says so.

How a listing of the whole layer is read

The two listings that hold everything — nine hundred capabilities, nine hundred objects — are read a page at a time and entered by their parts:

  • A page is fifty rows. ?page= says which; a number past the end is the last page and never an error. The control under the table says which rows are being shown, and offers the first page, the last, the neighbours of this one, and a gap for the rest.
  • Above the table are the listing's own parts — the registry's modules on /cockpit/capabilities, the listing's own kinds on /cockpit/objects — each with how many it holds under the filters in force. They are the same catalogues the sidebar shows, put where the listing is: a set of nine hundred rows is entered by its module or its kind rather than scrolled.
  • A link never loses a filter and never keeps a page number. Paging carries every filter with it; picking a part starts that part at its first page.

A graph page pages its two tables the same way, each by a parameter of its own: ?nodes= for the node table and ?edges= for the edge table, fifty rows a page, so paging one table never moves the other and a link carries both positions. Every node and every edge is still on some page as text, and the pager reaches each of those pages by its links alone, with no script; the reason is under "Why the tables under a graph are paged" below. The artifact manifest pages nothing: it is listed whole, because a reader checking whether a path is in it must be able to find it with the browser's own search.

The one page that is about this machine

Every other page is about the repository: the same answer in any clone, and safe to publish. /cockpit/continuity is not. It reads .ai/local/ — the open episodes, the resolved handover, the blockers of this checkout — and those records name this disk. That is the reason the Cockpit is bound to the loopback interface and the reason this page has no counterpart on the website (ADR 0014).

On the public site: a snapshot

The published site carries the Cockpit at /cockpit/ as a snapshot (ADR 0147): the Cockpit's own pages, read once at the published commit and written as files, by the exporter that publishes the UI (ADR 0141).

majordomus web report cockpit-snapshot

It writes into target/web/cockpit-snapshot. In order it reads every static route, then every Cockpit page the UI's snapshot wanted to link to, then, one page per family in turn, what those link to, until --limit pages or --budget-seconds. What differs from the Cockpit a process serves:

In the CockpitIn the snapshot
a link to another page of the Cockpita relative link to that page's file
a link to a page past the bound, a search, a form, a runnerthe snapshot's page about the live Cockpit, carrying the path the link meant
a link into the UIthat page of the UI's snapshot when it holds it, the live page when it does not
the scripts: the runner, the palette, the live executions, the polling panelsnone: no page carries a script, and every page is complete without one
/cockpit/continuitynot in the snapshot, and no link reaches it: it is the one page about a machine

Every page says that it is a snapshot, with the commit, the time and the executable that read it. A page that is about a machine and not withheld (peers, worktrees, health) shows the runner that rendered it. For the Cockpit of your own checkout, run majordomus serve.

The directory declares itself (surface.json) as the surface cockpit-snapshot, mounted at /cockpit, published-only. snapshot.json beside it lists every page written and every page left to the live Cockpit. test/cases/2981_the_cockpit_snapshot_holds_its_routes.sh holds it.

It is also the page that has to answer two different readers, and it says which is which.

The worker asks what am I resuming from? That is continuity.state: one episode — the one session-current.yaml resolves to — one handover, one checkpoint, each with the divergence label that says how far to trust it, and the questions that refuse completion. (ADR 0052 adds a freshness label beside the divergence one, because advanced is a true statement about git topology and says nothing about age.)

The operator asks is this subsystem working? That is the lifecycle.* group, and it exists because the worker's answer is structurally blind to the operator's question. The pointer is a symlink the most recent start event re-aims; on 2026-09-11 this repository held five open episodes in one checkout and every surface could name one. So the page carries, below the briefing's own cards:

CardWhat it showsCapability
Every open episodeone row per open episode: its standing (current, open, foreign, stranded), the provider that opened it, the branch, when it opened, the last ledger line stamped with it, and the tasks it touchedlifecycle.episodes
This process against the repositorythe commit the served index was built at, against the commit git reports on the call, and whether they agreelifecycle.runtime
Recoveryepisodes that cannot close themselves and the command that clears each, temporary files a killed close left in the tracked sessions section, the pointer's layout, and the started-against-closed arithmeticlifecycle.recovery
Providersper provider: the lifecycle events its adapter declares, whether it can archive prompts, its client configuration, and the enforcement entries this repository wires to its hooklifecycle.providers
Closed episodeshow many tracked records exist, how many closed on this branch, and the newest twenty, each linking to its object pagelifecycle.closed

Three rules this page keeps, and the reason for each.

No value is computed here. Every number, word and badge above arrives from a capability answer; the page decides colours and nothing else. A Cockpit that worked out for itself whether an episode was stranded would be a second session model, and the first thing two session models do is disagree.

No prompt is ever rendered. The prompt archive is the one part of the local half that is a conversation, and no card on this page reads it. What a worker typed is not operational state, and a surface that showed it would be a transcript with better formatting.

The page is a photograph, and does not poll. The executions pages follow a WebSocket because an execution emits events; the session store does not, and a browser asking every five seconds whether an episode is still open would spend the whole day answering "yes". A reload is the refresh, Cache-Control: no-cache makes it honest, and one section failing to read renders as a failed card rather than as a dead page — the section most likely to fail is a store nothing has written yet, and hiding the other five behind it would be the wrong trade.

What makes it a projection and not a dashboard

Every page asks a capability. A page calls Context::execute, which is the call MCP and the HTTP routes make. It is counted in the same perf counters, answered from the same cache and bound by the same validation. tests/cockpit.rs asserts that serving every page moves none of the counters that must only move at startup.

One write, and it asks first. Every capability a page reaches is a query or a command that changes this process's own memory, except plan.transition, which stamps one field of an issue's record and appends one ledger event. The issue page offers it as three buttons that are disabled until plan.js loads, ask before sending, start the move as an execution — so it has an identity and a page of its own — and show the status the capability derived from the record afterwards rather than the one the button named. Whether a move is legal is the capability's to decide: the page shows its refusal as it came.

No page reads the index. The other half of the sentence above, and the one that was documentation only until scripts/ci/cockpit-projection-check (gate cockpit-projection, ADR 0012) began refusing it: a view layer that reaches into the index because it is in the same process reads the repository outside the executor, outside the cache, outside the counters and outside the validation, and the day it disagrees with the capability neither answer is wrong in its own terms. The gate greps the Cockpit's own files, test modules included, and test/cases/389_cockpit_projection.sh drives it against fixture trees. The object count and the kind catalogue come from repository.info, a listing from objects.list, one object from objects.get.

Unknown is never drawn as healthy. ADR 0089 (project.ui-derived-state) widens the same checker. Its --ui-integrity gate (ui-integrity) refuses four things:

  • a direct ctx.registry, ctx.product or ctx.why read outside the navigation's commented allow-list;
  • a health check given HealthStatus::Ok as a literal;
  • a #[default] on a healthy enum variant;
  • an absent count rendered as zero in pages.rs.

A site that is right as written says why in a // ui-integrity: <reason> comment. The findings master already holds are a ratchet in .ai/repo/ui-integrity-baseline.txt, and test/cases/547_the_ui_says_only_what_is_derived.sh plants each violation in a fixture tree.

Nothing in it is a list. The sidebar's catalogues are the registry's modules, the kinds repository.info reports and the graph derivation table. The capability explorer is the registry filtered. The palette reads /api/v1/capabilities, /api/v1/graphs and /api/v1/objects, and takes the Cockpit's own pages out of the navigation the server already rendered.

The runner is generic. A capability's form is generated from its input schema: the control follows the JSON type (string, integer, number, boolean, enumeration, or a JSON textarea for anything structured), the description is the property's own doc comment, and requiredness is the schema's. Submitting sends the request the capability's own binding expects — a query string for a GET, a JSON body for a POST — to the capability's real route. There is no runner endpoint, no proxy and no second validation.

The examples are the benchmark cases. The inputs shown on a capability's page and the "load into the runner" buttons are BenchmarkCases, the same inputs the benchmark runs and the OpenAPI document shows.

The behavioural claim is one test: tests/cockpit.rs::a_capability_the_repository_adds_reaches_every_cockpit_surface writes a rule into a disposable repository and asserts it reaches the capability listing, a page of its own, the object explorer, the search and the listing the palette reads — with no line of the Cockpit written for it.

The four questions: a dashboard that is a projection

The overview opens with the Overview of the Dashboard Suite (ADR 0088): four questions — is it healthy, what changed, what is broken, what needs action — answered by dashboard.overview, which is an ordinary capability (GET /api/v1/dashboard/overview, MCP majordomus_dashboard_overview, majordomus dashboard overview). It has no data of its own. Each card is one fact read out of another capability's answer — health.report, release.version, plan.status, worktree.status, plan.next, continuity.state, and peers.list for this checkout's board — and it carries that capability, the input it was asked with, the RFC 6901 pointer its value was read from and the measure (exact, or count for the length of a list the source omits when empty), beside the source's own verdict, the page to drill into and the command that acts on it. A source that cannot answer makes its cards unknown, never ok.

The page writes each card's value as data-value — the JSON the route answered — so the page and the route are compared byte for byte. tests/dashboard.rs is the gate: it asks every card's source the card's own question over the source's own route, reads the pointer, and fails when the card's value differs; it names no card, so the next one is held to it unedited. test/cases/534_the_overview_answers_four_questions.sh holds the same contract through the built executable.

Graphs

Graphs are canonical in Rust (src/graph.rs): a Graph is an id, its words, a node-kind and an edge-kind vocabulary that say what each shape means, sorted nodes and edges, and the metadata a reader needs before drawing — counts, whether the result is acyclic, and whether the derivation stopped at its node limit.

GraphWhat it showsDerived from
registrymodules, the capabilities they compose, the file each was declared in, and the MCP / HTTP / CLI projectionsthe registry
layerevery directory an object was discovered in, nested, with the kinds each holdsobject provenance
rulesevery rule and the rules it declares it depends ondepends_on front matter
adrsevery decision, what it supersedes, and the rules, claims, files and tests it put in forceADR front matter
use-casesevery use case and the commands, doctrines and claims it namesuse-case front matter

Adding one is a function and one line in graph::DERIVATIONS. It then appears in graph.list, at /api/v1/graph, as an MCP tool answer, in the Cockpit's navigation, on a page of its own, and as a benchmark case — because GraphInput::benchmark_cases is graph::ids().

Invariants the property and unit suites hold: node ids are unique, no edge references a node the graph does not hold, and the same tree and executable produce the same bytes (no clock, no absolute path, no discovery order).

A drawing library is a consumer. The translation to Cytoscape's element shape is one function in share/cockpit/graph.js; a projection to Mermaid, Graphviz or a terminal would rebuild no semantics.

What the drawing decides

The viewer chooses a layout from the graph's measured shape, not one name for every graph. Every graph used breadthfirst, which puts one breadth-first level on one row; the composed graph is 1442 nodes and one level held nearly all of them, so cy.fit() zoomed out until that row fitted the frame and the drawing was a horizontal line about one pixel tall. measure() derives node count, edge density, breadth-first depth and — the number that diagnoses it — the widest level; choose() picks between the layouts cytoscape ships:

WhenLayoutWhy
24 nodes or fewercircleeverything fits on one ring at a readable size; physics over twenty nodes only wobbles
3+ levels, widest level ≤ 48, density < 2breadthfirstthe only built-in layout that shows direction; the guard is the widest level, because past it breadthfirst is a line
400 nodes or fewercoseforce-directed: the only built-in that puts a cluster together, so communities are visible
up to 900 nodes, degrees peakedconcentric by degreea sort rather than an iteration, so it is instant; hubs at the centre. Only when the degrees are peaked: on a flat distribution every node lands in one ring
largergrid, by kind then degreemeasured: concentric over 1452 nodes gave a disc 19000px across, so fit() hit minZoom and a node rendered under a pixel. At 480 square pixels per node no arrangement is both structural and legible, so each node gets a cell, kinds form bands, and the structure is reached through the filter and the selection

No layout extension is vendored and none should be: share/cockpit/vendor/ holds cytoscape.min.js alone, and the Cockpit's content-security policy names no remote origin.

Everything else the drawing shows is derived the same way. A node's diameter is its degree, square-rooted. A node kind's colour is --mj-series-<n> from share/design/tokens.yaml — eight roles that carry no meaning, chosen for separation — and which of them a kind gets is an FNV-1a hash of the kind's own name, so a kind is the same colour in every graph and stays that colour when a thirty-fifth kind is declared. Eight colours and 34 kinds means kinds do share a colour, and that is the price of the stability: the alternative — rank the kinds and hand out colours in order — moves every kind's colour the day one is added. The legend names which kinds share, and the filter separates them.

Cytoscape cannot read oklch, which is what the declaration's palette is. Handed one it logs that the property is invalid and falls back to black, and every token this file read had been landing that way. A token is therefore painted onto a one-pixel canvas and its channels read back, and the library gets [r, g, b], which it accepts. No colour string is composed anywhere: this is arithmetic at a library boundary, and the value is still the declaration's. Labels are the declared meta step and appear above 80 nodes only when the reader has zoomed close enough to read them, except for the twelve busiest, which are always named. An edge's kind is painted on the edges of the selected or hovered node, never on all of them at once. The legend beside the drawing is also the filter: a swatch and a count per kind, and a click that takes the kind out.

Nothing here is a colour, a size or a count written in JavaScript, and scripts/ci/design-check reads share/cockpit/graph.js like every other first-party surface — it used to exempt the file by path, which is what let a hue computed in a script stand for a year.

Why the tables under a graph are paged

A graph page lists every node and every edge, fifty rows at a time. The node table is paged by ?nodes= and the edge table by ?edges=; each keeps its own position in the URL, a number past the end is the last page, and the control under each table says which rows it shows.

The tables are not a convenience beside the drawing; they are the page. The drawing needs JavaScript, a 400 kB library and a canvas, and the page says so in its own prose: "everything it shows is in the lists below, which is what a reader without JavaScript, a crawler and a screen reader get". That sentence used to be the reason the tables were listed whole. It stopped being affordable: whole, composed was one document of more than a megabyte and thousands of table rows — a page nobody could scroll or search, and one no accessibility engine finished reading within its visit deadline. A document that size was not readable by the person the contract was written for either.

Paging keeps the sentence true because nothing is taken away. Every node and every edge is on exactly one page of its table, as text, and the pager is plain links: the first page, the last, the neighbours of the one shown and the previous and next, so a reader with no JavaScript reaches every page by following them, and a crawler does the same. The drawing is unchanged and still shows the whole graph; it reads the bulk format, not the tables. apps/majordomus-cli/tests/cockpit.rs walks the registry graph's tables page by page and through the pager's own links and requires every node and edge to turn up, and the page sweep there renders /cockpit/graphs/composed, the largest of them.

The bulk format is linked from the page: /api/v1/graph?id=<id>. A reader who wants the whole graph in one response, to search or to process, takes that.

test/cases/190_graph_drawing.sh runs choose() and measure() over synthetic shapes without a browser; scripts/lib/cockpit-probe.mjs asks the running renderer, through frame.mjGraph.extent(), how much of the frame the drawing occupies and fails below a tenth in either direction — for registry and again for the largest graph there is, which is the only one that ever had the defect. The assertion it replaces counted canvas elements, and cytoscape makes three of those over a one-pixel line.

Health

health.report reports one check per dimension. Every check carries who decided it and what reproduces it, because a health report that decides things itself is a fourth opinion:

CheckDecided by
the layer as it was readthe index's own diagnostics
the capability registrythe registry builder, which refuses to build on a duplicate or a malformed exposure
the declared scopethe scope declaration the index was built under
version controlgit, asked once at startup
benchmark coveragethe benchmark projection's coverage — the same one bench coverage --check reads
committed projectionsthe same rendering majordomus generate writes, compared with what is committed
the shared serverthe decision server.status makes, from this checkout's lease and one probe of the server it names
attached clientsthe in-memory peer board of this process

The server check reports where this checkout's shared server stands — absent, starting, ready, outdated or stale — with its address, the version it serves and the reason when the answer is not ready. absent and starting are ok: a checkout nobody serves is not an unhealthy one, and a lease still binding resolves itself within the bind grace. outdated and stale are warn with the reason as a finding: a server answering from code this tree no longer has, and a lease naming an address nobody answers at, are both somebody's to clear and neither stops this process from serving. It is the fourth of this crate's four readings of "ready", and the only one that belongs in a health report; docs/MCP.md names all four and what each is for.

It deliberately does not re-render every committed projection on each call: that would rebuild canonical state per request. It compares the one artifact derived from code alone (docs/generated/registry.json) and names majordomus generate --check as the complete answer.

It is also not majordomus doctor. The shell tool's doctor decides whether Majordomus is wired into this repository; these checks decide whether what this process serves is sound. Different subjects, and the Rust server dispatches no shell.

The browser layer

Progressive enhancement in the strict sense. Every page is complete HTML before any script runs; the graph pages carry every node and edge as tables, and the optional views say where the same facts are as text.

FileWhat it addsNeeds
cockpit.jsthe one Alpine component (theme, palette state), the shared helpersvendor/alpine.csp.min.js
palette.jsthe command palette (Ctrl/Cmd + K), entries from the registry—
runner.jsthe generic capability runner—
plan.jsthe moves on an issue page: starts plan.transition as an execution after a confirmation, follows it on a bounded poll, shows the status it answered or the refusal as it came, and reads the page again—
graph.jsthe Cytoscape view: pan, zoom, fit, search, neighbourhood focus, a details drawervendor/cytoscape.min.js
topology.jsthe registry graph in three dimensionsvendor/three.module.min.js
activity.jsa running plot of the execution and cache countersvendor/p5.min.js

If a vendored library is absent the frame says so and the page keeps working. None of them is on the critical path, each is fetched only by the page that uses it, and the two visual ones respect prefers-reduced-motion, pause when the tab is hidden and dispose what they allocate.

Why Three.js is there at all: the registry graph is layered — modules compose capabilities, capabilities are declared in files and project onto three interfaces — and a flat drawing must choose between showing the layers and showing the fan-out. One plane per layer shows both. Why p5 is there: the counters are already a table, and a table cannot show whether the executions arriving now are being answered from the cache. Both are optional views of data the page also prints.

Security

  • Escaping by construction. Markup is a tree of El values; the only way text reaches the output is a method that escapes it. There is no template string, and the tests render a rule whose title is a script tag.
  • A strict content-security policy. default-src 'none'; script-src 'self' 'sha256-…' — no unsafe-inline, no unsafe-eval, no remote origin, frame-ancestors 'none'. The one inline script is the pre-paint theme bootstrap, allowed by the digest of its own bytes, computed from the constant itself so the two cannot drift. This is why Alpine's CSP build is what is vendored and why every x- attribute names a property or a method rather than an expression.
  • Browser mutation protection. A request that carries an Origin header came from a page in a browser. For anything but GET and HEAD, that origin must be this server's own; otherwise it is refused with 403 before a handler runs. A client that is not a browser sends no Origin and is unaffected. Reads are already contained: this server sends no CORS headers, so a cross-origin page cannot read an answer. There is no session and no cookie, so there is no token to add — a token bound to nothing defends nothing.
  • No state change through GET. The Cockpit's own routes answer GET only; a command is POST to its own route, and the runner marks it as one.
  • Path handling. An asset name is refused before the filesystem is touched if it holds anything but [A-Za-z0-9._-/], an empty segment, . or ..; the resolved path is then checked to be inside the asset directory, which closes the door a symlink would open. Only a closed set of text media types is served.
  • Loopback by default. serve binds 127.0.0.1; binding anything else logs what it means.
  • What writes is declared. The runner can reach every capability, including the commands whose declared effect is repository_mutation. The Run card derives its warning from that effect, so a command that writes tracked files says so before it is sent, always as a POST from the page's origin. Every other capability either reads or changes only this process's own memory.

Assets

share/cockpit/ is where this tool keeps the Cockpit's files, the way share/kinds.yaml and share/schemas/ are where it keeps its kinds. They are read from disk, once, and served from memory with the digest of their bytes in the URL — so a changed file is a changed URL, and a versioned asset is answered immutable for a year.

Only the exact version a page emits is immutable; any other v (empty, a prefix, a stale one) and no v at all are answered no-cache. Every answer carries the digest as its ETag, and an If-None-Match that still holds is a 304 Not Modified with no body. Every asset URL a page loads carries its digest: a script module's relative imports are rewritten, when it is read, to the digested URLs of what they import, so a module's digest covers its imports and every importer names one URL; the libraries the scripts load at run time get theirs from the page body's data-vendor (cockpit::VENDOR); the favicon is digested like the stylesheet. A warm navigation therefore fetches only the page.

scripts/cockpit-assets            # compile the stylesheet, vendor the pinned libraries
scripts/cockpit-assets --check    # fail if the committed stylesheet differs from its source
FileCommitted?Why
cockpit.cssyesa generated artifact, compiled from src/cockpit.css with Tailwind and Flowbite's theme over the shared design sheets; drift-checked
src/cockpit.cssyesthe source: what only the Cockpit has (shell, palette, runner, log, canvases), one semantic class per component, over the tokens and primitives it imports from share/design/
favicon.svgyesa generated copy of the canonical mark share/design/brand/logo-mark.svg, written by majordomus generate design
*.jsyeswritten by hand, no build step — but nav.js and theme.js, byte copies of the site's, written and checked by scripts/cockpit-assets: the bar at the top of every page is the site's, and so are its scripts (MAJORDOMUS_UI.md)
vendor/alpine.csp.min.jsyesthe interaction depends on it and it is 70 kB
vendor/{cytoscape,three.module,three.core,p5}.min.jsno~2 MB, lazy, optional, every page complete without them

There are no utility classes in the Rust that renders the pages, and the one markup that has them is not the Cockpit's: the bar at the top of every page is majordomus.dev's own, compiled in from the built site and compiled into the stylesheet from that copy (project.the-ui-wears-the-sites-navbar). Everything the Cockpit's own markup names is defined in src/cockpit.css or in the shared share/design/primitives.css as a composition of Tailwind utilities over the design tokens, which keeps the design readable and makes the compiled stylesheet a function of those files and the pinned versions alone — which is what makes --check exact. No colour, size, status colour, theme key or mark is decided in the Cockpit: every one comes from share/design/tokens.yaml through the generated sheets, the same sheets the published site imports, and scripts/ci/design-check refuses a second decision. The Design page (/cockpit/design) renders the declaration the executable was built with and says whether the stylesheet it loaded agrees. See DESIGN_SYSTEM.md.

Node is a build dependency. The Rust executable serves these files from disk and has no idea they were built.

Performance

The pages are benchmark targets, declared beside GET / and GET /docs, so bench coverage --check counts them and a baseline can regress on them: system.http.cockpit_overview, system.http.cockpit_capabilities, system.http.cockpit_graph.

What the numbers are on any given machine is measured, never written here — the evidence lives under .ai/repo/benchmarks/rust/ and docs/generated/benchmarks.md. What is guaranteed is structural: no page rebuilds canonical state, an asset is read from disk once per process, a graph is cached by the executor, and no library that a page does not use is fetched.

Adding to it

A capability. Write the capability! block and run majordomus generate. It appears in the Cockpit's listing, on a page of its own with a generated form, in the search, in the palette, in the registry graph and as a benchmark target. Nothing in src/cockpit/ is edited.

A graph. Write the derivation and add one line to graph::DERIVATIONS. It appears in graph.list, at /api/v1/graph, in the navigation, on a page, and as a benchmark case.

A page. That is the one thing that is a Cockpit edit, because a page is the Cockpit's own shape: a function in pages.rs returning a Page, and one arm in Cockpit::route. A page that needs a script names it with .script("name.js"), which is what keeps a graph library off the landing page.

A component. One class in share/cockpit/src/cockpit.css and one function in view.rs. Never a utility class in the Rust.

Testing

apps/majordomus-cli/tests/cockpit.rs runs a real server over a real socket against a disposable repository:

  • every page renders complete HTML with the shell and the security headers,
  • a capability added to the repository reaches every Cockpit surface,
  • the runner form is generated from the input schema,
  • a graph page lists every node and edge before any library loads,
  • the health page shows the verdicts the engines reach,
  • assets are immutable by their exact digest, every URL a page emits carries one, a revalidation that holds is a 304, and no traversal escapes the directory,
  • a state-changing request from another origin is refused and a read is not,
  • the index answers JSON to a client and points a browser at the Cockpit,
  • repository content reaches the page as text and never as markup,
  • serving every page rebuilds nothing canonical,
  • the plan pages show the statuses the plan capabilities answered, and a move made from an issue page — directly or as an execution — changes the record and then the page (tests/cockpit_plan.rs).

The unit tests in src/cockpit/ cover the escaping (both contexts), the void elements, the status-word-to-class mapping, the asset cache and the path refusals, the CSP digest, and the navigation being the registry's rather than a list.

What it deliberately does not do

Four things a control plane is expected to have, left out on purpose. Each is a decision rather than a gap, and each names what would have to change first.

No streaming for its own sake. This section used to say there was no live transport at all, and gave the reason: a stream to make a counter update without a poll is a noun added to the core for a convenience, which optional-complexity exists to refuse. It also said what would change the trade — "when something genuinely long-running arrives, the stream will be worth its own decision, and the typed event envelope belongs in that decision rather than ahead of it".

That arrived, and that is ADR 22: a capability that reads every file of the layer takes long enough to watch rather than wait for, so an execution has an identity, typed events and a live channel at GET /events (EXECUTIONS.md). What did not change is the trade the paragraph protected. The activity page still polls, because counters are a convenience; no capability became a stream, because the fourth kind was drafted and dropped; and the live channel carries execution events and nothing else. A page that wants a number to tick has the same answer it had before.

No rule-precedence resolution. "What instructions apply at this path?" is a question the Cockpit is the right place to answer and the wrong place to decide. The shell tool owns context resolution (majordomus context resolve), and a second implementation in Rust would be a second opinion about precedence — exactly the drift every rule here is written against. What the Cockpit does instead is show the inputs: every rule as an object, the rule dependency graph, and the directory contracts as objects of kind context. The resolution itself waits for one engine both tools can call.

The health page is not majordomus doctor. Doctor decides whether Majordomus is wired into this repository — the hooks, the projections, the retention, the state. health.report decides whether what this process serves is sound. Different subjects with different engines, and the Rust server dispatches no shell, so there is no third thing that runs both. A reader who wants both runs both; each names the other's territory.

The write path is declared, not absent. Most capabilities the Cockpit can reach are queries or commands that change this process's own memory; starting an execution and cancelling one are two of those. The ones that write the repository declare the effect repository_mutation in their execution policy, and that policy is what the warning on the Run card reads. ADR 12 requires such a capability to be its own decision, and a unit test in builtin pins the set of them, so a new one shows up in a diff.

In a browser

scripts/cockpit-probe measures the Cockpit against a running server. Nothing in it lists a route: the areas are crawled out of the shell's own navigation — which the server rendered from the registry, the index and the graph derivations — and then one route per capability, per graph and per object comes from /api/v1/capabilities, /api/v1/graphs and /api/v1/objects. A capability, a kind or a graph added to the backend is probed the first time it exists.

just cockpit-probe                      # both sweeps
just cockpit-probe --quick              # the status sweep in full, one of each family in the browser
just cockpit-probe --status             # the status sweep alone; no browser needed
COCKPIT_PROBE_URL=http://127.0.0.1:8741 scripts/cockpit-probe    # a server that is already running

Two sweeps, because they answer different questions at different prices:

SweepOverAsserts
statusevery derived route200, text/html, and the security headers on the shell
browserevery area plus a sample of each generated family, at 390, 1024 and 1600 pxthe shell, the stylesheet applied, no horizontal overflow, no console error, no failed request

The browser sweep is the part a status code cannot reach:

  • Alpine started, under a policy with no unsafe-eval — which is the whole reason the CSP build is vendored, and the check that catches a regression to the CDN build.
  • The command palette opens on Ctrl/Cmd+K, fills with entries the registry supplied, filters, and navigates where the chosen entry says.
  • The theme toggle — the site's, in the site's bar — flips the root class and the choice survives a reload.
  • The skip link is the first tab stop and points at #main.
  • The runner is generated from the schema (the required string, the bounded integer, the disclosure of the capability's own benchmark cases), and submitting it calls the capability's real route and renders the answer — asserted against the request the browser actually made, not against the preview.
  • The graph draws over the same nodes the page lists, or says why it cannot.
  • frame-ancestors 'none' holds: another origin cannot frame the Cockpit.
  • JavaScript disabled: every sampled route still carries its navigation and its content.

The probe measures the server the repository's lease names, because one server per repository is the rule this tool lives by. When an editor holds the lease with an executable older than the Cockpit, the probe says so and names what to do rather than quietly starting a second server.

What it has already caught, which is the argument for it existing: Alpine's CSP build treating palette.open as an expression it will not evaluate — leaving a full-page modal backdrop over every click on a page that looked correct in the markup; the CDN build starting itself before the component was registered; a topbar eight pixels too wide for a 390 px viewport on every page; and a table wrapper whose negative margin made the document wider than the viewport.

Playwright drives the Chrome that is already installed (channel: 'chrome'), so nothing is downloaded and CI reuses the browser the site probe already installs. A missing Chrome, a missing Playwright or a missing Node is a SKIP of the browser sweep and not a failure — the status sweep still runs over every route.

One consequence of reading assets once: a changed stylesheet or script needs the server restarted before the probe sees it. That is deliberate — an asset is read from disk once per process — and it is why the probe reports the digest it was served.

Claims this document defines

Each links to its own page with implementation, test and where it is used.

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