Implementation status
The architecture pages describe the target model in present tense: the design, not a snapshot of the code. This page is the other axis, what is actually built, tracked per capability. The split is deliberate: the architecture stays a stable reference, and build progress lives here instead of rotting the design prose with “shipped in vX” notes.
Two axes, kept separate
Section titled “Two axes, kept separate”- Design certainty (“is this decided?”) lives inline on each page as
Open questionasides. A page can be almost fully settled yet still carry a few open points. - Implementation state (“is this built?”) lives here, and as each page’s status badge. A page can be fully designed and entirely unbuilt, or anywhere in between.
Neither axis is page-binary, and they do not move together.
A third surface carries history and direction, which neither badge captures: the decision log records why a call was made or reversed and where the build diverges from a page, and the roadmap indexes the epics and the architectural arc ahead.
Badge legend
Section titled “Badge legend”Each architecture page carries one status badge, and the grid below is sourced live from those
badges, so it never drifts. The badge is the page’s floor: a page is only Built once every
capability it describes is shipped, so a page with one unbuilt corner stays Partial.
| Badge | Meaning |
|---|---|
| Design | The model is specified; little or none of it is coded yet. |
| Partial | Some capabilities on the page are built and tested; others are still Design. |
| Built | Every capability the page describes is implemented and tested. |
| Diverged | Built, but the implementation differs from the design on this page. The page carries a note pointing to what shipped and why; this is a proposed architecture, and building it sometimes changes it. |
The repository is public and published ahead of the code: the foundation and the first estate tiers
are Partial (auth and the Storage Gateway, the generation pipeline, and the location / system /
component trees), while most of the monitoring spine is still Design. As a page’s badge flips, the
grid below follows automatically, because it reads the badges directly.
A Partial page can also carry a divergence in one of its built corners, where the shipped code
differs from that part of the prose. The page flags it inline and the
decision log records why and when; the Diverged whole-page badge is
reserved for a page that is otherwise Built. So the grid shows each page’s build floor, and the
inline note plus the decision log carry any per-capability drift above it.
The grid
Section titled “The grid”| Capability | Status |
|---|---|
| AI | Design |
| Alarms and actions | Partial |
| API | Partial |
| Audit | Partial |
| Calculations | Design |
| Cascade | Partial |
| Config, secrets, and variables | Partial |
| Core entities | Partial |
| Data collection | Partial |
| Datapoints | Partial |
| Events | Design |
| Expressions | Design |
| Files and blobs | Partial |
| Groups | Design |
| Health, KPIs, and service levels | Partial |
| Identity and access | Partial |
| Messaging | Design |
| Nodes | Partial |
| Scaling and deployment | Partial |
| Settings | Partial |
| Storage | Partial |
| Tags | Partial |
| Templates | Design |
| Time | Design |
| UI | Partial |
| Views | Design |
| Workers | Design |
Build progress
Section titled “Build progress”The code is built one vertical slice per PR, each a thin cut through the whole stack rather than a
horizontal layer. Slices are logged here as they land; a page’s badge only flips once all of its
capabilities ship, so an early slice can prove a seam without moving any page off Design.
-
Slice 1: the walking skeleton. The single binary boots in two run modes.
omniglass migrateapplies an embedded dbmate migration (pure DDL, run-once, idempotent) against a BYO Postgres, andomniglass serverservesGET /api/v1/healthz, which pings the database through the Storage Gateway seam (the only DB path, an interface from day one) and reports{status, db}. The HTTP API is Huma over chi; the OpenAPI 3.1 document is generated from the Go structs (make gen), the source the typed clients are generated from in later slices. Proven by a testcontainer integration test (migrate applies clean, the gateway ping succeeds, healthz returns db ok) and an end-to-end test that builds and runs the real binary. Deferred to later slices: messaging (NATS / JetStream), the worker and outbox, identity and access (Slice 2), the collection engine, and the rest of the generation pipeline. -
Slice 2: the auth and gateway foundation. The identity and access schema (principal with per-kind human and service, credential, role, principal_grant, and audit_log; uuidv7 keys), the four official roles seeded idempotently at boot, and
omniglass bootstrap <username>, which creates the first owner (anowner@allgrant plus a bearer credential) directly and idempotently. A bearer token resolves through the Storage Gateway to its principal and grants, which flatten through the role index (inheritance, wildcards, the:readfloor) into a capability set. Two seams every later route reuses: the capability fast-reject (401 unauthenticated, 403 missing capability) andGET /api/v1/auth/me(principal, permissions, grants).GET /api/v1/rolesis gated byrole:read. Proven by testcontainer integration and end-to-end tests (bootstrap idempotency, the 401 and 403 paths, the auth/me contract) plus pure unit tests of the capability logic. Deliberate thin cuts this slice: bearer-token auth only, and scope resolvesowner@allto all (the per-actionvisible_setresolver lands when there are entities to scope). Deferred: password and OIDC auth, the first-class agent principal, and CDC-driven cache invalidation of the role index. -
Slice 3: locations and the per-action scope resolver. The first scoped core entity, and where the ABAC scope deferred from Slice 2 lands. The
location_typeregistry (the only shape-definer, since a location has no template) seeds the four official types at boot, ranked (campus/building/floor/room, spaced by ten);locationis a name-addressable, variable-depth tree (parent_id) whose type is a foreign key.POST/GET/PATCH/DELETE/list /api/v1/locationseach declare alocation:<action>capability and resolve the caller’s per-actionvisible_setfrom their grants and the role index, which the Storage Gateway expands (a recursive subtree walk) into the row filter and applies on every query, writing theaudit_logrow in the same transaction. The over-permit fix is real: only the grants whose role carries the action contribute scope, so a read-anywhere principal with a narrow write grant cannot write outside it. The three-way status split holds: out of read scope is a non-disclosing 404, readable-but-out-of-action-scope is 403, and a delete is refused (409) while the location still has children. Thelocation_typeregistry that classifies a location is itself readable atGET /api/v1/types/location(ranked, gated bylocation:read), which the operator console type picker lists. Proven by a pure resolver unit suite, a testcontainer integration test of the gateway split and audit, and an end-to-end HTTP test (scoped subtree listing, the 404, the capability 403, full owner CRUD). Deliberate thin cuts: scope kinds resolved areallandlocationonly; thelocation_typeregistry seeds official rows only;rankorders and signals hierarchy but does not constrain nesting; update patchesdisplay_nameandlocation_type(rename and reparent deferred); occupancy counts child locations only (placed systems and components join when they land). Deferred: system and component entities (Slice 4), group and dynamic-group scope (Slice 5), operator-defined location types via the namespace shadow, hard containment rules, and anyLocationTemplate. -
Slice 3a: the generated CLI. The second stage of the generation pipeline lands:
make gennow runscmd/cligen, which reads the committedapi/openapi.jsonand emits the cobra command tree (internal/cli/api_gen.go, committed and reviewed like the spec). One command per operation, derived from the AIP path and method:omniglass location list/get/create/update/delete,role list,auth me,healthz; path parameters are positional args, the request body becomes--flags, and--helpplus the example come from the operation’s summary and description. A:verbcustom method maps to<resource> <verb> <id>generically, so future custom methods surface with no generator change. The generated tree composes with the hand-written commands (theserver/migraterun modes and the trustedbootstraplane) on one root through a stable seam (internal/cli/api_hooks.go: the JSON-over-HTTP client and the shared--server/--tokenflags), so regeneration never touches a hand-written command. The CLI is a client of the API like any other: it carries a bearer token and the server enforces the same capability and scope. Proven by an end-to-end test that builds the real binary, bootstraps an owner with the hand-written command, and drives the generated location commands against a live server (create, scoped list, get, a non-disclosing 404 as a non-zero exit, delete, and--help). Deliberate thin cuts: JSON output only (no table rendering), and a generic client rather than a per-type typed one. Deferred: the typed SPA client and UI (Slice 3b), shell completion, multi-server contexts, and the YAML JSONSchema target. -
Slice 3b: the operator console. The last stage of the generation pipeline and the first browser surface: a SolidJS SPA (Vite, daisyUI 5 on Tailwind 4,
@solidjs/router,@tanstack/solid-query,openapi-fetch)go:embed’d into the binary and served under/web.make gennow also emits the typed SPA client (openapi-typescripttoweb/src/api/schema.gen.ts), so the console cannot drift from the API. The embed uses the build-tag-with-fallback pattern (internal/webui): a barego build/go testneeds nodist/and serves a build-the-console placeholder, whilemake build-webruns the Vite build and compiles with-tags webto embed the real shell. The visual system is the “Omniglass Console” design ported faithfully (thetheme.csstokens and component classes, the nav rail, top bar, density/theme tweaks, and Home). Locations is the first live view: list, detail, and create through the typed client, gated by a bearer-token login and the AuthGuard; the other nav sections render honest stubs until their backends land. Proven byinternal/webuiunit tests (the SPA-fallback routing against a fake FS, and the real embed under-tags web), an API mount test (/webserves the shell and the bare/webredirects), and a vitest suite over the locations data layer. Deliberate thin cuts: only locations is wired live; styling is daisyUI (two brand themes via the plugin) with Kobalte deferred until the first interactive widget needs it; auth is bearer-token paste, not a full login. Deferred: the mock-data screens (alarms, systems, components, dashboards) as their backends land, HTTP/2 (h2c) and the SSE live relay, and full auth UX. -
Slice 4a: the system tier. The second scoped core entity: a
system_typeregistry (seeded official, ranked, likelocation_type) andsystemas a name-addressable, variable-depth tree (parent_idsubsystems), optionally located at a location (location_id) and classified by its type. Full scoped CRUD (POST/GET/PATCH/DELETE/list /api/v1/systems), gated bysystem:<action>, with the same per-actionvisible_set, three-way 404/403 split, and in-transaction audit as locations. The scoped-tree recursive walk is now a shared primitive (internal/storage/scopetree.go): locations were refactored onto it and systems consume it, so the over-permit-safe subtree filter is written once. Proven by the pure resolver unit cases for thesystemkind, a testcontainer integration test (subtree scope, the 404/403 split, occupancy, FK faults, located-at, audit), and an end-to-end HTTP test plus the generatedomniglass systemCLI. Deliberate thin cuts: each entity is scoped by its own subtree (the cross-tier cascade, a location scope also covering its systems, is a later slice); reparent and located-at editing are deferred (create-time only). Deferred: components (Slice 4b), the cascade, templates, datapoints, and the systems UI (console section stays a stub for now). -
Slice 4b: the component tier, and a generic scoped-CRUD primitive. The estate is complete:
component(the leaf) joins locations and systems, with acomponent_typeregistry and a tree that belongs to a primarysystem_idand is located at alocation_id. The per-entity boilerplate is now a shared generic (internal/storage/scopedcrud.go): the read, resolve, and delete paths (list/get/resolve-for-action/ delete-with-occupancy/by-name, all scope-aware and audited) are written once over ascopedConfig[T], and locations and systems were refactored onto it; each entity keeps only its own create/update (which differ by the foreign keys they resolve). So a new scoped entity is a registry, a thin create/update, and a route file, not a mirrored copy. And the authorization conformance suite made adding component one registry line: the full security matrix (capability 403, the over-permit scope 403, the non-disclosing 404, in-scope success) and the no-unguarded-route guard now cover all three entities automatically. Proven by the pure resolvercomponentcase, a testcontainer integration test (subtree scope, the split, occupancy, belongs-to + located-at FK resolution, faults, audit), the generic authz conformance over location + system + component, and the generatedomniglass componentCLI. Thin cuts as the other tiers: own-subtree scope (no cross-tier cascade), reparent/rebind/relocate deferred, components UI deferred. -
Identity slice 1: password auth and the session cookie. Humans now sign in to the console with a username and password, the local-password method the identity model designs: a
passwordcredential (argon2id, PHC-encoded, one per principal).POST /api/v1/auth/loginverifies it and sets an httpOnly,SameSitesession cookie, so the console no longer holds a token (services and the CLI still send a bearer header);POST /api/v1/auth/logoutrevokes the session and clears the cookie, and the authn middleware accepts either.omniglass bootstrap --passwordsets the first owner’s password. Proven by argon2 unit tests, a storage round-trip, a real-binary login integration test, and a browser e2e that signs in through the form. Thin cuts: self profile edit and change-password (slice 2), admin user CRUD and role assignment (slice 3); email invite and forgot-password are later. -
Identity slice 2: the self-service profile. A signed-in operator manages their own account, whatever their role:
PATCH /api/v1/auth/meedits their own display name (email stays administrator-set), andPOST /api/v1/auth/me:changePassword(the first AIP:verbcustom method on the surface) verifies the current password and sets a new one, reusing the slice-1 argon2 primitive. Both are authn-only and self-scoped (they touch only the caller’s own principal), so they join the small ungated allow-list alongsideGET /api/v1/auth/merather than carrying a capability. The console gains a Your profile page (profile form, change-password form, and a read-only Access panel that teaches the principal, grants, and permissions it operates on); the CLI gainsauth update-profileandauth change-passwordfrom the generator. Proven by a storage round-trip, a real-binary integration test (wrong current password 403, short new 422, rotation, self-scope), and the client hook units. Thin cut: changing a password does not revoke existing sessions (a later hardening); admin user CRUD and role assignment are slice 3. -
Identity slice 3a: the admin principal directory. The first surface to exercise the already-seeded
principal:*capability:GET /api/v1/principals(with akindfilter) andGET /api/v1/principals/{id}list and read every principal with its grants, andPOST /api/v1/principalscreates a human with an optional initial password (argon2id, reusing the slice-1 credential; no new migration). All three are gated by aprincipalcapability that resolves only at all-scope: a principal is not a scope-tree entity, so a location or system grant confers nothing and the gateway answers a non-all scope with 403, proven both ways. Responses carry credential-free profiles and grants, so no secret ever leaves the API. The console/usersstub becomes a real Users directory (grid, detail panel, and a create form); the CLI gainsprincipal list/get/create. Proven by a storage round-trip, a real-binary integration test (all-scope allow, scoped-deny 403, duplicate 409, short-password 422, secret redaction, created-human login), the route-gating guard, and the client data-layer units. Thin cuts: nocreated_atcolumn in the directory yet, and create is human-only. Deferred: update / disable / delete a principal, role assignment, the owner-invariant trigger, and service-account and credential management. -
Identity slice 3b: admin update a principal.
PATCH /api/v1/principals/{id}edits a human’s display name, email, and username, gated byprincipal:update(all-scope). Renaming is safe by construction: nothing keys on the username (credentials and grants reference theprincipal.iduuid), so a rename re-homes the login rather than breaking it. A non-human target is 422, a clash 409, an unknown id 404, a location-scoped admin 403. The console Users detail panel gains an Edit form; the CLI gainsprincipal update. Proven by a storage round-trip (the rename re-homes authentication), a real-binary integration test (allow, scoped-deny 403, 409, 404, 422), and the client unit. Deferred: disable / delete, role assignment, and editing a service account’s label. -
Identity slice 3c: role assignment and the owner invariant.
POST /api/v1/principals/{id}/grantsassigns a role at a scope andDELETE /api/v1/principals/{id}/grants/{grantId}revokes one, gated byprincipal_grant:create/:delete(all-scope). This is how a fresh user gets permissions. The owner-invariant trigger (ADR-0006, now resolved) lands here: aDEFERRABLE INITIALLY DEFERREDPostgres constraint trigger refuses to leave zeroowner @ allgrants atCOMMIT, so revoking the last owner is a 409 while a one-transaction owner swap still passes (mapped from the custom SQLSTATEOG001toErrLastOwner). Bad inputs are caught: duplicate 409, unknown role 422, a scoped grant with no id 422, unknown grant 404. The console detail panel gains a grant chip x (revoke) and an add-grant form (role picker, scope kind, scope id); the CLI gainsgrant create/grant delete. Proven by a migration round-trip, a storage test (grant, revoke, the last-owner refusal, the swap), a real-binary integration test, and the client units. Deferred: disable / delete a principal, group grants, and editing a grant in place (revoke and re-grant instead). -
Identity slice 3d: disable and enable a principal.
POST /api/v1/principals/{id}:disableand:enable(AIP custom methods, gatedprincipal:update, all-scope) soft-disable an account: a newactiveflag onprincipal, andAuthenticateBearer/AuthenticatePasswordrefuse a disabled principal, so it can neither sign in nor use a token. Its rows (and the audit trail that references it) are kept, which is why the model is disable, not delete:audit_logreferences the principal, so an actor that has acted cannot be removed. A last-active-owner guard refuses to disable the final activeowner @ all(409), mirroring the grant trigger for the active flag. The console shows an inactive badge and a Disable / Enable toggle; the CLI gainsprincipal disable/enable. Proven by a migration round-trip, a storage test (both auth paths refused, enable restores, last-owner refusal, the swap), and a real-binary integration test. Deferred: hard delete (would need an audit-actoron delete set null, a separate decision) and bulk operations. -
Identity slice 3e: the grant builder (console). A UI-only refinement of slice 3c: the user card’s three stacked selects become a filter-bar-style scope editor. A keyboard-staged combobox (type a role, Tab/Enter to the scope kind, then the entity as an indented tree) commits each grant as a chip, and the whole set of changes is staged locally and applied only on Save (stage, preview, save), so there are no accidental or unclear edits: removing an existing grant marks it (dimmed, undoable) instead of firing a live
DELETE, and a pending-diff preview shows exactly what Save will grant and revoke. No new API: it drives the slice-3cPOST/DELETEgrant routes, applying adds before removes so an owner swap never trips the last-owner guard mid-batch. The staging core (draft, per-chip state, add/remove diff, stage validation) is a pure unit-tested module (lib/grantdraft); theGrantBuildercomponent takes its roles, entity trees, and save mutation as props, proven by a component test that drives the keyboard pipeline and asserts the saved diff. Deferred: previewing a grant’s effective access across the scope subtree (#89) and group grants. -
Console: unified button vocabulary. Buttons across the console moved from ad-hoc daisyUI classes (
btn-primary,btn-ghost text-error, and an inconsistent disable/edit pairing) to a small set of semantic intent classes defined inapp.css(btn-action,btn-quiet,btn-danger,btn-warn,btn-ok),@apply-composed from the daisyUI theme tokens so a future custom theme restyles every button from one place. Astyle-guardunit test scans the source and fails on a rawbtn-primary/btn-ghost, pinning the vocabulary against drift. No behavior or API change: purely how buttons are styled. See the design system. Follow-on: custom theme switching beyond the two brand themes. -
Identity: disabled-account sign-in message. A disabled account that signs in with the correct password now gets an explicit “This account is disabled. Contact your administrator.” (a distinct
403), instead of the generic invalid-credentials message, so a locked-out operator knows to ask an admin rather than assuming a wrong password. The disclosure is oracle-safe by construction:AuthenticatePassworddrops theand pr.activefilter, always runs the argon2 verify (against the real hash, or a dummy hash of matching parameters on a miss, so there is no early-return timing leak), and only branches to the newErrAccountDisabledafter the password verifies. So a wrong password (or an unknown user) against a disabled account is indistinguishable from any other bad credential: same generic401, same work. The bearer/token path is unchanged. Proven by storage tests (correct password on a disabled account ->ErrAccountDisabled; wrong password ->ErrBadCredentials), a real-binary api test (disabled + correct ->403, disabled + wrong ->401), and a web unit. Deferred: auditing the disabled-login attempt (#97), which belongs at the api/outbox layer, not the gateway read, to avoid a write-on-read and an attacker-append vector. -
Identity: root-excluded grants and the deploy role. A grant gains an optional
exclude_rootmodifier that narrows its modify actions (update, delete) to the root’s descendants: the holder can create under and edit within the subtree, but aPATCH/DELETEon the root itself is a 403 (readable, outside the write scope). Read and create-placement keep the root, so aPOSTunder the root and aPATCHon a child still succeed. A new deploy official role (create + update on location / system / component, read via the viewer floor) is the integrator / field-tech grant:deploy @ location:room-42 (exclude_root)populates a room without touching its record. The modifier lives onprincipal_grant, resolved in one predicate (inScopeTree) shared by all three tree entities, not a new scope kind (ADR-0009); an inclusive grant on the same root wins. Proven by scope unit tests (per-action classification, inclusive-wins) and a real-binary API test (read root 200, create-under-root 201, update child 200, update root 403, delete 403). API and CLI carryexclude_root; the grant-builder toggle is a fast-follow (#99). -
Console: scope-aware per-row action gating. The read side now annotates every inventory row with the scope-aware
actionsit permits (create-a-child / update / delete), computed from the same per-actionvisible_setthe gateway enforces (a new batchInScopeIDsprimitive answers a whole page in one query per action). The console’sListViewgates each row’s Add-child / Edit / Delete affordance onrow.actionsrather than the coarsecan(...)capability, so a scoped operator (e.g.viewer@all + writer@root, or adeploygrant) sees only the buttons the server would actually allow, per row, with no client-side scope math.can(...)stays the coarse nav/section hint; the server remains the only authority. Proven by a storage test (batch membership across all / rooted / exclude-root scopes) and a real-binary API test (owner gets every action on every row, a viewer none, adeploy @ root (exclude_root)gets create-only on the root and create+update on descendants). Thin cut: the list carriesactions; single-item GET annotation is a follow-on (the blades render from the list rows). -
Identity: impersonation (view-as and act-as). An owner or all-scope admin holding
principal:impersonatecanPOST /principals/{id}:impersonateto mint a bounded, revocable token to view as (read-only) or act as (full) another principal, andPOST /auth/me:stopImpersonationto end it. Two guarantees: the escalation guard (rbac.Set.Covers) refuses impersonating a principal whose capabilities exceed the caller’s, so no capability is ever gained; and dual-actor audit, a new nullableaudit_log.real_actor_principal_id(threaded via a request-scoped context value, no gateway signature churn) records the true admin behind every impersonated mutation. The token is animpersonation_session(its own table, not a credential);authnresolves it on a bearer miss to the target, andrequire()refuses every non-read action in view-as. Self and nesting are refused; disabling either party kills the session on its next request (ADR-0008 / ADR-0010). Proven by a storage round-trip and a real-binary API test (act-as writes in the target scope with a dual-actor audit row, view-as GET 200 / PATCH 403, a lesser admin cannot impersonate the owner, self 422, a viewer capability-gated, stop kills the token, a split-grant admin cannot act-as into a disjoint scope). The console ships an Impersonate action (View as / Act as) on the user card and a persistent acting-as banner with Stop. Deferred: act-as within a scoped admin’s own scope by intersection (#101; act-as currently requires all-scope over the target’s write capabilities) and re-checking the guard per request (bounded by the TTL + revoke for now). -
Identity: grant scope operators (
scope_op). Theexclude_rootboolean generalizes into ascope_opoperator onprincipal_grant(ADR-0011):subtree(root + descendants, the default, == oldexclude_root=false),subtree_excl_root(descendants only for update/delete, root kept for read/create, == oldexclude_root=true), andself(exactly the root row for read/update/delete, no descendants and no create-placement, a leaf-lock) which is net-new, a grant on exactly one node. The purescope.Resolvegains aSelfIDsset (matched by id equality, never subtree-expanded; aselfgrant re-adds a root asubtree_excl_rootgrant stripped), and the three gateway walks (inScopeTree,InScopeIDs,scopedListSQL) gain a self arm. The migration recreates the dedup index to includescope_op(fixing a latent collision where two grants differing only by operator clashed) and threadsscope_opthroughRevokeGrant’s audit SELECT (previously dropped). The grant builder gains an operator stage (role -> kind -> entity -> operator, subtree pre-selected), and each scoped chip shows its operator glyph (= / ≥ / >), so #99 (setting the modifier from the console) ships here too. Proven by scope unit tests (self classification, self re-adds an excluded root, self-under-subtree is redundant), a storage round-trip (bad operator refused, a self grant persists alongside a subtree grant on the same root), a real-binary API test (aselfgrant reads and updates its node, descendants 404, list returns only the node), and web unit + component tests (operator in the grant identity, an operator change diffs as revoke + grant, staging a self grant sendsscope_op: self). The act-as scope intersection (#101) is not subsumed: it is plumbing, orthogonal to how a scope is expressed. -
Identity: owner accounts are un-impersonatable. A principal holding
owner @ allcannot be impersonated by anyone, including another owner, in either mode (ADR-0012): a target-side check in the:impersonatehandler before the mode branch (403), reusing the owner-invariant’srole='owner' and scope_kind='all'lane. The escalation guard (Covers) already blocked a lesser admin from an owner, butowner.Covers(owner)was true, so owner-impersonates-owner was possible; this removes the highest-trust-account takeover vector explicitly. Impersonate stays gated byprincipal:impersonateswept byprincipal:*(holding it already lets a caller create and use its own principals, so impersonate confers no new reach there). Act-as scope intersection (#101) is dropped: act-as stays all-scope-only. Proven by the real-binary API test (an owner cannot view-as or act-as another owner, 403; admin-to-owner stays 403; a non-owner target still works). -
Identity: a grant cannot exceed the granter. Grant creation is refused (403) when the granted role’s capabilities are not covered by the granter’s all-scope capabilities (
rbac.Set.Covers, ADR-0013), so no caller can promote anyone (including itself) above its own tier: an admin cannot grantowner(*:*) and therefore cannot self-promote to superuser.CreateGrantpreviously checked only all-scopeprincipal_grant:create, so admin could grant itselfowner@alland log in as a superuser; the guard lives in thecreate-granthandler, mirroring the impersonation escalation guard, and only all-scope grants count (a narrowly-scoped capability cannot be conferred estate-wide). The deliberate stance: admin is bounded on purpose, the top management role, not the superuser;owneris the break-glass superuser and the owner-invariant anchor. Proven by a real-binary API test (admin cannot grant owner to a user or itself, 403; admin can grant viewer/operator it covers; owner can grant owner). Role editing will need the same rule. -
Identity: the Roles view and role metadata. Roles gain a
display_nameanddescription(additive migration, seeded for the five official roles), andGET /rolesnow returns them plus each role’s effective permissions (flattened through the role index: inheritance, wildcard, and the:readfloor resolved). The console gains a read-only Roles page (Settings > Roles): a self-teaching catalog of each role’s display name, description, inheritance, and effective permissions, ordered by tier, rendered from the real seeded roles. The grant builder role picker gains a hover tooltip showing a role’s description and permissions, so an admin sees what a role grants while assigning it. No custom-role CRUD (a later slice; the ADR-0013 cover rule must extend to role editing then). Proven by a real-binary API test (owner effective set is*:*, admin’s is broad but not*:*, viewer’s includes*:read, operator inherits the read floor), storage round-trip, and web tests (the Roles page renders and tier-orders the seeded roles). -
Estate slice: per-type location icons.
location_typegains aniconcolumn (a glyph key, seededlandmark/building/layers/door-openfor the four official types, defaultmap-pin), projected onGET /types/location. The console resolves the key to an SVG and renders it as the leading glyph on every location in the tree, tinted the same hue as the type badge, so a campus reads differently from a building at a glance. The glyph rides a new reusableleadIconslot on the sharedListView, andresolveIconfalls back tomap-pinfor a key the console does not know, so the API can introduce a type icon without a coordinated release. Proven by a storage round-trip (the icon survives upsert and idempotent update), a seed test (the four shipped keys), a real-binary API test (the icon travels the wire), and a client unit (the resolver and its fallback). Deferred: per-location icon override, an operator icon picker (needs alocation_typewrite surface), and server-side key validation. -
Dev experience: an example estate on
make dev. Distinct from the three production seeding concerns the storage page names (schema migrations, boot seed, one-time backfills), a dev-only example seed, applied bymake devand never in production.internal/devseedembeds a fixture (a multi-site estate of three campuses,hq/east/airport, three sign-in-able users, and their grants spanningoperator@all,viewer@hqsubtree, anddeploy@eastsubtree-excl-root) and installs it through the Storage Gateway on the same trusted direct-DB lane asbootstrap, exposed asomniglass seed-dev. It is idempotent (a re-run ofmake devchanges nothing: locations are skipped by name, users by their taken-username error), so a fresh console comes up populated instead of empty and nobody hand-creates rows to demo a feature. Proven by a pure fixture-shape unit test and a testcontainer integration test (run twice: thirteen locations across three campuses, three users with password credentials, three grants of the expected scope, all stable across the second run), plus theseed-devcommand driven end-to-end against a real Postgres. The/ship-slicegate now requires any slice that adds a new operator entity to seed an example of it here. -
Identity: the auth event log. The
audit_logalready recorded every privileged mutation (withactorand, for impersonated actions,real_actor); this adds the read surface and captures auth events.GET /audit-log(newest first, filter by resource / verb, backward-paged bybefore) resolves each actor and real-actor to a username, and login / logout are now captured (resource = 'auth', via a non-transactional emit seam, since login is a read/no-tx path), and so are failed sign-ins: a wrong password on a real account islogin_failed(attributed to that principal) and a correct password against a disabled account islogin_denied(#97), while an attempt on an unknown username is not written (so scanning cannot flood the log). The console gains a read-only Audit page (Settings > Audit): every action and sign-in, with anas <actor>tag on anything done while impersonating. It is admin/owner-only:auditis a sensitive resource, so aviewer’s*:readdoes not conferaudit:read, only an explicit grant or*:*(a smallrbacsensitive-resource carve-out, ADR-0014). Proven by an rbac unit test (the carve-out:*:readdenied,*:*and explicit allowed), a storage round-trip, a real-binary API test (a login appears, a real-account failed login is attributed while an unknown-user attempt is not, a viewer is 403, the resource filter narrows, and an impersonated action carriesreal_actor), and web tests (the Audit page renders and marks impersonated rows). Deferred: a failed-login rate-limit counter. -
Identity: permissions are topic patterns. The capability matcher became a consistent NATS-style topic match (ADR-0015, superseding the ADR-0014 carve-out):
*matches exactly one token and>matches the tail, so a two-token pattern (*:read,*:*,principal:*) structurally cannot reach a three-token:adminpermission, and admin-sensitivity is a deeper token rather than a special case.audit:readbecame the admin-sensitiveaudit:read:admin(the audit route requires it),ownerbecame>, and thesensitiveResourcesset andgrantsAllhelper were deleted.Set.Allowsmatches by token (with the unchanged:readfloor);Set.Covers(the impersonation and grant-escalation guard) became pattern subsumption plus the floor, staying conservative. The one seed change isowner’s*:*->>; every other permission keeps its meaning (*already meant a single token). As a free consequence,principal:*no longer sweeps a future admin-tierprincipal:<action>:admin. Proven by an rbac unit matrix (*one token,>tail,*:read/*:*/principal:*miss:admin,>and explicit hit it, Covers subsumption), the whole authz suite (the viewer is still 403 on the audit trail, now structurally), and web tests (the Roles view renders>as an “everything” chip and marks an:admintier). The console Roles view and grant-builder render the new grammar. Foundational for a custom-role permission preview (a permission catalog). -
Identity: the audit page joins the list-view standard. The Audit page swaps its bare table for the console’s shared FilterBar faceted search (the same one the inventory lists use): filter by
who,action,resource, orid, chips combining to narrow, matched client-side over the loaded rows via thelib/predicateengine. Load older pages backward through the serverbeforecursor, so a filter that comes up short is a cue to load deeper. Proven by a predicate unit test (each facet, within-chip OR and cross-chip AND, the sorted value catalog) and a component test (the FilterBar narrows the rows, and load-older asks for events older than the oldest loaded). Deferred: a chip time-range facet (the sharedmatchOpcomparesgt/ltnumerically, so an ISO-timestamp facet needs a date-aware operator in the predicate primitive first). -
Console: the list surface splits into a shell plus swappable bodies.
ListShellowns the chrome every list wears (the FilterBar chip state and client-side predicate, the card, the error banner, a trailing action slot) and hands its body the filtered rows;FlatListis the flat body (a sortable table, an optional row-to-Drawer detail, an optional create Drawer, an optional footer). The Audit page becomes a thinFlatListconfig, behaviour-preserving. This is the primitive-first factoring of the four list idioms that had grown up (tree ListView, the Users split-panel, the Roles catalog, the Audit table): the tree pages move ontoListShell+ aTreeListbody, and Users ontoFlatList, in follow-up slices; Roles stays the read-only catalog until custom-role CRUD. Proven by the Audit component test (unchanged behaviour on the new primitives) and the existing web suite. -
Identity: principal groups (grant-by-group), backend. A
principal_groupplus membership, and a grant that targets a group (a nullablegroup_idon the oneprincipal_granttable, exactly-one-of principal/group). A member’s effective grants are its direct grants unioned with its groups’ grants in the grant loader, the single seam both the permission flatten and the gateway scope resolution already read, so an inherited grant scopes and flattens exactly like a direct one and no other code changes. Group CRUD, member add/remove, and group-grant assignment ship on the Gateway and the API (/principal-groups), gated by a newprincipal_groupcapability (admin getsprincipal_group:*); a group grant reusesprincipal_grant:createand the same escalation cover-check. Proven against real Postgres: a storage integration test (a viewer @ hq granted to a group resolves into a member’s read scope and permissions; removal and group-deletion drop it; dedup / duplicate-name / idempotent-add hold) and an HTTP end-to-end test (a no-access user gains read once added to a group holding viewer @ all and loses it on removal; management isprincipal_group-gated; an admin cannot group-grant owner). -
Collection slice (checkpoints 1-2): the storage tier and the node runtime. The first cut of the collection engine. Checkpoint 1 landed the storage tier: the
node,interface_type,interface,task,datapoint_type, andmetric_datapointtables (an idempotent dbmate migration), the reachabilitydatapoint_typecanon and theicmp/tcpinterface_types seeded at boot, a scope-safemetric_datapointwrite path, and the pure reject-not-project registry. Checkpoint 2 adds the node runtime:omniglass serverhosts an in-processnats-server(JetStream enabled) andomniglass nodeenrolls (create,POST /nodes/{name}:enrollmints the token,POST /nodes:claimexchanges it for the NATS credential), then over NATS pulls its worklist (og.v1.worklist.<node>request-reply, its enabled tasks plus aconfig_generation) and heartbeats (og.v1.heartbeat.<node>, the server stampslast_heartbeat_at). Per-node subject isolation is real: an in-process auth callback scopes each node’s NATS credential to its own subjects, proven by a negative integration test (node A cannot publish or pull as node B) against a real embedded server on an ephemeral port, plus a fake-seam unit test and the full enroll -> pull -> heartbeat round trip. A node is a first-classprincipalofkind='node'with a 1:1nodedetail table (name is the estate address the collection FKs reference) and a bearercredentialrow, reusing the human/service identity machinery. Deliberate thin cuts (ADR-0017): the credential is an interim shared secret stored as a bearer credential (the enrollment token doubles as the NATS password; nkey/JWT and the single-use bootstrap deferred), and the control plane is JSON over core NATS. Deferred to later checkpoints of this slice: the probes (tcp, then icmp), the protobuf telemetryEventover JetStream, the ingest consumer, and the API/CLI/UI surfaces (interface and task CRUD, the Nodes inventory and reachability panels). -
Collection slice (checkpoint 3): the reachability datapoint, end to end. The capability-primitive heart:
omniglass noderuns a real tcp reachability probe (collection.NewTCPDialer, closed over the socket boundary and proven by a real-socket integration test, not just a fake seam), ships the result as a protobufEventover JetStream (og.v1.telemetry.<node>), and thetcp.open/tcp.connect_timedatapoints land inmetric_datapointowned by the target component. Protobuf is new to omniglass:proto/og/v1/event.proto(Event+Datapoint, no gRPC service), generated by agen-protostage onmake gen. The server hosts anOG_TELEMETRYJetStream stream and a single durable consumer (og-telemetry-worker) that derives inline: it binds the owner server-side from the task’s interface (the node stamps no component identity), confines a node to its own tasks (an Event carrying another node’stask_idis orphan-dropped, no row written), applies reject-not-project (an unregistered datapoint name is dropped), and writes through the checkpoint-1InsertMetricDatapointspath (provenance=observed). Both invariants are negatively tested against real Postgres + a real embedded bus, and the whole path is driven end to end through the realserverandnodebinaries. Deliberate thin cuts (ADR-0018): the hot-path/async split collapses (the durable consumer is the at-least-once worklist, no raw-telemetry table or Postgres queue), and owner binding is the interface-prebind path only. Deferred: raw-Eventpersistence + replay/backfill, label-based multi-owner routing, the remaining probes (snmp, http), and the API/CLI/UI surfaces. -
Collection slice (checkpoint 4): the icmp (ping) probe, the second capability primitive.
omniglass nodenow also runs a real icmp reachability probe (collection.NewICMPPinger, unprivileged SOCK_DGRAM ICMP viapro-bing), emittingicmp.reachable(always present,1/0with areasonlabel) andicmp.rtt_avg(ms, absent when unreachable). It rides the checkpoint-3 pipeline unchanged: the same protobufEvent, JetStream stream, and owner-confining reject-not-project consumer carry it (the consumer does not branch on probe type). The capability question is the point of the primitive: a once-cached loopback self-check tells “this node cannot do ICMP at all” (an error, inconclusive, no datapoint) from “this target did not answer” (DATA:icmp.reachable=0with a down reason), and per the capability-wrapping doctrine a fake-Pingerunit test does not close the increment, a real-socket integration test (loopback echo + a TEST-NET-1 unreachable address wherereceived==0is data, not an error) is the closing gate. -
Collection slice (checkpoint 5a): the reachability verdict as a state. The first honest substrate for availability history. The node now computes the per-interface verdict
interface.reachable(up/down, the AND of the interface’s probe results) and emits it as a built-in state datapoint (seededdatapoint_typeatkind=state, domainup/down), riding the protostring_value. A newstate_datapointtable mirrorsmetric_datapoint(same owner exclusive-arc, same lineage CHECK) with a categoricalvalue textplus optionalvalue_json, and the Gateway gainsInsertStateDatapoints/LatestState/StateTransitions. The ingest consumer now routes by the registry kind (metric tometric_datapoint, state tostate_datapoint) after the unchanged owner-confinement and reject-not-project, so a foreign or unregistered state is orphan-dropped identically to a metric (negatively tested for the state path). The series is transition-only: the node remembers the last verdict per interface and emits only on a flip or first observation, and the ingest side re-guards by skipping a write whose value equals the latest stored value (the net for a node restart), proven by a negative test that repeated identical verdicts produce one row per transition, not per tick. Availability astime_in_stateover this state, and the operator surfaces that render the transitions, are checkpoint 5b. See ADR-0034. -
Collection slice (checkpoint 5b): the reachability surface, seen. The first datapoint read surface. A per-component read BFF
GET /components/{name}/reachability(permissioncomponent:read, scope-injected, so a viewer gets a 404 on a component outside its scope) composes, per interface, the latest verdict (LatestStateoninterface.reachable), the layer signals (LatestMetricontcp.open/icmp.reachableand their rtt), and the verdict’s transition history (StateTransitionsover a window). The operator console gains a Reachability panel on the component detail: one row per interface with a verdict pill (responding/down/stale/unknown, staleness derived at read time), an availability strip drawn from the state transitions (up/down over time, not a latency trend), and an expandable L3/L4 gate breakdown (ping and port, the inline probes this slice ships; the L7 app-layer gate lands with the snmp/http/ssh interface types) with the reason a down interface is down. It renders only real API fields. The availability-percentage SLI (time_in_stateover the verdict) still rides in with the health slice; the panel shows the transitions directly. Interface and task authoring (the “add check” affordance and the flat Nodes / Interfaces / Tasks pages) are checkpoints 5c and 5d. -
Collection slice (checkpoint 5c): the Nodes surface and enrollment. The first authoring surface for the node tier. The console
/nodesstub becomes a live Nodes inventory (the flat sibling of the tree lists, a config over the sharedFlatList): a row per collection daemon with a liveness status pill derived client-side fromlast_heartbeat_atagainst the server’s down window (OMNIGLASS_NODE_DOWN_AFTER, 90s:upwithin it,downonce stale,neverbefore first heartbeat), the relative last-heartbeat time, and a description, with name and status facets on the same FilterBar the other lists use. A row opens a detail Drawer (facts plus an Enroll / Re-enroll action), and New node creates a node then enrolls it (day-one: create, thenPOST /nodes/{name}:enroll). The minted enrollment token is a secret shown once: a modal reveals it in a monospace field with a copy-to-clipboard button and a clear “shown once, cannot be retrieved again” warning, and the token lives only for the modal’s lifetime (cleared on close, never written to the query cache,localStorage, or a log). The nav tab is gated onnode:read, create onnode:create, and enroll onnode:enrollvia the samecan()the sidebar and route guard read; the page renders only realNodeBodyfields. No API or engine change (the node routes already exist), somake genshows no drift. Proven by vitest: the data layer (list-envelope unwrap, create body, the:enrollcustom-method POST, the pure status derivation and its window boundary, the status facet catalog) and the page (a row per node with the derived pill, the create affordance hidden withoutnode:create, and the enrollment modal revealing the token, copying it to a faked clipboard, and clearing it on close). The Nodes page staysPartial: placement, assigned-task worklists, and node health beyond the heartbeat pill are still Design. Interfaces and Tasks authoring is checkpoint 5d. -
Collection slice (checkpoint 5d-api): the interface and task authoring backend. The operator CRUD over the two collection primitives that the reachability read and the node worklist consume. The Storage Gateway gains scoped
Create/List/Get/Update/Deletefor bothinterface(name, type, owning component, node placement, params) andtask(a content-addressed id over interface + mode + spec, so identical work dedupes), auditing every mutation in-transaction like the estate tiers; the Huma surface adds AIP CRUD at/interfaces,/interfaces/{name},/tasks, and/tasks/{id}, regenerated into the OpenAPI document, the cobra CLI (omniglass interface …/omniglass task …), and the typed SPA client. Both authorization layers apply on every route: aninterface:<action>/task:<action>permission (admin gainsinterface:*/task:*, operator keepscreate,update, the*:readfloor gives everyone read), and scope injected by the gateway. Neither primitive is a scope-tree entity of its own, so scope cascades through the owning component: an interface inherits its component’s read/action scope, a task inherits its interface’s component’s, reusing the component tier with no newscope_kind(an operator scoped to a component subtree administers exactly that subtree’s interfaces and tasks). A component-less (server-hosted) interface is reachable only under an all scope. An interface or task whose component is outside the caller’s scope is a non-disclosing 404 on read and update, and a create under an out-of-scope component is a 403, negatively tested at both the gateway and the API. No web surface yet: the flat Interfaces / Tasks pages and the “add check” affordance are checkpoint 5d-ui. -
Collection slice (checkpoint 5d-ui): the interface and task authoring surfaces. The console gains the two authoring pages the 5d-api CRUD feeds, plus the component-scoped Add reachability check affordance. The
/interfacesand/tasksstubs become live Interfaces and Tasks inventories (configs over the sharedFlatList, the flat sibling of the tree lists): Interfaces shows a row per endpoint (name, type, owning component, node placement, probed target derived fromparams) with name / type / component facets; Tasks shows a row per unit of work (display name or content-addressed id, interface, mode, an enabled pill, node) with interface / mode / enabled facets. A row opens a detail Drawer (facts plus an inline edit of the mutable fields and a delete), and New interface / New task open the create Drawer. Because there is nointerface_typelist endpoint, the type picker offers the built transports (icmp,tcp,ssh,http) this slice ships; a futureGET /interface-typesregistry route can replace the static options. The Add reachability check button on the Reachability panel (gated oninterface:createandtask:create) authors a check the way the node runs one: it creates the interface (type= the chosen transport, named by its protocol, owned by that component,params.targetthehost[:port]the probe dials) then a poll task over it (mode=poll, enabled), then invalidates the reachability, interfaces, and tasks queries so the panel and pages refresh. The two writes are handled honestly: if the task create fails after the interface exists, the affordance surfaces the partial state (the interface is created; the operator retries just the task) instead of swallowing it. Every nav tab and action gates on the real permission via the samecan()the sidebar and route guard read (interface:read/task:readtabs and URL guard; create / update / delete actions), and the pages render only realInterfaceBody/TaskBodyfields. No API or engine change (5d-api already ships the routes and typed client), somake genshows no drift. Proven by vitest: the two data layers (envelope unwrap, create / patch bodies, filter keys, the target derivation), both pages (rows, the create gate hidden without the perm, the type / mode / interface pickers), and the Add-check affordance (the gate needs both perms, submit fires the interface then the task create with the right bodies, and the two-step error path surfaces). This closes the collection slice’s authoring surfaces. (Superseded before the PR: checkpoint 5f removes the Add-check affordance, and 5g folds the standalone Interfaces and Tasks pages into the component and node details and derives the task, so these standalone surfaces and thetask:creategate do not ship; the flat pages became detail panels.) -
Collection slice (checkpoint 5e): the interface model reframe. Before its PR, the interface model was corrected: an interface is an API we intend to call (named by the protocol it speaks,
web/qrc, unique per component), not a network interface;interface_typeis the transport (the reach gate), not the protocol driver. Reachability is the first gate of a ladder (reach, auth, responds, collecting). The built transports grew fromtcp/icmpto also coversshandhttp(all reach by opening the tcp port; icmp pings), the Add-check picker separates the transport type from the protocol name, and the dev seed now models a lab polaris DSP with two protocol-named APIs on one device (webover http,qrcover tcp) somake devshows the “two APIs on one box” story. The driver layer that turns a device API into a normalized menu of datapoints and functions (OIDs, commands, parse) is a separate, later concern, decided in ADR-0035; this slice ships only the transport / reach tier of it. -
Collection slice (checkpoint 5f): reachability lands read-only; check authoring deferred. Before the PR, the component-scoped Add check affordance was removed: it was a stopgap that a proper driver-based authoring flow (attach a driver to a component, fill its inputs) replaces, and the driver/collect layer is under active design (template-centric vs driver-centric), decided deliberately rather than on momentum (ADR-0020 status note). So the reachability panel is now read-only display; a reachability check is authored by adding an interface to its component (checkpoint 5g settles the interface as the only authored primitive, its poll task derived, and folds the interface and task surfaces into the component and node details). The slice lands as remote node reachability: enroll a node, it probes, the operator sees per-interface reachability. The collect layer (drivers, the normalized menu, SNMP) is its own slice after the driver design. out of
TreeListinto a standaloneBladeStackprimitive over acreateBladeController, and both the inventory tree and the flat identity pages (FlatList) now consume it. A stack entry is a cross-entity{ kind, id }ref against a registry, so a user’s group opens the group’s blade over it and a group’s member opens that user’s blade, both stacking. Depth is bounded by construction: each page roots one kind and drills one direction (Users: user to group; Groups: group to user; Roles: role), so the drill graph is acyclic and the reverse relation on a terminal blade is read-only. A sharedDetailShell(Fact,RelatedList,DetailActions) standardizes the chrome, so a group’s members and a user’s groups render through one list idiom and every footer places the destructive action left of the primary. Roles moves from the card catalog onto the sameFlatList+ read-only blade. Behaviour-preserving for the inventory pages (the Locations blade is identical); proven by the web suite (theBladeStack,DetailShell, and both-way identity drill tests) and tsc. -
Console: the read -> Edit -> Save contract on a blade footer action bar. Every detail blade opens read-only; the header is chrome only (back, full-page, close), and a
createEditSlotper blade drives a sticky footer action bar thatBladeStackrenders: a destructive action on the left (always available), secondary actions in a⋯kebab, and Edit on the right (which swaps to Save / Cancel in edit mode). The body readsuseBladeEdit().editing()to switch its sections read-only vs live and registers the whole footer throughbind(editable,save,cancel,destructive,secondary), so editability and the destructive label follow the caller’s permission and a read-only blade (a role) registers nothing and shows no bar. In edit mode the profile becomes inputs, member add / remove stages locally, and theGrantBuildergains abindso its diff commits as part of the one blade Save; the destructive action (Delete for a group, Disable / Enable for a user, per the accounts-never-deleted invariant) confirms and is reachable straight from read mode. Save commits the staged session together and Cancel reverts. A single consolidated audit event and a config restore point (one backend action per Save) remain deferred to a batched endpoint; the user archive / purge lifecycle is its own slice. Proven by the web suite (the edit-slot, theBladeStackfooter-bar chrome, and the Group / User read-vs-edit tests) and tsc. -
Identity: the principal lifecycle (archive + purge), backend. Beyond the reversible disable, a principal can be archived (a soft delete,
archived_at: hidden from the directory, cannot authenticate, reversible) and then purged (an irreversible hard delete, gated on prior archival and on the admin-sensitiveprincipal:purge:admin, so admin and owner can purge but a two-tokenprincipal:*cannot reach it). The purge preserves the audit trail: everyaudit_logrow now denormalizes the actor’s label at write time, the audit foreign keys areON DELETE SET NULL, and the read coalesces the live join to the snapshot, so a purged actor’s history stays legible (ADR-0016, retiring the “never hard-deleted” rule). The last-active-owner guard extends to archive. Gateway (ArchivePrincipal/RestorePrincipal/PurgePrincipal), the:archive/:restore/:purgeAPI custom methods, and the generated CLI / client ship; proven against real Postgres by a storage integration test (lifecycle + audit-actor survival + owner guard) and an HTTP end-to-end test (the capability split, the archive-before-purge gate, soft-delete visibility). The console UI for the lifecycle is a follow-up (#143 backend). -
Identity: the principal lifecycle in the console, UI. The user blade’s footer action bar presents the lifecycle by state: the left slot is the reversible toggle (Disable / Enable, or Restore for an archived account), and the kebab holds the escalating red steps (Archive when live, Purge when archived and the caller holds
principal:purge:admin), each confirmed. The detail fetches the principal by id (not the directory list, which hides archived) so a just-soft-deleted user stays resolvable, and a Show archived toggle on the Users directory (a newinclude_archivedlist param) surfaces hidden accounts to re-find one.BladeSecondarygains a red tone and the destructive slot a restore tone; the webcan()already honours the three-tokenprincipal:purge:admin. This slice also renamed the soft-delete verb deactivate to archive (restore, not reactivate) so the ladder reads pause (disable) to remove (archive) to destroy (purge), instead of two pause-synonyms; the column, endpoints, capability, and list param follow. Proven by the web suite (the live and archived footer states and the show-archived flow) and a storage test for the include-archived list; completes #143 / #146. -
Identity: IAM console form + blade hardening, UI. A polish pass over the identity surfaces. Removing a principal now closes its blade cleanly: archiving, purging (and a group delete) closes the blade first, drops the dead detail query, then refreshes the list, so the blade never lingers on a stale or 404 view of the entity it just removed from the working set. Impersonation (start and stop) now reboots the console to Home (
window.location), so the permission-driven sidebar and route guard rebuild from the new identity rather than showing the previous principal’s navigation. The forms gain inline validation that mirrors the server’s Huma constraints: a username and a group name are lowercase handles (pattern^[a-z0-9][a-z0-9._-]*$, no capitals or spaces) and an email must be well formed (format: email), each enforced on the create body server-side and checked in the form so an invalid field shows an inline error and disables the submit before the round-trip (a newvalidgate on the blade edit contract disables the footer Save). The group create Drawer title now reads New group (it leaked theprincipal_groupresource key), the group create form gained the same subtitle the user form has, and the form placeholders are consistent across create and edit. A newly created user or group opens directly in edit mode so its grants (and a group’s members, child resources that need the parent id) are added in one flow rather than a second step. Proven by an API validation test (a malformed username, name, or email is a 422; a valid one is accepted) and web tests (the blade closes without an orphan refetch, and an invalid field blocks create and the footer Save). -
Identity: the Groups console + inherited-vs-direct grants, UI. A Groups page (
Settings > Groups, gatedprincipal_group:read) as a config over the sharedFlatList: list and create, and a row Drawer with member add/remove and the grant builder (reused, wired to the group grant endpoints, so a group’s members inherit what it grants). On the user detail, grants now split into direct (editable) and inherited from a group (read-only, taggedfrom <group>), so it is clear where a user’s access comes from; the principal grant body gainedgroup_id/group_namefor the distinction. Proven by the web suite and tsc, with the route guard and sidebar both gating/groupsonprincipal_group. This completes grant-by-group (#90); dynamic membership and SCIM group mapping remain deferred. -
Identity: password policy + generator. A single pure validator (
auth.ValidatePassword) enforces a password policy on the API password surfaces: create a user and self-service change-password. The direct-DB break-glass lanes (bootstrap,set-password) are exempt (fully trusted, DB-access-gated, the recovery path). The policy is at least 12 characters, not on an embedded common-password denylist, and not containing the username (NIST 800-63B style: length and a blocklist over composition rules). The API bodies raise theirminLengthto 12 and map a violation to 422 with a specific message; the console mirrors the length and username rules inline (a newpasswordError) and gates the submit, while the denylist stays server-side (a 422 on a manually typed common password). A sharedPasswordFieldcomponent adds a show/hide toggle and, on the New user and change-password forms, a crypto-strong Generate action (generatePassword, a readable charset with no look-alike characters, kept masked and copied or revealed on demand) plus a Copy button. Proven by aninternal/authunit test (short / common / contains-username / valid), an API test (a weak, common, or username-containing password is a 422 on create, a strong one is accepted), and web tests (the generator’s length / charset / policy-compliance,passwordError, and the Generate-fills-and-enables flow). Completes #104 and #151; an operator-configurable policy and a HIBP breached-password check remain deferred. -
Identity: admin password reset. An administrator can reset another user’s password without the user’s current one:
POST /principals/{id}:resetPasswordgated by a newprincipal:reset-passwordcapability (all-scope; admin holds it viaprincipal:*, so a future help-desk role can be granted only the reset), policy-enforced (a 422 on a weak password), refused on self (change your own password from your profile, which verifies the current one), behind the same takeover guard as impersonation (an owner cannot be reset by anyone, and a caller cannot reset a principal whose capabilities exceed their own, a sharedcheckTakeoverGuard, plus the sharedallScopeCoversall-scope-only cover that act-as uses so a reset cannot promote a narrow-scope capability estate-wide), and audited with the admin as the actor (the storage methodSetPrincipalPasswordsets by id, distinct from the self-serviceSetPasswordthat keys on username and audits the target). It also forces logout: a reset revokes every one of the target’s bearer credentials (all sessions and tokens) in the same transaction, so a compromised or departing account is cut off at once; the self-service change-password revokes the caller’s OTHER sessions (RevokePrincipalBearerskeeping the current session’s hash, stashed in the request context by authn). The console adds a Reset password kebab action on the user blade that opens an inline panel reusing thePasswordField(Generate, Copy, inline policy check); the set password stays copyable to hand over. This is the console counterpart to the CLIset-password(which, as a trusted direct-DB lane, stays policy-exempt). Proven by a storage test (the new secret authenticates and the old does not, the reset is audited as the admin, non-all scope and unknown id are refused), an API test (an operator lacks the capability 403, a weak / common / username-containing password is a 422, resetting yourself is a 422 and an owner is a 403, the admin reset lets the user sign in with the new password), and web tests (the kebab opens the panel and Generate + Set password calls the endpoint and confirms; the action is hidden on your own blade). -
Identity: bearer session expiry. Bearer credentials gain a nullable
credential.expires_at(a new additive migration), andAuthenticateBearerrefuses a credential whose expiry has passed (an expired session authenticates nothing). A web login installs its session cookie with a fixed absolute lifetime (12h), setting both the credential’sexpires_atand the cookieMax-Age, so a stolen session cookie is no longer valid forever; theIssueBearerCredentialgateway method gained anexpiresAtargument. CLI API tokens (omniglass token) and the bootstrap token pass a null expiry and do not expire, so automation keeps working. Proven by a storage test (a past-expiry bearer isErrCredentialNotFound, a future or null one authenticates). A sliding idle timeout and a background sweep of expired rows are deferred refinements; the security floor (expired is refused at auth) lands here. -
Identity: login brute-force lockout. Failed password logins are now throttled. Two additive columns on
human(failed_login_count,locked_until) count consecutive misses on a real account and, on the 5th, lock it for 15 minutes. Inside the windowAuthenticatePasswordrefuses every attempt (even the correct password) via a newErrAccountLocked, which the login handler maps to the same generic 401 as a bad credential so the lock is not an enumeration oracle (only thelogin_lockedaudit records it). The lock is decided after the argon2 verify (a locked account is not a faster probe), and a correct password below the threshold clears the counter. The decision itself is a pure, unit-tested function; proven end to end by a storage integration test (5 misses lock, the correct password is refused while locked, an expired window lets it through and resets) and a real-endpoint test (the lockout as seen overPOST /auth/login). Per-IP throttling and a configurable threshold are deferred. -
Identity: force a password change after an admin reset. An admin reset now sets a new additive
human.must_change_passwordflag; the user’s own change-password clears it. While set, theauthnchoke point (the same place view-as read-only is enforced) refuses every route with a 403 except reading their own principal and the change itself, so the admin-known secret cannot be used to act.GET /auth/mecarriesmust_change_password, and the console’sAuthGuardswaps the whole app shell for a forced change-password screen until it clears (auseChangePasswordthat invalidates/auth/mereleases the gate on success). Proven by a storage test (a reset sets the flag, a self-change clears it), a real-endpoint test (a flagged user reads/auth/mebut every other route is a distinct “password change required” 403 until the change, then the gate releases), and a web test (the guard renders the forced screen only when flagged). Enforcement is a hard block on all routes; the CLI break-glass lanes do not set the flag. -
Identity: address a principal by username or uuid. Every
/principals/{id}route (read, update, grants, the lifecycle verbs, reset, impersonate) now accepts either the principal’s uuid or a human’s current username. A new gateway primitiveResolvePrincipalRefresolves it (a uuid passes through unchanged, so an unknown uuid keeps its existing not-found handling; otherwise a username lookup, and an unknown username is a 404), and each handler resolves at the top, before any self-check (so addressing yourself by username is caught for a reset or impersonation). The uuid stays the stable identity; a username is a convenience address resolved at call time. Service principals have no username and stay uuid-addressed. This makes the CLI usable without a uuid lookup first (omniglass principal archive alice). Proven by a storage test (username resolves, a uuid passes through, an unknown username is not found) and an API test (get by username matches get by uuid, a:verbmethod archives by username, an unknown username is a 404). -
Tag: value-domain enums and existing-value autocomplete. A tag key can now constrain its values to an enum, answering the enum half of the value-domain open question. A new
allowed_valuescolumn ontag(empty = free text) is the value set a bound value must belong to;SetTagBindingenforces it (a non-member is a dedicated 422,ErrTagValueNotAllowed), soenvironmentcan be declared as one ofprod/staging/dev. The Tags directory create and edit forms gain a value-domain control (a checkbox that turns a key into an enum plus a value-list editor), and the TagAdder value stage renders a strict dropdown for an enum key. A free key instead autocompletes the distinct values already in use for it, via a newGET /tags/{name}:valuesread (select distinct value), so an operator reaches for an existing value without the key declaring a set. Pureweb/src/lib/tagdrafthelpers (isEnumKey,valueOptions,valueAllowed) and a pureinternal/tag(ValidateAllowedValues,ValueAllowed) hold the logic. Proven by unit suites on both pure cores, a storage integration test (the enum admits members and rejects non-members, a free key admits anything, narrowing the enum is enforced on the next bind, the distinct-values query dedupes and sorts) and an HTTP e2e (the enum 422, the values endpoint, the round-trip). A typedvalue_typeand input normalization stay deferred (ADR-0024). -
Tag: apply and remove tags from the entity detail blade (TagAdder). The capability that closes the set-side of the tag console: an operator now binds and unbinds tag values from the UI, not only the CLI. Each component, system, and location detail blade carries a Tags panel listing the tags bound directly on the entity as removable colored chips, plus a staged key -> value add row. The key stage autocompletes the registry filtered by
applies_tofor the entity kind and by what is already bound (a pureweb/src/lib/tagdraftcore:keySuggestions,exactKey,canCoin,valueValid); withtag:create, a Create key affordance opens the Tags directory’s create form (#192’sCreateTagForm, now exported) in a drawer and returns with the minted key selected. Writes are immediate (each is the entity’s own:setTag/:removeTagwrite, gated by its:update), so there is no separate Save; the affordances hide without the permission. The resolved cascade (inherited tags) stays in the directory Tags column, not the panel. Proven by atagdraftunit suite (applies_to filtering, already-bound exclusion, exact-match and coin eligibility, value validity) and aTagAdderrender test (chips, the update-gated add row and per-chip remove, the read-only and empty states). The full winner-plus-shadowed cascade provenance in the blade, the dynamic tag search facets, and a stored per-key color override are later slices (#189). -
Tag: the colored effective-tags column on the directories. The first VISIBLE tag surface, consuming the batch resolver: Components, Systems, and Locations each gain a Tags column rendering their effective tags (the resolved cascade winners, not just direct bindings) as
key = valuepills. Color is client-derived and needs no backend:tagHue(key)(a pureweb/src/lib/tagcolorFNV-1a hash into a curated, yellow-green-pruned 12-hue ramp) maps a key to a stable hue, so the same key is the same color everywhere; a sharedTagPillscomponent crosses only that hue into a new unlayered.tag-pillCSS recipe (text and outline are the seed, the fill is the seed at 15% viacolor-mix), with lightness and chroma in per-theme tokens so one hue reads correctly and stays contrast-safe in both themes. The chips stay on one line, fading at the edge when they overflow, and a portaled hover tooltip reveals the full wrapped set (awrapprop onTagPillsis the seam for a future per-table wrap toggle). The column registers in each page descriptor, so the columns menu shows, hides, and reorders it, and it sorts by key set. A stored per-key color override is a later slice; the type-to-add editor and tag search follow (#189). Proven by atagHuedeterminism unit test (stable, in-ramp, spread) and aTagPillsrender test (sorted chips, the--tag-hper key, the empty dash), plus the page-descriptor conformance matrix. -
Tag: batch effective-tags on the directory rows. The first slice of the tag-apply UI, backend only: the directory list routes (
GET /components,/systems,/locations) now carry aneffective_tagsmap (key to winning value, winners only) on each row, resolved for the whole page in one batched query per kind (Gateway.EffectiveTags), feeding the coming Tags column with zero per-row fetch. Effective resolution extends past the component: a location resolves the install-wide tier + its own location tree, and a system resolves it + its system tree + the location it is placed at (ADR-0022), so a system in a PCI building surfacescompliance: pci. Three per-kind recursive-CTE resolvers thread a target id through the ancestor chains and rank per (target, key); the resolver is scopeless by contract (the list already filtered the ids to the read scope, the rowActions batch pattern). Proven by a storage integration test (the four-band component cascade, the placed-system-inherits-location case, the non-propagating flat case, a batched shared-ancestor pair with no false cycle, and the empty/unknown edges) and an HTTP e2e (effective_tagson the component, system, and location list bodies). The Tags column, the type-to-add editor, and tag search follow in later slices (#189). -
Tag: the console key directory. The tag vocabulary gets its operator surface: a Tags directory under Catalog on the shared FlatList shell, where an admin mints a key (
tag:create), edits its governance fields (applies_to and the cascade-versus-flatpropagatestoggle,tag:update), and deletes it (tag:delete, cascading its bindings). Rows address by the key name (the write paths key on the name), and the create drawer plus the edit blade reuse the estate’s drawer and blade primitives, no fork. Binding a value onto an entity and the effective-tags cascade panel are the next console slice (#189). Proven by a data-layer unit test (the envelope unwrap, the create/update/delete request shapes, the error throw) and the full web suite, and live-verified againstmake dev(the directory, the create drawer, the edit blade). -
Tag: the governed label vocabulary and its cascaded bindings. The tag primitive lands as its own page: a governed
tagkey registry (a normalized lowercase-identifier vocabulary, minting gated by all-scopetag:create, broadened totag:*for admin), a per-entitytag_bindingcell owned on the same exclusive arc as a secret and a variable (platform | location | system | component), and a resolver that unions keys and overrides values most-specific-wins down the cascade. The governance split is the point: minting a key is admin-curated, but setting a value is the entity’s own write (component:updateand friends), so an operator tags what it may already edit with no new grant; a platform-tier binding istag:updateplusplatform:update. A key carriesapplies_to(an entity-kind allow-list, checked on bind) andpropagates(cascade inheritance versus a flat per-entity set, the shape a file reuses).GET|POST /tags,PATCH|DELETE /tags/{name},POST /tags/{name}:setPlatform|:clearPlatform(the platform-tier value), and per-entity custom methodsGET /{components,systems,locations}/{name}:listTagsandPOST .../{name}:setTag|:removeTag(bindings are entity custom methods, like the principal lifecycle, so the generated CLI stays collision-free), plusGET /components/{name}/effective-tags(the per-component cascade). Deferred (ADR-0021): the console surface (a Tags directory and per-entity editor, #189), binding via groups and atemplate-scoped binding (#184), value-domain governance (#190), and binding onto a file (#191). Proven by a pureinternal/tagvalidation unit test (key normalization, value bounds, the applies_to allow-list), a storage integration test (the registry CRUD and all-scope gate, the applies_to and value gates, the binding upsert and the owner-update scope split, the full union-on-key / override-on-value cascade incl. the non-propagating flat case, and the delete-key cascade to bindings), and an HTTP e2e (the cascade over the wire and the mint-versus-bind authz split: an operator binds on its component but cannot mint a key nor bind on a system it cannot write, a viewer reads but cannot mint or bind). -
Variable: the cascade-resolved free value. The second member of the config, secrets, and variables trio lands, after the secret. A variable is a typed plaintext value (a macro) owned on the same exclusive arc as a secret (
platform | location | system | component) and resolved most-specific-wins down the cascade, but shown in the clear (no crypto, no masking, no reveal). Typing is inline: avalue_typeofstring/int/float/bool/jsonplus a jsonbvalue, validated against the type in a pureinternal/variablepackage (no shape registry, unlike the secret).GET|POST /variables,PATCH|DELETE /variables/{id}, andGET /components/{name}/effective-variables(the per-component cascade view, since retired, #281). Reads ride the viewer floor; create and update are granted to operators (variable:create,update), delete staysvariable:delete(admin, owner), the same split the secret got. Scoping a variable required wiringvariableinto the ABAC resolver’s owner-arc tiers, exactly as the secret did. The console adds a Variables directory (type in its own column) with a type-aware value editor; a per-component effective-variables cascade panel also shipped and was later retired (#281, the panel-retirement note below). Deferred (ADR-0020): thetemplateowner scope and cascade groups (#184), avariable_typeregistry (types are inline), the$var:interpolation consumer, and the secret-flagged variable. Proven by avalue_typevalidation unit test (each scalar’s valid and invalid forms), a storage integration test (jsonb round-trip per type, full cascade precedence, the owner-scope split), an HTTP e2e (the cascade, the authz split incl. the scoped-operator create / update and the viewer 403s), a scope-resolver unit test for the owner-arc kinds, and the web data-layer suite. -
Secret: the cascade-resolved encrypted value. The first of the config, credentials, and variables trio lands, and the prerequisite for the collection driver’s interface inputs (#155). A secret is a typed, encrypted-at-rest value owned on the exclusive arc (
platform | location | system | component) and resolved most-specific-wins down the cascade. Its shape is asecret_typeregistry (per-field secrecy and origin;snmp-communityandbasic-authseeded); crypto is envelope AES-256-GCM behind a pluggable KEK provider (env / file / fallback), with(owner, name, field)bound as AAD so a ciphertext cannot be lifted between rows. The load-bearing new piece is the cascade resolver: it walks the three owner trees up from a component, tags each owner with a band and depth, and ranks per name (highest tier, then deepest), returning the winner and the shadowed candidates.GET /types/secret,/secrets(all-scope list, create, update, delete),GET /components/{name}/effective-secrets(the masked cascade view, since retired, #281), and the audited decryptsPOST /secrets/{id}:revealand:copy(a clipboard copy, recorded under a distinctcopyverb). Masked reads ride the viewer floor; create and update are gated bysecret:create/secret:updateand granted to operators in their scope; delete stayssecret:delete(admin, owner); reveal and copy by the sensitivesecret:reveal, which the*:readfloor does not carry, so only admin (secret:*) and owner may decrypt. Scoping a secret required wiringsecretinto the ABAC resolver’s owner-arc tiers (location / system / component), so a scoped grant now confers secret scope over its subtree (before, only an all-scope owner could). The console adds a Secrets directory (type in its own column) with per-field in-place reveal + copy adornments; a per-component effective-secrets list also shipped (each resolved secret opening a blade that decrypts the value and shows the full cascade top-to-bottom) and was later retired (#281, the panel-retirement note below). Renamed credential to secret (ADR-0017). Proven by a real-crypto testcontainer test (seal, jsonb round-trip, unseal; encrypted-at-rest; full cascade precedence), an HTTP e2e (the cascade, the authz split incl. the scoped-operator create / update, and the reveal / copy round-trips plus their 403s), a scope-resolver unit test for the owner-arc kinds, and the web data-layer suite. Deferred: the variable and config members of the page, the interpolation consumer, secret lifecycle (oauth2 refresh, rotation), and groups / weighted precedence in the cascade. -
Identity: clear the login lockout on a password rotation. Rotating a password now clears the brute-force lockout (
failed_login_count = 0,locked_until = null) in the same transaction as the new secret. Before this the lock (from the login-throttle slice) only cleared lazily at the next login, so an admin reset left the account locked for the rest of the 15-minute window even with the new password. Both rotation lanes clear it: the admin reset (SetPrincipalPassword, the reachable intervention while an account is locked) and the self-service change / CLI set-password (SetPassword). No new endpoint or capability: rotating the secret is the intervention (auto-unlock over a dedicated:unlockaction). Proven by a storage test that locks the account with five misses, confirms it is locked, rotates via each lane, and asserts the new secret authenticates immediately with no wait. No migration (the columns exist); no operator-facing surface change. -
Identity: profile pictures for human principals. A human principal can carry a profile picture, managed on two lanes: self (any signed-in user sets or removes their own via
POST /auth/me:setAvatar/:removeAvatar, authn-only and self-scoped, no capability, on the ungated self-service lane) and admin (a newprincipal:set-avatarall-scope capability sets or removes any principal’s viaPOST /principals/{id}:setAvatar/:removeAvatar, audited with the admin as actor;adminholds it throughprincipal:*andownerthrough>, so noroles.yamlchange). A pureinternal/avatar.Normalizeprimitive is the server-authoritative pipeline: accept JPEG/PNG/WebP (GIF and all else rejected), reject a payload over 8 MiB or any dimension over 8000px (decompression-bomb guards), center-crop to a square, resize 256x256, re-encode JPEG q82; a bad or oversize image is a 422. Two additive columns onhuman(avatarbase64,avatar_updated_at) store the one normalized size; the bytes never load on theloadPrincipalhot path (which selects onlyavatar is not null), so a cheaphas_avatarbool rides the read models (GET /principals/{id}, the Users list,GET /auth/me). The read endpoint is JSON (GET /principals/{id}/avatargatedprincipal:read,GET /auth/me/avatarself), returning{ image_base64 }the console renders as adata:URL, deliberately not a rawimage/jpeghandler so every route stays under the Huma authz middleware (ADR-0018); a principal without a picture is a 404. The console Profile page gains an image-backed avatar with upload/remove (initials fallback), and the Users directory renders per-row thumbnails plus an admin upload/remove panel gated onprincipal:set-avatar. The CLI and typed client fall out of the generator (omniglass principal setAvatar/removeAvatar). Proven by theinternal/avatargolden-fixture unit suite (a square JPEG out, non-image / GIF / oversize rejected), a storage round-trip (set / get / clear,has_avatarandavatar_updated_at, the all-scope gate on the admin write), and a real-binary API test (self set + read + remove, garbage 422, the admin permission 403 withoutprincipal:set-avatarand success with), plus web tests for both surfaces. Deferred: the top-nav avatar, non-human avatars, multiple sizes / srcset, and external (Gravatar) sources. -
Identity: view and revoke your own sessions (slice 1 of 2). A signed-in user can now see and end their own sign-ins and API tokens. Two gateway primitives land on the existing
credentialtable (no migration;created_atandexpires_atalready exist):ListBearerCredentialsreturns each of a principal’s bearer credentials with only non-secret metadata (id,ogp_prefix, created, expiry) and never selects the rawsecret_hashinto a returned field (the request’s ownsha256(token)is compared in SQL to flag thecurrentone, so the hash never leaves the database), andRevokeBearerByIDdeletes one scoped to the owning principal so a caller can only revoke their own. Over the API,GET /auth/me/sessionslists them (labelledsessionortokenby the credential’spurpose, the current one flagged) andPOST /auth/me/sessions/{id}:revokeends one (204); both are authn-only and self-scoped, so a credential id belonging to another principal is a non-disclosing 404, and revoking the current credential signs it out. The console’s Your profile gains a Sessions card listing each credential with a Revoke action (Sign out on the current one), through the generated typed client and TanStack Query. The generated CLI gainsomniglass session list/session revoke <id>. Proven by a storage test (list returns both with metadata and no secret and the current flag, revoke by id scoped to the principal, a cross-principal or malformed id is a no-op) and a real-binary API test (the current session is flagged, a second session is revoked and stops authenticating, another principal’s id is a 404, and revoking the current one signs out). The admin surface (an operator revoking another principal’s sessions) is slice 2. -
Identity: every credential is time-bounded, and sessions split from API tokens. Building on the slice above, the tokens-never-expire behavior is reversed (ADR-0019): a web-login session keeps its 12h absolute lifetime, while a CLI/API token (
omniglass token) and the bootstrap token (omniglass bootstrap) now get a 90-day default expiry with a--ttloverride hard-capped at 365 days (a--ttlabove the cap is a clean error, before any DB work), so no eternal secret sits in the field. Because both kinds now carry an expiry, a newcredential.purposecolumn (session/token), not the nullableexpires_at, is the discriminator; a migration adds it and backfills existing bearers (expiry set tosession, elsetoken).IssueBearerCredentialandBootstrapOwnertake the purpose and expiry;ListBearerCredentialsreturns the purpose and now filters to live rows only (expires_at is null or expires_at > now(), mirroringAuthenticateBearer), so a dead credential is never listed. The console’s Your profile splits the one list into a Sessions section and an API tokens section, both rendering a sharedSessionsListprimitive, and a token now shows its expiry. Enforcement stays lazy (an expired row is refused at auth, no background sweep). Proven by a storage test (the list carries the right purpose and excludes an expired row), an updated real-binary API test (session vs token asserted via purpose, the minted token carries a future expiry), and a CLI unit test (a--ttlabove the 365-day cap errors for bothtokenandbootstrap). Deferred: a sliding idle timeout, a housekeeping sweep of long-expired rows, and nearing-expiry notifications. -
Identity: an admin can view and revoke a user’s sessions (slice 2 of 2). An administrator can now see and end another principal’s sign-ins and API tokens, so a lost laptop or a leaked token is cut off without resetting the account. No new storage: it reuses slice 1’s
ListBearerCredentials(passing a nilcurrentHash, so no row is ever flaggedcurrentwhen viewing someone else) and the principal-scopedRevokeBearerByID. A newprincipal:revoke-sessioncapability (a normal two-token permission, held byadminandownerthrough theirprincipal:*/>wildcards, kept separable for a future help-desk role) gates bothGET /principals/{id}/sessionsandPOST /principals/{id}/sessions/{sid}:revoke. The revoke is bounded to the target (asidthat is not theirs is a non-disclosing 404), sits behind the same takeover guard as impersonation and the password reset (an owner’s sessions cannot be revoked by a lesser admin, nor a principal whose capabilities exceed the caller’s), and is audited with the acting admin as the actor. The console’s Users blade gains Sessions and API tokens sections (a sharedSessionsListnow backs both them and the self-service Profile card), hidden unless the caller holds the capability, with a Revoke per row. The blade’s action-rail kebab also offers Revoke all sessions and Revoke all tokens (each confirmed), bulk-ending one purpose at a time through a newPOST /principals/{id}/sessions:revokeAllbacked by a purpose-filteredRevokeBearersByPurpose(revoking sessions never touches tokens), returning the count and under the same gate, takeover guard, and audit as the single revoke. The generated CLI gainsomniglass principal sessions <id>/principal revoke-session <id> <sid>/principal revoke-all-sessions <id>(the cligennameOverrideseam groups them underprincipalso they do not collide with the self-servicesessioncommands, which share the leaf noun). Proven by real-binary API tests: an admin lists a target’s sessions and revokes one so it stops authenticating, bulk-revokes all sessions (both cookies stop authenticating, the tokens survive) then all tokens, an operator is 403, revoking an owner’s session or bulk-revoking its tokens as a lesser admin is 403 (the takeover guard), asidthat is not the target’s (or an unknown principal) is a 404, a bad purpose is a 422, the secret never appears, and the revoke is audited with the admin as actor. -
Identity: a password change keeps API tokens, and self-service bulk revoke. Two refinements to the session/token model. First, a password change now revokes sessions only, never tokens (#194): a token is its own bearer secret, not tied to the password, and has its own revoke surface, so both the admin reset (
SetPrincipalPassword, nowpurpose = 'session') and the self-service change (RevokeBearersByPurposeExcept(pr, "session", keep), keeping the current session) leave the target’s tokens intact. Second, the self-service Profile gains Revoke all on each of its Sessions and API tokens sections (#195), through a new authn-only, self-scopedPOST /auth/me/sessions:revokeAll(a{ purpose }body) that ends all of one kind at once but always keeps the credential making the request, so a user is never signed out of the one they are on; the generated CLI gainsomniglass session revoke-all. Both are built on one shared primitive,RevokeBearersByPurposeExcept(the plainRevokeBearersByPurposebecomes its nil-keep case, and the now-unusedRevokePrincipalBearersis retired). The admin blade also stops offering revoke on an owner target (whom the takeover guard makes un-revocable): its session/token list renders read-only, with a line explaining an owner can be seen but not ended. Proven by a storage test (the keep-current filter revokes the others and keeps the current + the tokens), an updated real-binary API test (a password change and an admin reset both leave the token authenticating while the sessions die), a new self-service bulk-revoke API test (all sessions except current, then all tokens, a bad purpose 422), and web tests (the Profile Revoke all posts the purpose, and an owner target hides every revoke). -
Identity: break-glass
set-passwordlocks out live sessions. The direct-DBomniglass set-password <user> <pw>(the trusted recovery lane, and the only way to touch a compromised owner, whose credentials the API guards leave un-revocable) previously rotated only the password, so a stolen session cookie or API token kept working after the reset (#198). It now also revokes the target’s live sessions (a break-glass is a lockout), and revokes its API tokens too with--revoke-tokens(kept by default, matching the password-change-keeps-tokens rule; a full compromise wants the flag). It reuses theRevokeBearersByPurposeprimitive afterSetPassword, resolving the principal by username; no API or console change (break-glass stays behind Postgres access). Proven by a real-Postgres CLI test: a live session stops authenticating after the reset, the token survives without the flag and is revoked with it, and the new password authenticates while the old one does not. -
Identity: self-service tokens, token descriptions, and session identification. Three coupled additions to the bearer-credential model. A signed-in user can mint its own API token from the console (a Create token action on the Profile API tokens section) or the API (
POST /auth/me/tokens, authn-only and self-scoped): a required description and an optionalttl_days(default 90, capped at 365), returning the secret once (#204, #205). A token now must carry a description (the CLIomniglass tokengains a required--description, the bootstrap token gets a default, a session leaves it empty), so a user can tell tokens apart. And every credential records the device and address that created it: three additivecredentialcolumns (description,user_agent,client_ip), captured by a middleware before Huma so both login and the token mint stamp them, plus the existinglast_used_atnow bumped on authentication (throttled to the minute). The session and token lists (self and admin) show a device label (a coarse User-Agent parse,Chrome on macOS;CLI / APIfor a token), the creating IP, and a last active time;IssueBearerCredentialis struct-ified to aBearerIssueto carry the metadata. Location from the IP (a GeoIP lookup, #193) is deferred: v1 is IP and User-Agent only. Proven by a storage test (the identity fields round-trip andlast_used_atbumps on auth), a real-binary API test (self-mint returns the token once, it authenticates and lists as a described token, a blank description or over-cap ttl is a 422), adeviceLabelunit test, and a Profile web test. -
Identity: the Users, Roles, and Groups directories move to the admin tier. The directory reads of
principal(list, get, and the profile-picture route),role, andprincipal_groupare promoted from a two-token<resource>:readto the admin-sensitive<resource>:read:admin, so theviewerread floor (*:read) no longer reaches the Users, Roles, and Groups pages, on the console or the API. This supersedes the earlierrole:read/principal_group:read/principal:readgates named in the slices above.admincarries an explicit<resource>:read:adminalongside its wildcards, the same shape asprincipal:purge:admin;owner’s>is unaffected, and create, update, and the lifecycle verbs stay two-token. Proven by a real-binary API test (a viewer@all 403s on/principals,/roles,/principal-groups; admin and owner 200), the nav filter’s web test (the three tabs hidden from*:read, shown to admin), and rbac matcher cases (ADR-0023). Secrets, which an operator legitimately reads in scope, are handled by a later slice. -
Secret: admin-sensitivity and a scoped, sensitive-off-the-floor directory. The second half of the visibility rework, for secrets. Two axes now decide who reaches a secret (#210): placement scope (unchanged) gives locality, and a new per-secret
admin_sensitivecolumn flips a secret to the:admintier, so a platform credential (a Zoom or Microsoft client secret) stays admin/owner-only even at the same scope as an operational device secret. The type carries adefault_admin_sensitivethat seeds the create form (a newoauth2-clienttype defaults sensitive,snmp-community/basic-authoperational).secretalso joins a sensitive-resource set a bare*does not reach, in both the direct match and the:readfloor (Go rbac and the clientcan()), so aviewer(only*:read) reads no secrets whileoperator/deploygain a scopedsecret:read,reveal,create,update;admin’ssecret:*becomessecret:>. Enforcement is acanAdmincapability computed at the API and passed to the Storage Gateway: the/secretsdirectory is now scope-filtered and hides admin-sensitive rows, and reveal/update/delete of a hidden secret is a non-disclosing 404; creating an admin-sensitive secret needs the admin tier. Proven by a real-binary API test (an operator sees and reveals its in-scope device secret but a non-disclosing 404 on the admin-sensitive one and an out-of-scope one, and is 403 creating a sensitive one; a same-scope admin and the owner see and reveal it), storage integration tests (the scoped list, the seeded type defaults), rbac matcher and covers cases, and the nav/can()web tests (Secrets hidden from*:read, shown to an explicitsecret:read). The move of Secrets, Variables, and Config into Catalog is a separate branch (ADR-0025). -
Tag: filter the directories by their effective tags. The last major tag piece, closing the set-and-see loop’s read half: the Components, Systems, and Locations chip filters gain a single tag field that discloses one facet per tag key in use, so an operator narrows a directory by any tag through one guided step (tag, then the key, then the value) rather than a top-level field per key. A tag facet reads a row’s effective value (a component matches on a tag it inherits from its system or location, not only a direct binding), autocompletes the values already in use for that key, and offers two new value-less operators, is set (
?) and is absent (!?), that test only whether the tag is present. These land in the sharedlib/predicateengine (anexists/absentOpKeycarrying avaluelessflag, threaded throughopsFor,matchOp,buildPredicate, andtokenToChip) so every FilterBar inherits them, plus atagFilterKeyshelper that projects oneFilterKeyper tag key present on the loaded rows; the FilterBar keeps those presence facets out of the top-level field list and groups them under thetagentry (a directkey:still works as a fast path).ListConfig.filterKeysbecomes accessor-reactive (aFilterKeys<T>is an array or an accessor, resolved in a reactive scope), so the facet set appears the moment the effective tags resolve and tracks whatever the estate is tagged with; each page merges its static facets with atagFacetsmemo derived from the rows. Deferred: server-side tag filtering at pagination scale (today the match is client-side over the loaded rows) and a stored per-key color override (#226). Proven by alib/predicateunit suite (the presence operators, the per-key facet projection and its dedup against the static keys, the value-less tokenizing) andFilterBarrender tests (thetaggroup discloses its keys, and a?/!?token commits a value-less chip that renders with no value button). -
Estate: the
typecapability resource gates the location/system/component type registries. The three classifier registries (location_type,system_type,component_type) gain full CRUD behind one new, capability-only, unscopedtyperesource, replacing the borrowedlocation:readgate onlist-location-types.type:readneeds no new grant (already covered byviewer’s*:readfloor);type:create,update,deleteis granted toadmin. A shared storage primitive (typeregistry.go) carries the sentinels and the delete guard: anofficial(seed-owned) row is read-only (422 on update/delete), and a row still referenced by a location, system, or component is refused on delete (409, a Gateway pre-count backstopped by the parent foreign key’sNO ACTION).system_typeandcomponent_typealso gain their firstlistroute (previously list-only forlocation_type, absent for the other two), retiring the console’s fake-from-existing- rows type picker workaround.secret_typeis untouched (list-only; its fields-schema editor is a separate follow-up). Proven by a storage round-trip per registry (create, duplicate id 409, update, official read-only, in-use delete refused) and a real-binary API test per registry (the CRUD lifecycle, 422 official, 409 in-use, auto-discovered 403 withouttype:*by the route-gating guard). The generated CLI and web client ship in the same slice (make gen). Deferred: the Types catalog console page (segmented tab per kind, CRUD drawer over the three writable kinds plus a read-only view ofsecret_type) is a follow-up slice. -
Console: nav IA rework, estate values get their own group and Settings becomes Admin. The sidebar’s
nav.tsgains a new Values top-level group (Variables, Secrets, Config), the estate-attached values that used to sit under Settings, standing beside Inventory (Components, Systems, Locations, and Nodes, the collection daemons) rather than nested inside it. Interfaces and Tasks are dropped from the nav: an interface is a panel on a component and a task is a panel on a node, both future work, not directories of their own. The Settings group is renamed Admin (Users, Roles, Groups, Audit) and gains an ungated soon Settings leaf reserving the platform-preferences page (severity scales, schedules, retention, defaults) (#222). Routes stay flat and unchanged (/secrets,/variables,/config,/nodes, the new/settingsstub), so only the grouping and labels move; this supersedes the earlier same-day plan to move those values into Catalog (decision log). Proven bynav.test.ts(the Inventory and Values group order and labels, Admin’s renamed label and its soon-stub surviving for both an owner and a bare*:readviewer, the moved entries keeping their existing gates) and aSidebar.test.tsxrender test (the Inventory and Values groups actually render in the expanded submenu). -
Console: the Types catalog page. The UI follow-up to the type registry CRUD API above: Catalog > Types (
/types) is a segmented tab control over all four classifier registries (location_type,system_type,component_type,secret_type), the first catalog page to span several registries in one page rather than a directory per primitive. Each tab (Location, System, Component, Secret) renders its ownFlatListdirectory scoped to that kind’s rows, so the tab is the facet: no cross-kind table and no kind filter to type in. Name and official/custom still narrow the active tab, and rows are addressed<kind>:<id>on the shared blade stack since an id is unique only within its own kind. The create drawer opens scoped to the active tab when it is a writable kind (location, system, component); an official (seed-owned) row and every row on the Secret tab render with neither Edit nor Delete, the Secret tab instead showing each row’s declared fields (name, scalar type, secrecy, origin) read-only, with a note that the fields-schema editor is a follow-up. The page reuses the shell built for the identity surfaces (FlatList,BladeStack, the blade edit contract) rather than a bespoke layout, so a fifth classifier registry is aTYPE_KINDSentry and a route, not a new page shape. Proven by a web data-layer unit suite (lib/types.ts): the four-registry aggregation tags each row’s kind, a location row carries its icon and a secret row its fields, and create/update/delete each refuse thesecretkind without a network call. Deferred: thesecret_typefields-schema editor (noted above) and a page-level component test of the blade CRUD flow. -
Inventory: create-as-route and the read-only view invariant. Creating a component, system, or location no longer returns you to the list.
Newnavigates to/<entity>/create, a draft accordion (Identity and Placement writable, the Tags section locked until the entity exists); Save commits the row and hands off to/<entity>/<id>in edit mode via a one-shot pending-edit flag, so you tag and finish configuring in place. The detail is one accordion, read-only in view and the sole writer in edit: the own-field inputs and theTagAdderwrite controls appear only in edit (the footer Edit/Delete and the read-only effective-secrets/variables panels, both since retired (#281), were exempt), which retires the old drawer-reopen edit and the mutation-in-view. This is the Users inline-blade-edit model generalised to inventory, on both the docked blade and the full page (ADR-0027, #231). The sharedTreeListgains a per-surface edit slot onListCtx(the full page makes its own, since the sharedrenderDetailmust not calluseBladeEditoutside a blade provider), plusrenderCreate/onNew/onEdithooks and an optionalFormBody. Proven by the live console (draft to create to edit hand-off, then a read-only fresh visit) and per-page web tests for the three entities (the draft route renders, the view has no tag-add control). Deferred: a shared cross-page form shell (slice 2) and moving the Users page onto it (slice 3). -
Estate:
rankretired from the type registries; alphabetical sort.rankwas sort-only (no nesting enforcement, per its own seed comment) onlocation_type,system_type, andcomponent_type; a new dbmate migration (ALTER TABLE ... DROP COLUMN IF EXISTS rank, idempotent) drops it from all three tables, andListLocationTypes/ListSystemTypes/ListComponentTypesnowORDER BY display_name, id. The field is gone from the three type bodies, the boot-seed YAMLs, the generated client and CLI, and the Types catalog page (no Rank column, no Rank field on create or edit). This is the mechanical precursor toallowed_parent_types(placement constraints onlocation_type), which lands as its own slice (#239). Proven by the storage round-trip per registry (alphabetical order, not insertion or id order), the boot-seed idempotency test (the lowest-alphabetical official row per registry), and a web test suite update (no Rank fixtures, no Rank assertions). -
Estate: technical-name rename with an inline name check. A component, system, or location’s technical name (its
name, the URL address) is now editable from the detail accordion in edit mode rather than fixed at creation. Becauseid(uuidv7) is the identity and every foreign key, tag/variable/secret binding, and placement references that UUID, a rename is a one-column update with no cascade. A sharedValidateEntityNameslug rule (^[a-z0-9][a-z0-9-]*$) is the server source of truth, mirrored client-side, and now gates create as well as rename. An inline Check button calls a collection-levelPOST /<entity>:checkNamethat reports{valid, available, reason}; availability is deliberately scope-blind to match the global unique constraint (a scope-aware check would false-positive on a name held outside the caller’s scope) and is gated by<entity>:update. The check is advisory: Save stays enabled and the unique constraint (409) is the real gate.make gencarries the new field and method through to the typed client and CLI. Proven by the rename round-trip per entity (a child or binding’s UUID foreign key still resolves after the parent renames, and the old name frees for reuse), dup-name 409, bad-slug 422 at both create and rename, the three checkName states, and a scope-blind test (adeployprincipal sees an out-of-scope name as taken, not available), plus per-page web tests for the editable field and the read-only view (#245). Deferred: an old-name to id redirect for bookmarked links (a soft 404 today), and wiring the check into the create draft’s live validation. -
Files: the content-addressed blob store and the file handle. The first files slice. A
blobstore lands as a Storage Gateway primitive: ablob.Storeseam with a default pgblobs backend (bytes held inline in Postgres), keyed by the sha256 of the bytes so identical bytes dedup to one row (on conflict do nothing), integrity-verified on read. On top of it, thefilehandle, searchable metadata (name, content_type, size, sha256, sensitive) that points at a blob by hash, gets CRUD over the API: create-from-upload hashes and dedups server-side, plus get, list, download (bytes read back and hash-verified), and delete (which frees the blob in the same transaction when no other handle still references it, dedup-aware, so storage is reclaimed; async mark-sweep GC of aged/event-referenced blobs is a later slice). Access is two layers with no new machinery: thefile:<action>permission, and a per-filesensitiveflag reusing the secret:admin-tier rule (a flagged file is hidden from a non-admin lister and a non-disclosing 404 to a non-admin reader, admin-only to create), defaulting off and leavingfileoff the sensitive-resource set so the viewer floor reads ordinary files. Files carry no placement scope (a file is tenant-wide; its 1:many locality is a future attachment, not an owner arc). The generated CLI (afilecommand group) and typed client follow from the Huma structs, and the Files directory ships under Values (upload, download, delete, a sensitive badge). Proven by the pure blob and file-validation unit tests, the pgblobs testcontainer round-trip and dedup gate (the capability-wrapping close), the gateway integration tests (round-trip, dedup, delete-leaves-blob, the sensitive gate), and a real-binary API e2e (upload to download identical bytes, the viewer/operator/admin permission and sensitive gates) (ADR-0029, #242, #244). Deferred: attach-to-entities-and-types (slice 2), tags-on-files (#191), async mark-sweep GC of aged/event-referenced blobs, the S3 and disk backends, and the classification lattice (#243). -
Estate:
allowed_parent_typesconstrains where a location may be placed.location_typegainsallowed_parent_types(text[], default{}, a new idempotent migration): a set oflocation_typeids and/or the reservedrootsentinel a location of that type may sit under. Empty is unconstrained (every existing custom type, unchanged); non-empty is enforced onCreateLocationand a new move primitive onUpdateLocation(aparentpatch field, cycle-guarded). A violation is a distinctstorage.PlacementError(wrapsstorage.ErrPlacementNotAllowed, carries the offending child and parent type names), mapped to a 422 that names both.CreateLocationTyperefuses the idroot, so a real type can never collide with the sentinel. The four seeded types ship their sets:campus={root},building={root,campus},floor={building,campus},room={floor,building,campus}. The Types catalog gains an allowed-parents editor (a checkbox list over the location types plus a Root option) on the location tab’s create and edit forms, and the location edit form’s Placement section makes Parent editable: a picker (riding #240’s inventory edit model, the sameShow when={editing()}split as every other editable field) narrowed to the type’s allowed parents and excluding the location’s own subtree, wired to the move primitive; moving back to root is not offered (ADR-0030, #239). Proven by a storage integration suite (unconstrained allows all, a listed parent and root both succeed, an out-of-order placement is refused on create and on move with the type names in the error, a cycle move is refused distinctly, and an existing noncompliant placement is untouched by an unrelated update until something tries to move it), a boot-seed idempotency assertion, and API/web tests (including the reparent picker’s candidate filtering and self-exclusion). Deferred:allowed_parent_typesforsystem_type/component_type, the same read-only-in-edit Parent gap on Systems and Components, and a drag-to-reparent affordance (this slice’s picker is a select, not a drag surface). -
Collection slice (checkpoint 5g): the derived interface/task model. The reachability primitives were reframed to their honest shape (ADR-0036): the interface is the only authored primitive and the task is derived. An interface is now named by its protocol (the name derives from its
interface_type, unique per component), so the create surface takes a type, not a free-text name. Creating an interface derives its one poll task, so the task surface is read-only: thePOST/PATCH/DELETE /tasksroutes and thetask:create/task:updategrants are gone, leavingGET /tasksandGET /tasks/{id}.task.node_nameis dropped and projected frominterface.node_name(the worklist and the telemetry owner-confinement now join the interface for placement), and a node purge cascades its interfaces and their tasks (interface.node_nameandtask.interface_idareON DELETE CASCADE). The console follows the entity model, folding the collection children onto their parents: the standalone Tasks page is removed and a node’s derived tasks read as a panel on the node detail, and the standalone Interfaces tab is removed and a component’s interfaces (with their reachability, add, and edit/delete) read as a panel on the component detail (an interface belongs to its component). The interface create form drops the name input (name derives from the protocol), the node detail moves onto the shared read-edit-save blade, and the collection pages route through the shared Button primitive. Storage, the API, the CLI, and the typed client regenerate from the reduced surface, somake genis clean. Proven by the reduced storage, API, and web suites over the derived model (an interface derives its task, the task surface is read-only, placement projects, a node purge cascades). This keeps the collection and nodes pagesPartial: the driver / collect layer (the normalized menu, SNMP, the$sec:/$var:interpolation consumer, templates) is still Design. -
Catalog: the
component_makemanufacturer registry. The first landed slice of a larger make/model catalog: a flat, seed-and-custom registry (id,official,display_name,icon,support_phone,website) naming who makes a component, on the same official-row-read-only pattern as the*_typeregistries. Full CRUD (POST/GET/PATCH/DELETE/list /api/v1/component-makes) gated by a new, capability-only, unscopedmakeresource (make:readsits in the viewer*:readfloor,make:create,update,deleteat the admin tier, mirroringtype:*); eight official makes (Crestron, Biamp, QSC, Shure, Cisco, Extron, Sony, Samsung) are upserted idempotently at boot. Thewebsitefield is validated to anhttp/httpsURL on create and update, both client and server side (a non-browser caller is refused with a 422), closing a stored-XSS path ajavascript:/data:value would otherwise open when the console rendered it as a live link; a value that still fails the check renders as plain text, never a dead or unsafe anchor. The console ships a Makes catalog page (FlatList+ blade, an official row read-only, same shape as Types) and the generated CLI (omniglass component-make list/get/create/update/delete). Proven by a storage round-trip (official read-only, duplicate id, alphabetical list), a seed idempotency test, a real-binary API test (the CRUD lifecycle, the website-scheme 422, the capability gate), and web unit and component tests covering the safe-URL render guard. Deliberate thin cuts: no in-use delete guard (nothing references a make yet, so a custom row deletes unconditionally; the referential 409 lands withcomponent_model), nocomponent_typegenus tree, nocomponent_model, and no picker wiring a component to a make. Deferred: the rest of the make/model catalog (epic #254, #255). -
Field: an operator-defined typed attribute on a type (slice 0). The first cut of the field primitive: an operator declares a typed field on a
component_type(afield_definition: aname, adata_typeofstring/int/float/bool/json, and an optional type-level default validated against thedata_type, unique per(component_type, name)), a component sets a literal for a field defined on its type (afield_value), and the component’s effective value resolves to the set literal or the type default (anis_setflag marks the override). The whole vertical shipped: storage (transactional, audited), the API (the definition catalog flat andfield:<action>-gated, the value routes ABAC-scoped to the owning component), the generated CLI and typed client, and the UI (define on the component-type blade under Types, plus an Effective fields panel on the component detail that sets a literal and shows override-versus-default). Owner is the component only: macro interpolation ($var:/$sec:/$datapoint:in a value), the cross-type cascade (product → location → system → component, deepest-wins), thesourcesmodel, typedfilefields, and definitions on the non-component_typeowners are deferred to later slices; the UI cannot yet clear a set value back to the type default (the clear route exists on the API and CLI but is not wired into the panel). The config, secrets, and variables page moves toPartialfor the field member. Proven by a storage integration test (definition CRUD and the default type-match guard on create and update, the effective set-or-default read and the not-applicable guard, and value update/delete reverting the component to the default), an HTTP e2e (the effective read, the override set path, and the viewer-reads / operator-sets authz split at thefield:creategate), and a scope-resolver unit test for the field arc kinds (a component-scoped operator resolves a subtree scope, a viewer resolves empty). -
Platform: the settings engine (install-wide level). The settings subsystem ships its first slice: a pure
settingspackage that resolves an effective configuration document by deep-merging ordered layers (embedded declared defaults, an optional operatorfile, and the install-wide DB override) most-specific-wins in JSON map-space, tracking per-key provenance and enforcing a top-down lock (broader level wins). The singlesetting_overridetable holds only the override level, since the base layers are recomputed in memory each boot and restore is therefore a delete (ADR-0033), and its Gateway methods are unscoped, gated by thesettings:<action>permission alone (ADR-0034), reusing the cascade primitive on the principal axis (ADR-0035). The API exposes an adminGET /settingswith provenance, a client-safe authn-onlyGET /settings/me, andPATCH/DELETE/POST /settings:restoreDefaultswrites undersettings:read/settings:update; the generated CLI and typed client follow. Twoprofile-domain namespaces seed (uiandkeybindings), andui.themeis wired end to end: the console reads its theme from/settings/me, so an admin setting the org theme default re-themes the SPA on next load. The Admin Settings page (namespace sections, provenance badges, lock chips, restore-to-default) replaces the/settingsnav stub. Proven by the pure engine unit suite (deep merge, RFC 7386 merge-patch, cascade resolution, lock enforcement, provenance), a testcontainer override round-trip (upsert, audit-in-transaction, delete-restores), an HTTP e2e (admin read with provenance, patch-then-read reflects the override,/settings/mereadable by a non-admin, the admin read forbidden to a viewer), and web tests (the page renders a provenance badge; the theme mapper). Deferred to the fast-follow: the group and user override rungs and the Profile preferences tab, thesettings:locksplit for group-admins,platform-domain namespaces (retention,integrations), a GitOps read-only mode, and live file reload (SIGHUP). -
Platform: typed settings (slice 1). A setting is now declared once as a field on a canonical
SettingsGo struct: reflection over itsdefault,enum,pattern, andsettings:"<domain>,<visibility>"tags builds the code-defaults layer and the namespace registry (the embeddeddefaults.yamland the hand-keptNamespaces()list are retired), so adding a setting is one tagged field with no second place to drift (ADR-0041). The cascade still merges partial maps, but the effective read unmarshals into the typed struct: the APIvaluesis now the typedSettings(the generated client getsvalues.ui.themeas a union), and Go code reads a setting offsettingsSvc.EffectiveTyped(ctx). Writes validate against the reflected schema (HumaSchemaFromTypeplusValidate): an unknown namespace is a 404, an unknown key or a value failing itsenum/pattern/type is a 422. Amake genstep slices those field constraints out of the OpenAPI intoweb/src/api/settings.schema.gen.ts, and the settings form validates each field inline against it (an enum renders as a select of the generated options, a bad value shows an inline message and blocks Save), with the server 422 as the backstop, so the console and the server enforce the same rules from one source.default_landinggains a^/pattern to make the inline validation demonstrable. Proven by the reflection unit suite (defaults coercion, registry from tags), the validator suite (unknown-namespace, unknown-key, enum and pattern violations, null-delete skipped), HTTP e2e (a badPATCHis 422 and leaves no override, an unknown namespace is 404), and web tests (the inline validator plus the form rendering the generated enum). Deferred: the declarative operator-file machinery (a generated schema for the operator file, file-layer validation, and letting the file layer outrank the database at the platform level). -
Field: an optional
display_nameon a definition. Afield_definitiongains a nullabledisplay_name, a human label threaded schema through UI: the console shows the label wherever it is set (the Fields editor on the component-type blade and the Effective fields panel), while the rawnamestays the unique key and the interpolation handle, so an unset label falls back to the key. The column is additive and idempotent, presentation only. Proven by the storage round-trip (the definition create and update carry the label, an unset label stores NULL), the HTTP e2e (the create body acceptsdisplay_nameand it round-trips through the catalog list), and the dev seed (the seeded display fields carry labels without changing row counts). The config, secrets, and variables page keeps itsPartialfield badge. -
Field: batched edit, upsert, and inherited-versus-set clarity. The Fields panel’s edit flow is reworked. Setting a value is now an idempotent upsert (
SetFieldValue): the first set creates, a later set patches in place (no more409 field already existson a second set), and a set to the unchanged value is a no-op, so theset-field-valueroute returns200and staysfield:create-gated. In the UI the per-field save is gone: a field’s edit stages a draft and the blade’s Save changes flushes every touched field (an upsert, or a delete for a cleared override) alongside the component core, through a newonSavecontributor on the blade edit slot. Edit mode now tells inherited from set: an inherited field is an empty input with a greyedunsetplaceholder, a set field shows its value with a clear (×) that stages a revert to the type default (replacing the revert arrow). Proven by a storage integration test (a second set patches in place, a same-value set writes no audit row), the updated HTTP e2e (the second POST upserts to200keeping itsvalue_id), and a blade-slot unit test (contributors flush before the primary save, and a throwing contributor aborts and keeps the blade in edit). The config, secrets, and variables page keeps itsPartialfield badge. -
Config, secrets, and variables: the per-component effective-secrets and effective-variables panels retire. The standalone Effective secrets and Effective variables panels on the component detail, and their
GET /components/{name}/effective-secrets/GET /components/{name}/effective-variablesroutes (with the generatedomniglass effective-secret list/effective-variable listcommands and the matching typed-client methods), are removed (#281, under the field epic #266). The panels listed every cascade-resolving cell that reached a component, mostly inherited noise; the field primitive is the schema-over-cells consumer, so a component’s values are now its fields (override versus type-default, the Effective fields panel), and a secret or variable reaches a component by being sourced into a field (the deferred fieldsourcesmodel) or bound to a collection interface input, not through a per-component cascade-browse panel. Kept: the storage cascade resolvers (ResolveSecrets/ResolveVariables) as the internal primitive the future$sec:/$var:interpolation consumer will call, and the Secrets and Variables directories (browse, create, edit, reveal) with all their routes and CLI. The config, secrets, and variables page drops the retired-panel claims and the decision log records the call. No schema change: the value cells, the resolvers, and the directories are all unchanged. -
Node identity and edit (N1). A node reaches parity with the component/system/location primitives: it gains a nullable
display_nameand an optionallocation(a descriptive placement referencinglocation(name),ON DELETE SET NULL, not a scope; a node stays estate-wide), added by an additive migration.namestays the immutable key and estate address.UpdateNodeandPATCH /nodes/{name}(gated a newnode:update, covered by the ownernode:*) patch display_name, description, and location, with an unknown location a 422 through the FK. The console blade becomes read-edit-save: the title is the display_name (falling back to the name), Edit is the primary action and Enroll / Re-enroll moves to the secondary kebab, the list row labels by display_name with the key and location as its subtitle, and the create Drawer carries the two identity fields (its enroll flow is unchanged). Proven by the storage round-trip (each patched field, name immutable, location set / clear, theON DELETE SET NULL), the real-binary API test (the PATCH round-trip, thenode:update403, the unknown-location 422, create mints no token in the body), and the web suite (the Edit action gated onnode:update, the editable fields, Re-enroll from the kebab, the labelled rows). Deferred to their own issues: the token-after-create flow (#287), node tags (#285), decommission (#286), and inline create for flat lists (a platform list-primitive concern). The nodes page staysPartial. -
Node tags (N2). A node becomes a taggable owner kind, alongside the install-wide tier / component / system / location. The
tag_bindingowner arc gains anode_idleg (an additive migration re-adding the two CHECK constraints;ON DELETE CASCADEso a node purge drops its bindings), and the governedapplies_toset can now includenode. A node is estate-wide, not a scope tree, so tagging it needs an all scope (node:update, reusing the identity gate) and its effective tags are the install-wide layer plus its own direct bindings, no inheritance. The generic per-entity tag routes grow the/nodes/{name}:listTags/:setTag/:removeTagmethods, and the node body carrieseffective_tags. The console node blade gains a Tags panel (the sharedTagAdder: read pills, add / remove in edit), and the node list a Tags column plus a per-key filter facet, the same shape as the component list. Proven by a storage integration test (the applies_to gate, the all-scope requirement, effective = install-wide- direct, unbind), a real-binary API test (bind / list / effective / unbind and the
node:update403), and the web suite. Follows node identity (N1); the tags page staysPartial. Deferred: setting tags during node create (an inline-create platform concern).
- direct, unbind), a real-binary API test (bind / list / effective / unbind and the
-
Node decommission (N3). The node blade gets its destructive action:
DeleteNode+DELETE /nodes/{name}(gated a newnode:delete, covered by the ownernode:*). It is a hard delete of the node’skind='node'principal, which cascades the node detail and, through it, its interfaces and their derived tasks, its node-owned tags and self-telemetry, and its enrollment credential, every referencing FK is alreadyON DELETE CASCADE, so no migration. A node is estate-wide, so it needs an all scope; the delete is audited, and the component telemetry the node collected (owner arc = component,node_idnull) survives untouched. The console blade adds a Delete action (confirm, then close and refresh), completing its cred-action set (Edit primary, Enroll / Re-enroll kebab, Delete destructive). Proven by a storage test (the cascade of interfaces / tasks / tags, the component datapoint surviving, the all-scope gate) and a real-binary API test (the DELETE and thenode:delete403). Closes the node-lifecycle arc (N1 identity, N2 tags, N3 delete). The nodes page staysPartial. -
Field: the override model. Field rendering converges on one generic primitive,
FieldControl(the field-facing sibling of theKVRow/KVStackedkey:value pair), consumed everywhere a resolved-value-with-override appears. It renders in two modes. Read is a slim one-line row (label left, value right) where an override reads with an accent dot on its key and its value in the accent colour, while inherited stays muted. Edit is a stacked cell whose key row carries a right-aligned Override switch: switch off inherits the resolved value (the type default this slice) with no editable input; switch on reveals a type-aware input seeded from that value, and revert is the switch off (no separate clear). This fixes the bool case the old renderer got wrong: inherited, a bool shows the resolved word (true/false) muted rather than a toggle you appear to have set, and override on gives a real editable toggle. A newfield_definition.requiredboolean (additive migration, default false, carried through the effective read) marks a required field with a red*, forces its override on, and gates the blade Save: the red input box and a “This value is required” label appear only after a submit attempt leaves the field empty, and Save is blocked while any required field is unfilled.EffectiveFieldsis the first consumer, now rendering every field throughFieldControland still batching each touched field onto the blade Save. The$source picker (variable / secret / file) and the symbol-plus-name display of a sourced value are drawn in the control but wired in a later slice; this slice sets literals only. Proven byFieldControlunit tests (read dot-and-colour and inherited-muted, edit switch-off-value versus switch-on-input and revert, the bool word-versus-toggle split, and the required marker plus the submit-only red box and Save gate), theEffectiveFieldsblade-batch test, and a storage/HTTP round-trip carryingrequired. The config, secrets, and variables page keeps itsPartialfield badge. -
The property catalog. The
datapoint_typecatalog is generalized into the primitive-agnosticpropertycatalog (the physical table; the concept, the/propertiesAPI, the GoProperty, and the console all readproperty, while a property’s identifier stays akey): the typed set of signals a datapoint observes and a field declares. The(scope, name)ladder collapses to anameprimary key plus anofficialboolean (seed-owned properties read-only);value_typebecomesdata_typeover{string, int, float, bool, json}(textbackfills tostring,booladded);kindis nullable (a declared-only attribute property has none); andvalidationis a JSON Schema enforced by Huma’s own validator with no new dependency. Value and source tables keep keying by the name string (no FK), so the rename is behavior-preserving: the ingest registry, the reachability BFF, and the metric/state sinks are unchanged. A newinternal/keyprimitive holds the one canonical name-format rule (lowercase, dot-hierarchied) and the typed value validator; tag’s key rule folds onto it. The Storage Gateway gains custom-property CRUD (gatedproperty:create/:update/:delete, official properties read-only, audited in the same transaction), exposed at/propertieswith a generated CLI and client, and a new Catalog > Properties console page (list, blade, create form). An official seed ships the reachability properties plus a starter attribute set (serial_number,mac_address,firmware_version,model_number). Proven byinternal/keyunit tests, thepropertyCRUD integration test, the/propertiesHTTP e2e (theproperty:create403 and official read-only), and the console suite. The type-schema (field_definition.key, PR-B) and reconciliation are deferred (ADR-0043, #297). The config, secrets, and variables page staysPartial. -
The component classification catalogs. The
component_makeregistry is generalized into avendorcatalog with akind(manufacturer/integrator/developer), and two new leaf catalogs join it as the rest of the component-classification reference data: adriver(id, display_name, version) and acapability(id, display_name). Each of the three reuses theofficial-boolean chassis the type and property registries prove: seed-owned official rows are read-only (update / delete refused 422), a custom row is full CRUD gated by the resource’s<resource>:create/:update/:deletepermission (admin gains<resource>:*, the*:readfloor gives everyone read) and audited in the same transaction, with official rows seeded at boot. All three ship end to end: the Storage Gateway CRUD, the Huma surface (/vendors,/drivers,/capabilities) regenerated into the OpenAPI document, the cobra CLI, and the typed SPA client, plus a gated Catalog console page each (/vendors,/drivers,/capabilities) with a list, detail blade, and create form, official rows read-only in the UI. Proven by the per-catalog CRUD integration tests (official read-only, the scoped permission 403), the HTTP e2e, and the console suites. This is PR2 of the estate-model shift toward property / event / command + vendor / product / driver / capability / standard / role / health;product,product_capability, andcomponent.productare the next slice (ADR-0044). The core entities page staysPartial. -
The product catalog.
productlands as the concrete SKU that ties the ADR-0044 leaf catalogs together: akind(device/app/service/vm), an optionalvendor_id(who makes it) anddriver_id(what talks to it), an optionalparent_product_id(a variant points at its base product), theofficialboolean, and the capabilities it provides through theproduct_capabilityjoin (a video bar provides microphone, speaker, camera, codec; a replace-the-whole-set update). It reuses theofficial-boolean chassis: seed-owned official rows read-only (update / delete 422), custom rows full CRUD gated byproduct:create/:update/:delete(the*:readfloor gives everyone read) and audited in the same transaction, official rows seeded at boot. The slice also addscomponent.product_id(on delete restrict), the pointer from a component to the product it is, making the product the source of a component’s shape and retiring thecomponent_type-as-shape notion; a product still referenced by a component cannot be deleted (409). It ships end to end: the Storage Gateway CRUD, the Huma surface (/products) regenerated into the OpenAPI document, the cobra CLI (omniglass product list/get/create/update/delete), the typed SPA client, and a gated Catalog console page (/products) with a list, detail blade, and create form (vendor, driver, parent, and capability pickers), official rows read-only in the UI. Proven by the product CRUD integration test (official read-only, theproduct:create403, an unknown vendor / driver / capability reference 422, and the in-use 409 when a component points at it), the/productsHTTP e2e, and the console suite. This is PR3 of the estate-model shift toward property / event / command + vendor / product / driver / capability / standard / role / health (ADR-0045). The core entities page staysPartial. -
The
eventlog-kind sink. The collection pipeline gains its third sink. A neweventtable is the log-kind sink (a past occurrence) besidemetric_datapoint/state_datapoint(a sampled present value), carrying the same datapoint owner exclusive-arc (owner_kindpluscomponent_id/system_id/location_id/node_id, one-set CHECK) and the same provenance (observed/calculated/intended/declared, defaultobserved), plus amessage(text) and structuredattributes(jsonb). A log-kind datapoint that the ingest consumer used to drop (it had no sink) now routes toevent:deriveDatapointsreturns metrics, states, and events, and the consumer callsInsertEvents, so a log ridesstring_value(its message) orjson_value(its attributes) under the same owner-confinement and reject-not-project gates as the other two sinks. A boot-seed propertysyslog.line(kindlog) is the canonical starter. The reservedevent_idcolumns onmetric_datapointandstate_datapointare closed into real foreign keys toevent(id)(on delete set null), so an intended-provenance datapoint references theeventthat produced it. Storage addsInsertEvents(batch, in-tx) andListComponentEvents(newest first); the read routeGET /components/{name}/events(list-component-events, gatedcomponent:read, non-disclosing 404 out of scope) returns the last 24 hours capped at 200, regenerated into the OpenAPI document, the cobra CLI, and the typed SPA client, and the component detail page gains an Events panel over it. Proven by the consumer unit tests (a log routes to the event slice, a metric/state still land in their sinks), theInsertEvents/ListComponentEventsstorage integration test, and the/components/{name}/eventsHTTP e2e (the newest-first window and the out-of-scope 404). With metric, state, and log all flowing, this is the P1 follow-up of the estate-model roadmap (ADR-0046); the datapoints and data collection pages stayPartial(thelog_datapointtable, theevent_typeregistry, and log-to-event promotion are stillDesign). -
The fields fold: the product contract and the property value store. The standalone fields feature retires: a field was never a primitive, it was a property with
declaredprovenance. Two tables take its place.product_propertyis the product’s declared-property contract (product_id,property_name, an optionaldefault_value, arequiredflag, unique per pair), replacingfield_definitionand its per-component_typecatalog;data_typeandvalidationare not duplicated, they stay on the property catalog.property_valueis the value store, carrying the same owner exclusive-arc asmetric_datapointandevent(owner_kindpluscomponent_id/system_id/location_id/node_id, one-set CHECK) plus aninstancediscriminator, aprovenance(observed/calculated/intended/declared, defaultdeclared), and a jsonbvalue; its series key isunique nulls not distinct, since the arc leaves three owner columns NULL and the default NULLS DISTINCT would let duplicates through. The resolverEffectivePropertiesis one SQL UNION: the contract arm (everyproduct_propertyof the component’s product, valuedcoalesce(the component's declared value, the contract default),from_contracttrue) plus the off-contract arm (declared values the contract does not declare), so a productless component still resolves, to its off-contract set alone. Six routes ship, regenerated into the OpenAPI document, the cobra CLI, and the typed SPA client:GET /products/{id}/propertiesandPUT/DELETE /products/{id}/properties/{property}(gatedproduct:read/:update/:delete, an official product read-only 422), andGET /components/{name}/propertiesplusPUT/DELETE /components/{name}/properties/{property}(gatedcomponent:read/:update, ABAC-scoped with a non-disclosing 404 out of scope, audited in the same transaction). The CLI readsomniglass product properties|set-property|delete-propertyandomniglass component properties|set-property|clear-property. The console renames the operator word from Fields to Properties: the component detail gains a Properties panel (contract rows, a dashed-bordered off contract group for the ad-hoc ones, an override toggle with an accent dot on an override, a required property blocking Save), and the product detail gains a Declared properties contract editor (declare, edit, withdraw, read-only for an official product). Retired with the feature:field_value,field_definition,component.component_type, and thecomponent_typetable with its/types/componentroutes, its console registry section, and its seed. A component’s shape now comes from its product (optional: a productless component simply has no contract), and the categorycomponent_typeused to carry (display, codec) is expressed by the capabilities that product provides. The seeded products ship a starter contract (cisco-room-barandsamsung-qm55declareserial_number,firmware_version, andmodel_numberwith defaults), androles.yamldrops the now-unclaimedfield:*permissions, sinceproperty:*already covers the tier. Proven by theproduct_propertyandproperty_valuestorage integration tests (the in-place contract upsert, a nil default round-tripping as SQL NULL, both resolver arms, the idempotent re-set on one series row, the clear-to-default, and the productless component), the/products/{id}/propertiesand/components/{name}/propertiesHTTP e2e (the official-product 422, the unknown-property 422, the out-of-scope non-disclosing 404, the withdraw-twice 404, and the clear back to the contract default), and the console suites for both new surfaces. This is PR5 of the estate-model shift (ADR-0047). No page badge moves: core entities and config, secrets, and variables both stayPartial(the cross-owner cascade, the non-declaredprovenance producers, and thestandard/location_typecontracts are still ahead). -
The
standardblueprint, the owner-generic resolver, and the template-fork seed model.system_typeis promoted tostandard: the blueprint a system conforms to, the system-side counterpart ofproduct. The renamed table gainsparent_standard_id(variants, mirroringproduct.parent_product_id) and a declared property contract, andsystem.system_typebecomessystem.standard_id, now optional, so a one-off system that conforms to no standard is first-class exactly like a productless component. The seeded rows carry over. Since a standard now owns a contract it is a Catalog entity, not a bare type registry: it leaves the sharedtype:*permission for its ownstandard:read/:create/:update/:delete(read on the viewer*:readfloor, the writes admin-tier, exactly likeproduct:*) and its routes move from/types/systemto/standards. Two contract tables joinproduct_propertyon the identical shape:standard_propertyandlocation_type_property(<classifier>_id,property_name, an optionaldefault_value, arequiredflag, unique per pair);data_typeandvalidationare never duplicated onto a contract, they stay in the property catalog. The resolver then generalizes toEffectiveProperties(ctx, ownerKind, ownerID, read), resolving component, system, location, and node off one parameterized SQL template driven by anownerContracttable (instance table, classifier column, contract table, contract key, arc column): component reads throughcomponent.product_id, system throughsystem.standard_id, location throughlocation.location_type, and a node, having no classifier, resolves ad-hoc values only. The two-arm shape is unchanged (contract armcoalesce(the instance's value, the contract default)withfrom_contracttrue, UNION the ad-hoc arm), so three classifier/instance pairs cannot drift apart.guardOwnerScopenow scope-checks every owner arc on a value write, not just the component one. The routes ship regenerated into the OpenAPI document, the cobra CLI, and the typed SPA client: the/standardsCRUD;GET /standards/{id}/propertiesplusPUT/DELETE .../{property}(gatedstandard:read|update|delete);GET /location-types/{id}/propertiesplusPUT/DELETE .../{property}(gatedtype:*, the registry CRUD staying at/types/location); and the value sidesGET /systems/{name}/propertiesandGET /locations/{name}/propertiesplus theirPUT/DELETE .../{property}(gatedsystem:*/location:*, ABAC-scoped with a non-disclosing 404 out of scope, audited in the same transaction). The CLI readsomniglass standard properties|set-property|delete-property,omniglass location-type properties|set-property|delete-property, andomniglass system|location properties|set-property|clear-property. The console gains a Standards catalog page with a Declared properties contract editor (the Products pattern), a contract editor on the location type blade, and a Properties panel on the system and location details (the component pattern: contract rows, a dashed-bordered off contract group, an override toggle, a required property blocking Save); the Types page drops its System tab. The seed model is the conceptual half of the slice: a standard and a location type are created by forking an in-code template, a one-time fork with no inheritance, so a template can be improved in any release because nothing in any tenant points at it. What lands is therefore operator-owned: shipped standards and the four shipped location types seedofficial: falsethrough seed-if-absent paths (SeedStandard/SeedLocationType,ON CONFLICT DO NOTHING), never the authoritativeUpsert*, whoseON CONFLICT DO UPDATEwould silently revert an operator’s edit on the next boot. Forking applies template -> standard, never standard -> system: a system conforms with live inheritance. The canonical catalogs are the exception and keep the authoritative upsert withofficial: true,propertyabove all, since it is the shared vocabulary a driver maps onto and a release must be able to correct it. Proven by thestandard,standard_property, andlocation_type_propertystorage integration tests, the owner-generic resolver tests across all four owner kinds (including the classifier-less node and one-off system), the seed regression test that edits a seeded standard, re-runs the seed, and asserts the edit survived, the/standardsand four property-route HTTP e2e suites (the official read-only 422, the unknown-property 422, the out-of-scope non-disclosing 404 on both the read and the write, the withdraw-twice 404, and the clear back to the contract default), and the console suites for the new surfaces. This is PR6 of the estate-model shift (ADR-0048). No page badge moves: core entities staysPartial(the cross-owner cascade, the non-declaredprovenance producers,system_membercomposition, and template pinning are still ahead), and API staysPartial. -
System roles, required capabilities, and the assignment guard. A system now declares what it needs filled.
system_roleis the slot (a table microphone, a main display), declared either on astandard, where every conforming system inherits it live, or directly on onesystem(ad-hoc, which is how a one-off system gets roles at all). Both owners ride the same exclusive arcproperty_valueuses (owner_kindplusstandard_id/system_id, a one-set CHECK, and aunique nulls not distinctkey over the arc columns and the role name, since the arc leaves one owner column NULL). A role carries aquorum(how many components should fill it, floored at one) and requires a conjunctive set ofrole_capabilityrows: a component must provide every listed capability. Two more tables complete it:component_capability(component_id,capability_id,present) is the component’s own capability facts layered over its product’s (present=trueadds one the product does not claim,present=falsesuppresses one it does), androle_assignmentrecords who fills the role in this system, with the component FKon delete restrictso a component staffing a role cannot be deleted out from under it. Two resolvers ship with them.EffectiveCapabilities(component)is the product’s capabilities UNION the component’s additions MINUS its suppressions, so a productless component resolves to just its own declarations; it is the single definition of “what this component can do” for the whole platform.EffectiveRoles(system)merges the inherited arm (from_standardtrue) with the ad-hoc arm, each role carrying its required capabilities, its quorum, its assignments, and the servedassignedandunderstaffedcounts, so no surface does the arithmetic itself. The slice’s decision is the guard:AssignRolerefuses (422) when the component’s resolved capabilities do not cover the role’s requirement, and the refusal NAMES the missing ones (component "panel-1" cannot fill role "table-mic": missing microphone, speaker, sorted so the same gap always reads the same way), a refusal on modeled grounds in the same class as the location placement constraint, and named for the same reason. Capabilities became a resolved set precisely to make that strictness survivable:productis optional on a component, so a guard over a product-only fact would have locked every productless component out of every role. Eight routes ship, regenerated into the OpenAPI document, the cobra CLI, and the typed SPA client:GET /standards/{id}/rolesplusPUT/DELETE /standards/{id}/roles/{role}(gatedstandard:read/:update/:delete);GET /systems/{name}/roles(the resolved read) plusPUT/DELETE /systems/{name}/roles/{role}andPUT/DELETE /systems/{name}/roles/{role}/assignments/{component}(gatedsystem:read/:update); andGET /components/{name}/capabilitiesplusPUT/DELETE /components/{name}/capabilities/{capability}(gatedcomponent:read/:update). Every system and component route resolves its owner within the caller’s scope first, so an out-of-scope target is a non-disclosing 404 on the read and the write alike, and every write is audited in the same transaction. The CLI readsomniglass standard roles|set-role|delete-role,omniglass system roles|set-role|delete-role|assign-role|unassign-role, andomniglass component capabilities|set-capability|clear-capability. The console follows the property pattern one tier up: a Roles editor on the standard blade (declare a role, set its quorum, pick the capabilities it requires), a Roles panel on the system detail (each role with its source, its staffing, an understaffed marker, and assign / unassign), and a Capabilities panel on the component detail (the resolved set, with the component’s own additions and suppressions marked against its product’s). The shippedmeeting-roomstandard declaresroom-mic(microphone + speaker, quorum 2) andmain-display(flat-panel-display, chosen so the shipped Samsung QM55 can actually fill it), seeded if absent on the operator-owned lane. Proven by theEffectiveCapabilitiesstorage integration test (the product’s set, an addition, a suppression, and the productless component resolving to exactly its own declarations), theEffectiveRolesand assignment test (both arms resolving, quorum 2 reading understaffed 2 then 1 after one assignment, the idempotent re-assign, the named shortfall on a display that provides neither microphone nor speaker, a productless component that declares what the role needs being staffed successfully, the unassign-twice miss, the unknown-role not-found, and the one-off system seeing only its own roles), the/systems/{name}/rolesHTTP e2e (the inherited and ad-hoc rows, the 422 that names the gap, the out-of-scope non-disclosing 404) plus the seeded-standard e2e, and the seed regression that retunes a seeded role’s quorum, re-runs the seed, and asserts the retune survived. This is PR7 of the estate-model shift (ADR-0049). No page badge moves: core entities staysPartial(a role’s impact on health,system_membercomposition, template pinning, the cross-owner cascade, and operational mode are still ahead) and API staysPartial. -
Health: the alarm-capability-role chain, and a verdict recorded as a transition. A system and a location now answer “is this working right now?” and “since when?”. An
alarmis component-local (aseverityofinfo/warning/critical, amessage, araised_at, and a nullablecleared_at, so clearing keeps the row and the record of what was wrong outlives the fix), andalarm_capabilitynames the capabilities it degrades. That naming is the only route out of the component: a component satisfies a role only when it provides every required capability and none of those is currently degraded; a role with fewer satisfying components than its quorum is impaired; an impaired role contributes the newsystem_role.impact(outage/degraded/none, defaulting todegraded, landing here because this is the slice that reads it); a system takes the worst contribution among its roles and a location the worst among the systems placed anywhere beneath it. The verdict domain ishealthy<degraded<outage, and the judgement is a pure package (internal/health) unit-tested with no database:Satisfies, the quorum boundary, worst-wins, and two deliberate safety defaults in opposite directions (an unrecognized impact readsdegraded, so a bad value never makes an impaired role silently harmless; an unrecognized recorded value readshealthy, so one stray row cannot paint an estate broken). The conceptual heart is how health is recorded. It is written transition-only ontostate_datapoint, which is already exactly that primitive (a row only when the value differs from the last one stored, andStateTransitionsreads the ordered flips the reachability strip draws), on the owner arc withprovenance='calculated'andsource_rule='health-rollup'; there is no new history table. Two alternatives were rejected for failing the requirement that the edges be accurate weeks later: compute-on-read keeps no history at all, and compute-and-write-through-on-read keeps a history sampled by whoever opens a page, so the recorded edge is stamped when somebody looked rather than when the estate changed. Instead the verdict is recomputed at the writes that can change it, in the same transaction: alarm raise and clear, assign and unassign, role declare and withdraw, a quorum or impact change, a component capability or product change, system create (which gives a system’s history a defined beginning), a standard change (which moves every conforming system), and a relocation (recomputing both the location arrived at and the one left, since that one may have just improved). A read never writes, and it computes the verdict it serves from the same resolved rows it displays, a correctness fix for a report that could otherwise sayhealthybeside an impairedoutagerole; recorded transitions stay the source for history. The slice also forced a latent bug into the open: recording an opening verdict at system creation gave every system astate_datapointrow from birth, and every rename then failed on the owner foreign key, because those FKs address the owner by name and declared noON UPDATE. Migration20260721170000re-adds all fourstate_datapointowner FKs withon update cascade, which is what name-as-address always meant; the same gap onmetric_datapoint,event,property_value,alarm, and the role tables is tracked in #314. Five routes ship, regenerated into the OpenAPI document, the cobra CLI, and the typed SPA client:GET/POST /components/{name}/alarmsandDELETE /components/{name}/alarms/{id}(gatedcomponent:read/:update, an unknown capability or bad severity a 422), plusGET /systems/{name}/healthandGET /locations/{name}/health(gatedsystem:read/location:read, scope-injected with a non-disclosing 404), each returning the verdict, the contributing roles with their degraded capabilities and the causing alarms (or the systems beneath, for a location), and the recorded transitions over the last 30 days. The CLI readsomniglass component alarms|raise-alarm|clear-alarm,omniglass system health, andomniglass location health, and the seed adds ahealthstate-kind property. The console carries a health badge on the system and location details and in the systems list, a Health panel that names the causal chain role by role (alarm -> degraded capability -> role below quorum -> verdict), a History strip reusing the reachability availability-strip shape, and an Alarms panel on the component (raise, clear, and a recently-cleared group). Proven by theinternal/healthunit suite (the quorum boundary, a degraded assignee dropping a role below it, worst-wins at both levels, the impact mapping, and the verdict round-trip) and the storage and HTTP integration suites (the transitions written through the whole chain, an irrelevant degraded capability changing nothing, the report naming the cause, impact and quorum, the moves on unassign / standard change / product change / relocation, the fresh system’s report, health surviving a rename, the alarm listing and its refusals, and the out-of-scope non-disclosing 404). This is PR8 of the estate-model shift (ADR-0050), and it closes epic #266: the slice that consumes what the previous seven built. Health advances fromDesigntoPartial(alarms raised by anevent_rule, system- and location-owned alarms, theunknownverdict, theglobalestate top, and the SLI / SLO / SLA and KPI tier are stillDesign); core entities and API stayPartial. -
System membership (
system_member): a component’s binding to a system is now a row rather than a write-once pointer nobody read, and a role attaches to it. Membership is many-valued, so a rack DSP serving three rooms is a member of all three, which the old single pointer could not say at all, andis_primarykeeps one answer for a question asked with no system in hand (a default for context-free callers, not a resolution rule; a partial unique index makes a second primary impossible). Staffing a role creates the membership, so the two can never disagree, while giving up a role leaves it, because a member carrying no role (a power conditioner, a spare) is ordinary. A one-time backfill reads both of the places membership used to be implied, the role table and the old pointer, since each holds components the other drops. The API addsGET /systems/{name}/members,GET /components/{name}/memberships,PUT/DELETE /systems/{name}/members/{component}andPOST /systems/{name}/members/{component}:setPrimary; the CLI addsomniglass system members|add-member|remove-member|set-primary-memberandomniglass component systems. Proven by the storage integration suite (the shared device in two systems, the first membership taking the default without being asked, a later one not stealing it, the default moving rather than duplicating, assignment creating the binding, removal refused while a role is still filled, the cascade from both ends, and the out-of-scope non-disclosing 404) and by a backfill test that executes the shipped migration file rather than a copy, covering the shared device, the pointer-only component, and both-at-once, and asserting a second run changes nothing. Resolution behaviour is a deliberate thin cut:component.system_idstays and keeps feeding the four cascade resolvers unchanged, so this slice ships and is verified on its own. Slice 1 of epic #324 (ADR-0051); core entities and API stayPartial. -
Membership-scoped resolution and the end of the component’s system pointer: the tag and variable cascades seed their system band from
system_memberrather thancomponent.system_id, and tag resolution takes the system to resolve against (GET /components/{name}/effective-tags?system=), falling back to the component’s primary membership when none is given. A shared device therefore answers differently for each system it serves, which the single pointer could not express at all. Secrets carry no system band, on ownership grounds: a credential belongs to the device, not to the room it serves.component.system_idis dropped; the component body now reportssystem(the primary, by name) andsystem_count, and the console reads both instead of joining uuids client-side. Proven by an integration suite written before the change, since a mis-seeded chain is valid SQL that silently returns fewer rows: the same component resolvingprodfor one system andlabfor another, the context-free read following the primary and moving when the primary moves, a component in no system resolving the other bands without error, a system the component is not a member of lending it nothing, and a system-owned secret never reaching a component. Slice 2 of epic #324 (ADR-0052); cascade and core entities stayPartial. -
The effective-values resolution view, resolved per membership: the component detail gains an Effective tags panel showing, per key, the winning value, the tier it won from (global, location, system, or the component itself) and the owner’s name, with the shadowed candidates struck through beneath it.
GET /components/{name}/effective-tagshad existed for some time with no console consumer, so the provenance it returns was being thrown away and the list’s flat pills were the only view of a resolved value. A system selector appears only when the component belongs to more than one system, and switching it re-resolves against that membership (?system=), so a shared device visibly answersprodfor one room andlabfor another. A component in a single system sees no selector and reads exactly as before. This closes epic #324: the engine became per-membership in slice 2, and this is the surface that teaches it. Cascade and core entities stayPartial. -
A name is the address (ADR-0053): the API accepted names on write and returned uuids on read, so a component created with
{"parent": "rack"}read back as{"parent_id": "0198f..."}and no response body could be replayed as the request that produced it. Every client had to fetch a second collection and join by uuid to render one label. Normalized on component, system, and location (parent,location) and on the tag, variable, and secret bodies, which already carriedowner_namebeside the redundant id. Enforced byTestResponsesAddressEntitiesByName, a guard over the generated OpenAPI that fails on any field naming another entity by uuid, and proven by an HTTP round-trip test on all three entities. Breaking at v0.0.0, deliberately. The schema stragglers (secret,variable,tag_bindingowner arcs) keep their uuid keys for now and are called out in the ADR. -
The owner arcs key by name (ADR-0055): the nine arc columns on
tag_binding,variable, andsecretconvert fromuuid references <entity> (id)totext references <entity> (name) on update cascade, matching every table from the collection era onward and completing what ADR-0053 deliberately left. The two conventions no longer meet inside a query: the cascade resolvers projected uuid chains purely to match these three tables, and each chain now projects the name while still recursing onparent_id. Proven byTestOwnerArcsSurviveARename, which binds a tag, a variable, and a secret at a location, renames it, and asserts all three still resolve; mutation-checked, since removingon update cascademakes the rename fail outright rather than merely orphan the rows.tag_binding.node_idkeeps its uuid (a node is addressed by its enrollment identity) andtag_binding.tag_idis tracked separately in #340. -
Every foreign key stores a primary key (ADR-0056): all 30 name-keyed foreign keys convert to the target’s primary key (a uuid,
principal_idfor a node), reversing the direction ADR-0053 set and ADR-0055 completed.on update cascadeis gone with them: it was write amplification across every referencing row to protect a key that did not need to be a name. The conversion also fixed a refusal, not just an inefficiency:interface.componentreferencedcomponent (name)with noon updateclause, so renaming a component that owned any interface failed outright with a foreign-key violation. Verified against the restored pre-conversion schema rather than argued. In exchange the API accepts either form wherever a reference is written (uuid tried first) and every response carries both the name and the id. Shipped in five slices grouped by subsystem so each file changed once; the last converts the collection tier (metric_datapoint’s four arcs,interface’s component and node arcs,node.location_name) and every remaining node reference. Proven byTestCollectionReferencesSurviveARename, which renames a component and a location under a live interface, task, and datapoint and asserts the node’s resolved worklist and the component’s interface list still come back; mutation-checked in both directions. Health keeps names internally on purpose, since its advisory lock hasheshealth/<kind>/<name>and a mixed currency would silently stop serializing. -
The cascade’s least-specific tier is
platform. One word,global, named two unrelated things: the install-wide binding tier and the singleton estate owner where health and KPIs roll up. The tier is renamedplatformon both axes, at exactly the rung it already occupied, anddefaultis documented off the axis entirely (ADR-0057). An idempotent migration rewritesowner_kindonvariable,secret, andtag_bindingplussetting_override.scope, with the check constraints and the partial unique indexes keyed on the value; no precedence changes, no rows are added, and every existing deployment resolves byte-identically after upgrade. The settings engine’scodelevel becomesdefaultand itsgloballevelplatform, the enum ridesmake geninto the OpenAPI, the typed client, and the CLI (tag setPlatform/clearPlatform,--owner-kind platform), and the console renders the tier and a declared default as what they are (“Declared default” carries no origin badge, because nobody set it). The new capability is authorization: a write at the tier now needsplatform:<action>on top of the resource permission, published per route asx-omniglass-platform-permissionand seeded toadminonly, so an all-scoped operator runs every site without being able to move the install-wide value under them.platformjoins the sensitive-resource set (withsecretandsettings) so a bare*:updatecannot reach it: install-wide authority is never implied by estate reach, in the rbac core and in the console’s mirror of it. The console gates every tier control on both halves, on all three surfaces that write at the tier: the Settings page, and the Secrets and Variables pages, where a caller without the tier half is not offered the Platform scope on the create form nor Edit / Delete on a tier row, and reads which capability is missing instead of meeting a 403. Breaking: a secret sealed at the old tier cannot be decrypted after upgrade, since the AAD bindsownerKind|ownerID|name|field(accepted deliberately, see the ADR). Proven by a testcontainer upgrade test (pre-rename rows resolve identically after the migration, the constraints and unique indexes still hold one row per identity), the settings resolver unit suite on the renamed levels, and an HTTP end-to-end authz test (an all-scoped role withoutplatform:*is refused at the tier and permitted below it, and the seeded roles carry the capability exactly where they should). The dev seed carries the no-root-location rule intomake dev: the example estate is a forest of three unparented tops with devices under two of them, so the location binding at HQ visibly misses the East campus and only theplatformvalue reaches both (proven by a pure fixture test and a seeded effective-tags assertion). A closing migration putssetting_override.scopeunder its own CHECK. It was the one tier column with no constraint behind it, which is how the rename passed a green suite while the Go layer still wroteglobaland orphaned every override in silence. The legal set is the levels that are persisted as rows, todayplatformalone:defaultis reflected off theSettingsstruct andfileis read from disk at boot, so neither can ever be a row, and the group and user rungs will widen the CHECK when they land. The cascade page moves toPartial: its binding chain ships for tags, secrets, and variables, while the template bands, group placement by weight, rule accumulation, and the resolve view stayDesign. -
The node API commands are reachable, and a guard keeps the CLI namespace honest (ADR-0058): the hand-written edge run mode and the generated
nodegroup both registered asnode, so cobra resolvedomniglass node listto the daemon and it failed asking for--token. Every generated node command was unreachable while the CLI guide documented them as working. The run mode is nowomniglass node run. The durable half isTestNoCommandNameCollisions, which walks the assembled tree and fails on any duplicate name: written first, it found the knownnodeandtype listcases plus two nobody had reported,grant createandgrant delete, where the principal-group variants shadow the principal ones and granting a role to a principal has no CLI path at all (#357). Those four are carried as an explicit list that may only shrink, since the guard also fails on an entry that stops colliding. -
The CLI naming rule is derived from the whole route, not the leaf noun (ADR-0059): every collection segment is a level, so a subresource is addressed under its owner (
component property list,principal grant create). The old leaf-noun rule produced 24 collisions across 195 operations (property listseven ways), each silently unreachable and each patched by hand afterwards:nameOverridehad reached 53 entries whose comments all described the same defect. It is now 14, all genuinely non-AIP/authroutes. The generator’s grouping also became a real N-level tree, since bucketing by the first word and naming by the last collapsed three-word paths back into collisions. 67 of 202 commands renamed, 135 unchanged, zero collisions.omniglass statu(the depluralizer eatingstatus) is gone. Two guards keep it: the collision test, whose known-collision list is now empty, andTestDocsOnlyNameRealCommands, which fails when a guide teaches a command that does not resolve and immediately found one that had never existed in any build plus two with no API route (#359). -
The
/typesumbrella is retired (ADR-0060): the location type registry was addressed two ways,/types/locationfor its CRUD and/location-types/{id}/propertiesfor its contract, so one entity had two command groups and an operator had to know both spellings. The registry CRUD moves toGET/POST /location-types,PATCH/DELETE /location-types/{id}, and the secret shape registry toGET /secret-types. The rule is now stateable in one line: a resource is one kebab-case noun, and nesting means ownership. Thetypecommand group is gone;location-type listis the registry andlocation-type property listits contract. Addressing only: same handlers, same permission gates, same scope injection, no schema change. -
“Latest” in a calculated series means the highest id (ADR-0061): a health row’s
tsisclock_timestamp()evaluated before its identity id is assigned, so two concurrent inserts can land with the two orderings disagreeing about which row is current. Every production reader already ordered by id; the health test helper ordered byts, which is what mademake testintermittently red with a verdict the engine never produced.LatestState, which backs the ingest transition guard, ordered bytsalone and now tie-breaks on id, so a poll cycle stamping several rows in one instant can no longer resolve to an arbitrary one. Reproduced deliberately (six concurrent copies of the storage package; never once in nine idle full-suite runs) and verified over 24 runs under the same load. -
The variable and secret cascades get their routes.
GET /components/{name}/effective-variablesand/effective-secretsresolve the same cascade the tag route resolves, returning the winner and the shadowed candidates with the tier each came from. The gateway resolvers had been written and tested since the cascade slice and were unreachable: no route, so no CLI command and no console read, while the CLI guide taughtomniglass effective-secret listas though it worked (#359, found by the docs-command guard). The two are gated differently on purpose: a variable read ridesvariable:read, a secret read ridessecret:read, which the viewer floor deliberately does not carry, and returns masked fields. The effective read answers which secret applies and where it comes from, never what it contains; plaintext stays behind the audited reveal, proven by a test that fails if the seeded plaintext ever appears. The CLI commands generated with no generator change (component effective-variable list,component effective-secret list), which is the parent-qualified naming rule paying off. Thin cut: the console’s resolution panel still shows tags only (#365). -
A list row carries both identities. Every estate entity has a key (
name, the kebab identifier the API and CLI address it by) and an optional display name. The console showed one or the other and never both, so the string an operator needs foromniglass component get <key>was invisible on every list but Nodes. The row now shows the display name with the key beneath it, muted and in the data face, always visible rather than on hover: hover does not exist on touch, is not discoverable, and cannot be selected to copy, and copying it is the point. When an entity has no display name the label is the key, so it renders once, in the data face, marking it as an identifier. Nothing is derived: sentence-casinghq-boardroom-dspgives “Hq boardroom dsp”, and this domain is acronyms (DSP, HDMI, NVX, PTZ, UC, AVoIP), so mechanical casing mangles them and makes an absent display name look like a typo rather than an absence. The rule now lives in one place (entityLabel), replacing nine copies ofdisplay_name || nameacross the node helper, three page mappers, two locallabelhelpers, and the owner and parent pickers. -
Create forms lead with the display name and derive the key as you type. An operator types “Conf Room 301” and the key fills in as
conf-room-301, editable underneath, instead of having to invent a kebab identifier and get the character class right.deriveKeyfolds diacritics (so “Café” iscafe, notcaf), collapses punctuation runs, trims leading and trailing separators, and respects the 100 character ceiling without leaving a trailing-; it only ever produces the empty string or a key matching the^[a-z0-9][a-z0-9-]*$the API enforces, asserted against that pattern directly. The moment the operator edits the key it becomes theirs and stops following, which is the rule that makes the pattern usable rather than infuriating, and the field says which state it is in. An existing entity’s key is owned from the start, so relabelling can never rename: the API takes a rename explicitly and it stays a deliberate act. The coupling is one primitive (createIdentity) rather than three copies of a signal pair, on Components, Systems, and Locations. -
The resolution panel reads all three cascades, in one list. It explained the tag cascade only; the variable and secret cascades had routes since the effective-values slice and no console read. All three are the same engine, so they share one table with a kind column rather than tabs or stacked sections: their bands genuinely differ (a variable seeds its system band from the primary membership, a secret has no system band at all), and one list makes that visible instead of hiding it behind a control. A secret shows its provenance and the word
masked, never a value: which credential a device resolves and from where is the operationally useful fact, and plaintext stays behind the audited reveal. The system selector appears only when a tag is in play, since it is the only kind that answers per-system. A caller withoutsecret:readsimply sees the other two kinds. Fixed on the way:bandLabelstill said global for the install-wide tier, months after ADR-0057 renamed it toplatform. Its test assertedfrom globalagainst a fixture whoseowner_kindwasglobal, a value the API stopped producing at that rename, so the label and its test were stale together and the suite stayed green. -
productandvendortake uuid primary keys (ADR-0062, slice 1 of #262): their kebab id becomesname, a unique renameable handle, and five inbound foreign keys move to the uuid (product.vendor_id,product.parent_product_id,product_capability,product_property, andcomponent.product_id). The registries were the last place a foreign key pointed at a mutable string, so a product id could not be corrected after a typo or a rebrand. The API now carries both forms on every body and accepts either wherever a product or vendor is named. Proven byTestRegistryHandleRenameKeepsReferences, which renames both handles under a live sub-product, contract, capability set, and component instance, and asserts every reference resolves and reads the new handle; mutation-checked. The remaining seven registries follow, and five of those are a rename ofidtonamerather than an addition, since the registries did not agree with each other on the column name to begin with. -
capabilityandstandardtake uuid primary keys (slice 2 of #262, ADR-0062): their kebab id becomesname, and eight inbound foreign keys move to the uuid,capabilityfromproduct_capability,role_capability,component_capability, andalarm_capability;standardfromstandard_property,system_role, its ownparent_standard_id, andsystem.standard_id. Every read that returned a capability set (the effective-capabilities resolver, the role requirement lists, an alarm’s degraded set, the health rollup) now projects the capability’s name, so the wire surface is unchanged while the storage key is a uuid. Two migration facts from slice 1 recurred and are worth the note: a renamed table keeps its old primary-key constraint name (herestandardstill answered tosystem_type_pkey), so the drop looks the name up from the catalog rather than guessing; and recreating a column drops the NOT NULL and the unique constraints it carried, so an unknown capability that resolves to null on a not-null arc is mapped back to the ref-not-found sentinel rather than surfacing as a 500. The rename test now renames a capability and a standard under a live product, role, and contract and asserts each still resolves and reads the new handle. -
propertytakes a uuid primary key, and telemetry keys become real foreign keys (slice 3 of #262, ADR-0062): the largest registry conversion.property’s kebab id becomesname, and its seven references move to the uuid: four contract/value tables (product_property,standard_property,location_type_property,property_value) that already referenced it, plus thekeycolumn onmetric_datapoint,state_datapoint, andevent, which had been a loose property name with no foreign key. Those three become realproperty_idforeign keys, so a rename now follows an observation series, not only a contract. Every telemetry key was already a registered property (reject-not-project drops an unregistered name at collection), so the constraint only makes the invariant the database’s as well. Every read still projects the property’s name askeyandproperty_name, so the wire surface is unchanged. Proven byTestRegistryHandleRenameKeepsReferences, extended to rename a property under a live contract, a declared value, and a metric datapoint, and assert the datapoint reads the new key. The remaining four leaf registries are slice 4. -
location_type,secret_type,driver, andinterface_typetake uuid primary keys (slice 4 of #262, ADR-0062): the last of the epic, closing the invariant that no foreign key anywhere points at a mutable string.location_type,secret_type, anddriverare a rename of their kebabidtoname;interface_typealready called its slugnameand only gains the uuid, aspropertydid. Five inbound foreign keys move to the uuid:location.location_typeandlocation_type_property.location_type_id,secret.secret_type,product.driver_id, andinterface.type. Every read still projects the type’s name (a scalar subquery aliased to the wire field), so the wire surface is unchanged while the storage key is a uuid, and the product body now carries adriverhandle besidedriver_idas it carriesvendorbesidevendor_id.allowed_parent_typesstays a text array of type names, not a foreign key, since it is a self-reference the placement validator resolves by name. Proven byTestRegistryHandleRenameKeepsReferences, extended to rename all four under a live location, a location-type property contract, a secret, a product, and an interface, and assert every reference resolves and reads the new handle; mutation-checked. With this slice every registry is uuid-keyed; retiring the slug-key carve-out in the doctrine and the guard allow-lists is the epic’s closing slice. -
The slug-keyed exception is retired (slice 5 of #262, ADR-0062, the epic’s close): a docs-and-guard slice with one real fix. The api-first rule now reads “every foreign key stores a uuid, no exception”; ADR-0056’s slug-keyed carve-out is marked retired. The
TestReferencesCarryBothFormsguard drops its slug-keyed allow-list: the registry references (product_id,vendor_id,driver_id,standard_id, and the two parents) move into the both-forms rule, and emptying the allow-list surfaced a real gap, the component response carriedproduct_idwithout the product’s name, now fixed by adding theproducthandle to the component body (the guard’s whole purpose, catching a uuid-only reference). The storage helper collapses in step: the per-registryproductRefCol/vendorRefColand theregistryHandlesset fold into oneregistryRefCol(ref), since every registry behaves identically now. Epic #262 is complete: no foreign key anywhere points at a mutable string. -
role(IAM) takes a uuid primary key, the last named entity still keyed by its slug. It gains a uuididand demotes the slug to a unique, renameablename, and its one inbound reference,principal_grant.role_id, moves to the uuid. The RBAC engine is unchanged: it keys roles by name and expandsinheritsby name, so the well-known handles (owner/viewer/operator/deploy/admin) stay stable and the storage layer resolvesrole_idto a name on read and a name (or uuid) to the uuid on write. The owner invariant, previously holding the literal slug'owner'in its trigger, now resolves owner by name ((select id from role where name = 'owner')), and the roles view and grant bodies carry the role’s name beside the uuid. With this every named entity in the schema is uuid-keyed; the only non-uuid identities left are deliberate (node.principal_id, the polymorphicscope_id/resource_id, content-addressedblob/task). -
The type-registry references carry both forms: the
interface,location, andsecretbodies gainedtype_id/location_type_id/secret_type_idbeside their name (type/location_type/secret_type), which were previously name-only, an inconsistency with the both-forms rule the epic enforces everywhere else.TestReferencesCarryBothFormsnow guards both directions: the forward scan already caught a*_idwith no name; a reverse pass over a curated set of unambiguous registry name-fields (location_type,secret_type) now catches a registry handle with no id, closing the gap that let those name-only references slip past (the forward scan has no*_idfield to trip on).typestays forward-covered on the primary interface body alone, since it also names an RFC-9457 error type and a secret field’s data type. Theproperty_namecontract and value bodies are a follow-up. -
The interface’s registry field is renamed
typetointerface_type(withinterface_type_id), matchinglocation_typeandsecret_typeso the interface reference reads uniformly and can join the guard’s reverse set (a baretypecould not, since it also names an RFC-9457 error type and a secret field’s data type). The create flag becomes--interface-type. The diagnostic reachability row keeps itsinterface_typename-only (a display value never fed back to a write), recorded as an explicit reverse-check exemption. -
The declared-property contract and value bodies carry
property_idbesideproperty_name, the last name-only registry references: the product / standard / location_type contract lines, the component / system / location value bodies, and the effective-property resolution.property_namejoins the guard’s reverse set, so a contract or value body can no longer name a property without its uuid. The event body carriesproperty_idbeside itskeytoo (keyis the datapoint-key vocabulary for the property name on the telemetry bodies): the event row already storedproperty_idexactly as metric and state do, so exposing it is just a projection. The guard acceptskeyas property_id’s name pair on the telemetry body;keystays forward-covered rather than joining the reverse set, since it is also a tag-binding field and so ambiguous. Every registry reference in the API now carries both forms. -
The migration chain is collapsed to a single init. The 69 per-slice migrations that carried the schema from the first commit through the uuid-key epic are squashed into one
db/migrationsfile that reproduces the current schema exactly. Verified by round trip: a fresh apply of the full chain and a fresh apply of the lone init produce a byte-identicalpg_dump. It is pure DDL (no seed rows, which a squash drops), reuses the original initial migration’s version so an existing database treats it as already applied, and carries a realdownthat drops every table. The workflow is unchanged going forward: the next schema change is still a new migration on top, never an edit to this one. One migration-history test that rolled the database back to a now-absent version was removed; the resolution behaviour it checked is covered by the cascade suite. (The stale constraint names the renames left,component_make_*onvendorand so on, are preserved faithfully by the squash; renaming them is a separate cosmetic pass.) -
The observation tables lose the
_datapointgenus:metric_datapointbecomesmetricandstate_datapointbecomesstate, matching their bare-noun siblingeventon the principle that the noun is the entity and classification takes a_type/_kindsuffix (thedatapoint_typeclassifier already becameproperty). Each table name now equals theproperty.kindvalue it stores (kind='metric'tometric), and the freed namedatapointstays available for the future UNION view over the kind-tables. Verified collision-free (not Postgres keywords, no existing table or column namedmetric/state/log). Dependent object names (pkey, sequence, indexes, FK and CHECK constraints) keep their_datapointidentifiers for now, as the#262renames leftvendorandstandard; the pending migration collapse cleans every stale identifier in one pass. Table-only rename, no data or wire change. -
The stale schema identifier names are cleaned, and two role tables disambiguated. The collapse preserved every dependent object name verbatim, so the single init carried constraints, indexes, and sequences whose prefixes no longer matched their tables:
metric_datapoint_*andstate_datapoint_*on the renamedmetric/state,component_make_*onvendor,system_type_*onstandard,datapoint_type_*onproperty. Each is brought back in line with its table, and the#262artifact is fixed too: every registry had a*_uuid_id_not_nullon its uuididcolumn while the text handle kept the old*_id_not_null, now*_id_not_nullonidand*_name_not_nullonname. Separately,role_capabilityandrole_assignmentbecomesystem_role_capabilityandsystem_role_assignment: both belong to the AV system role (a slot a component fills in a system), not the IAMroletable, and the barerole_prefix invited exactly that confusion. Identifiers only, no behaviour change: the init applies clean and the full suite is green. -
The name foundation of ADR-0063 lands:
property_type,property,property_type_id. The signal-definition registry (long carried in the docs asdatapoint_type, and built asproperty) becomesproperty_type; the latest-value storeproperty_valuetakes the freed bare nounproperty; and every data table’s FK to the registry (metric,state,event,product_property,standard_property,location_type_property, andpropertyitself) is repointedproperty_idtoproperty_type_id, on the wire as well as in the column. A guarded dbmate migration does the table and column renames (the stale dependent object names are left for a cosmetic pass, as the collapse did); the storage structs (PropertyType,Property), the API bodies, the generated OpenAPI, typed client, CLI, and JSONSchema, and the console all follow. This closes thedatapoint_type-to-propertydivergence the docs carried: the registry isproperty_typeeverywhere. The rename is complete, not a thin cut: the public resource surface moves too (the registry isGET/POST /property-typesunder theproperty_type:*permission, while the owner-scoped/{owner}/propertiesvalue routes stay), the both-forms name pair reads(property_type_id, property_type_name), the audit resources areproperty_type/property, and a second migration block renames every dependent constraint and index in line with its table. The fuller ADR-0063 model (the provenance-keyed cache, theevent_typefamily, thecommandpillar) is still staged.