Decision log
The architecture pages are written in the present tense as the target design, and each carries a status badge that says how much of it is built (implementation status). Neither axis carries history: why a call was made, when it was reversed, or why the shipped code differs from the page that describes it. That is what this log is for.
A page tells you what the design is and how much is built. This log tells you how it got there: the decisions that bind the design, the ones that were reversed in the open, and the points where the implementation has deliberately (or accidentally) drifted from the prose. It is the project’s architecture decision record (ADR), kept lightweight and append-only.
How it works
Section titled “How it works”- One entry per decision that reverses a prior call, settles an open question, or records a point where the build diverges from a page’s present-tense design.
- Each entry carries a date, a status (
Proposed,Accepted, orSuperseded), the decision in one line, the context that forced it, and the page(s) it touches. - A divergence entry is the partner of a page’s inline note: the page says what is true now, this log says why and when it diverged, and which issue tracks closing the gap.
- Entries are never edited away. A reversed decision is marked
Supersededand points at the entry that replaced it, so the trail of reasoning survives. Nothing in this log is deleted when a page changes. - New reversals and divergences are added per slice, as part of the ship gate: if a slice changes a settled call or ships something that differs from its architecture page, the entry lands in the same PR.
This log was seeded on 2026-06-30 from the first architecture-drift review, which backfilled the entries below from the project’s history. From here it grows one slice at a time.
| ID | Date | Status | Decision |
|---|---|---|---|
| ADR-0001 | 2026-06-27 | Accepted | AI acts as a human / service principal; a first-class agent principal is deferred |
| ADR-0002 | 2026-06-27 | Accepted | Authorization is role + scope grants, not a per-principal allow-list |
| ADR-0003 | 2026-06-27 | Superseded by ADR-0050 | The healthy state is named ok, not up |
| ADR-0004 | 2026-06-27 | Resolved | Bearer shipped first; password credentials (argon2id) landed in identity slices 1-2. OIDC / NATS still deferred |
| ADR-0005 | 2026-06-27 | Resolved | omniglass bootstrap <username> [--password]; the password-on-create path shipped, the iam namespace is deferred |
| ADR-0006 | 2026-06-27 | Resolved | The single-owner invariant is now a DEFERRABLE constraint trigger, landed with grant revocation |
| ADR-0007 | 2026-07-01 | Accepted | A principal is not a scope-tree entity; the principal capability confers access only at all-scope |
| ADR-0008 | 2026-07-06 | Accepted | Disable revokes live sessions via the per-request active re-read; no token-version column (nothing consumes it) |
| ADR-0009 | 2026-07-06 | Superseded by ADR-0011 | The deploy “act on the subtree but not the root” capability is an exclude_root grant modifier, not a new scope kind |
| ADR-0010 | 2026-07-06 | Accepted | Impersonation ships view-as + act-as as an impersonation_session (not a credential), guarded by capability-cover, with a real-actor audit column |
| ADR-0011 | 2026-07-06 | Accepted | Generalize the exclude_root boolean into a scope_op operator (subtree / subtree_excl_root / self), a flat enum, not a predicate-expression tree |
| ADR-0012 | 2026-07-07 | Accepted | Owner accounts are un-impersonatable by anyone; impersonate stays swept by principal:*; drop act-as scope intersection (#101) |
| ADR-0013 | 2026-07-07 | Accepted | Grant creation is refused when the granted role’s capabilities exceed the granter’s all-scope capabilities (admin cannot self-promote to owner) |
| ADR-0014 | 2026-07-07 | Superseded by ADR-0015 | The audit trail is admin/owner-only: audit is a sensitive resource that *:read does not confer, only an explicit audit:read or *:* |
| ADR-0015 | 2026-07-07 | Accepted | Permissions match like NATS subjects (* one token, > tail); admin-sensitivity is a deeper :admin token no partial wildcard reaches; owner is > |
| ADR-0016 | 2026-07-09 | Accepted | A principal can be hard-deleted (purge, gated on archival); the audit trail survives via a denormalized actor label and ON DELETE SET NULL, retiring the “never hard-deleted” rule (soft-delete verb: archive) |
| ADR-0017 | 2026-07-09 | Accepted | The access-secret member of the config / credential / variable trio is renamed credential to secret: an encrypted-at-rest typed value resolved most-specific-wins down the cascade |
| ADR-0018 | 2026-07-10 | Accepted | A profile picture is read through a JSON image_base64 endpoint the console renders as a data URL, not a raw image/jpeg handler, so every route stays under the Huma authz middleware |
| ADR-0019 | 2026-07-11 | Accepted | Every credential is time-bounded (reverses tokens-never-expire): session 12h, token / bootstrap 90d default with a --ttl capped at 365d; a credential.purpose column, not the expiry shape, tells session from token |
| ADR-0020 | 2026-07-11 | Accepted | The variable member ships plaintext, typed inline against a value_type enum (no variable_type registry), on the secret owner arc; template scope, groups, the $var: consumer deferred |
| ADR-0021 | 2026-07-12 | Accepted | The tag primitive ships its first slice (governed key registry, per-entity bindings, cascade); minting a key is admin tag:create, setting a value is the entity’s own update |
| ADR-0022 | 2026-07-13 | Accepted | Directory rows carry batch-resolved effective tags; effective resolution extends to systems and locations, and a placed system inherits its location’s tags |
| ADR-0023 | 2026-07-13 | Accepted | The IAM directory reads (principal, role, principal_group) move to the admin tier (<resource>:read:admin), so viewer’s *:read floor no longer reaches Users, Roles, and Groups |
| ADR-0024 | 2026-07-13 | Accepted | A tag key may declare an allowed_values enum (empty = free text), enforced on the binding write; a free key autocompletes its distinct in-use values |
| ADR-0025 | 2026-07-13 | Accepted | secret leaves the bare * wildcard’s reach (direct match and read floor); a per-secret admin_sensitive flag flips a secret to the :admin tier, so operators read operational device secrets in scope while platform credentials stay admin/owner-only at the same scope |
| ADR-0026 | 2026-07-13 | Accepted | Console nav IA: Variables, Secrets, and Config get their own top-level Values group; Inventory holds the estate entities including Nodes; Interfaces and Tasks become facet panels; the Settings group is renamed Admin |
| ADR-0027 | 2026-07-14 | Accepted | Inventory create/edit unify on the detail accordion: New routes to /<entity>/create (a draft) and Save hands off to /<entity>/<id> in edit; view is read-only, edit is the sole writer; the create/edit Drawer is retired |
| ADR-0028 | 2026-07-14 | Accepted | rank is dropped from location_type, system_type, and component_type; the three list operations sort by display_name, id instead |
| ADR-0029 | 2026-07-14 | Accepted | Files slice 1: a content-addressed blob store primitive (pgblobs) and a tenant-wide file handle; no placement arc (a file is 1:many, its locality is a future attachment), a binary sensitive flag reusing the secret :admin tier (defaults off), a delete frees its unreferenced blob synchronously (async mark-sweep GC deferred), base64-in-JSON on the wire |
| ADR-0031 | 2026-07-14 | Accepted | component_make slice 1: an official boolean (not an origin enum) for consistency with the type registries; the in-use referential delete guard deferred to the component_model slice (nothing references a make yet); website scheme-validated to http/https, client and server, against stored XSS |
| ADR-0032 | 2026-07-17 | Accepted | Every gated route stamps its required permission into the OpenAPI (x-omniglass-permission), so the permission universe is derived from the routes rather than a hand-kept catalog |
| ADR-0033 | 2026-07-17 | Accepted | The settings engine persists only the override level; the code and file base layers are recomputed in memory each boot, so restore is a delete (diverges from scaling.md’s “materialized in Postgres”) |
| ADR-0034 | 2026-07-17 | Accepted | Settings Gateway methods are unscoped: ABAC storage-scope is not applicable to platform / principal config; only the settings:<action> permission gates them |
| ADR-0035 | 2026-07-17 | Accepted | Settings resolve down the principal hierarchy reusing the cascade primitive, with per-key provenance and a top-down broader-wins lock |
| ADR-0036 | 2026-07-07 | Accepted | A node is a principal of kind=node with a 1:1 detail table and a bearer credential row (interim shared secret), and per-node NATS isolation is static per-connection subject permissions via an in-process auth callback; nkey/JWT deferred |
| ADR-0037 | 2026-07-07 | Accepted | Telemetry is a protobuf Event over a JetStream durable consumer; the consumer binds the owner from the task’s interface and confines a node to its own tasks inline (no separate raw-telemetry table or Postgres queue); raw persistence + replay and label-based multi-owner routing deferred |
| ADR-0038 | 2026-07-07 | Accepted | The per-interface reachability verdict interface.reachable is a built-in state (not a metric); availability is time_in_state over it; readiness is interface-type-defaulted and interface-overridable, node-executed, not a calc_rule |
| ADR-0039 | 2026-07-08 | Accepted | An interface is a device API named by its protocol (not a NIC); interface_type = its transport (the reach gate), a driver = the collect layer (protocol handler + transports + normalized menu, what a device CAN do), a template curates (SHOULD), the instance holds what IS there; OIDs/commands live in the driver, not the template |
| ADR-0040 | 2026-07-14 | Accepted | The task is derived read-only plumbing: creating an interface derives its one poll task, so task create/update/delete routes and the task:create / :update grants are dropped; task.node_name is removed and projected from interface.node_name (the worklist and telemetry owner-confinement join the interface), and a node purge cascades its interfaces and their tasks. Reverses the checkpoint-5d task-CRUD build; refines ADR-0039 |
| ADR-0041 | 2026-07-19 | Accepted | A setting is declared once, as a tagged field on a canonical Settings Go struct; reflection produces the code defaults layer and the namespace registry, Huma reflects the struct into the OpenAPI schema so the typed client values is a Settings struct, and both the server PATCH and the generated client validate against that same reflected schema. Closes the slice-0 write-validation thin cut; retires the hand-kept defaults.yaml and Namespaces() slice |
| ADR-0042 | 2026-07-19 | Accepted | A field’s resolved value is deepest-set-wins down product -> location -> system -> component, falling to the field’s type default when nothing is set at any scope; the type default is the floor of the cascade, not a competitor to it. This slice is component-only (resolved = the component’s set value, else the type default); the multi-scope cascade is tracked by #291 |
| ADR-0043 | 2026-07-19 | Accepted | The datapoint_type catalog is generalized into a primitive-agnostic property catalog (the typed set of signals a datapoint observes and a field declares): the unused scope ladder becomes an official boolean, value_type becomes data_type (text to string, add bool), kind is nullable (a declared-only property has none), and validation is a JSON Schema validated by Huma’s own validator (zero new dependencies). Value/source tables key by name (no FK), so the rename is behavior-preserving; the type-schema (field_definition.key) is the only binding and lands in PR-B |
| ADR-0044 | 2026-07-20 | Accepted | The component_make catalog is generalized into vendor (a kind of manufacturer / integrator / developer), and two new leaf catalogs join it, driver (id, display_name, version) and capability (id, display_name), as the component-classification reference data: each a gated CRUD Catalog console page with read-only official seeded rows. product + product_capability + component.product are the next slice. This is PR2 of the estate-model shift toward property / event / command + vendor / product / driver / capability / standard / role / health |
| ADR-0045 | 2026-07-20 | Accepted | product lands as a first-class catalog entity, the concrete SKU that binds a vendor, a driver, a kind (device / app / service / vm), and a capability set via the product_capability join; parent_product_id models variants, and component.product_id (on delete restrict) points a component at the SKU it is, making the product the source of a component’s shape and retiring the component_type-as-shape notion. PR3 of the estate-model shift; consumes the vendor / driver / capability catalogs from ADR-0044 |
| ADR-0046 | 2026-07-20 | Accepted | A log-kind observation is no longer dropped at ingest: it lands in a new event table, the log-kind sink (a past occurrence) beside metric_datapoint / state_datapoint (a sampled present value). event carries the same datapoint owner exclusive-arc and provenance, plus message + attributes, and the reserved event_id FK stubs on the two datapoint tables are closed (on delete set null). Scope excludes the datapoint->sample rename (a later cleanup) and property_value / the current-value store (the fold-fields slice). P1 follow-up of the estate-model roadmap |
| ADR-0047 | 2026-07-21 | Accepted | The standalone fields feature retires and folds into the estate model: a field was only ever a property with declared provenance, never a primitive of its own. product_property is the product’s declared-property contract (product_id, property_name, default_value, required), replacing field_definition; property_value is the value store, carrying the same owner exclusive-arc as metric_datapoint / event plus instance and provenance, replacing field_value. EffectiveProperties unions the contract arm (coalesce(set value, contract default)) with the off-contract arm, so a productless component still resolves. field_definition, field_value, component.component_type, and the whole component_type registry retire. PR5 of the estate-model shift |
| ADR-0048 | 2026-07-21 | Accepted | system_type is promoted to standard, the blueprint a system conforms to and the system-side counterpart of product: it gains parent_standard_id (variants), a declared-property contract, and its own standard:* Catalog resource, and system.standard_id becomes optional. standard_property and location_type_property join product_property, and one owner-generic EffectiveProperties(ownerKind, ownerID) resolves component, system, location, and node off a single parameterized template. A standard and a location type are created by forking an in-code template (one-time, no inheritance), so a shipped row is operator-owned (official: false, seeded if absent), while a system conforms to its standard with live inheritance; only the canonical catalogs keep the authoritative upsert. PR6 of the estate-model shift |
| ADR-0049 | 2026-07-21 | Accepted | A system_role is a slot a system needs filled (a table microphone, a main display), declared on a standard (inherited live by every conforming system) or on one system (ad-hoc) over the same exclusive arc property_value uses, requiring a conjunctive role_capability set and carrying a quorum. A component’s capabilities become a resolved set (EffectiveCapabilities = its product’s, plus its own component_capability present=true rows, minus its present=false ones), because product is optional and a strict guard over a product-only fact would lock a productless component out of every role. AssignRole refuses (422) and names the missing capabilities, joining the location placement constraint as a refusal on modeled grounds that names the parties. Quorum ships here (staffing is visible without health); impact and the SLI rollup land in PR8. Supersedes the system_template_member role-requirement design. PR7 of the estate-model shift |
| ADR-0050 | 2026-07-21 | Accepted | Health is recorded as a transition and recomputed at the write, never on read. An alarm is component-local and names the capabilities it degrades; a component satisfies a role only when it provides every required capability and none of them is degraded; a role below its quorum is impaired and contributes its impact (outage / degraded / none); a system takes the worst of its roles, a location the worst of its systems. The verdict domain is healthy / degraded / outage and the judgement is a pure package (internal/health), unit-tested with no database. The recorded carrier is state_datapoint, already transition-only, so the history is edges and only edges; compute-on-read (no history) and write-through-on-read (the edge timestamped when somebody looked) are both rejected. A read never writes, and it computes the verdict it serves from the same rows it shows, so a report cannot contradict its own evidence. PR8 of the estate-model shift, closing epic #266 |
| ADR-0054 | 2026-07-21 | Accepted | A panel’s action bar is declared, not laid out: a blade body binds through lib/blades, a Drawer form body through lib/formactions, and BladeStack / Drawer draw the result through the one PanelFooter rail. The opt-in DrawerFooter helper is deleted. A convention a body must remember can be forgotten, and was, by two forms for months while it was copied into six new pages around them |
| ADR-0057 | 2026-07-21 | Accepted | The cascade’s least-specific binding tier is renamed global to platform on both axes (same rung, no precedence change); a default is off the axis entirely, a column on a type declaration rather than a tier; there is no root location; a write at the tier needs its own platform:<action> permission. Breaking: a secret sealed at the old tier can no longer be decrypted (the AEAD binds the owner kind) |
Entries
Section titled “Entries”ADR-0001: AI acts as a user; the agent principal is deferred
Section titled “ADR-0001: AI acts as a user; the agent principal is deferred”- Date: 2026-06-27 | Status: Accepted | Pages: identity and access, AI
- Decision: An AI tool authenticates over OAuth as an ordinary
humanorserviceprincipal and acts with exactly that principal’s grants. A dedicated first-classagentprincipal kind is not in the initial architecture. - Context: A separate
agentidentity would need its own authN, its own grant semantics, and its own audit treatment before any AI surface exists to use it. Treating AI as a scoped, audited user reuses the whole identity machinery and keeps the audit trail honest (the acting principal is the human or service). - Note: The schema’s
principal.kindCHECK already reserves theagentvalue so a later slice adds the kind without editing the applied auth migration; noagentidentity is issued today. If and when a first-class agent identity is built, that is a new entry that supersedes this one.
ADR-0002: Roles carry requirements, not an allow-list
Section titled “ADR-0002: Roles carry requirements, not an allow-list”- Date: 2026-06-27 | Status: Accepted | Pages: identity and access
- Decision: Authorization is built from additive
(role x scope)grants, where a role is a capability set of<resource>:<action>permissions. An earlier sketch attached a per-principal allow-list of permitted actions directly. - Context: A per-principal allow-list does not compose: the same operator role at two scopes, or a role inherited and extended, would be re-listed by hand per principal. Roles plus scope make the common case (the same role at different places) a single reused definition, and keep permissions additive and positive (no negative entries). It is also what makes the per-grant binding (an action and its scope bind in the same grant) expressible.
ADR-0003: Health reads ok, not up
Section titled “ADR-0003: Health reads ok, not up”- Date: 2026-06-27 | Status: Accepted | Pages: health
- Decision: The healthy state of a component or system is named
ok. An earlier draft usedup. - Context:
upreads as reachability (the device answers), which is only one input to health. Health is a rollup verdict (“is this system working?”) that can be unhealthy while every device is reachable, or healthy while a redundant member is down.oknames the verdict rather than the ping, so the word does not promise something narrower than the model delivers. - Superseded by ADR-0050
on the word only. The reasoning holds and the built domain still names the verdict rather than the ping;
it spells the members
healthy/degraded/outage, becauseoutagesays what a broken system means to the people in the room wheredownsays what a device is doing.
ADR-0004: Credentials ship bearer-only
Section titled “ADR-0004: Credentials ship bearer-only”- Date: 2026-06-27 | Status: Resolved (identity slices 1-2) | Pages: identity and access
- Resolved: Password credentials shipped in identity slice 1 (#35) and slice 2 (#70):
credential.kindnow allowsbearerorpassword(argon2id, PHC-encoded, one password per principal), and a human signs in with a username and password behind an httpOnly session cookie. Theoidc/natsmethods and the full(method, identifier)lookup key remain deferred (future slices). - Decision (divergence): The shipped
credentialtable carrieskind = 'bearer'only, stored as the token’s sha256 with a non-secretogp_locator prefix. The design’s fuller model (thepassword,oidc, andnatsmethods, and the(method, identifier)lookup key) is deferred, not yet built. - Context: The auth foundation slice needed exactly one working authN method to prove the capability and
scope seams end to end. Bearer tokens are the thinnest honest cut: a service credential the bootstrap and
the CLI can both carry. Password login is the first slice of the identity tier epic (#27)
(slice #28), which adds
passwordto thecredential.kindCHECK in a new migration (never editing the applied one). OIDC and the NATS node credential follow with their own surfaces. - Closes the gap: epic #27.
ADR-0005: The first owner is omniglass bootstrap
Section titled “ADR-0005: The first owner is omniglass bootstrap”- Date: 2026-06-27 | Status: Resolved (identity slice 1) | Pages: identity and access
- Resolved:
omniglass bootstrap <username> --password <pw>shipped in identity slice 1 (#35): bootstrap now installs a password credential on create (plus--email/--display-name), so the owner can sign in to the console without a separate step. Theog iamadmin command namespace is still deferred (it lands with the admin user surface, slice 3). - Decision (divergence): The first owner is created by
omniglass bootstrap <username>, which mints anowner@allgrant plus a bearer credential in one transaction. The design page describes the eventualog iam create-owner --username ... --email ...password path under aniamcommand namespace; that namespace and the password credential are deferred. - Context: Bootstrap has to work before any login surface exists, so it pairs with the bearer-only
credential decision (ADR-0004): one trusted, idempotent command that produces a token the operator pastes
into the console or CLI. The
iamcommand family (and the password-on-create path) lands with the identity-tier admin surfaces. - Closes the gap: epic #27.
ADR-0006: The owner invariant is enforced by bootstrap for now
Section titled “ADR-0006: The owner invariant is enforced by bootstrap for now”- Date: 2026-06-27 | Status: Resolved (identity slice 3c) | Pages: identity and access
- Resolved: The
DEFERRABLE INITIALLY DEFERREDconstraint trigger (principal_grant_owner_guard) shipped with grant revocation (issue #82): it refuses to leave zeroowner @ allgrants atCOMMIT, so revoking the last owner is a clean 409 while a swap (grant a new owner + revoke the old in one transaction) still passes. The gateway maps its custom SQLSTATEOG001toErrLastOwner. - Decision (divergence): “At least one active
owner@allgrant exists at all times” is upheld today by the bootstrap path (it always creates one) and the absence of any grant-revocation surface. The design’s deferrable Postgres constraint trigger that enforces it atCOMMIT(so the swap-owners-in-one-txn pattern works) is not yet built. - Context: With no API to revoke a grant or delete a principal, the last-owner removal the trigger guards against is not yet reachable, so the trigger is not load-bearing until grant CRUD ships. It is required before the admin user-management slice exposes grant revocation (epic #27, slice 3).
- Closes the gap: epic #27.
ADR-0007: Principals are gated at all-scope, not scope-tree
Section titled “ADR-0007: Principals are gated at all-scope, not scope-tree”- Date: 2026-07-01 | Status: Accepted | Pages: identity and access
- Decision: A
principalis not a scope-tree entity: it is not “under” a location, system, or component, so theprincipal:<action>capability confers access only at all-scope. A grant scoped to a location or system carries no principal access, and the Storage Gateway refuses a non-all scope on the principal directory with a 403 (ErrPrincipalForbidden) rather than silently returning an empty list. This falls out of the scope resolver:applicableKinds("principal")is empty, so only anallgrant resolves to a non-empty set. - Context: The admin principal directory (slice 3a, issue #77)
is the first surface to gate on
principal:*. Modelling users as scope-tree entities would be wrong (there is no “users under HQ”), and returning an empty list to a mis-scoped admin would hide a misconfiguration, so making all-scope explicit keeps the capability honest and surfaces the error. The same rule governs the later principal-mutation and grant surfaces. - Closes the gap: n/a (a design decision, not a divergence).
ADR-0008: Disable is hard revocation; no token-version column
Section titled “ADR-0008: Disable is hard revocation; no token-version column”- Date: 2026-07-06 | Status: Accepted | Pages: identity and access
- Decision: Disabling a principal revokes its live sessions immediately, achieved by the authn path
re-reading
principal.activeon every request, not by a session-version / epoch column.AuthenticateBearerandAuthenticatePasswordboth filterand pr.activein the credential lookup on every call, with no caching anywhere in the authn path, so the very next request on an already-issued bearer or session cookie after a disable gets zero rows and a 401.SetPrincipalActiveflips the flag in one statement: disable is revocation, atomically. Notoken_versioncolumn is added. - Context: Issue #94 asked for “hard session
revocation on disable”, assuming disable was soft (a propagation delay). It is not: the per-request active
check already is the hard-revocation mechanism, proven end to end by
TestDisableRevokesLiveSessionAPI(a live token is 401 on its next request the moment it is disabled) andTestDisablePrincipal. Atoken_versioncolumn would matter only as an invalidation signal for an authn-result cache, which does not exist; adding it now would be a dead column with no reader, against the primitive-first and meaningful-migration disciplines. Revisit if any cache/memoization is introduced in the authn path (an epoch bump would then be its invalidation signal). - Closes the gap: issue #94, closed as already satisfied.
ADR-0009: Root exclusion lives on the grant, not a new scope kind
Section titled “ADR-0009: Root exclusion lives on the grant, not a new scope kind”- Date: 2026-07-06 | Status: Accepted | Pages: identity and access
- Decision: The “act on the subtree but not the root” capability (the deploy / integrator case, issue
#87) is a boolean
exclude_rootmodifier onprincipal_grant, not a newscope_kind(e.g.location_descendants) and not a role-level flag. It narrows only the modify actions (update, delete) to the root’s descendants; read and create-placement keep the root. An inclusive grant on the same root wins over an excluding one. - Context: A new scope_kind would fork the kind handling three ways (location / system / component) and
grow the scope vocabulary; a role-level flag could not vary per grant (the same deploy role granted
root-inclusive in one place and root-excluded in another). The grant modifier composes with the
additive-grant model and confines the change to one predicate (
inScopeTree) shared by all three tree entities. Keeping read and create-placement inclusive means aPATCHon the root is the existing readable-but-out-of-write-scope 403, soexclude_rootreuses the three-way status split rather than adding a fourth case. Shipped with a newdeployofficial role (create + update on the three tree tiers, read via the viewer floor). The grant-builder toggle to set it from the console is a fast-follow (#99). - Closes the gap: issue #87.
ADR-0010: Impersonation is a session, not a credential; guarded by capability cover
Section titled “ADR-0010: Impersonation is a session, not a credential; guarded by capability cover”- Date: 2026-07-06 | Status: Accepted | Pages: identity and access
- Decision: Admin/owner impersonation ships with both modes (view-as read-only, act-as full) in one
slice. An impersonation token is an
impersonation_sessionrow (its own table: target, real actor, mode, expiry, revoke), not acredential(which authenticates a principal as itself). Authorization to impersonate is the escalation guardactor.Covers(target)(the caller’s capabilities must cover the target’s) plus theprincipal:impersonatecapability at all-scope. Capability cover applies to both modes; scope is where the modes diverge: view-as is cross-scope (read-only grants no write authority, and seeing another scope is the troubleshooting case), but act-as additionally requires the caller’s all-scope grants alone to cover the target: a capability held only through a narrower grant does not count. Without that, act-as would let a split-grant admin (all-scope user management, but infra scoped to campus X) impersonate a campus-Y admin and gain write in Y, since an impersonated request resolves its ABAC scope from the target: a scope escalation. Because the rule is capability-cover against the caller’s all-scope grants (not a hardcoded list of scoped resources), it closes non-tree escalation too: a user-admin who holds grant authority only through a scoped grant (empty effective scope, cannot create a grant directly) cannot launder all-scope grant authority by acting-as a grant admin. Accountability is a nullableaudit_log.real_actor_principal_idwritten on the row directly, not reconstructed from a time-window join (clock skew and concurrent sessions make that unreliable for an accountability record), and the self-service mutations (/auth/meprofile and password) audit too so an act-as edit is never untracked. - Context: view-as is enforced by refusing every non-read action when the request carries a view-as
claim; act-as threads the real actor through the audit writer via a request-scoped context value
(
storage.WithRealActor), so no mutating gateway signature changes.authntries the impersonation session on a bearer-hash miss, so the sameAuthorization: Bearerpath serves both. Disabling either party kills the session via the per-requestactivere-read (ADR-0008). The console ships an Impersonate action (view-as / act-as) and an acting-as banner. Deferred: re-checking the escalation guard on every request (bounded instead by a short TTL plus revoke), and act-as within a scoped admin’s own scope by intersecting the target’s scope with the caller’s (#101), rather than the current all-scope-only act-as rule. - Closes the gap: issue #85.
ADR-0011: Grant scope is an operator, not a boolean modifier
Section titled “ADR-0011: Grant scope is an operator, not a boolean modifier”- Date: 2026-07-06 | Status: Accepted | Pages: identity and access
- Decision: Generalize the
exclude_rootboolean (ADR-0009) into ascope_opoperator onprincipal_grant(issue #102):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(the root row only for read/update/delete, no descendants and no create-placement, a leaf-lock, net-new). The operator is a flat enum column, not a full predicate-expression tree or a per-grant tuple list. It is part of a grant’s identity: the dedup index includesscope_op, so the same role at the same root with a different operator is a distinct grant. - Context: Grant scope wants one composable axis, not a growing pile of booleans; the grant builder is
already a filter-bar-style operator UI, so the operator vocabulary is the natural fit. The flat enum was
chosen over a predicate-expression scope and a per-grant tuple list (negation, multi-root
in): those buy expressiveness the boolean’s two states never needed, at the cost of a much larger blast radius on the two authorization invariants (permission-on-every-route, scope-on-every-query).selfis the cheap third value (a scalar= any()arm, no new recursive CTE) that turns a boolean rename into a real operator, and grant on exactly one node is a frequently-wanted capability the boolean could never express. The purescope.Resolvegains aSelfIDsset; the three gateway walks (inScopeTree,InScopeIDs,scopedListSQL) gain a self arm. The migration also recreates the dedup index to includescope_op, fixing a latent collision, and threadsscope_opthroughRevokeGrant’s audit SELECT (previously dropped). The operator model does not subsume the act-as scope intersection (#101): that blocker is plumbing (carry the real actor’s grants and intersect two Sets per row), unchanged by how a Set is expressed. A future tuple model (negation, multi-root) stays a documented path if a real carve-out requirement lands. The console grant builder gains an operator stage (role -> kind -> entity -> operator), so #99 (setting the modifier from the console) ships here too. - Supersedes: ADR-0009 (the boolean is retired for the operator).
- Closes the gap: issue #102.
ADR-0012: Owner accounts are un-impersonatable; impersonation stays capability-gated, not scope-intersected
Section titled “ADR-0012: Owner accounts are un-impersonatable; impersonation stays capability-gated, not scope-intersected”- Date: 2026-07-07 | Status: Accepted | Pages: identity and access
- Decision: Harden the impersonation authorization model on tiers, not scope. (1) A principal holding
owner @ allcannot be impersonated by anyone, including another owner, in either mode (issue #106): a target-side check in the:impersonatehandler, before the mode branch. (2) Theprincipal:impersonatecapability stays swept by theprincipal:*wildcard (admin) and*:*(owner); it is not carved out as a sensitive action, because holdingprincipal:*already lets a caller create and use its own principals, so impersonate confers no new reach there. (3) Drop act-as scope intersection (#101): act-as stays all-scope-only. - Context: The escalation guard (
Covers) already blocks a lesser admin from impersonating an owner, butowner.Covers(owner)is true, so owner-impersonates-owner was possible. An owner is the highest-trust account and impersonating one is a full-takeover vector, so the explicit owner-protection rule removes it entirely and reads more clearly than relying on cover arithmetic. Owner detection reuses the samerole='owner' and scope_kind='all'lane as the owner invariant, so it is not new role-name branching. Scope intersection (a scoped admin acting-as within its own subtree by intersecting two scope Sets per row) was dropped as complexity for a narrow case; the tier model plus all-scope-only act-as is simpler and safe. The impersonated-vs-direct distinction an operator needs in the audit trail is already recorded byaudit_log.real_actor_principal_id(ADR-0010); surfacing it is a later auth-event audit slice. - Refines: ADR-0010.
- Closes the gap: issue #106; closes #101 as dropped.
ADR-0013: A grant cannot confer capabilities the granter lacks
Section titled “ADR-0013: A grant cannot confer capabilities the granter lacks”- Date: 2026-07-07 | Status: Accepted | Pages: identity and access
- Decision: Grant creation is refused (403) when the granted role’s capabilities are not covered by the
granter’s all-scope capabilities (
rbac.Set.Covers, the same primitive as the impersonation escalation guard). So no caller can promote anyone, including itself, to a tier above its own: an admin cannot grantowner(*:*), because admin is an enumerated role that does not cover the global wildcard. Issue #109. - Context:
CreateGrantpreviously checked only that the granter held all-scopeprincipal_grant:create(action.All), not that the granter covered the granted role, so an admin could grant itselfowner@alland log in as a superuser, leaving the admin/owner distinction unenforced. The check lives in thecreate-granthandler (capability is a route/handler concern; ABAC scope stays the gateway’s), mirroring the impersonation guard. Only the caller’s all-scope grants count, so a capability held through a narrower grant cannot be conferred estate-wide (the same reason act-as requires all-scope cover). The consequence is a deliberate stance: admin is bounded on purpose, the top management role, never the superuser, and does not auto-gain future resources;owner(*:*) is the break-glass superuser and the owner-invariant anchor. The same cover rule must extend to role editing when it lands (you cannot edit a role above your own tier); tracked with that slice. - Refines: ADR-0010 (reuses its capability-cover primitive on the grant path).
- Closes the gap: issue #109.
ADR-0014: The audit trail is a sensitive read, not reached by a partial global wildcard
Section titled “ADR-0014: The audit trail is a sensitive read, not reached by a partial global wildcard”- Date: 2026-07-07 | Status: Accepted | Pages: identity and access
- Decision: Reading the audit trail requires the
audit:readcapability, andauditis a sensitive resource: a partial global wildcard (*:<action>, e.g. theviewerrole’s*:read) does not confer it. Only an explicit grant on the resource (audit:read, held byadmin) or the full*:*superuser wildcard (held byowner) reaches it. So the audit trail is admin/owner-only; a read-only user does not see logins, impersonations, and access changes (issue #116). - Context: The
:readfloor and the*:readviewer role mean “read everything,” which is right for the estate but wrong for the security audit trail: exposing who impersonated whom and every access change to any read-only operator leaks security posture. Rather than gate the route with a non-read action (a hack),rbacgains a small sensitive-resource set: inSet.Allows, a*resource entry that is notallActionsskips a sensitive resource, so*:readno longer matches it while*:*still does and an explicitaudit:readstill does. This is the narrow, honest version of the “sensitive permission” idea (distinct from the impersonate call in ADR-0012, where theprincipal:*resource wildcard legitimately confersprincipal:impersonate; here it is the global*:readwildcard over a sensitive read). The set is extensible if other sensitive reads appear (it holds onlyaudittoday). - Closes the gap: issue #116.
- Superseded by ADR-0015: the
carve-out is replaced by consistent topic-pattern matching, where
:adminis a deeper token no partial wildcard reaches.
ADR-0015: Permissions are topic patterns (single-token and tail wildcards)
Section titled “ADR-0015: Permissions are topic patterns (single-token and tail wildcards)”- Date: 2026-07-07 | Status: Accepted | Pages: identity and access
- Decision: Permissions match like NATS subjects (which the node path already uses, so the stack shares
one wildcard convention): a colon-delimited token path where a literal matches itself,
*matches exactly one token, and>matches one or more tokens and must be last. A normal permission isresource:action; an admin-sensitive one isresource:action:admin. Because*is a single token, a two-token pattern (*:read,*:*,principal:*) structurally cannot match a three-token:adminpermission: admin-sensitivity is a deeper token, not a special case. The whole-estate superuser is>(issue #118). - Context: The prior ad-hoc wildcard let a two-token
*:*match a three-tokenx:y:z, an inconsistency: the second*was silently absorbing a tail. Making matching a real topic match removes every special case, the ADR-0014sensitiveResourcesset is deleted.viewer’s*:readmissesaudit:read:adminbecause two tokens cannot match three;ownerreaches it via>;admincarriesaudit:read:adminexplicitly. It also fixes, for free, a boundary wart from the grant guard:principal:*is nowprincipal:<one token>, so it does not sweep an admin-tierprincipal:<action>:admin, those stay owner-only unless granted explicitly.Set.Allowsmatches by token;Set.Covers(the impersonation and grant-escalation guard) becomes pattern subsumption plus the:readfloor, staying conservative (a reach covered only by the union of several patterns returns false, deny). The only seed change isowner’s*:*becoming>; every other permission keeps its meaning because*already meant a single token. A closed grammar also makes “what does this pattern set grant” exactly enumerable against a permission catalog (the set of allresource:action[:admin]the routes declare), the basis for a future custom-role preview. - Supersedes: ADR-0014.
- Closes the gap: issue #118.
ADR-0016: A principal can be purged, and the audit trail is denormalized to survive it
Section titled “ADR-0016: A principal can be purged, and the audit trail is denormalized to survive it”- Date: 2026-07-09 | Status: Accepted | Pages: identity and access
- Decision: A principal gains a full lifecycle: disable (reversible, the
activeflag), archive (a soft delete,archived_at, hidden from the directory and unable to authenticate, reversible), and purge (an irreversible hard delete of the row). Purge is gated on prior archival (archive-before-delete) and on the admin-sensitiveprincipal:purge:admin, soadmin(which carries it explicitly) andowner(>) can purge but a two-tokenprincipal:*cannot reach it (ADR-0015). To keep the audit trail through a hard delete, the actor’s human-readable label is denormalized into everyaudit_logrow at write time, and the audit foreign keys becomeON DELETE SET NULL: a purge nulls the id link but the text survives, so “who did X” outlives the principal. The read side coalesces the live join to the snapshot. - Context: ADR-0006’s single-owner invariant
meant accounts were disabled, never hard-deleted, since audit rows referenced them (
RESTRICT). But operators need to remove accounts created by mistake, a common task, without erasing history or orphaning the trail. Denormalizing the actor label decouples the audit record from the principal row, so the row can be purged while the history stays legible; the archive gate prevents an accidental one-click hard delete, and the last-active-owner guard (extended to archive) means a purgeable account is never the last owner. This retires the “never hard-deleted” statement in the identity-access page. - Naming: the soft-delete verb was renamed deactivate to archive (and reactivate to restore) when
the console UI landed (#146): “disable” and
“deactivate” read as synonyms, blurring two distinct operations. The ladder is now a suspend (disable,
reversible, still listed) then an offboard (archive, soft delete, hidden, recoverable) then a destroy
(purge), so the labels read pause to remove to destroy, matching the industry suspend-vs-delete pair. The
column, endpoints (
:archive/:restore), capability (principal:archive), and list param (include_archived) all follow the verb. - Closes: issue #143 (backend), #146 (console + rename).
ADR-0019: Every credential is time-bounded; token purpose, not expiry shape
Section titled “ADR-0019: Every credential is time-bounded; token purpose, not expiry shape”- Date: 2026-07-11 | Status: Accepted | Pages: identity and access
- Decision: All credentials are time-bounded (reverses the earlier tokens-never-expire choice). A
web-login session keeps a 12h absolute lifetime; CLI/API tokens and the bootstrap token get a 90-day
default expiry with a
--ttloverride capped at 365 days; nothing is issued without an expiry. Sessions and API tokens are distinguished by acredential.purposecolumn, not by whetherexpires_atis set. Expiry is enforced lazily at authentication; there is no background sweep, and session/token lists show only live credentials. Deferred: a sliding idle timeout, a housekeeping sweep of long-expired rows, and nearing-expiry notifications. - Context: The credential-expiry slice (#157)
bounded only the web-login session and left the CLI/API token unbounded (
expires_atnull), overloading “has an expiry” to mean “is a session”. That left an eternal secret in the field, against the every-secret- rotates principle, and coupled the session-vs-token distinction to a nullable column that both kinds now populate. A dedicatedpurposecolumn names the concept directly, so the list and the console read the discriminator rather than inferring it, and the default 90-day / 365-day-cap window keeps a minted token usable for real automation without becoming permanent.AuthenticateBeareralready refused a passed expiry, so enforcement needed no change: giving tokens a future expiry is enough, and the list reuses the sameexpires_at is null or expires_at > now()filter so a dead row is never shown. - Reverses: the tokens-never-expire behavior introduced with #157.
- Closes: issue #172 (self-service sessions and the every-credential-expires model).
ADR-0018: The avatar read endpoint is JSON, not raw image bytes
Section titled “ADR-0018: The avatar read endpoint is JSON, not raw image bytes”- Date: 2026-07-10 | Status: Accepted | Pages: identity and access
- Decision: A human principal’s profile picture is read through a JSON endpoint
(
GET /principals/{id}/avatargatedprincipal:read,GET /auth/me/avataron the self lane) that returns{ image_base64 }, which the console decodes into adata:URL for the<img>. The write lanes take base64 JSON in (POST /principals/{id}:setAvatarand the/auth/meself lane), and the server-normalized 256x256 JPEG is stored base64 on thehumanrow; the principal read models carry only ahas_avatarbool, so no image payload rides a list or theloadPrincipalhot path. - Context: The slice design spec proposed a raw
image/jpegread endpoint (withETag/Cache-Control/304) so a browser<img src>could load it directly. But a raw-bytes handler would be a chi-native route sitting outside the Huma authz middleware, breaking the two-layer invariant that a<resource>:<action>capability is checked on every route, and a bare<img src>cannot send a bearer header, so a token-only (non-cookie) session could not authenticate the image. Keeping the read as a Huma JSON route puts it under the sameauthn+require("principal","read")(admin) or authn-only (self) path as every other route, and the typed client (session cookie or bearer, both work) fetches the JSON and builds the data URL. The one normalized size is small (roughly 30 to 50 KB base64), so per-request payload is not a concern, and HTTP caching overavatar_updated_atis a later refinement if it is ever needed. This supersedes the spec’s raw-bytes read decision; the write transport (base64 JSON) is unchanged.
ADR-0017: credential is renamed secret; the cascade is the reuse mechanism
Section titled “ADR-0017: credential is renamed secret; the cascade is the reuse mechanism”- Date: 2026-07-09 | Status: Accepted | Pages: config, credentials, and variables
- Decision: The access-secret member of the config / credential / variable trio
is renamed credential to secret, and its first slice is built: a typed, encrypted-at-rest value owned on the
exclusive arc (
global | location | system | component) and resolved most-specific-wins down the cascade. A secret is an encapsulated typed cell (asecret_typeshape with per-field secrecy and origin), not a bag of references: the reuse a tool like Windmill gets from variable references, the cascade already provides here (define once at a broad scope, inherit it below), so composition solves a non-problem. Interpolation references live at the consumption site ($sec:name.pathin an interface input or a function arg), never inside a secret’s own fields. Crypto is envelope AES-256-GCM behind a pluggable KEK provider (env / file / fallback), the value sealed under a per-value DEK wrapped by the KEK, with(owner, name, field)bound as AAD; the provider seam lets a KMS or Vault drop in without a model change. “credential” is retained for the authentication credential (a principal’s bearer or password), a distinct resource; only the collection-side access secret is renamed. - Context: The written variables page named this member
credentialand left itDesign. Building it surfaced two calls. First, naming: “credential” collided with the identity credential and undersold the general case (ansnmp_community, an API key, anoauth2blob are all just sensitive cascaded values); “secret” is the Cloudflare-style vars-and-secrets pair and reads correctly. Second, shape: Windmill’s resource-references-variables split was considered and rejected, because our cascade is the sharing mechanism and an atomic one-form typed cell (doctrine 4) suits an operator better than composing references. Reveal (plaintext decrypt) ships as an audited,secret:reveal-gated endpoint that the*:readfloor does not reach, so only admin and owner may decrypt; the interpolation consumer (splicing a value into a live request) is deferred to the collection-driver slice that first needs it. This reverses thecredentialnaming and any “references inside the value” reading on the page; thevariableandconfigmembers stayDesign. - Closes: issue #155 (secret slice 1).
ADR-0020: variable slice 1 types inline and mirrors the secret arc
Section titled “ADR-0020: variable slice 1 types inline and mirrors the secret arc”- Date: 2026-07-11 | Status: Accepted | Pages: config, secrets, and variables
- Decision: The variable member of the trio ships its first slice: a typed, cascade-resolved plaintext
value owned on the exclusive arc and resolved most-specific-wins down the cascade, with a
Variables directory and a per-component effective-variables panel, mirroring the secret
member minus crypto, masking, and the reveal.
variable:create,updateis granted to operators (delete stays admin and owner), the same split secret got. Three parts of the written design are deferred to keep the slice one vertical cut. First, typing is inline: avalue_typeenum (string | int | float | bool | json) on the row plus a jsonbvaluevalidated against it in a pureinternal/variablepackage, not avariable_typeshape registry. A scalar needs no governed vocabulary, and the page itself calls variables the “operator-defined, not curated” member, so a registry would contradict the model. Second, thetemplateowner scope (the design’sglobal -> template -> instance) is out: slice 1 mirrors the secret arc (global | location | system | component), and template scope plus cascade groups land together in #184, because they touch the shared resolver once for both members. Third, the$var:consumer and the secret-flagged variable are deferred (the consumer has no live interpolation site yet, as with$sec:). - Context: The written variables page sketched a
variable_typeregistry and a shared config/variable cell carryingobserved_valueandreconcile. Building the member showed those belong to config (the declared-vs-observed member), not the free macro: a variable has no observed side. Sovariableshipped as its own single table, typed inline, and the page’s Storage section is corrected to match. This diverges from the page’svariable_type-registry and shared-cell sketch; theconfigmember staysDesign. - Closes: issue #183 (variable slice 1).
ADR-0021: tag slice 1, a governed key registry with entity-update-gated bindings
Section titled “ADR-0021: tag slice 1, a governed key registry with entity-update-gated bindings”- Date: 2026-07-12 | Status: Accepted | Pages: tags, config, secrets, and variables
- Decision: The tag primitive ships its first slice on its own tags page: the governed
tagkey vocabulary, the per-entitytag_bindingvalue cell owned on the exclusive arc (global | location | system | component), and a resolver that unions keys and overrides values most-specific-wins down the cascade. Two permissions, not one: minting a key is a tenant-wide governance action gated by an all-scopetag:create(broadened totag:*for admin, covering update and delete of keys), while setting a value is the owner’s ordinary write (component:updateand friends), so an operator who may edit an entity may tag it with no new grant; a global binding, having no owning entity, is gated bytag:update. A key carriesapplies_to(an entity-kind allow-list, empty = universal, checked on bind) andpropagates(a flag that toggles cascade inheritance versus a flat per-entity set, the shape a file will reuse). Key names are validated as lowercase identifiers in a pureinternal/tagpackage, keeping the vocabulary normalized. Four parts of the written design are deferred to keep the slice one vertical cut. First, the operator console surface (a Tags directory and a per-entity tag editor) is out; the slice ships over the API and the generated CLI, matching the files-first ordering the estate chose. Second, binding through groups and atemplate-scoped default are out, landing with the shared-resolver work in #184 that the variable member also waits on. Third, value-domain governance (a key constraining or normalizing its values) stays the page’s open question; slice 1 ships free-text values. Fourth, binding a tag onto a file waits on the files primitive. - Context: The tag design lived inside the config, secrets, and variables page as the
fourth cascade user. Building it earned tags a page of its own, because its governance model is distinct: unlike a
variable (one free value, one
variable:*permission), a tag splits a curated key vocabulary (admin-minted) from routine value binding (operator-open via the entity’s own write), and it resolves with a union-on-key combinator rather than a single value. The exclusive-arc scope and the cascade walk are shared with the variable and secret resolvers; the combinator and the two-permission split are what make it its own primitive. This diverges from the variables page’s single-table sketch (the binding is its owntag_bindingcell) and its “bindable via groups” note (deferred); the variables page’s tag section now frames the shared cascade and points at the tags page. - Closes: issue #188 (tag slice 1). The deferrals are filed: the console surface #189, value-domain governance #190, and binding onto a file #191; groups and template scope ride #184.
ADR-0022: effective tags resolve onto systems and locations; a placed system inherits its location
Section titled “ADR-0022: effective tags resolve onto systems and locations; a placed system inherits its location”- Date: 2026-07-13 | Status: Accepted | Pages: tags
- Decision: The directory Tags column shows a row’s effective (resolved-cascade) tags, not its direct
bindings, so the list routes (
GET /components,/systems,/locations) carry aneffective_tagsmap (key to winning value, winners only) per row, resolved for the whole page in one batched query per kind (Gateway.EffectiveTags(kind, ownerIDs), three per-kind recursive-CTE resolvers that thread a target id through the ancestor chains and rank per(target, key)). This required defining effective tags for systems and locations, which previously only components resolved: a location resolvesglobalplus its own location tree; a system resolvesglobal, its own system tree, and the location it is placed at (itslocation_idtree). A placed system therefore inherits its location’s tags (a system in a PCI building surfacescompliance: pci), consistent with how a component picks up its ownlocation_id. A component is unchanged (the full four-band arc). The resolver is scopeless by contract: the list query has already filtered the ids to the caller’s read scope, so the batch adds no per-id check, matching the existingrowActionsbatch. Winners only in the column; provenance (which scope a value came from) stays in the per-entity effective-tags detail view. - Context: The tag-apply UI needs each directory row to show what tags actually apply to it. The cheaper option was
to embed a row’s direct bindings (a flat, non-recursive
where owner_id = any($1)lookup); the architect chose effective so the column reflects inherited values, not just locally-set ones. That choice moved real work to the backend (a batched recursive cascade versus a flat index scan) and forced the systems-and-locations effective definition, whose one genuine call was whether a system inherits its location: yes, because a system carries alocation_idexactly as a component does, so treating it as placement-that-inherits is the consistent reading. The added cost is a small bounded per-row recursion over the shallow estate trees, one round-trip, and is capped by the directory page size. This is the first (backend) slice of the tag-apply UI; the Tags column, the type-to-add editor, and tag search consume it in later slices. - Closes: issue #201 (batch effective-tags resolver); part of #189.
ADR-0023: the IAM directory reads (principal, role, principal_group) are admin-tier
Section titled “ADR-0023: the IAM directory reads (principal, role, principal_group) are admin-tier”- Date: 2026-07-13 | Status: Accepted | Pages: identity and access
- Decision: The read (list and get) of
principal,role, andprincipal_groupmoves from a two-token<resource>:readto the admin-sensitive<resource>:read:admin, so theviewerread floor (*:read) no longer reaches the Users, Roles, and Groups directories.admincarries an explicitprincipal:read:admin,role:read:admin, andprincipal_group:read:adminalongside its<resource>:*wildcards, the same shape as the existingprincipal:purge:admin;owner’s>is unaffected. Create, update, and the lifecycle verbs stay two-token: they were never reachable by a non-admin, so only the directory read needed promoting. The console gates the three Settings tabs on the same three-token permission and the route guard reads it from the shared nav map, so the sidebar and the server never diverge. - Context:
deploy(an integrator or field tech) inheritsviewer, whose*:readis a single-token resource wildcard. Because*matches exactly one token,*:readmatchedprincipal:read/role:read/principal_group:read, and the read floor shares that reach, so a field tech could enumerate every user, role, and group over the API (a real 200, not just a visible menu). Promoting the directory reads reuses ADR-0015’s deeper-token rule rather than adding a matcher special case: admin-sensitivity is a third token*cannot reach. Secrets are a separate concern (an operator legitimately reads device secrets in scope), handled by a forthcoming slice that combines placement scope with a per-secret admin-sensitive flag; this ADR is the IAM directories only. - Closes: issue #197.
ADR-0024: a tag key may constrain its values to an enum
Section titled “ADR-0024: a tag key may constrain its values to an enum”- Date: 2026-07-13 | Status: Accepted | Pages: tags
- Decision: A tag key gains an
allowed_valuesset (a newtext[]column, empty by default). Empty leaves the key free-text, unchanged; a non-empty set is the enum a bound value must belong to, soenvironmentcan be declared as one ofprod,staging,dev. The binding write enforces it:SetTagBindingrejects a value outside a key’s non-empty allowed set with a dedicated 422 (ErrTagValueNotAllowed), so the constraint is a real server gate, not a UI hint. The Tags directory create and edit forms carry a value-domain control (a checkbox that turns the 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 offers value autocomplete from the distinct values already bound for it, through a newGET /tags/{name}:valuesread (aselect distinct value), so an operator reaches for an existing value without the key having to declare a set up front. Only the enum (a string set) ships; a typedvalue_type(int, bool, date) and input normalization (lowercase, trim, fold) stay the page’s open question. - Context: The tags page left value-domain governance an open question, with the enum, a
typed value_type, and normalization all on the table. Operators asked first for the plain case, a key like
environmentthat should only ever be one of a short list, so that shipped: a string enum on the key, enforced on write, with a strict picker. The distinct-in-use autocomplete is the free-key counterpart, cheap (oneselect distinct) and immediately useful, so the two ship together. This resolves the enum half of the page’s open question; the value_type and normalization halves remain deferred. - Closes: issue #190 (tag value-domain governance, enum).
ADR-0025: secret is a sensitive resource; a per-secret admin_sensitive flag flips a secret to the :admin tier
Section titled “ADR-0025: secret is a sensitive resource; a per-secret admin_sensitive flag flips a secret to the :admin tier”- Date: 2026-07-13 | Status: Accepted | Pages: identity and access, variables
- Decision: Two orthogonal axes now decide who reaches a secret. Placement scope (the
global/location/system/componententity a secret attaches to on the exclusive arc) gives locality, unchanged. A new per-secretadmin_sensitiveflag gives same-scope sensitivity: when set, every action on that secret is lifted to the:admintier, so a scoped two-token grant (secret:reveal) cannot reach it and onlyadmin(secret:>) orowner(>) may see, reveal, update, delete, or create it. The flag defaults from the secret’ssecret_type(secret_type.default_admin_sensitive: an SNMP community defaults operational, an OAuth2 client secret defaults admin-sensitive) and the row’s own value is authoritative; the column default istrue(a secret is admin-only until marked operational). Enforcement is a capability flag computed at the API (canAdmin= the caller holdssecret:<action>:admin) and passed to the Storage Gateway alongside scope: the gateway hides admin-sensitive rows from a lister/resolver without it, and returns a non-disclosing 404 (not a 403) to a revealer/updater/deleter without it, so a platform credential’s existence and field names are not disclosed through the read, reveal, list, or cascade paths. (One residual: because a secret name is unique per owner, an operator with create scope at the same owner can distinguish a create-collision 409 from a 201, a narrow existence-and-name oracle, no field values. It predates this slice, since operators already heldsecret:createwithoutsecret:read; closing it needs a namespace or create-path change and is a tracked follow-up, not a value-disclosure path.) Separately,secretjoins a sensitive-resource set that a bare single-token*does not reach, in both places*grants read (the direct topic match and the read floor);>(owner), a literalsecret:read, and asecret:*still name it. Soviewer(only*:read) reads no secrets at all (not the directory, not the per-component effective-secrets cascade),operator/deploygain a scopedsecret:read,reveal,create,updateand see and reveal the operational secrets in their subtree, andadmin’ssecret:*becomessecret:>so it reaches the admin tier. The/secretsdirectory, previously all-scope-only, is now scope-filtered. The clientcan()mirrors both the sensitive-set and the:readfloor so the console hides exactly what the server denies. - Context: A field tech setting up a site must create and read back that site’s device secrets (an SNMP
community, a device login), but the platform integration credentials (a Zoom or Microsoft client secret the
collection engine consumes) must never be revealed below admin. A device secret and a platform credential can sit
at the same scope (both global), so placement alone cannot separate them, and a low/medium/high sensitivity
ladder was rejected as arbitrary and hard-fixed to three tiers. A per-secret binary flag reusing
ADR-0015’s
third-token
:adminrule expresses the real distinction without a new matcher concept. Takingsecretoff the bare*wildcard (rather than promotingsecret:readwholesale to:admin, which would deny operators their device secrets) is the one lever that keeps the two-tokensecret:readoperators legitimately hold while stoppingviewer’s*:readfrom reaching it. Negative grants (deny-after-allow) were rejected as a footgun the:admintier and the sensitive-set already cover. This is Slice B of the same visibility rework as ADR-0023; the IAM directories use the:admintier (no legitimate sub-admin reader) and are not in the sensitive-set,variablestays viewer-visible by decision and is not in the set. The move of Secrets, Variables, and Config out of Settings into Catalog is a separate branch, not this slice. - Closes: issue #210.
ADR-0026: Console nav IA: estate values get their own top-level group; the Settings group becomes Admin
Section titled “ADR-0026: Console nav IA: estate values get their own top-level group; the Settings group becomes Admin”- Date: 2026-07-13 | Status: Accepted | Pages: ui
- Decision: The operator console left nav is reorganized around five genera: Catalog (the reusable,
estate-agnostic model), Inventory (the estate instances: locations, systems, components, and nodes), Values
(the operator-set values resolved down the scope cascade: variables, secrets, config), the observed surfaces
(Explore, Alarms, Dashboards, Learn), and platform Admin. Secrets, Variables, and Config are values operators
set on estate entities, so they move from the Settings menu into a Values group of their own, standing
beside Inventory rather than nested inside it as a band. Config’s meaning is fixed as the CI store:
operator-set desired component and system configuration, optionally observed back from the device to detect
drift and reconcile, distinct from platform Settings and from Variables. Inventory gains Nodes (the
collection daemons, a monitored, scope-controlled entity, ungated “soon” until
node:readlands) alongside Locations, Systems, and Components; Interfaces and Tasks are dropped from the nav entirely, since an interface is a facet of a component and a task a facet of a node, not a directory of their own. The Settings group is renamed Admin (Users, Roles, Groups, Audit) and gains an ungated “soon” Settings leaf that reserves the platform-settings-table page. - Context: Settings had become a junk drawer mixing platform governance, platform config, and estate-attached
values. Those three values attach to a single estate entity on the scope cascade (the same genus as a tag
assignment) but are not estate entities themselves, so they earned a home of their own, not Settings, not
Catalog, and not a band folded inside Inventory. This supersedes the “into Catalog” line of ADR-0025 above:
the earlier same-day plan named Catalog, and the decision is a dedicated Values group. Interfaces and Nodes were
first sketched as Inventory children alongside the estate entities; Nodes stayed (a node is monitored and
scope-controlled exactly like a location, system, or component, so it belongs with them, not under Admin), but
Interfaces and the Tasks a node runs were cut from the nav once it was clear each is a facet of one owning
entity’s detail page (a component’s device endpoints, a node’s collection assignments), not a set an operator
browses on its own. The relaxed whole-group-drop (an ungated Settings “soon” stub keeps the Admin group visible
to a viewer, showing only that greyed placeholder while every data-bearing child stays admin-gated and hidden)
is deliberate until the platform-settings backend ships and the leaf is gated on
setting:read:admin. Design:docs/superpowers/specs/2026-07-13-operator-console-nav-ia-design.md. - Closes: issue #222.
- Update (2026-07-14): Files joins the Values group. The files slice (ADR-0029) first shipped the Files directory under Inventory, but a file is not part of the monitored estate (no health, not polled): it is operator-uploaded content. So the Values group broadens from “operator-set values resolved down the cascade” to operator-set values and content, with the (deliberately non-cascading, flat) file as its content member alongside the cascaded variables, secrets, and config (#249).
ADR-0027: create is a route; inventory create and edit unify on the detail accordion
Section titled “ADR-0027: create is a route; inventory create and edit unify on the detail accordion”- Date: 2026-07-14 | Status: Accepted | Pages: design system, core entities
- Decision: The inventory entities (component, system, location) drop the create/edit Drawer. Creating one
is now a route:
Newnavigates to/<entity>/create, a draft accordion where Identity and Placement are writable and the binding sections (Tags, and later Secrets/Variables) are shown locked until the entity exists; Save commits the row and hands off to/<entity>/<id>in edit mode (a one-shot pending-edit flag consumed when the detail resolves, the UsersopenPrincipalInEditpattern). The detail is one accordion, read-only in view and the sole writer in edit: no in-body field or binding mutation control renders while not editing (the footer’s Edit / Delete chrome and the read-only effective-secrets/variables panels are exempt). This is the Users inline-blade-edit model generalised to inventory, and it holds on both the docked blade and the addressable full page. No new routes: the static/createoutranks/:namein the router, socreateis a reserved segment. The sharedTreeListprimitive gains a per-surface edit slot onListCtx(the full page makes its own slot, since the sharedrenderDetailmust not calluseBladeEditoutside a blade provider), plusrenderCreate/onNew/onEdithooks and an optionalFormBody, so a page opts into the model without breaking the others. - Context: Creating an inventory entity returned you to the list, so setting a tag meant find, reopen, edit; and
TagAdderrendered a write control in view. A drawer that opened in edit after create would need a fragile cross-surface hand-off (the code-grounded review of the drawer design surfaced a full-pageuseBladeEditcrash, aFormBodyfooter collision, and a pending-edit gap). Framing create as its own URL dissolved the “create is blade-only” false dilemma: a draft with an address is deep-linkable full-page and dockable as a blade, and Save is a route hand-off, not a surface hop. Own-field edits commit on Save (Cancel reverts them); tag bindings keep their immediate per-binding write, so Cancel does not roll a tag back, and the tag control sits apart from the Save/Cancel form. Slice 2 (a shared cross-page form shell) and slice 3 (moving Users onto it) are deferred. - Closes: issue #231.
ADR-0028: rank retired from the type registries; sort is alphabetical
Section titled “ADR-0028: rank retired from the type registries; sort is alphabetical”- Date: 2026-07-14 | Status: Accepted | Pages: core-entities, Types guide
- Decision:
rankis dropped fromlocation_type,system_type, andcomponent_type: the column (a new idempotent migration), the three API bodies and create/update inputs, the boot-seed YAMLs, the generated client and CLI, and the Types catalog page (no Rank column, no Rank field on create or edit).ListLocationTypes,ListSystemTypes, andListComponentTypesnow order bydisplay_name, idinstead. - Context:
rankwas sort-only from the start (the location_type seed comment already said so: “rank does NOT constrain nesting”), never an enforcement mechanism. The upcomingallowed_parent_typesplacement constraint onlocation_typeneeded a clean field to introduce without a stale, unused ordering column sitting beside it, so retiringrankis the mechanical precursor to that slice rather than part of it: this PR only removes the field and switches the sort,allowed_parent_typesis a separate slice. Alphabetical is the obvious default with no enforcement semantics to preserve; an operator who wants a specific browse order can still rely on the id or display name they chose. - Closes: part of issue #239 (the
allowed_parent_typeshalf continues in a follow-up PR against the same issue). Design:docs/superpowers/specs/2026-07-14-type-placement-constraints-design.md.
ADR-0029: files slice 1, a content-addressed blob store and a tenant-wide file handle
Section titled “ADR-0029: files slice 1, a content-addressed blob store and a tenant-wide file handle”- Date: 2026-07-14 | Status: Accepted | Pages: files and blobs, storage, identity and access
- Decision: The files subsystem ships its first slice: a content-addressed
blobstore as a Storage Gateway primitive (ablob.Storeseam, default pgblobs backend holding bytes inline, keyed by the sha256 of the bytes, dedup viaon conflict do nothing, integrity-verified on read), and afilehandle, searchable metadata (name, content_type, size, sha256, sensitive) that points at a blob by hash, with CRUD over the API, the generated CLI, and the typed client, plus the Files directory (under Values; see the ADR-0026 update). Four calls shape it. (1) No placement arc on a file. A file is tenant-wide, not on the exclusive arc a secret sits on, because a file relates 1:many (to entities and types) rather than 1:1; that locality is a future many-to-many attachment, not an owner column, so the gateway injects no ABAC tree scope on a file query. (This reverses an in-design proposal to givefilea secret-style owner scope.) (2) Sensitivity reuses the secret mechanism, binary, defaulting off. A per-filesensitiveflag reuses ADR-0025’s:admin-tier rule (hidden from a lister without the tier, a non-disclosing 404 to a reader without it, admin-only to create), but defaults false (a file is shared unless marked, where a secret defaults sensitive because it is a credential), andfileis not added to the sensitive-resource set, so the viewer floor (*:read) reads ordinary files. (3) A delete frees its blob synchronously; async GC deferred.DeleteFiledrops the handle and, in the same transaction, frees the blob when no other handle references it (a dedup-aware refcount: a deleted file reclaims its bytes rather than leaking storage, but a blob shared by another handle is kept). The general async mark-sweep GC (for blobs referenced by other things, an aged large log body, acollection.failedraw, an attach event, none of which exist yet) stays a later slice; today afileis the only referencer, so the synchronous check is complete. (4) One backend, base64-in-JSON on the wire. Only the pgblobs backend ships (S3 and disk behind the same seam later); upload and download carry the bytes base64 in JSON, reusing the avatar precedent (ADR-0018) so the whole surface stays under the Huma authz middleware and generates a uniform client and CLI. content_type lives on the file, not the blob: content-addressing is about the bytes, so identical bytes are one blob regardless of declared type. - Context: files.md specified the two-layer model (handle plus content-addressed blob) and an index-probe GC; its open questions (inline-versus-blob threshold, chunking, the grace floor) are untouched here. The 1:many insight is what separated a file’s locality (attachment, deferred) from its access (permission plus sensitivity), and is why the file does not copy the secret owner arc. A full classification + clearance lattice (an ordered ladder on the resource, a clearance on the principal, an external-principal class) was considered for the sensitive axis and split into its own epic (#243) rather than inflating this slice; the binary flag is a 2-rung subset it will subsume. Multipart streaming for very large blobs is deferred with the S3/chunking slice.
- Divergences logged: files.md moved
content_typefrom the blob to the file; the in-design file owner/scope arc was dropped (a file is off the placement arc). Both are reflected in the page. - Lands: epic #242, #244.
ADR-0030: allowed_parent_types constrains where a location may be placed
Section titled “ADR-0030: allowed_parent_types constrains where a location may be placed”- Date: 2026-07-14 | Status: Accepted | Pages: core-entities, Types guide, Work with an entity
- Decision:
location_typegainsallowed_parent_types(text[], default{}): a set whose members arelocation_typeids and/or the reservedrootsentinel (a placement at the top, no parent). An empty set is unconstrained (the default, and every existing custom type until an operator opts in); a non-empty set is enforced: a placement is valid iff the parent is null and the set containsroot, or the parent location’s type is in the set.rootcannot collide with a real type id:CreateLocationTyperefuses it. Enforcement is forward-only, onCreateLocationand the location move path (UpdateLocation’s newParentNamepatch field, added this slice so the “grandfathered until moved” guarantee is real and testable, not merely a claim); an existing placement a type’s set no longer allows is untouched until something tries to move it. The four seeded types get their sets:campus={root},building={root,campus},floor={building,campus},room={floor,building,campus}. Re-parent ships operator-usable this slice: the location edit form’s Placement section makes Parent editable, a picker built on #240’s inventory edit model (the sameShow when={editing()}field/fact split every other editable field on the accordion uses), narrowed to the set and excluding the location’s own subtree; moving back to root is not offered (the move primitive does not support it this slice). - Context:
rank(ADR-0028) was sort-only and never expressed the estate’s real hierarchy rule (a floor does not belong above a room). Achild.level > parent.levelrule was rejected: it does not generalize past locations (systems and components have no total order), while a type-level allowed-parent set expresses both the general “may skip a level” case and the specific “may never be root” or “may never nest under this particular type” cases with one field. A separateroot_placeableboolean was rejected in favor of folding root into the set as a sentinel, keeping one field and one validation path. Enforcing retroactively was rejected: seeding a type’s set must never invalidate an existing estate. Locations had no move or re-parent capability at all before this slice (create-time placement only); the storage/API primitive was originally scoped without a UI trigger (the console’s placement fields render read-only in every edit context today, on all three inventory pages), but the decision changed once #240’s create-as-route edit model landed as the concrete field pattern to hang a reparent picker off: one PR ships the enforcement point and a real way to use it, rather than a primitive an operator cannot reach. The picker’s candidate list is narrowed client-side (a UX nicety); the server-sidevalidatePlacementcall is the actual gate, so a stale or bypassed client filter still gets an inline 422, not a silently-accepted violation. One divergence from the design surfaced while building the move primitive:UpdateLocationchecks placement before the cycle guard, not after, so a move that is simultaneously a placement violation and a structural cycle (moving a location under its own descendant, where the descendant’s type also does not allow this child) reports thePlacementError(422, naming both types) rather than the genericErrLocationCycle; the design left the check order unstated, and the more specific, actionable error was chosen to win. Systems and components loseranktoo but get noallowed_parent_typesthis slice, and keep their existing read-only-in-edit Parent field: a leaf or must-nest constraint there is closer to a boolean than an ordered set, deferred until a concrete need names the shape, and extending the same editable-Parent pattern to two more pages is a follow-up, not bundled here. - Closes: issue #239. Design:
docs/superpowers/specs/2026-07-14-type-placement-constraints-design.md.
ADR-0036: A node is a kind=node principal with an interim bearer credential and static per-connection NATS subject permissions
Section titled “ADR-0036: A node is a kind=node principal with an interim bearer credential and static per-connection NATS subject permissions”- Date: 2026-07-07 | Status: Accepted | Pages: nodes, identity and access
- Decision: A node is a first-class
principalofkind='node'with a 1:1nodedetail table (keyed byprincipal_id, alongsidehumanandservice), exactly as identity and access describes. Itsnameisnot null uniqueon the detail table and stays the estate address the collection FKs (interface.node_name,task.node_name,metric_datapoint.node_id) reference. The node runtime ships with two deliberate calls that diverge from the present-tense design, both reversible in a later hardening slice. (1) The node’s credential is a bearercredentialrow on its principal, minted, stored (only assha256), and verified through the same helpers a service bearer token uses (AuthenticateBearer), and the enrollment token doubles as the node’s NATS password (a shared secret), rather than being a single-use bootstrap exchanged for a distinct long-lived credential. The decentralized nkey/JWT operator-account model that identity and access describes for nodes (anatscredential kind, a signed nonce, a JWT carrying the node’s subject permissions) is deferred; thecredentialkind CHECK is not widened for it here. (2) Per-node NATS isolation is static per-connection subject permissions: the embeddednats-serverruns an in-processCustomClientAuthenticationcallback that resolves each connecting node by name, verifies its bearer credential, and registers a user whose publish/subscribe grants are scoped to that node’s ownog.v1.*.<node>subjects, so a node cannot publish or pull as another. - Context: Checkpoint 2 of the reachability slice needed a real, negatively-tested per-node isolation
mechanic against an embedded server, without carrying the full JWT/nkey machinery a single slice should not.
The auth-callback path adds per-node users dynamically at enrollment time with no config reload, which is
the simplest mechanism that keeps the isolation invariant real: the negative test proves node A cannot use
node B’s subjects (and a confused-deputy reply cannot forge another node’s liveness), and a wrong credential
is rejected at connect. The subject encodes the node name in its last token and the callback grants only that
node’s subjects, so the subject is the transport isolation boundary (the payload-owner admission fence is
a later checkpoint). Modeling the node as a
kind=nodeprincipal (rather than the standalone table an earlier checkpoint built) puts it on the shared identity spine from the start: it has a realprincipal_idso it can be an audit actor, its credential rides the audited human/service machinery, and only the credential scheme (interim bearer vs nkey/JWT) remains to tighten. JetStream is enabled on the server now (it boots and shuts down cleanly), but the control-plane messages (worklist, heartbeat) are JSON over core NATS; the protobuf telemetryEventover JetStream is the next checkpoint. - Closes the gap: the nkey/JWT node identity (the
natscredential kind and the signed-nonce admission) and the single-use enrollment token are tracked with the node-identity hardening slice.
ADR-0037: Telemetry is a protobuf Event over JetStream with an inline owner-confining consumer
Section titled “ADR-0037: Telemetry is a protobuf Event over JetStream with an inline owner-confining consumer”- Date: 2026-07-07 | Status: Accepted | Pages: collection, datapoints
- Decision: A node ships each collected batch as a protobuf
Event(proto3,proto/og/v1/event.proto,Event+Datapointmessages only, no gRPC service) published toog.v1.telemetry.<node>. This is omniglass’s first protobuf; the wire is generated withprotoc+protoc-gen-govia agen-protostage onmake gen, and the generatedevent.pb.gois committed. The server hosts a JetStream stream (OG_TELEMETRYoverog.v1.telemetry.*) and a single durable consumer (og-telemetry-worker, AckExplicit) whose handler, per Event, derives and writes inline: it decodes the batch, resolves the owner as the task’s interface component, confines the node to its own tasks, applies reject-not-project against thedatapoint_typeregistry, writes the surviving typed rows through the checkpoint-1InsertMetricDatapointspath (owner_kind=component,provenance=observed), and acks. A permanent condition (an undecodable payload, or an orphan the confinement fence drops) is terminated/acked so it is not redelivered; only a transient failure (a DB error) is left unacked so JetStream redelivers. The node stamps no component identity: its only assertion is the publishing subject (its own name) plus thetask_id; the server binds and confines. - Context: The prior (v2) design split telemetry into a hot path that persisted a raw event to a
telemetrytable and an async Postgres queue worker that derived from it. Checkpoint 3 deliberately collapses that split: the JetStream durable consumer is the at-least-once worklist, so there is no raw-telemetry table and no Postgres queue in this checkpoint; the handler derives, confines, writes, and acks in one place. This keeps the reachability slice small while keeping the two invariants real and negatively tested: a node cannot land a datapoint for a component it holds no task for (an Event carrying another node’stask_idis orphan-dropped, no row written), and an unregistered datapoint name is dropped, not projected. Owner binding is the interface-prebind path only (task -> interface -> component); there is no separately-authoredtransform_rule(omniglass has none), so label-based multi-owner routing, discovery rules, and node-self binding are a later checkpoint. - Closes the gap: raw-
Eventpersistence (backfill/replay) and the raw -> admission -> trusted two-lane topology, plus label-based multi-owner resolution, are tracked with a later collection checkpoint.
ADR-0038: The reachability verdict is a built-in state
Section titled “ADR-0038: The reachability verdict is a built-in state”- Date: 2026-07-07 | Status: Accepted | Pages: datapoints, collection
- Decision: The per-interface reachability verdict
interface.reachable(value domainup/down) is a first-class state datapoint, not a metric, seeded as an officialdatapoint_typeatkind=state,value_type=text,validation: {values:[up,down]}. It is gated per interface: the verdict is the AND of that interface’s applicable probe results (for the inline tcp/icmp interfaces this is degenerate, one probe drives the verdict; it generalizes to an interface with several probes). The node computes it after running the interface’s probe(s) and emits it as anobservedstate datapoint instanced by the interface; the ingest consumer routes by the registry kind (a metric name tometric_datapoint, a state name tostate_datapoint) after the same owner-confinement and reject-not-project, so a foreign or unregistered state is dropped identically to a metric. 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). Availability istime_in_stateover this state (health’s primitive one tier down), a later slice; the raw probe metrics (tcp.open,icmp.reachable, the rtts) keep emitting unchanged. Readiness config (an ssh command + regex, an snmp OID) is an interface-type default, interface-overridable concern executed on the node, not a server-sidecalc_rule; 5a builds no readiness-config column, its verdict is the inline probe result. - Context: Reachability history is only honest if the verdict is a dwell-measurable signal: availability
is time-in-state, which needs a categorical state with transitions, not a numeric sample per tick. Modelling
the verdict as a metric would conflate the raw per-probe reading (
tcp.open, a firehose sample) with the interface-level judgement (an availability substrate), and it would maketime_in_statea re-derivation over a numeric series rather than a read over the state’s own transitions. Making it a state, and computing it at the node as the AND of the interface’s probes, keeps the verdict where the probe results are, keeps the raw metrics untouched, and lets the read side reconstruct the availability strip directly fromstate_datapoint. - Divergence: checkpoint 1 seeded the
datapoint_typecanon metric-only (the reachability probe metrics), and cp3’s ingest consumer assumed every surviving datapoint was a metric (InsertMetricDatapointsfor all). This entry records the divergence: 5a adds the first state to the seed and makes the ingest consumer route by kind (the cp3-deferred “route by kind, not assume metric” note now come due). Thestate_datapointtable mirrorsmetric_datapoint(same owner exclusive-arc, same lineage CHECK) with a categoricalvalue textplus an optionalvalue_json. - Closes the gap: the availability SLI (
time_in_stateoverinterface.reachable) and the operator surfaces that render the transitions are a later slice (5b); readiness config as an interface-type default is a later interface-type concern.
ADR-0039: An interface is a device API; the interface type is its transport, not its driver
Section titled “ADR-0039: An interface is a device API; the interface type is its transport, not its driver”- Date: 2026-07-08 | Status: Accepted | Pages: collection, nodes
- Decision: An
interfaceis an API endpoint we intend to call on a component, identified by the protocol it speaks (web,qrc,ttp,snmp), not a network interface; a host or IP is a variable it consumes, not its identity. It is named by that protocol and is unique within its component (unique(component, name)), never a hand-typed label. Two axes are decoupled: the transport (how bytes move) and the driver (the protocol handler that produces the normalized functions and datapoints).interface_typeis the transport (ssh,tcp,http,snmp,udp,telnet,icmp): a node-side wire capability that also carries the default reachability probe (tcp/ssh/http open the port, icmp pings). Reachability is the first gate of a ladder (reach to auth to responds to collecting) and needs only the transport. A driver is the collect layer: a protocol handler plus the transport(s) it can run over plus the normalized catalog (functions and datapoints, how to fetch them as commands/OIDs/paths, parse, a version). The same handler can run over several transports (a CLI oversshortelnet), so the driver declares its transports and the instance picks one; a genuinely different grammar over a different transport (an ssh CLI vs a tcp JSON-RPC) is a different driver producing the same catalog. Device-specific fetch detail lives in the driver, never the template:snmpis the transport, abiamp-snmp(orgeneric-snmp) driver holds the OID map. The entities then split on CAN / SHOULD / IS: the driver owns what a device family CAN do and how (transports, catalog, normalization, discovery rules, version); a template (per model) owns what an operator SHOULD watch and how it looks (curate the driver’s menu to a default subset, thresholds and event rules, an icon); theinterfaceinstance owns what IS actually there (transport, host, credentials, a driver when it collects, the discovered subset, per-device overrides). Discovery is a driver rule whose result lands on the instance; filtering-for-choice is a template default plus an instance override; capability is the driver. The reusable driver is data on one generic engine (a declarativecanonical datapoint <- fetch <- parse), official or org-custom via the(namespace, id)shadow registry, with a pluggable-Go escape hatch only for a wire the engine cannot express; a “device pack” bundles a driver plus a template, and a template declares its driver deps (version-pinned) so a missing or shadowed driver surfaces, never silently misbinds. The house<entity>/<entity>_typepattern holds:interface_typeis the transport (a reachability interface’s type genuinely is its transport), anddriverearns its own registry (SNMP and multi-transport protocols prove it folds into neither transport nor template). - Context: The 5a build named interfaces by a hand-typed string (
boardroom-tcp) withtype= the probe (tcp/icmp), which conflated identity with transport and implied operators name and wire-configure devices by hand. The reframe: operators are not programmers, so the value is a driver that normalizes a device family into a pick-from menu, which makes the template a light curation, policy, and presentation layer and means the operator never authors a protocol. You cannot cleanly split “how you talk” from “what you say” (the command is both), so the seam is elsewhere: the transport is the reusable connection, the driver is the reusable normalized menu over it, and the template is a selection plus policy. Keeping the driver as data (not Go per family) is what makes it community-shippable; growing the canonical menu device-by-device (not a universal ontology up front) is what keeps it honest; separating menu-of-types from discovered-instances is what fits programmable devices (a DSP’s blocks are per-install); versioning the driver is what lets a template’s picks resolve as the menu matures. - Scope now (tier-0): this slice (#114) ships only the first gate.
interface_typeis the transport primitive (icmp,tcp,ssh,httpseededbuilt), each carrying a tcp-connect or ping reachability probe; aninterfaceis named by its protocol and typed by its transport; the dev seed models a lab polaris DSP with aweb(http) and aqrc(tcp) interface, the “two APIs on one device” story. The driver catalog, normalization, discovery, templates, versioning, and the shadow-resolved device pack are later slices of the collection epic (slices 2 to 4 realize this model). - Refines: ADR-0038 (the reachability verdict is the first rung of the gate ladder this ADR names).
- Status note (2026-07-08): the
interface = API/interface_type = transporthalf is built and stable (this slice). The driver / collect layer (the separatedriverentity, the normalized menu, and the driver-centric split itself) is under active design: it departs from the original template-centric architecture (where protocol handling lived in the template), which is a serious enough change to redesign deliberately rather than on momentum. Recorded here as the current-best direction, not a locked gate; driver-centric vs template-centric is re-examined, and this ADR revised or superseded, in a later ADR before the collect layer is built.
ADR-0040: The task is derived read-only plumbing, projected from its interface
Section titled “ADR-0040: The task is derived read-only plumbing, projected from its interface”- Date: 2026-07-14 | Status: Accepted | Pages: collection, api
- Decision: The
interfaceis the only authored collection primitive; thetaskis derived. Creating an interface derives its one poll task, so the task surface is read-only (GET /tasks,GET /tasks/{id}only): thePOST/PATCH/DELETE /tasksroutes and thetask:create/task:updategrants are removed. A task carries no node column;task.node_nameis dropped and its placement is projected frominterface.node_name, so the worklist and the telemetry owner-confinement join the interface rather than reading a task-local node. A node purge cascades its interfaces and their derived tasks (interface.node_nameandtask.interface_idareON DELETE CASCADE). - Context: The checkpoint-5d build gave both primitives a full CRUD surface and a node placement of their own. That let an operator author a task divorced from its interface, and left a task’s node and its interface’s node as two independently-set fields that could disagree. The reframe makes the interface the one thing an operator authors (an API on a component, ADR-0039): a reachability check is an interface, its poll task is the plumbing that runs it, and placement is a property of where the interface is reached from, stated once. This is the honest shape for the reach tier; the richer driver-authored collection surface (multiple functions over one interface) is a later slice and does not reintroduce operator task CRUD.
- Refines: ADR-0039 (the interface is the authored API; this ADR settles that its task is derived, not co-authored).
ADR-0031: component_make registry slice 1, an official boolean, a deferred referential guard, and website scheme validation
Section titled “ADR-0031: component_make registry slice 1, an official boolean, a deferred referential guard, and website scheme validation”- Date: 2026-07-14 | Status: Accepted | Pages: core entities, Vendors guide
- Decision: Three calls on the first slice of the
component_makemanufacturer registry (id, display_name, icon, support_phone, website), lands ahead of the rest of the make/model catalog. (1)official boolean, not anoriginenum. The design sketch (below) proposedorigin official | seed | customon make and model, matching the model layer’s eventual needs. Slice 1 ships a plainofficialboolean instead, becausecomponent_typeand the other registries already distinguish seed-owned from operator rows with a boolean, and a two-value distinction gains nothing from a three-value enum until a realseed(installed, mutable) tier exists to fill it;origincan still land oncomponent_modelif that tier turns out to be real. (2) The in-use / referential delete guard is deferred.component_type,location_type, andsystem_typeall refuse a delete while a location, system, or component still references the row (409).component_makeships no equivalent guard: nothing references acomponent_makeyet (component_model, the referencing entity, does not exist), so a custom make deletes unconditionally (an official row is still refused, 422, the seed-owned rule). The guard is added whencomponent_modellands and gives the registry something to be in-use by, rather than building an unused check now. (3) Website URL scheme validation, client and server. The create/edit form renderswebsiteas a live anchor; an operator-entered value with no scheme check is a stored-XSS vector (javascript:/data:executing on click). AvalidWebsiteSchemeguard on the API (http/httpsonly, empty allowed, else 422) and a matchingsafeUrlguard on the console (render a live link only when safe, else plain text, never a dead or unsafe anchor) close it in both places: server-side so a non-browser caller (CLI/curl) cannot persist a dangerous scheme, client-side so a value written before the server-side check existed (or by any path that bypassed it) still renders safely. - Context:
docs/superpowers/specs/2026-07-14-component-make-model-catalog-design.mdsketches the full make/model catalog (component_make, acomponent_typegenus tree,component_model, andcomponent.model_id) as four independent vertical slices; this is slice 1, make alone, with no dependency on the tree or the model layer. A review pass on the first cut of the console page (Task 4) found the missing website-scheme check as a stored-XSS gap before this shipped, closed in the same slice rather than carried as a follow-up. - Divergences logged: the design sketch’s
origin official | seed | customenum is not what shipped;official booleandid, per (1) above. The design’s delete-refused-while-referenced rule is not enforced yet; per (2), it is deferred to thecomponent_modelslice that gives it something to check. - Lands: epic #254, issue
#255. Design:
docs/superpowers/specs/2026-07-14-component-make-model-catalog-design.md. Plan:docs/superpowers/plans/2026-07-14-component-make-registry.md.
ADR-0032: the required permission is published per route, and the permission universe is route-derived
Section titled “ADR-0032: the required permission is published per route, and the permission universe is route-derived”- Date: 2026-07-17 | Status: Accepted | Pages: identity and access, API, Access guide
- Decision: Every capability-gated route registers through one helper,
gated(op, tokens...), which sets theauthn+requiremiddleware (unchanged enforcement), stamps the operation with anx-omniglass-permissionOpenAPI extension, and records the permission in an in-process registry. The required permission for each request is therefore published in the generatedapi/openapi.json, and the permission universe (the deduped set of every stamp) is derived from the routes, not a hand-kept catalog.GET /rolesreports the universe plus, per role, the held subset (resolved by the samerbac.Set.Allowsmatcher as the effective set), and the console role blade renders it as a netHeld / Missing / Allview. Two build-time guards keep it honest: a published-gate guard (every gated route is stamped, allow-listed routes are not, so “gated” and “published” are the same set) and a seed-drift guard (every seed-role grant resolves into the universe or sits in an explicitaheadOfRoutesallow-list). - Context: the authz contract already existed (
require(...)enforced a permission on every route) but lived only in Go middleware, invisible to the spec, the clients, and any reader; and a role blade could show only what a role granted, never the capabilities it lacked. Three options were weighed for the universe source: a hand-kept catalog YAML (drifts), a runtime-only set (invisible in diffs), and the route-derived stamp (self-maintaining, reviewable in the committed spec, exactly the enforced surface). The stamp won because it makes the universe fall out of the API-first pipeline with no second source to drift. Held is resolved server-side so the single rbac matcher is not duplicated in the SPA. Grants that resolve to nothing (for examplealarm:*,interface:*before those subsystems have HTTP surfaces) are legitimate but ahead of their routes; they show as held-nothing and are allow-listed until the route lands. - Lands: issue #272 under epic
#27. Design:
docs/superpowers/specs/2026-07-17-net-permissions-role-blade-design.md.
ADR-0033: settings persist only the override level; base layers are recomputed in memory
Section titled “ADR-0033: settings persist only the override level; base layers are recomputed in memory”- Date: 2026-07-17 | Status: Accepted | Pages: settings, scaling and deployment
- Decision: The settings engine stores only the override level in Postgres (
setting_override). The two base layers, the embeddedcodedefaults and the operatorfile, are recomputed into memory on every boot and never written to the table, so the effective document is the in-memory base layers merged with the live DB override. Restore is therefore aDELETE: dropping a namespace’s override row (or truncating the scope) re-exposes the base defaults, with no separate reset column and no re-seed of the file into the store. - Context: The scaling page sketched a single settings store “materialized in
Postgres … seeded declaratively from a settings file reconciled on every boot” (an
ON CONFLICT DO UPDATEof the file into the table). Building the engine showed that materializing the file into the DB is the wrong shape: it duplicates the GitOps source into a second authoritative copy that can drift, and it conflates ship-with defaults (a compile-time asset) with operator changes (the only thing worth persisting). Keeping the base layers in memory makes the file always-fresh (a ConfigMap change lands on restart), keeps the store lean (it holds only what an operator actually changed), and makes restore fall out of the model as a delete rather than a re-seed. This diverges from the scaling page’s “materialized in Postgres” wording; the settings page carries the corrected model and the scaling page moves toPartial. - Closes: issue #271 (settings engine slice-0), under
epic #270. Design:
docs/superpowers/specs/2026-07-17-settings-engine-design.md. - Amended by ADR-0057: the model is unchanged, the level names are not:
codeis nowdefault(off the axis, the setting’s own declaration) andglobalis nowplatform(the install-wide rung).
ADR-0034: the settings Gateway is unscoped; only the permission gates it
Section titled “ADR-0034: the settings Gateway is unscoped; only the permission gates it”- Date: 2026-07-17 | Status: Accepted | Pages: settings, storage, identity and access
- Decision: The Storage Gateway methods for settings (
GetSettingOverrides,UpsertSettingOverride,DeleteSettingOverride,DeleteAllSettingOverrides) are unscoped: no ABAC storage-scope predicate is injected. Only thesettings:<action>permission at the route gates them (settings:readadmin read with provenance,settings:updatewrite / restore / lock, both admin-tier; the client-safe/settings/meis authn-only). This is a deliberate carve-out from the “scope on every applicable query” invariant, recorded so it reads as intentional. - Context: The two authorization layers (identity and access) are a
<resource>:<action>permission on every route and an ABAC scope injected on every applicable query. Platform and cascade settings describe the platform and its principals, not the estate, so there is no location / system / component subtree to scope them by, exactly as with the registry-type reads (GET /types/...), which are also unscoped. Forcing a scope predicate here would be meaningless (there is nothing to filter on) and would misrepresent settings as estate data. The carve-out is narrow: it applies only because the data is platform config. When the group and user override rungs land, override reads and writes will be constrained by the acting principal (a user edits only their ownuserrow), but that is a per-principal ownership check, a different mechanism than estate ABAC, not a return of tree scope. - Closes: issue #271 (settings engine slice-0).
- Amended by ADR-0057: the model is unchanged, the level names are not:
codeis nowdefault(off the axis, the setting’s own declaration) andglobalis nowplatform(the install-wide rung).
ADR-0035: settings resolve as a cascade over principals with a broader-wins lock
Section titled “ADR-0035: settings resolve as a cascade over principals with a broader-wins lock”- Date: 2026-07-17 | Status: Accepted | Pages: settings, cascade
- Decision: A setting’s effective value resolves down the principal hierarchy (global to group to user),
reusing the same cascade primitive the estate uses down location to system to
component: ordered layers deep-merged in JSON map-space (most-specific-wins by key presence), with per-key
provenance (the winning level) reported alongside the value. Layered on top is a top-down lock: an admin
locks a key at a level, pinning that level’s value and forbidding any more-specific level from overriding it, and
when two levels lock the same key the broader level wins (a
globallock supersedes agrouplock, so top-down admin authority is absolute). Slice-0 ships the global rung; group and user are a fast-follow. - Context: Omniglass already had one cascade resolver (the estate’s secrets / variables / tags / config,
config and credentials). Rather than write a second resolver for settings, the engine
points the same primitive at the identity axis (doctrine 5, primitive-first): a value defined once at a broad
scope inherits below, which is exactly the reuse a variable-reference model (Windmill-style) would buy, provided
here by inheritance for free. The lock is the piece the estate cascade did not need: settings are governance
(an admin enforcing an org default a user cannot escape), so the engine adds a per-key lock with a broader-wins
conflict rule, the inverse of the most-specific-wins value rule, applied to the enforcement axis. Provenance
reuses the estate’s effective-values vocabulary (the winning level per key), extended from three estate bands to
five principal levels plus a lock chip. The pure
settingspackage is the primary unit-test target; the DB override is supplied through a narrow function seam so the package never imports storage. - Closes: issue #271 (settings engine slice-0), under epic #270.
- Amended by ADR-0057: the model is unchanged, the level names are not:
codeis nowdefault(off the axis, the setting’s own declaration) andglobalis nowplatform(the install-wide rung).
ADR-0036: retire the standalone effective-secrets and effective-variables per-component panels; fields become the component value surface
Section titled “ADR-0036: retire the standalone effective-secrets and effective-variables per-component panels; fields become the component value surface”- Date: 2026-07-16 | Status: Accepted | Pages: config, secrets, and variables, identity and access, API
- Decision: The standalone per-component Effective secrets and Effective variables panels are removed,
along with their
GET /components/{name}/effective-secretsandGET /components/{name}/effective-variablesroutes (and the generatedomniglass effective-secret list/effective-variable listcommands and the matching typed-client methods). A component’s value surface is the field primitive: a component’s values are its fields, each resolving override-versus-type-default and shown in the Effective fields panel. A secret or a 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 unchanged: 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. - Context: The per-component effective-* panels predated the field primitive and listed every
cascade-resolving cell that reached a component, which at any real depth is mostly inherited noise (a global SNMP
community, a location poll interval) rather than anything set on that component. The
field primitive
(#266) is the schema-over-cells consumer the design always
intended: a component carries a typed set of fields, each resolving to a set literal or its type default, and the
intended
sourcesmodel lets a field draw its value from a variable, a secret, a datapoint, or a file. Once fields are the value surface, a second per-component cascade browser over the raw cells is redundant and misleading (it reads as though the cells attach to the component when they only resolve onto it). Retiring the panels narrows the component detail to its fields and keeps the cells’ own management on the Secrets and Variables directories, where the cascade is authored. The resolvers stay because the interpolation consumer ($sec:/$var:) still needs them; only the browse-panel surface retires. - Closes: issue #281 (retire the per-component effective-secrets / effective-variables panels), under the field epic #266.
ADR-0041: settings are a reflected typed struct with generated client and server validation
Section titled “ADR-0041: settings are a reflected typed struct with generated client and server validation”- Date: 2026-07-19 | Status: Accepted | Pages: settings
- Decision: A setting is declared once, as a tagged field on a canonical
SettingsGo struct (internal/settings/schema.go), and that single declaration is the whole source of truth. Reflection over the struct produces thecodedefaults layer (Defaults(), from each leaf’sdefault:tag) and the namespace registry (Namespaces(), from thejsonandsettings:tags), replacing the hand-kept embeddeddefaults.yamland the hand-keptNamespaces()slice (both retired). Huma reflects the struct into the OpenAPI schema, somake genyields the typed SPA clientvalues(aSettingsstruct, not a free-form object). Writes validate against that same reflected schema on both sides: the server backstopsPATCH /settings/{namespace}(unknown namespace to 404, unknown key / wrong type /enumorpatternviolation to a 422 naming thenamespace.key,nullallowed as a delete), and amake genstep slices the field constraints out ofapi/openapi.jsoninto a committed client artifact (web/src/api/settings.schema.gen.ts) that drives inline form validation (enum-as-select, Save blocked while invalid). The cascade merges partial generic maps as before; typing lives only at the edges (the effective read unmarshals intoSettings, and Go code reads a setting through theEffectiveTypedaccessor). - Context: Slice-0 shipped the engine with untyped values: a setting lived in two hand-kept places (the
Namespaces()slice and the embeddeddefaults.yaml), the API exposedvaluesas a free-form object, the generated client typed it asRecord<string, unknown>, and the PATCH write accepted any namespace, key, or value and stored it as-is (the documented write-validation thin cut). That is the one surface that dodged doctrine 1 (API-first, typed, generated). MakingSettingsa reflected struct pulls the default, the schema, the typed client, both validators, and the typed accessor from a single declaration, so adding a setting is one tagged field and there is no second place to drift. The cascade keeps merging partial maps because a Go struct cannot express “unset” versus a zero value; typing is applied only at the edges. This closes the slice-0 write-validation thin cut and retires thedefaults.yamlasset and theNamespaces()list. - Deferred: the declarative operator-file machinery (a generated JSONSchema for the operator
settings.json, validation of the file layer at boot, and letting the file layer take precedence over the database, the GitOps-wins / read-only lever) is a future slice on the same epic, as are operator-open namespaces (a typed map with aDefault()method) and the group and user cascade rungs; none is built here. - Closes: issue #288 (settings engine slice-1), under epic #270.
ADR-0042: Field cascade and the type-default floor
Section titled “ADR-0042: Field cascade and the type-default floor”- Date: 2026-07-19 | Status: Accepted | Pages: config, secrets, and variables
- Decision: A field’s resolved value is deepest-set-wins down the field arc
product -> location -> system -> component: a value set at any scope beats every broader scope. When nothing is set at any scope, the value falls to the field’s type default. The type default is therefore the floor of the cascade, not a competitor to it: a value set at any higher scope always beats the default, and no cascade rule is bent to make that true. Raised during design as “does a value set higher in the cascade beat the type default?”, the answer is yes, and it costs the model nothing, because the default is simply the bottom rung. - Context: The override-rendering slice needed the resolution rule pinned before the renderer could say
what “inherited” means. Modelling the type default as a competing scope would force an ordering
question at every read (does a
locationvalue or the type default win?); modelling it as the floor removes the question: any set value at any scope wins, and the default is what remains when the arc is empty. This slice is component-only: resolved = this component’s set value, else the type default, so the deeper arc (product,location,system) is drawn in the model but not yet walked. The multi-scope cascade itself lands later. - Closes the gap: the multi-scope cascade is tracked by #291; this ADR settles only the resolution rule and the type-default floor.
- Amended by ADR-0057: the resolution outcome is unchanged (any value set at any scope beats the default, and the default is what remains when the arc is empty), but the default is off the axis, a column on the definition row, rather than the cascade’s bottom rung. The vocabulary moved; the rule did not.
ADR-0043: The property catalog
Section titled “ADR-0043: The property catalog”- Date: 2026-07-19 | Status: Accepted | Pages: config, secrets, and variables
- Decision: The
datapoint_typecatalog is generalized into a primitive-agnosticpropertycatalog: one typed catalog whose entry (a property) is a canonical, typed name that a datapoint observes and a field declares, identified by akey(its canonical name). The physical table isproperty; the concept, the API resource (/properties), the GoPropertytype, and the console all readproperty, while a property’s identifier (itskey, and thefield_definition.keyreference) stayskey. Four shape changes folddatapoint_typein: the unused(scope, name)ladder (org/templatenever had an operator write path,template_idwas a dangling column) collapses to anameprimary key plus anofficialboolean (seed-owned rows are read-only);value_typebecomesdata_typeover the unified set{string, int, float, bool, json}(textbackfills tostring,boolis added);kind(metric/state/log) becomes nullable, since a declared-only attribute property has no observed kind; andvalidationis a JSON Schema fragment (pattern/enum/minimum/maximumand, for a json-typed property, a nested object schema), enforced by Huma’s own validator with the stored schema loaded throughyaml.v3, so there is no new dependency. Value and source tables continue to key by the name string (no foreign key, reject-not-project at ingest exactly as before), so the rename is behavior-preserving: the collection registry, the reachability BFF, and the metric/state sinks keep working unchanged. - Context: Datapoints already had a typed canonical-key catalog (
datapoint_type: name, value_type, display_name, unit, validation, kind), while fields, variables, secrets, and tags had no catalog at all, an operator typed a key and it was registered nowhere. Rather than build a parallel table, this slice makes the one catalog primitive-agnostic soserial_numberis the same concept whether a device reports it (observed, a datapoint) or an operator types it (declared, a field). Theofficialboolean chassis is chosen over finishingdatapoint_type’s never-built scope-shadow precedence: it is the proven, finished model the*_typeregistries already use. - Values reference the key, not a
<primitive>_keylayer: the only binding is the type-schema (field_definitiongaining akeyreference so a field draws from the catalog), which is PR-B, not this slice. Provenance rides the value (an observed metric versus a declared field value share the key), so reconciliation of declared-versus-observed sources needs no middle table; it is deferred. - Deferred: the
field_definition.keyreference (PR-B); the type-schema editor (how acomponent_typeselects properties); reconciliation (the declared-versus-observed drift signal); a console editor for the validation JSON Schema (set via the API for now); variables/secrets/commands/tags adopting akeyreference; and an operator shadow of an official property. - Closes: issue #297 (the field catalog, expanded to the property catalog), under epic #266.
ADR-0044: The component classification catalogs
Section titled “ADR-0044: The component classification catalogs”- Date: 2026-07-20 | Status: Accepted | Pages: core entities
- Decision: The
component_makecatalog is generalized into avendorcatalog carrying akind(manufacturer/integrator/developer), so the one organization that makes, integrates, or writes for a component is a single reference entity rather than a make-only registry. Two new leaf catalogs join it as the rest of the component-classification reference data: adriver(id,display_name,version, the software that speaks to a component) and acapability(id,display_name, a thing a component can do). Each of the three is a gated CRUD Catalog console page (/vendors,/drivers,/capabilities), reusing theofficial-boolean chassis the type and property registries already use: seed-owned official rows are read-only (an official row refuses update and delete, 422), a custom row is full CRUD gated by the resource’s<resource>:create/:update/:deletepermission and audited in the same transaction. The official rows are seeded at boot. This is a pure classification slice:product(the specific model an organization sells), theproduct_capabilitylink, and thecomponent.productpointer that binds a component to its product are the next slice, not this one. - Context: The estate model is shifting from the make/model catalog sketch toward a fuller classification
vocabulary: property / event / command on the signal side (property landed in
ADR-0043) and vendor / product / driver / capability / standard / role /
health on the component side. This is PR2 of that shift.
component_makewas manufacturer-only, but the same organization concept covers an integrator who installs the estate and a developer who writes a component’s software, so generalizing make into akind-tagged vendor is the honest widening rather than three parallel organization registries. Driver and capability are leaf catalogs (no tree, no cross-references yet), so they ship as the plain seeded-plus-CRUD pattern the registries already prove; they gain their bindings (a driver to an interface / product, a capability to a product) when the product slice gives them something to reference. - Deferred:
product,product_capability, andcomponent.product(the next slice); a referential delete guard on vendor / driver / capability (nothing references them yet, exactly ascomponent_makeshipped with no guard untilcomponent_modelwas to land); and an operator shadow of an official row. - Refines: ADR-0031
(the
component_makeregistry is renamed and generalized tovendorwith akind; itsofficial-boolean, deferred-delete-guard, and website-scheme-validation calls carry over unchanged).
ADR-0045: The product catalog
Section titled “ADR-0045: The product catalog”- Date: 2026-07-20 | Status: Accepted | Pages: core entities, Products guide
- Decision:
productis a first-class catalog entity, the concrete SKU (a Cisco Room Bar, a Samsung QM55) that ties the ADR-0044 leaf catalogs together. A product carries a stableidanddisplay_name, akindfrom a fixed enum (device/app/service/vm, defaultdevice, enforced by a DB CHECK and at the API edge), an optionalvendor_id(who makes it) anddriver_id(what talks to it), an optionalparent_product_id(a self-reference: a variant points at its base product), and theofficialboolean the type and classification registries already use. The capabilities a product provides are a many-to-many set in theproduct_capabilityjoin (a video bar provides microphone, speaker, camera, codec); setting capabilities on an update replaces the whole set. It is a gated CRUD Catalog console page (/products) on the same chassis as the leaf catalogs: seed-owned official rows read-only (update and delete 422), custom rows full CRUD gated byproduct:create/:update/:deleteand audited in the same transaction, official rows seeded at boot. Crucially,component.product_id(on delete restrict) now points a component at the product it is: the product is the source of a component’s shape (its vendor, driver, and capability set), replacing thecomponent_type-as-shape notion. The restrict FK is the referential guard the leaf catalogs deferred: a product still referenced by a component cannot be deleted (409). The vendor, driver, and parent FKs areon delete set nullinstead (deleting a vendor nulls a product’s pointer, it does not block). - Context: The estate model is shifting toward property / event / command on the signal side and vendor /
product / driver / capability / standard / role / health on the component side.
ADR-0044 landed vendor, driver, and capability as leaf
catalogs with nothing to reference them; product is PR3, the layer they were built for and the first
consumer of all three. A component’s shape used to be a job for a
component_typegenus; binding a component to a product instead makes the shape data-driven from the SKU (the same product supplies the same vendor, driver, and capabilities to every component that is one), which is whycomponent.productis arestrictFK, notset null: a product in use is load-bearing for its components. - Deferred: a product’s own template or field-schema binding; and the remaining component-side catalogs (standard, role, health).
- Supersedes: the
component_type-as-shape notion (a component’s shape now comes from itsproduct, not its genus type). Consumes ADR-0044 (product is theproductlayer that ADR deferred; it references vendor, driver, and capability). One divergence from the leaf catalogs’ prediction: their deferred delete guard lands only as thecomponent.productrestrict (409), while a product’s own vendor / driver references null out (on delete set null) rather than blocking the referenced row’s delete.
ADR-0046: The event log-kind sink
Section titled “ADR-0046: The event log-kind sink”- Date: 2026-07-20 | Status: Accepted | Pages: core entities, datapoints, data collection, API, Nodes and reachability guide
- Decision: A collected log-kind observation now has a durable home. A new
eventtable is the log-kind sink of the collection pipeline, the counterpart ofmetric_datapoint/state_datapoint: where a datapoint records a sampled present value, aneventrecords a past occurrence (a device log line, a structured frame). It carries the same datapoint owner exclusive-arc (owner_kindpluscomponent_id/system_id/location_id/node_id, one-set CHECK) and the same provenance vocabulary (observed/calculated/intended/declared, defaultobserved) as the datapoint sinks, plus amessage(text) and structuredattributes(jsonb). The ingest consumer’sderiveDatapointsnow returns metrics, states, and events, and the persistence path callsInsertEvents: a log-kind datapoint that used to be dropped at ingest (it had no sink) is routed toevent, ridingstring_value(its message) orjson_value(its attributes), under the same owner-confinement and reject-not-project gates as the metric and state sinks. A boot-seed propertysyslog.line(kindlog) is the canonical log-kind 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, provenanceobserved) andListComponentEvents(name, since, limit)(newest first); the read routeGET /components/{name}/events(operationIdlist-component-events, gatedcomponent:read, non-disclosing 404 out of scope) returns the last 24 hours, capped at 200, and is the log-kind mirror of the reachability read. The console component detail page gains an Events panel over it. - Context: The estate model is shifting toward property / event / command on the signal side; property
landed in ADR-0043. Log was already a first-class datapoint kind in the
registry, but the ingest consumer had no sink for it: a log-kind datapoint was silently dropped after the
metric/state route split (ADR-0038), a checkpoint gap
rather than a design choice. This is the P1 follow-up of the estate-model roadmap: give the log kind a
durable home so the third sink flows like the other two, and close the
event_idstubs the datapoint tables reserved for exactly this. Reusing the datapoint owner-arc and provenance (not a bespoke shape) keeps a log occurrence owned, addressed, and traced identically to the values beside it, the primitive-first move. - Deferred: the
datapoint->samplerename (a naming cleanup that lands in a later slice, so the datapoint tables keep their current names here);property_valueand the materialized current-value store (the fold-fields slice); the normalized event_type registry and the promotion of a raweventoccurrence into a registered event (events); a scope-wideeventread (this ships the per-component read only); and anycalculated/intended/declaredevent producer (the write path isobservedcollection only). - Supersedes: the checkpoint behavior where a log-kind datapoint had no sink and was dropped at ingest
(recorded in ADR-0038); the log kind now persists to
event. Divergence from datapoints: that page’s present-tense design routes the log kind to alog_datapointtable and treatseventas a strictly normalized,event_type-registered occurrence promoted from a raw line. The built log sink is theeventtable directly (the raw occurrence lands there, not in a separatelog_datapointtable), and thelog_datapointtable plus the promotion ladder stayDesign; the pages carry an inline note pointing here until the two models are reconciled in the fold-fields / rename cleanup.
ADR-0047: The fields fold: product_property and property_value
Section titled “ADR-0047: The fields fold: product_property and property_value”- Date: 2026-07-21 | Status: Accepted | Pages: core entities, config, secrets, and variables, API, Properties guide, Products guide
- Decision: The standalone fields feature is retired and folded into the estate model, because a
field was never a primitive: it was a property with
declaredprovenance, and the same is true of config (a property withintendedprovenance). Two tables replace it.product_propertyis the product’s declared-property contract:(product_id -> product, property_name -> property, default_value jsonb, required bool), unique per(product, property), so what a product’s instances expose is data on the SKU rather than a catalog hung off a genus type.data_typeandvalidationare not duplicated here; they stay in thepropertycatalog, the single source.property_valueis the value store: it carries the same owner exclusive-arc asmetric_datapointandevent(owner_kindpluscomponent_id/system_id/location_id/node_id, one-set CHECK), plusproperty_name, 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 Postgres’s default NULLS DISTINCT would let duplicate rows through. This slice writes onlyowner_kind=componentwithprovenance=declared; the rest of the arc and the other three provenances are the seats later producers sit in. The resolver isEffectiveProperties(component, scope), one SQL UNION of two arms: the contract arm (everyproduct_propertyof the component’s product, value =coalesce(the component's declared value, the contract default),from_contract=true) and the ad-hoc arm (declared values the contract does not declare,from_contract=false), so a productless component still resolves, to its ad-hoc set alone. Six routes carry it: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). The console renames the operator word from Fields to Properties: a Properties panel on the component detail (contract rows, plus a dashed-bordered off contract group for the ad-hoc ones, an override toggle with an accent dot, a required property blocking Save) and a Declared properties contract editor on the product detail (declare, edit, withdraw, read-only for an official product). Retired with the feature:field_value,field_definition,component.component_type, and thecomponent_typetable itself with its routes (/types/component), its console registry section, and its seed. A component’s shape now comes from its product (ADR-0045), still 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. - Context: ADR-0043 made the catalog primitive-agnostic and deferred the
one binding it needed,
field_definition.key, so a field could draw its type from the catalog. Building that binding forced the realization that the binding was the wrong shape: once a field’s name, type, and validation all come from a property, a “field” is a property the operator declares rather than the device observes, and the only thing left that was field-specific was where the schema hangs. The answer was already on the table: ADR-0045 made the product the source of a component’s shape, so the per-type field catalog becomes the per-product contract over the property catalog, and the field value becomes an arc-owned, provenance-tagged property value beside the samples and occurrences it sits next to. Folding is primitive-first: one value store the cascade, reconciliation, and the current-value read can all be built on, rather than three parallel ones (field_valuefor declared, the datapoint tables for observed, an unbuiltconfigtable for intended). - Deferred:
standard_propertyandlocation_type_property(the other contract owners, each waiting on its owner entity,standardand alocation_typeschema); the driver access/mode column onproduct_property(whether a driver can get, set, or only declare a property, which lands with the driver slice); the non-declared provenance producers (intendedconfig writing a desired value,observedmaterializing a current value out of the datapoint stream,calculatedfrom a rule), which the provenance column seats but nothing writes yet; the multi-owner arc onproperty_value(only the component arm is written and scope-injected today); and thedatapoint->samplerename, still a later cleanup. - Supersedes: ADR-0043’s deferred
field_definition.keyproperty binding. This is that binding, done differently: rather than a field definition gaining a key reference, the field catalog itself became the product contract over the property catalog, andfield_definitionretires. Also completes ADR-0045’s partial supersession of thecomponent_type-as-shape notion: that ADR repointed shape at the product but left the table standing; this one dropscomponent.component_typeand thecomponent_typeregistry outright. - Tracked under epic #266. This is PR5 of the estate-model shift toward property / event / command plus vendor / product / driver / capability / standard / role / health.
- Amended by ADR-0057:
the contract default is unchanged, its vocabulary is.
coalesce(the instance's set value, the contract default)is the fall-through to a declaration, not the bottom rung of a cascade, soproduct_property.default_value(and its two siblings) is the shipped instance of the off-axis default rather than a tier underplatform.
ADR-0048: The standard blueprint and the template-fork seed model
Section titled “ADR-0048: The standard blueprint and the template-fork seed model”-
Date: 2026-07-21 | Status: Accepted | Pages: core entities, API, identity and access, storage, Standards guide, Types guide, Properties guide
-
Decision: Three moves land together, because each one only makes sense with the others.
1.
system_typeis promoted tostandard. A standard is the blueprint a system conforms to (huddle room, classroom, auditorium): the system-side counterpart ofproduct, not a label hung off a system. The table is renamed and gainsparent_standard_id(a variant points at its base, mirroringproduct.parent_product_id) and a declared-property contract.system.system_typebecomessystem.standard_idand is now optional, exactly likecomponent.product_id: a one-off system that conforms to no standard is first-class and carries only its own ad-hoc values. The seeded rows carry over unchanged. Because a standard now owns a contract, it leaves the sharedtype:*registry permission and takes its ownstandard:read/:create/:update/:deleteCatalog resource (read on the viewer*:readfloor, the writes at the admin tier, exactly likeproduct:*), and its routes move from/types/systemto/standards.2. Two more contract tables, and one owner-generic resolver.
standard_propertyandlocation_type_propertyjoinproduct_propertyon the identical shape (<classifier>_id,property_name, an optionaldefault_value, arequiredflag, unique per pair).data_typeandvalidationare never duplicated onto a contract; they stay in thepropertycatalog. The resolver then generalizes:EffectiveProperties(ctx, ownerKind, ownerID, read)resolves 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 its contract throughcomponent.product_id, system throughsystem.standard_id, location throughlocation.location_type; a node has no classifier, so it resolves ad-hoc values only. The query shape is unchanged from ADR-0047: a contract arm (coalesce(the instance's value, the contract default),from_contract=true) UNION an ad-hoc arm. This is the primitive-first move: three classifier/instance pairs, one resolver, so they cannot drift. Alongside it,guardOwnerScopenow scope-checks every owner arc on a value write (it previously returned nil for everything but the component arc), so an out-of-scope system or location is a non-disclosing 404 on the write path as well as the read.3. The seed model: templates live in code, not the database. A standard (and a location type) is created by forking an in-code template. The fork is one-time, with no inheritance, so nothing in any tenant ever points back at a template and templates can be improved in any release. That dissolves the shipped-defaults-versus-local-edits problem at the root: the thing the vendor updates (the template) and the thing the operator owns (the row) are never the same object. Four consequences follow.
- Forking applies to template -> standard, not standard -> system. A system does not fork its standard, it conforms to it, with live inheritance: the standard’s contract default resolves for every conforming system until that system overrides it, and revising the default moves every system that has not.
- Therefore a shipped standard or location type is operator-owned, not official. Both seed with
official: falsethrough seed-if-absent paths (SeedStandard/SeedLocationType,ON CONFLICT DO NOTHING), never the authoritativeUpsert*. They are freely editable and deletable from the moment they land. - An authoritative upsert here would be a bug, not a policy.
ON CONFLICT DO UPDATEwould silently revert an operator’s edit on the next boot, which is the exact failure this model avoids. A regression test edits a seeded standard, re-runs the seed, and asserts the edit survived. - The canonical catalogs are the exception and keep the authoritative upsert with
official: true:property(and latercommandandevent_type) is the shared vocabulary a driver maps onto, so a release must be able to correct it. The classification catalogs (vendor,driver,capability,product,interface_type,secret_type) androlestay on that same authoritative path for now.
Four route groups carry the contracts and the values, all regenerated into the OpenAPI document, the cobra CLI, and the typed client:
GET /standards/{id}/propertiesplusPUT/DELETE /standards/{id}/properties/{property}(gatedstandard:read/:update/:delete);GET /location-types/{id}/propertiesplusPUT/DELETE .../{property}(gatedtype:*, since the location type registry is still atyperegistry); and the value sidesGET /systems/{name}/propertiesplusPUT/DELETE .../{property}(gatedsystem:read/:update) and the same for/locations/{name}/properties(gatedlocation:read/:update). The value routes are scope-injected, so an out-of-scope system or location is a non-disclosing 404. -
Context: ADR-0047 deferred
standard_propertyandlocation_type_propertybecause neither owner was ready:system_typewas a bare label registry with no contract to hang anything on. Building that contract forced the promotion, since a registry that declares what its instances expose is a blueprint, and a blueprint is the system-sideproduct. Makingstandard_idoptional followed immediately: a productless component was already first-class, and a system that matches no blueprint has the same claim. The seed question surfaced during the build and is the harder half. Shipping a room standard as an authoritativeofficialrow would make it read-only (an operator could not tune “Huddle Room” to their own estate) and would revert local edits on every boot if it were writable. Both failures come from one mistake: treating example content and canonical vocabulary as the same kind of thing. Splitting them (a template in code that is forked once, versus a catalog row that is upserted authoritatively) lets the release improve its examples forever without ever touching an estate’s data, and keeps the one thing that genuinely must stay identical install to install, the property vocabulary, under release control. -
Deferred: the in-code template mechanism itself and its create-from-template console affordance (#317); this slice ships the seed-if-absent behavior and the operator-owned rows that the mechanism will produce, with the shipped starter set still declared as seed YAML. The official / community / private catalog tiering for
product/driver/property/event_typeplus a disable flag (#318). Also still deferred: a standard’s role set and health composition, the non-declaredprovenance producers, the cross-owner cascade overproperty_value, and thedatapoint->samplerename. -
Supersedes: the
system_type-as-label notion (a system’s blueprint is a first-class Catalog entity with its own contract and its own permission, and it is optional), completing on the system side what ADR-0045 and ADR-0047 did on the component side. Also supersedes the assumption running through ADR-0044, ADR-0045, and ADR-0047 that everything the seed ships isofficialand read-only. That now holds only for the canonical catalogs: the shipped standards and the four shipped location types (campus/building/floor/room) areofficial: falseand fully editable, so any prose promising a read-only seed-owned row for those two registries is stale. -
Tracked under epic #266. This is PR6 of the estate-model shift toward property / event / command plus vendor / product / driver / capability / standard / role / health.
ADR-0049: The system role: capability-gated staffing and the resolved capability set
Section titled “ADR-0049: The system role: capability-gated staffing and the resolved capability set”-
Date: 2026-07-21 | Status: Accepted | Pages: core entities, API, glossary, templates, health, Standards guide, Capabilities guide, Work with an entity
-
Decision: A system says what it needs filled, and the platform refuses a component that cannot fill it. Four tables and two resolvers carry that.
1. A
system_roleis a slot, declared on the arc. A role (a table microphone, a main display) is 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). The two owners ride the same exclusive-arc patternproperty_valueuses: anowner_kindplusstandard_id/system_id, a one-set CHECK, and aunique nulls not distinctkey over the arc columns and the role name (the default NULLS DISTINCT would let duplicates through the NULL arm). A role carries aquorum: how many components should fill it, at least one, because a role no component need fill is not a role.2.
role_capabilityis conjunctive. A role requires a set ofcapabilityrows, and a component must provide every one of them. Requiring nothing admits anything, which is the honest reading of an empty requirement, not a special case.3. A component’s capabilities become a resolved set.
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.EffectiveCapabilities(component)is then the product’s set UNION the additions MINUS the suppressions, and a productless component resolves to just its own declarations. This is the single definition of “what this component can do” for the whole platform, and it is the set the guard checks.4.
EffectiveRoles(system)merges both arms. The roles the system’s standard declares (markedfrom_standard) UNION those declared directly on it, each with its required capabilities, its quorum, and the components filling it here. A one-off system has only the ad-hoc arm. The resolver servesAssigned()andUnderstaffed()(quorum minus assignments, floored at zero) rather than leaving arithmetic to each surface, so staffing reads the same way everywhere.5. The guard refuses, and the refusal names the gap.
AssignRoleis a 422 when the component’s resolved capabilities do not cover every capability the role requires, and the message 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. It joins the location placement constraint as a refusal on modeled grounds, and follows the same rule that one set: name the parties. A bare “no” leaves the operator nothing to do, and the whole value of modeling capability is that the refusal is actionable. Assignment is otherwise idempotent, androle_assignment.component_idison delete restrict, so a component staffing a role cannot be deleted out from under the system.Eight routes carry it, regenerated into the OpenAPI document, the cobra CLI, and the typed 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. 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, so an operator’s quorum retune survives a re-seed. -
Context: The strict refusal was decided first: a role that names a requirement and then lets anything fill it is decoration. But a component’s capabilities came only from its product, and
productis deliberately optional on a component (ADR-0047), so under a strict guard a productless component could have filled no role at all. Three ways out, and only one of them keeps both halves: make product mandatory (reverses a call made one slice ago for good reasons), soften the guard to a warning (throws away the point of the model), or let a component declare its own capabilities over its product’s. Layering resolves the tension without touching either commitment, and it is not a new shape: it is exactly the contract-plus-override the declared properties already use, where a product declares a default and an instance overrides it, applied to capabilities instead of values. Quorum lands in this slice and impact does not, because staffing is visible without health at all: a role wanting 2 with 1 assigned is under-staffed today, on data the operator entered, with no engine reading anything. -
Deferred:
impact(outage/degraded/none, what an unfilled or failing role does to its system) and the whole SLI rollup, which land in PR8 with the engine that reads them; the console surfaces for both arcs (the standard’s role editor, the system’s roles panel, the component’s capability editor); role-scoped config, a value declared against a role slot and resolving onto whichever component fills it (templates describes it, nothing builds it); a cap at quorum (a role may be over-staffed, and nothing refuses the extra assignment, because “more than enough” is not an error); and thesystem_membercomposition table, which staysDesign. -
Supersedes: the
system_template_memberrole-requirement design on templates. That page still describes a role slot frozen into asystem_template_version, whose requirement is a set of canonical datapoints and commands and whose instance assignment is asystem_memberrow. What shipped puts the slot on the standard / system arc (a standard is the blueprint now, so the role belongs with it), states the requirement as a capability set (a coarser, operator-legible vocabulary that exists as a catalog today, where canonical commands do not), and records the assignment inrole_assignment. Templates and their frozen BOM stayDesign; the two models are reconciled when template pinning is built, and until then the built role model is the one on core entities. Also supersedes the reading, running through ADR-0044 and ADR-0045, that a capability is a product-only fact: a capability is now a fact about a component, which its product supplies a default for. -
Tracked under epic #266. This is PR7 of the estate-model shift toward property / event / command plus vendor / product / driver / capability / standard / role / health.
ADR-0050: Health is a recorded transition, computed from the alarm-capability-role chain
Section titled “ADR-0050: Health is a recorded transition, computed from the alarm-capability-role chain”-
Date: 2026-07-21 | Status: Accepted | Pages: health, core entities, API, glossary, Standards guide, Work with an entity
-
Decision: Health is a verdict on a system or a location, derived from what is wrong with the components staffing it and recorded as a transition at the moment it changes. Five calls carry that.
1. Capability is the routing key, and an alarm is how a component loses one. An
alarmis component-local (component_id, aseverityofinfo/warning/critical, amessage, araised_at, and a nullablecleared_at), andalarm_capabilitynames the capabilities it degrades. Clearing keeps the row, so the record of what was wrong and when survives the fix. The chain from there is one sentence per hop: 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 its declaredimpact(outage/degraded/none, a column onsystem_role, defaulting todegraded); a system takes the worst contribution among its roles, and a location the worst among the systems placed anywhere beneath it. That chain is why a capability is flat and why a role requires a set of them: capability is the only vocabulary shared by the thing that breaks (a component) and the thing that cares (a slot in a room), so it is the only honest place to route through. Impact lives on the role, not on the alarm or the component, because the same broken box matters differently depending on the slot it was filling: a dead confidence monitor is not a dead main display.2. The judgement is a pure package.
internal/healthtakes resolved inputs and returns a verdict, with no database:Component.Satisfies, the quorum boundary, worst-wins at both levels, and the impact mapping are unit tests, not SQL. Two of its defaults are deliberate safety calls in opposite directions. An unrecognized impact readsdegraded, so a bad value can never make an impaired role silently harmless. An unrecognized recorded value readshealthy, so one stray row cannot paint an estate broken. The rule behind both: fail loud about a judgement, fail quiet about a record.3. Health is recorded as a transition-only state, on
state_datapoint. The requirement this whole design serves is an accurate history of the edges: exactly when a system stopped working, answerable weeks later.state_datapointis already that primitive (the ingest path writes a row only when the value differs from the last one stored, andStateTransitionsreads the ordered flips the reachability availability strip draws), so health reuses it rather than adding a history table: the owner arc,provenance='calculated', andsource_rule='health-rollup'(the lineage CHECK requires a non-nullsource_rule). The first value for an owner is always recorded, evenhealthy, so a reader can tell “healthy since we started watching” from “never evaluated”.4. Recompute happens at the writes that can change health, in the same transaction. Every mutation that can move a verdict recomputes the affected chain before it commits: raising or clearing an alarm, assigning or unassigning a component, declaring or withdrawing a role, changing a role’s quorum or impact, changing a component’s capabilities or its product, creating a system, and changing the standard it conforms to or the location it sits in (recomputing both the old and the new location, since the one it left may have just improved). A read never writes. Two alternatives were considered and rejected, and both fail the same requirement. Compute-on-read keeps no history at all, so “when did this break” is unanswerable by construction. Compute-and-write-through-on-read keeps a history that is sampled by whoever opens a page: the edge timestamp becomes the moment somebody looked, not the moment the estate changed, and an estate nobody watched over a weekend has no weekend. A transition is only worth recording if it is recorded where the change happened.
5. A report computes the verdict it serves from the evidence it shows. The health report originally served the last recorded verdict while resolving the contributing roles live, which let a system with nothing recorded yet report
healthybeside an impairedoutagerole: the report contradicted itself. The served verdict is now derived from the same resolved rows the report displays, so the headline and the reason can never disagree. This is not self-healing on read: nothing is written, and the recorded transitions remain the source for history. A missing trigger can therefore cost an edge in the history, but it can never make a report lie about the present.Routes, regenerated into the OpenAPI document, the cobra CLI, and the typed client:
GET/POST /components/{name}/alarmsandDELETE /components/{name}/alarms/{id}(gatedcomponent:read/:update);GET /systems/{name}/healthandGET /locations/{name}/health(gatedsystem:read/location:read, scope-injected, an out-of-scope owner a non-disclosing 404), each returning the verdict, the contributing roles (with the degraded capabilities and the causing alarms) or the systems beneath, and the recorded transitions over the last 30 days. The CLI readsomniglass component alarms|raise-alarm|clear-alarm,omniglass system health, andomniglass location health. The seed adds ahealthstate-kind property (healthy/degraded/outage) so the recorded series is typed like any other. -
Context: The architect’s requirement was stated plainly: “The most important thing about health is that we have a real, accurate history of the edges. We need to know exactly when a system went from healthy to unhealthy, and be able to look back at it weeks later.” Every call above falls out of taking that literally. Once the history has to be accurate, the write side is the only correct place to compute from, and once the carrier has to be edges,
state_datapointis already the right table and a newhealth_historywould have been a second, worse copy of it. Recording an opening verdict at system creation then surfaced a latent bug the rest of the schema had been quietly carrying: every system now had astate_datapointrow from birth, and every rename 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 history follows the entity rather than pinning its old name. Health did not create that bug, it made it reachable for every system, which is the useful kind of forcing function. -
Deferred: the same FK gap on
metric_datapoint,event,property_value,alarm, and the role tables’ name-addressed columns, tracked in #314. An alarm today is written by an operator or an API caller, not produced by anevent_ruleover datapoints; the rule that opens and clears one automatically is the next tier. Also deferred: system- and location-owned alarms (the alarm arc is component-only today), theunknownverdict with its coverage and staleness reasons, theglobalestate top, the SLI / SLO / SLA family and the KPI set, an alarm’s interaction with operational mode (maintenance suppressing a contribution), and dependency suppression. -
Supersedes: three earlier calls on health. (a) The value vocabulary: ADR-0003 named the healthy state
okover an orderedok < degraded < down; the built domain ishealthy<degraded<outage, keeping that entry’s reasoning (name the verdict, not the ping) and changing only the words, sinceoutagesays what a broken room means to the people in it. (b) Where impact is declared: the design hung an optionalhealthimpact on theevent_rule, so an alarm moved its owner’s health directly. Impact now lives on the role, and an alarm reaches a system only through the capabilities it degrades. An alarm on a component that fills no role moves that component’s own verdict and nothing above it, which is the correct answer and was previously an accident of tagging. (c)health_role: therequired/redundant/informationalmember tag on asystem_template_memberis superseded by quorum plus impact on asystem_role, which expresses the same three cases without a fourth vocabulary (required is quorum 1 with impactoutage, redundant is a quorum below the number assigned, informational is impactnone). It also closes ADR-0049’s deferral ofimpactand its “quorum ships without health” note. -
Tracked under epic #266. This is PR8 of the estate-model shift toward property / event / command plus vendor / product / driver / capability / standard / role / health, and the slice that closes the epic: it is the one that consumes what the previous seven built.
ADR-0051: Membership is the attachment, and a role is what it does
Section titled “ADR-0051: Membership is the attachment, and a role is what it does”- Status: accepted, built.
- Context: a component’s relationship to a system was two unrelated facts that could silently
disagree.
component.system_idwas a single pointer, set once at create with no path to change it, which no authorization and no health path ever read;role_assignmentwas many-to-many and carried what the component actually does. Nothing reconciled them, and the console rendered the first under the heading “Components” while the panel directly below listed the second, so a fully staffed system displayed0 components. The contradiction was visible to operators before it was understood by us. - Decision: membership is a first-class binding,
system_member (system_id, component_id, is_primary), and a role attaches to it. Staffing a role creates the membership, because a component filling a job in a system that the system does not count as a member is a contradiction. The reverse is not symmetric: giving up a role leaves the membership, because the device is still in the room, and a member carrying no role (a power conditioner, a spare) is ordinary. - Why membership cannot simply replace the pointer: the cascade seeds its system band from one row
and ranks with
row_number() over (partition by ... order by band desc, depth asc), which has no tiebreaker after depth. A many-valued seed would make an effective tag, variable, or secret resolve nondeterministically for precisely the shared-device case. Membership is therefore many-valued whileis_primarykeeps a single answer for callers with no system in hand. It is a default, not a resolution rule: anything naming a system resolves against that system, and a component’s first membership takes the default with nobody asking, so the single-system case never meets the concept. - Cascade from both ends, and deliberately no restrict on the component. A binding is meaningless once
either side is gone.
role_assignmentkeeps itson delete restrictbecause deleting a component that fills a job would silently break a system’s health; duplicating that restrict on membership would add a step to every component removal while protecting nothing new. - Backfill reads both of the old places. The role table alone drops every component that belonged to a
system without filling a declared role; the pointer alone drops the shared device’s other systems. The old
pointer seeds
is_primary, since answering which system chain feeds a component’s config is exactly what it used to do. A component left with several memberships and no pointer gets no default, because there is no honest way to guess which one was meant. - Thin cut: resolution behaviour does not move in this slice.
component.system_idstays and keeps feeding the four cascade resolvers unchanged, so this ships and is verified on its own. - Supersedes: core-entities’s “a truly shared device skips the system
layer”, which was the best available answer while the only binding was a single pointer. A shared device
is now a member of every system it serves. It also narrows that page’s
system_memberdesign: the shipped row is the binding alone, without the role column or the pin to a frozensystem_template_version, so a member can exist without a role. - Tracked under epic #324, slice #325.
ADR-0052: The cascade resolves through membership, and secrets carry no system band
Section titled “ADR-0052: The cascade resolves through membership, and secrets carry no system band”- Status: accepted, built.
- Context: ADR-0051 made
membership explicit but deliberately left resolution alone: the tag, variable, and secret cascades
still seeded their system band from
component.system_id, the write-once pointer. That left the pointer alive for one reason only, and left the “config differs per system” case unanswerable. - Decision: the system band is seeded from
system_member. Tag resolution takes the system to resolve against, and resolves against it only if the component is a member: naming a system it has no binding to must not lend it configuration. With no system given it falls back to the primary membership, which is the entirety of whatis_primaryis for.GET /components/{name}/effective-tags?system=exposes the first case. - The seed stays single-valued, as a correctness requirement. The rank orders by band then depth with no tiebreaker after that, so two seeds in one band resolve nondeterministically. Membership is many-valued; the chain it feeds is not. This is the same fact that made the pointer worth keeping under ADR-0051 and is now satisfied without it.
- Secrets lose the system band entirely, on ownership rather than determinism: an interface belongs to a component, a shared device has one password, and the room it serves is the wrong owner for a credential. It also removes the one case where an ambiguous inheritance would have been dangerous rather than merely wrong.
component.system_idis dropped. With nothing reading it, the column, its API field, and its console consumers go. The component body now reportssystem(the primary, by name) andsystem_count, which also retires one of the three places the API emitted a raw uuid for a field it accepts by name (#328).- Written test-first because the failure mode is silent. A mis-seeded
sys_chainis still valid SQL that returns fewer rows: a system-owned tag would simply stop reaching its components, with no error and no 500, and the resolution blade would show the location winner as though the system band never had a candidate. - Supersedes cascade’s “the primary-system pointer is the single system chain that feeds the cascade”, which described the mechanism when a pointer was the only binding available.
- Tracked under epic #324, slice #327.
ADR-0053: A name is the address, a uuid is identity
Section titled “ADR-0053: A name is the address, a uuid is identity”- Status: superseded in part by
ADR-0056. Its API half stands: a reference is
addressed by name. Its schema half (“a new table references an estate entity by
namewithon update cascade”) is reversed, and responses now carry the id beside the name rather than instead of it. - Context: the pattern was real and dominant but never applied to the original entities.
Eleven tables keyed their estate references by
name, six byid, split by age. Worse, the API accepted names on write and returned uuids on read: a component created with{"parent": "rack", "location": "hq-b1"}read back as{"parent_id": "0198f...", "location_id": "0198f..."}. The body did not round-trip, so every client fetched a second collection and joined by uuid to render one label. The console carried exactly that map until it was deleted in #329. - Decision: every request and response addresses another entity by its name. A uuid appears
only as an entity’s own
id. Two exceptions: an entity with no name (an interface, a stored value, an audit row, a grant, a principal) and a slug-keyed catalog, whose id already is a name. A new table references an estate entity bynamewithon update cascade. - Normalized:
parent_idandlocation_idon component and system,parent_idon location, and the redundantowner_idon tag bindings, variables, and secrets, which already carriedowner_namebeside it. Nine fields, seven of them found by survey and two by the guard test, which caught aSecretBody.owner_idthe survey missed. - Enforced by contract, not prose.
TestResponsesAddressEntitiesByNamewalks the generated OpenAPI and fails on any field naming another entity by uuid. The failure it prevents is invisible otherwise: a body emittingparent_idstill serves 200s, and the cost only appears in the clients. - Deliberately not in scope:
secret,variable, andtag_bindingstill key their owner arcs by uuid in the schema, and the cascade resolvers compare those uuids directly. Converting them is a data migration plus a rewrite of resolution SQL reworked in ADR-0052. The API contradiction is what operators saw and is fixable without touching resolution. The rule binds new tables; the stragglers convert when something else needs to touch them. - Breaking. Response shapes change. At v0.0.0 this is the right moment, since the cost only grows.
- Tracked as #334, following #328.
ADR-0054: The shell owns a panel’s action rail; the body registers and never draws
Section titled “ADR-0054: The shell owns a panel’s action rail; the body registers and never draws”- Date: 2026-07-21 | Status: Accepted | Pages: design system
- Decision: A panel’s action buttons are declared as data, not laid out as markup. A blade body
binds
destructive/secondary/primary(plus the Edit -> Save cycle) throughlib/blades; a Drawer form body bindssubmitLabel/submitIcon/submit/busy/disabled/cancelthroughlib/formactions.BladeStackandDrawereach compose their own button vocabulary but draw it through the singlePanelFooterrail. A form body renders no footer markup at all. - Context: The blade already worked this way. The Drawer did not: its rail was an opt-in
DrawerFooterhelper that every form body had to remember to import and wrap its buttons in. Fourteen forms remembered. Two did not, and hand-rolled their own right-aligned row instead, where they survived nine merged PRs unchanged while the helper was copied into six newly added pages around them. The cost was not only the two misses: among the forms that did use it, some rendered a Cancel and some did not, some spun a spinner and some swapped the label to “Creating…”. A rail reached by convention drifts in both directions at once. - Why a slot rather than a lint rule: A lint rule finds the miss after it is written. A slot makes
it unwriteable: there is no exported helper to forget, and a body that wants a button has exactly
one way to ask for one. The enforcement is the deleted export, and
rail-ownership.test.tsis the belt to that braces. - Scope, honestly: This converges two of the three rails. Full-page create forms
(Locations, Systems, Components) still draw an inline
border-t ... pt-4row of their own. That rail is inline in a scrolling page rather than pinned to the viewport, so it is a different layout problem, and it converges when the CRUD form primitive lands and owns both form factors. - Deliberate convergences: submit labels no longer change while in flight (the shell’s spinner says it), and the new-interface blade lost its Cancel button, since a blade already dismisses two ways and no other blade in the stack carries one.
- Tracked under #332.
ADR-0055: The tag, variable, and secret owner arcs key by name
Section titled “ADR-0055: The tag, variable, and secret owner arcs key by name”- Status: superseded by ADR-0056, which converts these nine columns back to uuids along with every other name-keyed foreign key. Kept in full because the reasoning below is a worked example of the mistake: it is internally consistent, it shipped green, and it is wrong at the premise.
- Context: ADR-0053 fixed what operators saw and deliberately left the schema alone. Three tables still keyed their owner arcs by uuid while every table from the collection era onward keyed by name, so the two conventions met inside single queries: the cascade resolvers walked chains of uuids purely to match these three, and a component’s name had to be carried alongside its id to bridge them.
- Decision: the nine arc columns on
tag_binding,variable, andsecretbecometext references <entity> (name) on update cascade on delete cascade. The columns keep their_idsuffix, matchingrole_assignment.component_idandstate_datapoint.component_id, which are likewise text referencing a name. on update cascadeis the load-bearing clause. A name is only safe as a key if a rename carries.TestOwnerArcsSurviveARenameis the proof and is mutation-checked: with the clause removed the rename is refused outright with a foreign-key violation, so the test cannot pass vacuously. It also could not have failed before this change, since the arcs held uuids that a rename never touches.- The resolvers now project names. Each chain still recurses on
parent_id, which stays a uuid, and only what it projects changed.owner_idin theownersCTE is a name, so the final joins that resolve a display name match onnamerather thanid. - The scope walk still uses ids, resolved from the name at the point of the check. Identity stays internal, and the walk is the only place that needs it, which is the rule working as intended rather than an exception to it.
- Not converted, deliberately:
tag_binding.node_idreferencesnode.principal_id, a node’s enrollment identity and the only handle it has.tag_binding.tag_idis a genuine instance of the same rule (tagis uuid-keyed with a unique name) but is the binding’s subject rather than its owner, and it touches the tag CRUD surface rather than resolution; tracked as #340. Removing it from the guard test’s slug-keyed allow-list, where it had been listed on a false claim that the tag catalog is slug-keyed, is part of this change. - No
migrate:down. Reversing would have to resolve names back to uuids the forward migration no longer records, and any rename since would make that resolution wrong rather than merely absent. - Tracked as #339.
ADR-0056: Every foreign key stores a primary key
Section titled “ADR-0056: Every foreign key stores a primary key”- Date: 2026-07-22 | Status: Accepted; the slug-keyed carve-out below is retired by ADR-0062 | Pages: storage, api-first
- Decision: every foreign key stores the target’s primary key: a uuid for an estate entity,
principal_idfor a node, and the slug itself for a slug-keyed catalog (product,standard,property,interface_type) where the name already is the key. No column references aname. In exchange, the API accepts either form wherever a reference is written (a path segment or a join field in a body), trying the uuid first, and every response carries both: the name an operator reads and the id it resolves to. - Context: this reverses a direction set two ADRs ago. ADR-0053 found eleven tables keyed by name and six by id, and resolved the split by declaring the majority correct. ADR-0055 then converted the six to match, and the follow-on work was converting the rest. The premise was never examined: a friendly, renameable key is valuable because it can change, which is the one thing a foreign key must not do.
- The tell was
on update cascade. ADR-0055 called it “the load-bearing clause” and was pleased that removing it made the test fail. That machinery exists only to fund the wrong choice: it is write amplification across every referencing row, on a rename, to protect a key that did not need to be a name. A uuid arc needs no clause, and the equivalent test passes because there is nothing to rewrite. - A rename was not merely inefficient, it was refused. With
interface.componentreferencingcomponent (name)and noon updateclause, renaming a component that owned any interface failed outright with a foreign-key violation. The name-keyed convention had spread past the point where its own cascade covered it, and the operator-facing symptom was a rename that returned an error. - Scope: all 30 name-keyed foreign keys, converted across five slices grouped by subsystem rather
than by table, so each file changed once: the estate arcs, then health and roles, then the
collection tier (
metric_datapoint,interface,node) and every node reference. - What stays a name. The columns whose target is slug-keyed are already conformant and were not
touched. Health passes names internally on purpose: its advisory lock hashes
health/<kind>/<name>, and a mixed currency would hash two keys for one owner and silently stop serializing. (The slug-keyed targets themselves later took uuid keys too, so those columns moved to the uuid; see ADR-0062.) - Guarded both ways.
TestResponsesAddressEntitiesByNamefails on a response that names an entity by uuid alone; the per-tier rename tests fail if an arc stops following a rename. Each conversion was mutation-checked rather than trusted: breaking the projection had to turn the suite red. - Tracked as #343.
ADR-0057: The cascade’s least-specific tier is platform, and a default is not a tier
Section titled “ADR-0057: The cascade’s least-specific tier is platform, and a default is not a tier”- Date: 2026-07-21 | Status: Accepted | Pages: cascade, settings, config, secrets, and variables, tags, identity and access, scaling and deployment
- Decision: Six calls, one vocabulary.
globalbecomesplatformas the cascade’s least-specific binding tier, on both axes: the estate arc (owner_kindonvariable,secret, andtag_binding) and the settings level (setting_override.scope). It occupies exactly the rung it occupied before (segment_rank 0). It is a decision like every other rung, what an admin set for the whole install, not a floor beneath the chain.codebecomesdefault, and adefaultis off the axis on both engines. A default is what a value is absent any decision: a column on a definition row, beside the unit, the kind, and the validation rule. It is not a rung, it shadows nothing, and nothing shadows it; the fold falls through to it when no rung bound anything, and the resolve view reports it as a declaration rather than as a winning source. A default is a column on a declaration row, so a kind with no declaration row has no default: a setting has one (its struct tag) and a property has one on its classifier’s contract (product_property.default_valueand itsstandard_property/location_type_propertysiblings, read ascoalesce(the instance's set value, the contract default)byEffectiveProperties, ADR-0047), while a variable, a secret, and a tag have none. Absent means absent. Note the property default sits on the contract, not on thepropertycatalog entry: the catalog declares what a name means, a classifier declares what it is for the things that conform to it. That is a narrower claim than the one this ADR was drafted against, when the precedent was the retiredfield_definition.default_valuehanging off acomponent_type, and it is the stronger one for the rule here, since the coalesce is the fall-through in the code path itself.- There is no root location. The location tree keeps N unparented tops. A tier above today’s tops is a
new
location_typeand a real node, never a magic one, and a top-level location is not a substitute forplatform: binding at one top misses every sibling, and a top added later is silently uncovered. - The install-wide tier survives on the estate axis, uniform across kinds:
platform | location | system | component. - A write at the tier needs
platform:<action>, checked in addition to the resource permission and published per route as anx-omniglass-platform-permissionstamp. This separates full-estate scope from install-wide authority: a senior operator may hold an all-scope grant without being able to change the value that applies to the whole install.platform:*is seeded toadmin(and reachesownerthrough>);operatoranddeployhold noplatformwrite, and nothing implies one. “Nothing implies one” is enforced by puttingplatformin the sensitive-resource set besidesecretandsettings(ADR-0025), so a bare single-token*never names it: a custom role carrying*:updateholds every estate write and still no install-wide authority. Only a literal, aplatform:*, or a>names the tier. rootis not used as a tier name, solocation_type.allowed_parent_typeskeeps its reserved"root"sentinel meaning “top, no parent”, unchanged.
- Context: One word named two unrelated things, in two engines, with three spellings. On the estate axis
globalwas both a tier an operator writes at and, in the prose, a floor where ship-with policy supposedly lived: cascade read “Ship-with default policy lives atglobal, the floor of the chain”, three lines under a heading that says the registry is outside the cascade. That was drift, not design:internal/seed/writes eight YAML files and every one defines a type; none writes a binding, and there has never been a ship-with row at the tier. Meanwhile the settings engine had already split the two ideas and picked different words for them (codefor the declaration,globalfor the install-wide override), so the same distinction existed twice under three names. Separately,globalalso names the singleton estate owner where health and KPIs roll up (a different concept that keeps the name), which made “global” ambiguous in the one place ambiguity is most expensive. Naming the binding tierplatformand the declarationdefaultgives each idea one word, and it drops an assumption the estate never had: that “everything” and “the planet the sites are on” are the same thing. - What does not change: no precedence change (every row keeps its rung under a new name, so no deployment resolves differently), no new rows (the migration renames a value, inserting nothing and adding no column), no reordering of the segment ranks or the comparison key, and no capability removed.
- Breaking change, accepted deliberately:
secretAADbinds a sealed field to its owner arc,ownerKind|ownerID|name|field. A secret sealed at the tier before this rename authenticates againstglobal|global|...; after it, the derivation yieldsplatform|platform|..., the AEAD check fails, and that ciphertext never opens again. Only the renamed tier is affected: a scoped secret carries a real owner id and is untouched. Accepted because no deployment holds tier secrets yet, and each alternative (freezing the AAD at the legacy string, a Go-side re-seal backfill, or a reveal-time fallback) buys compatibility nothing currently needs at the price of a permanent legacy branch. Recorded here rather than discovered later by a reader. - Amends: ADR-0033, ADR-0034, ADR-0035, and ADR-0042, and ADR-0047. Each keeps its model; only the level names and the default’s place in the vocabulary move.
- Closes: issue #316.
ADR-0058: A run mode is a verb under its noun, and no command may be shadowed
Section titled “ADR-0058: A run mode is a verb under its noun, and no command may be shadowed”- Date: 2026-07-22 | Status: Accepted | Pages: CLI guide
- Decision: the edge run mode becomes
omniglass node run, a leaf under the generatednodegroup, rather than a top-levelnode. A guard test walks the assembled command tree and fails on any duplicate name, so the hand-written and generated command sets can no longer collide silently. - Context: the hand-written run mode and the generated API group both registered as
node. Cobra does not treat that as an error: both are added and lookup returns the first, so every generated node command was unreachable.omniglass node listresolved to the daemon and failed asking for--token, while the CLI guide documented it as working. - Why a guard rather than a rename alone. This is the third instance:
membersunder the principal groups (#326),type list(#319), and nownode. Each was found by a person typing it. The two command sets compose on one root, so no single file owns the namespace and no review of either set can catch it. The tree is the only place they meet, so it is the only place the check can live. The guard was written first and found two more nobody had reported:grant createandgrant delete, where the principal-group variants shadow the principal ones, so granting a role to a principal has no CLI path at all (#357). - The known collisions are an explicit list that may only shrink. The guard fails on any name not on it, so a new collision cannot land, and it also fails on an entry that has stopped colliding, so a fix must delete its entry rather than leave that name unwatched. It is a ratchet, not an allow-list.
- Root cause, left for #357:
commandWordsderives the group from a single path segment, so/principals/{id}/grantsand/principal-groups/{id}/grantsboth becomegrant. Fixing that renames documented commands and is a naming decision, not a mechanical one. - Cost accepted:
omniglass nodeis a documented invocation and it changes. At v0.0.0 that is a docs edit, and a mode reads as a verb anyway, besidenode listandnode enroll. - Tracked as #354.
ADR-0059: Every collection segment is a command level
Section titled “ADR-0059: Every collection segment is a command level”- Date: 2026-07-22 | Status: Accepted | Pages: api-first, CLI guide
- Decision: the CLI command path is derived from the whole route: every collection segment
contributes a level and the verb is last, so a subresource is always addressed under the resource
that owns it.
/components/{name}/propertiesiscomponent property list;/principals/{id}/grantsand/principal-groups/{id}/grantsareprincipal grant createandprincipal-group grant create. - Context: the old rule used only the collection nearest the leaf, so it could not tell two
parents apart. Across 195 operations it produced 24 collisions,
property listseven ways. Cobra does not treat a duplicate name as an error: it registers both and returns the first, so the second was unreachable and the only symptom was a command that ran the wrong thing. Granting a role to a principal had no CLI path at all (#357). nameOverridewas the rule, written out by hand fifty times. It had grown to 53 entries, and the comment on nearly every one said the same thing: “the leaf-noun heuristic would collapse both into one group.” Each was added after somebody typed a broken command. It is now 14 entries, all of them the genuinely non-AIP/authfamily, and none of them about a collision.- A name depends only on its own route. This is the property worth having: under a disambiguate-only-when-ambiguous rule, adding a route could rename an existing command. Here it cannot, so the naming is stable as the API grows.
- The grouping had to become a tree. Fixing the derivation alone was not enough: the generator
bucketed commands by their first word and used only the last word as the leaf name, so a
three-word path rendered as two and collided again. It now builds an N-level tree, which is also
what makes
node runandtype secret listrender as written. omniglass statushipped. The depluralizer took-soffstatus. A small irregular set is declared instead; the route vocabulary is ours, so this is a known list, not an English problem.- Cost accepted: 67 of 202 commands are renamed, 135 unchanged. At v0.0.0 that is a docs edit, and the guides are corrected mechanically from the route map in the same change.
- Two guards, because regeneration does not fix prose.
TestNoCommandNameCollisionsfails on any duplicate name (its known-collision list is now empty, and its second half forced those entries out once fixed).TestDocsOnlyNameRealCommandswalks the guides and fails on a documented command that does not resolve; it immediately foundomniglass secret-type list, which had never existed in any build, and two commands with no API route behind them (#359). - Supersedes the naming half of ADR-0058, whose guard this keeps and whose exception list this empties.
- Tracked as #357.
ADR-0060: A resource is one kebab-case noun; nesting means ownership
Section titled “ADR-0060: A resource is one kebab-case noun; nesting means ownership”- Date: 2026-07-22 | Status: Accepted | Pages: api-first, API, types
- Decision: a resource is addressed by one kebab-case noun, and a nested path segment means the
nested thing is owned by it. The
/typesumbrella is retired:GET/POST /location-types,PATCH/DELETE /location-types/{id},GET /secret-types. - Context: the location type registry was addressed two ways. Its CRUD lived under
/types/locationwhile its property contract lived on a flat/location-types/{id}/properties, so one entity had two command groups (type location updateandlocation-type property list) and an operator had to know both./types/secrethad no flat form at all. - The umbrella misused nesting. A nested segment says the child belongs to the parent
(
/principal-groups/{id}/members).locationis not owned bytypes; it is a registry that happens to be one of several. Grouping by category is a documentation concern, not an addressing one. - Two mechanisms, now unambiguous. A hyphen joins a noun that happens to be two words
(
principal-group,location-type,audit-log,effective-tag); a space means the thing beneath it (location-type property,principal-group member). Before this, the same registry used both, which is what made the rule unstateable. - Found by asking what the rule was, not by a failure. The CLI naming fix (ADR-0059) made the two spellings sit next to each other in one command tree, where the contradiction was obvious. The generator was correct throughout; the routes disagreed with themselves.
- Addressing only. Same handlers, same
<resource>:<action>gates, same scope injection, no storage change. Thetypecommand group disappears andnameOverrideneeds no entry for any of it. - Breaking: three route shapes change. At v0.0.0 that is a regeneration plus a docs pass, and
TestDocsOnlyNameRealCommandsfails on any guide left teaching the old names. - Tracked as #361.
ADR-0061: A calculated series is current at its highest id, not its newest timestamp
Section titled “ADR-0061: A calculated series is current at its highest id, not its newest timestamp”- Date: 2026-07-22 | Status: Accepted | Pages: datapoints
- Decision: for a calculated series (health, and anything else the engine derives), the current
value is the row with the highest id.
tsrecords when the value was computed and is for display and history; it does not decide which row is current. For an observed series,tsstill orders, because it is the observation time and a late arrival must not displace a newer reading, butidbreaks a tie. - Context:
recordHealthwritesselect clock_timestamp(), ..., so the timestamp is evaluated in the SELECT list while the id comes from the identity sequence applied when the row is inserted: the clock is read before the id is assigned. Two concurrent inserts can therefore commit withtsinverted relative toid, and a reader ordering bytsthen disagrees with the writer about which row is current. - Production was already right, the test was not. Every production reader of a recorded verdict
(
recordHealth’s own transition check,subtreeSystemHealth) orders byid, so the writer and the readers agreed. The health test helper ordered byts, which is why it reported verdicts the engine never produced. The intermittent failure was in the harness, not the product. LatestStateis a real exposure and is fixed here too. It backs the ingest transition guard and ordered bytsalone, so a poll cycle stamping several rows in one instant resolved to an arbitrary one and the guard could compare against a row that is not current. It now tie-breaks onid.- Reproduced deliberately rather than waited for. The failure needs contention: it never appeared in nine consecutive full-suite runs on an idle machine, and appeared within one or two attempts when six copies of the storage package ran at once. Under that same load the fix held for 24 runs.
- The regression test writes the inversion directly rather than racing it into existence, and asserts
the two orderings genuinely disagree before asserting the outcome, so it cannot pass vacuously. It
reads through
LocationHealth, which reports the recorded verdict;SystemHealthrecomputes live and cannot witness the defect. - Tracked as #356.
ADR-0062: A registry takes a uuid primary key and a renameable handle
Section titled “ADR-0062: A registry takes a uuid primary key and a renameable handle”- Date: 2026-07-22 | Status: Accepted | Pages: storage, api-first
- Decision: a registry has a uuid
idand a unique, renameablename, the shapetagand every estate entity already have.productandvendorconvert first; the remaining seven follow, slice by slice, tracked as #262. - Context: ADR-0056 says every foreign key stores
its target’s primary key, and epic #343 made that true everywhere except the slug-keyed registries,
where the name is the key. That exception was the last place a foreign key referenced a mutable,
human-authored string. A product id was a typo or a rebrand away from being wrong forever, and two
device packs both defining
cisco-room-kit-procollide on the primary key itself. name, notslugorkey. Six tables and every estate entity already call the human handlename, and the API bodies already sayname. A third word for the same concept would be worse than the inconsistency it fixed. Renaming the family tosluglater is a separate, mechanical decision.- The registries already disagreed with each other, which is worth recording:
propertyandinterface_typecall their slugname, whilecapability,driver,location_type,secret_type, andstandardcall theirsid. So the later slices are a rename for five of them and an addition for two, not one uniform change. nodestays the exception. Its primary key isprincipal_id, because a node is the detail row of a principal and its key IS that foreign key. It is deliberate and it is not changing.- The API carries both and accepts either, as the estate entities do:
id(uuid) andname(handle) on every body, and a path or reference resolves whichever form it is given. A kebab handle can never look like a uuid, so the two cannot collide. - The rename test is written first, each slice. It renames a handle and asserts every reference still resolves and now reads the new one. That is the capability the epic buys, so it is what the slice proves.
- The exception is retired, not just the tables. With all nine registries converted, a closing slice
removes the slug-keyed carve-out from the doctrine: the api-first rule now
states every foreign key stores a uuid with no exception, ADR-0056’s
carve-out is marked retired, and the
TestReferencesCarryBothFormsguard drops its slug-keyed allow-list (the registry references move into the both-forms rule, and the one that surfaced a real gap, a component response that carriedproduct_idwithout the product’s name, is fixed). The storage helper collapses too: the per-registryproductRefCol/vendorRefColand theregistryHandlesset fold into oneregistryRefCol(ref), since every registry now behaves the same.
ADR-0063: The telemetry model is typed registries over bare-noun data tables
Section titled “ADR-0063: The telemetry model is typed registries over bare-noun data tables”- Date: 2026-07-23 | Status: Accepted | Pages: datapoints, events, variables, storage, glossary
- Decision: every component interaction normalizes to one of three registries, each suffixed
_type(property_type,event_type,command_type), over bare-noun data tables (metric,state,property,event,command). A registry is a classification, so it takes the_typesuffix; the bare noun holds the instances. This retires the last confusion left by the datapoint_type to property rename: today’spropertyregistry becomesproperty_type(kindin{metric, state}), and today’sproperty_valuebecomesproperty, the latest-value cache.eventandcommandgain their own registries and are no longer modeled as apropertykind. - Context: using bare nouns for registries was the root inconsistency.
propertynamed the registry whileproperty_valuenamed the data;eventhad been folded into the property registry askind=log, the “false unification” datapoints and events explicitly warn against; and the code saidpropertywhile the pages still saiddatapoint_type. Suffixing the registry_typeand freeing the bare noun for the data fixes all three at once, and it vindicates the two-registry separation the pages always wanted:property_typeandevent_typestay distinct catalogs, they were never one universal registry. - The reusable pattern. A registry (
<noun>_type) defines canonical entries. A realization (the bare<noun>) records data referencing one registry entry by FK, over the same exclusive owner arc (component / system / location / node) the estate already uses, tagged with a provenance or origin.metric/state/propertyreferenceproperty_type;eventreferencesevent_type;commandreferencescommand_type. One rule, four tables, no bare-noun registries. - A log is a collection of events, so there is no
logtable. The row is anevent(one occurrence); a log is the stream of them, the way a registry is a collection of keys. So the earlier plan to rename the occurrence tableeventtologis reversed: the table staysevent, and “a component’s log” is a query over its events (observed origin). Component-observed versus platform-derived is anoriginon the row, not a separate table; promoting a raw line into a typed event is enrichment of the row, not a move between tables. - The owner arc stays; owner-prefixed tables (
component_metric,system_metric, …) are rejected. The exclusive arc already carries a component’s and a system’s metrics in one table, and it is the estate’s established primitive (property,tag_binding,secret,variableall use it). Splitting by owner would multiply the firehose tables fourfold, fragment the hot path, and force a UNION for the query that matters most: a system’s health rolling up its components’ metrics wants one table. The only gain is a non-null single FK, which the arc’s check already enforces logically. propertyis a latest-value cache; the firehose staysmetric/state.propertyholds the newest value per series,(owner, property_type, instance, provenance), the same series identity the firehose uses, upserted on intake.metricandstateremain the append-only samples. The cache exists to answer “what is it now” and “what did we last tell it” without scanning the firehose.- Provenance in the cache is rows, not columns. Each provenance is its own series row (
observed,calculated,intended), soobserved=45andintended=50are two rows, not two columns. Columns would put device-intake, command, and config all updating the same row (lock contention, lost updates on the hot path) and bake the provenance set into the schema. The “want / told / is” one-liner is the right read shape, delivered as a pivot view over the rows: read-shape is not write-shape. declaredresolves on demand;intendedis stored. The config setpoint (declared) is resolved live from the cascade and never rows into the cache, because it is always current and needs no history. The last commanded value (intended) is stored, because settlement needs the fact plus itsts. So the cache’s provenance set is{observed, calculated, intended}.- Two different drifts fall out of that split. Command settlement compares
observedtointended, is windowed, and is short-lived (“did my last command take?”). Config drift comparesobservedtodeclared(resolved live), is ongoing (“is it where config wants it?”). This is exactly whyintendedis stored anddeclaredis not. - The settle window is a driver fact, carried on
command_type. How long a command takes to actuate (an input switch is near-instant, a lamp warmup is tens of seconds) is device-physical, so it lives on the driver, on thecommand_typethe driver populates (the driver as a declarative menu of canonical properties, events, and commands), not on the abstractproperty_type. Settlement is computed, never a stored flag: withinnow - intended.ts < settle_windowthe value is pending and drift is suppressed; past the window,observedmatchingintendedis settled and a mismatch is a failed command. - Staging. The name foundation is cheap and lands first:
propertytoproperty_type,property_valuetoproperty, and themetric/stateFK repoint, a wide but mechanical sweep with no behavior change. The event family (event_type, theoriginand causation columns, pullingkind=logout ofproperty_type) rides the calculation and promotion layer, stillDesign. The command pillar (command_type,command, the settle window, command settlement) is greenfield. Each architecture page is rewritten to this model in the slice that builds its part, per docs with everything; until then the pages carry an inline note pointing here.