API first
The Go HTTP API is the single integration contract. The SPA, the CLI, the node worklist, and the YAML authoring tooling are all generated clients of it. Nothing but the API talks to the database, and the API is described by one machine-readable spec that cannot drift from the implementation.
The source of truth is the Go API
Section titled “The source of truth is the Go API”Request/response types are Go structs (Huma). The OpenAPI 3.1 document is generated from
them, server-less, and committed. Everything downstream is generated from that document.
This is the rule: you change a Go route or shape, you regenerate, you commit the derived
artifacts. The gen-drift workflow (.github/workflows/gen-drift.yml) enforces it in CI,
failing the PR on any diff in the committed generated artifacts.
The generation pipeline
Section titled “The generation pipeline”| Generator | Input | Output | Consumer |
|---|---|---|---|
cmd/openapigen | Huma Go structs | api/openapi.json (+ .yaml) | everything below |
cd web && npm run gen:api | openapi.json | web/src/api/schema.gen.ts | typed openapi-fetch SPA client |
cmd/cligen | openapi.json | internal/cli/api_gen.go (cobra) | the CLI, patched via api_hooks.go |
cmd/docsgen | the live cobra tree | docs/src/content/docs/reference/cli/index.md | the published CLI reference |
cmd/erdgen | the embedded migrations, applied to a throwaway Postgres and introspected | the D2 region of docs/src/content/docs/architecture/data-model.md and the schema facts docs/src/generated/schema.json | the published ERD and every docs storage table |
cmd/seedgen | the twelve embedded seed YAMLs, parsed in-process (no database) | docs/src/generated/seed.json, roles carrying effective permissions | the docs’ shipped-set claims |
cmd/configgen | the declarative env registry in internal/config | docs/src/generated/config.json | the deployment guides’ env tables |
gen-proto | proto/og/v1/*.proto | committed *.pb.go | the NATS TelemetryBatch telemetry message |
Two more stages are planned but have no generator yet: an MCP tool catalog for AI agents over the API contract, and a JSONSchema for YAML editor validation. They land with their consuming slices.
One command runs them all (make gen); two focused targets regenerate a subset
(make gen-proto for the protobuf wire, make gen-web for the spec plus the typed SPA
client). The committed *.pb.go lets a contributor build without protoc or a running
server.
A name is the address, a uuid is identity
Section titled “A name is the address, a uuid is identity”Every response carries both forms of a reference: the name an operator types and the id it
resolves to. {"parent": "rack", "parent_id": "0198f..."}. The name is what a body round-trips;
the id is the stable handle that survives a rename.
One name rule is enforced on every table declared to bear a name. A name is the identifier an
operator types and an address carries (the rm215a in boi.17c.rm215a): one kebab token
on ^[a-z0-9][a-z0-9-]*$, a 100 character ceiling, the uuid shape refused. One validator enforces
it, storage.ValidateName(table, name), which reads the table’s declared identity shape to settle
whether that table bears an operator-typed name at all rather than trusting whoever wrote the call
site. The rule lives in the contract, not just below
it: the create body carries pattern and maxLength, so the generated OpenAPI, the typed client,
the CLI, and the YAML JSONSchema all enforce it, and the Storage Gateway enforces it again for
callers that never touch a route.
Every exception is named, in code. internal/storage/identity_shape.go declares one of four
identity shapes for every table: key-bearing (an operator types its name), keyspace (an operator
types its name there too, on that same rule), a human identifier that is not a name, and id-only.
The last two carry a written reason, and the guard refuses an exception without one.
Which table is which is not written down here, because a hand-copied list is the drift class the
generate-first rule exists for. identity_shape_test.go checks the declaration against the generated
schema, so a new table is a failing test until somebody classifies it, and cmd/identitygen renders
it into core entities. An earlier version of that guard only inspected
tables carrying a name column, which made it blind to 28 of the 51: absence of a name is not
evidence of absence of an identifier, and a username and a content hash both escaped.
There used to be a second rule beside that one, the keyspace rule, since retired: a dot-joined
path of kebab segments on a 128 character ceiling (icmp.rtt-avg), with the validator selecting
between the two from the table’s declared shape. The two collapsed into one (#586). icmp.rtt-avg is now
icmp-rtt-avg, a name is a single token on every table that carries one, and with no path left to
parse there is one ceiling and nothing to select between.
The exclusions are load-bearing rather than tidy. Barring . is the single-token rule itself: a
name occupies exactly one position and can never split into two. Barring * and > keeps a name
from reading as a NATS subject pattern. Barring $ is what lets an address use sigil accessors
without reserving any word, so a location may still legitimately take the name sys.
The test is a round trip: a response body can be fed back to the write that produced it
(create a component with {"parent": "rack"}, read it back as {"parent": "rack"}). When that
fails, every client has to fetch a second collection and join by uuid to render one label, each
slightly differently.
One exception, narrow: an entity addressed by id, either because nobody names it (a stored
property value, an audit row, a grant, a principal) or because its name is not unique fleet-wide
(an interface, named after its interface_type and unique only within its component). A registry
used to be a second exception, a slug-keyed catalog whose id was its
name (product_id: "kestrel-vroom"); that is gone. Every registry now has a uuid primary key and
a renameable name (ADR-0062),
so it obeys the rule like any fleet entity.
Every foreign key stores the target’s primary key, a uuid, with no exception: a rename then
has nothing to rewrite, because nothing points at the friendly name. A _id column holding a
name, kept alive by on update cascade, is the shape this rule exists to prevent; that machinery
is now retired everywhere, including the last place it lived, the registries.
A path or a join field accepts either form. GET /components/{ref} and a body’s
{"parent": "..."} both take a uuid or a name; the uuid is tried first, so an id never
collides with a name.
TestReferencesCarryBothForms enforces this over the generated OpenAPI in both directions, so a body
cannot silently reintroduce a uuid-only reference (a *_id with no name) nor a name-only registry
reference. Its exempt list is the whole of the remaining exception (the nameless entities and a
still-slug-keyed taxonomy), and adding to it is a decision: if the target has a name, carry the
name.
Conventions (AIP-style)
Section titled “Conventions (AIP-style)”These are the conventions a route follows while you write it; the complete API contract (the error envelope, idempotency, long-running operations, versioning, and the authorization status mapping) is the architecture of record.
Every operation lives under /api/v1/*. The path shape is derivable, not special-cased:
- Plural collections, standard CRUD by primary key:
POSTcreates (409 on PK collision),GETreads,PATCHupdates by PK (AIP-134, partial) but never thename,DELETEremoves. No upsert/register shortcuts. :verb(not/verb) for non-CRUD custom methods:/components/{name}:rename,/components/{name}/commands:issue,/auth/me:changePassword,/auth/me/sessions/{id}:revoke,/nodes:claim,/principals/{id}:disable.- A rename is a custom method, not a field write. Every renameable collection carries a
:rename, gated<resource>:renameand not<resource>:update, because moving a name breaks the bookmarks, runbooks, and integration config held outside the platform while nothing inside it breaks (every reference stores the uuid). That is whynameis absent from thePATCHbody of a component, a system, a location, and a principal group (ADR-0076). - Singular kind sub-segments:
/rules/calc,/property-types,/location-types,/types/event. - official / private namespace on every registry and rule family (below).
- List conventions (AIP-132 target):
filter/orderBy/pageSize+pageToken(cursor, never offset) /fields. Thefilterruns through the one expression engine (Expr, one dialect, not pluggable), the same language across rule scopes, dynamic groups, and list filters.
The API is self-describing: the running server serves GET /api/v1/openapi.json,
/openapi.yaml, and a human reference page.
The read side is views (backend-for-frontend)
Section titled “The read side is views (backend-for-frontend)”Writes go through resource CRUD (each emitting an audit_log row in the same transaction).
The per-route gate
Section titled “The per-route gate”Every typed route carries a per-route coverage test (an openapi_coverage_test.go-style
gate) and the CLI-covers-every-route test, so the generated clients never fall behind the
API. After any route change: make gen, add the per-route test, keep the coverage tests
green.