Skip to content

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.

  • 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, or Superseded), 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 Superseded and 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.

IDDateStatusDecision
ADR-00012026-06-27AcceptedAI acts as a human / service principal; a first-class agent principal is deferred
ADR-00022026-06-27AcceptedAuthorization is role + scope grants, not a per-principal allow-list
ADR-00032026-06-27Superseded by ADR-0050The healthy state is named ok, not up
ADR-00042026-06-27ResolvedBearer shipped first; password credentials (argon2id) landed in identity slices 1-2. OIDC / NATS still deferred
ADR-00052026-06-27Resolvedomniglass bootstrap <username> [--password]; the password-on-create path shipped, the iam namespace is deferred
ADR-00062026-06-27ResolvedThe single-owner invariant is now a DEFERRABLE constraint trigger, landed with grant revocation
ADR-00072026-07-01AcceptedA principal is not a scope-tree entity; the principal capability confers access only at all-scope
ADR-00082026-07-06AcceptedDisable revokes live sessions via the per-request active re-read; no token-version column (nothing consumes it)
ADR-00092026-07-06Superseded by ADR-0011The deploy “act on the subtree but not the root” capability is an exclude_root grant modifier, not a new scope kind
ADR-00102026-07-06AcceptedImpersonation ships view-as + act-as as an impersonation_session (not a credential), guarded by capability-cover, with a real-actor audit column
ADR-00112026-07-06AcceptedGeneralize the exclude_root boolean into a scope_op operator (subtree / subtree_excl_root / self), a flat enum, not a predicate-expression tree
ADR-00122026-07-07AcceptedOwner accounts are un-impersonatable by anyone; impersonate stays swept by principal:*; drop act-as scope intersection (#101)
ADR-00132026-07-07AcceptedGrant creation is refused when the granted role’s capabilities exceed the granter’s all-scope capabilities (admin cannot self-promote to owner)
ADR-00142026-07-07Superseded by ADR-0015The audit trail is admin/owner-only: audit is a sensitive resource that *:read does not confer, only an explicit audit:read or *:*
ADR-00152026-07-07AcceptedPermissions match like NATS subjects (* one token, > tail); admin-sensitivity is a deeper :admin token no partial wildcard reaches; owner is >
ADR-00162026-07-09AcceptedA 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-00172026-07-09AcceptedThe 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-00182026-07-10AcceptedA 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-00192026-07-11AcceptedEvery 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-00202026-07-11AcceptedThe 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-00212026-07-12AcceptedThe 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-00222026-07-13AcceptedDirectory rows carry batch-resolved effective tags; effective resolution extends to systems and locations, and a placed system inherits its location’s tags
ADR-00232026-07-13AcceptedThe 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-00242026-07-13AcceptedA 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-00252026-07-13Acceptedsecret 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-00262026-07-13AcceptedConsole 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-00272026-07-14AcceptedInventory 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-00282026-07-14Acceptedrank is dropped from location_type, system_type, and component_type; the three list operations sort by display_name, id instead
ADR-00292026-07-14AcceptedFiles 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-00312026-07-14Acceptedcomponent_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-00322026-07-17AcceptedEvery 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-00332026-07-17AcceptedThe 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-00342026-07-17AcceptedSettings Gateway methods are unscoped: ABAC storage-scope is not applicable to platform / principal config; only the settings:<action> permission gates them
ADR-00352026-07-17AcceptedSettings resolve down the principal hierarchy reusing the cascade primitive, with per-key provenance and a top-down broader-wins lock
ADR-00362026-07-07AcceptedA 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-00372026-07-07AcceptedTelemetry 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-00382026-07-07AcceptedThe 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-00392026-07-08AcceptedAn 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-00402026-07-14AcceptedThe 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-00412026-07-19AcceptedA 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-00422026-07-19AcceptedA 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-00432026-07-19AcceptedThe 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-00442026-07-20AcceptedThe 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-00452026-07-20Acceptedproduct 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-00462026-07-20AcceptedA 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-00472026-07-21AcceptedThe 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-00482026-07-21Acceptedsystem_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-00492026-07-21AcceptedA 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-00502026-07-21AcceptedHealth 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-00542026-07-21AcceptedA 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-00572026-07-21AcceptedThe 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)

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 human or service principal and acts with exactly that principal’s grants. A dedicated first-class agent principal kind is not in the initial architecture.
  • Context: A separate agent identity 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.kind CHECK already reserves the agent value so a later slice adds the kind without editing the applied auth migration; no agent identity 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.
  • Date: 2026-06-27 | Status: Accepted | Pages: health
  • Decision: The healthy state of a component or system is named ok. An earlier draft used up.
  • Context: up reads 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. ok names 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, because outage says what a broken system means to the people in the room where down says what a device is doing.
  • 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.kind now allows bearer or password (argon2id, PHC-encoded, one password per principal), and a human signs in with a username and password behind an httpOnly session cookie. The oidc / nats methods and the full (method, identifier) lookup key remain deferred (future slices).
  • Decision (divergence): The shipped credential table carries kind = 'bearer' only, stored as the token’s sha256 with a non-secret ogp_ locator prefix. The design’s fuller model (the password, oidc, and nats methods, 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 password to the credential.kind CHECK 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. The og iam admin 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 an owner@all grant plus a bearer credential in one transaction. The design page describes the eventual og iam create-owner --username ... --email ... password path under an iam command 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 iam command 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 DEFERRED constraint trigger (principal_grant_owner_guard) shipped with grant revocation (issue #82): it refuses to leave zero owner @ all grants at COMMIT, 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 SQLSTATE OG001 to ErrLastOwner.
  • Decision (divergence): “At least one active owner@all grant 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 at COMMIT (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 principal is not a scope-tree entity: it is not “under” a location, system, or component, so the principal:<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 an all grant 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.active on every request, not by a session-version / epoch column. AuthenticateBearer and AuthenticatePassword both filter and pr.active in 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. SetPrincipalActive flips the flag in one statement: disable is revocation, atomically. No token_version column 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) and TestDisablePrincipal. A token_version column 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_root modifier on principal_grant, not a new scope_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 a PATCH on the root is the existing readable-but-out-of-write-scope 403, so exclude_root reuses the three-way status split rather than adding a fourth case. Shipped with a new deploy official 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_session row (its own table: target, real actor, mode, expiry, revoke), not a credential (which authenticates a principal as itself). Authorization to impersonate is the escalation guard actor.Covers(target) (the caller’s capabilities must cover the target’s) plus the principal:impersonate capability 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 nullable audit_log.real_actor_principal_id written 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/me profile 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. authn tries the impersonation session on a bearer-hash miss, so the same Authorization: Bearer path serves both. Disabling either party kills the session via the per-request active re-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_root boolean (ADR-0009) into a scope_op operator on principal_grant (issue #102): subtree (root + descendants, the default, == old exclude_root=false), subtree_excl_root (descendants only for update/delete, root kept for read/create, == old exclude_root=true), and self (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 includes scope_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). self is 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 pure scope.Resolve gains a SelfIDs set; the three gateway walks (inScopeTree, InScopeIDs, scopedListSQL) gain a self arm. The migration also recreates the dedup index to include scope_op, fixing a latent collision, and threads scope_op through RevokeGrant’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 @ all cannot be impersonated by anyone, including another owner, in either mode (issue #106): a target-side check in the :impersonate handler, before the mode branch. (2) The principal:impersonate capability stays swept by the principal:* wildcard (admin) and *:* (owner); it is not carved out as a sensitive action, because holding principal:* 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, but owner.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 same role='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 by audit_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 grant owner (*:*), because admin is an enumerated role that does not cover the global wildcard. Issue #109.
  • Context: CreateGrant previously checked only that the granter held all-scope principal_grant:create (action.All), not that the granter covered the granted role, so an admin could grant itself owner@all and log in as a superuser, leaving the admin/owner distinction unenforced. The check lives in the create-grant handler (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:read capability, and audit is a sensitive resource: a partial global wildcard (*:<action>, e.g. the viewer role’s *:read) does not confer it. Only an explicit grant on the resource (audit:read, held by admin) or the full *:* superuser wildcard (held by owner) 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 :read floor and the *:read viewer 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), rbac gains a small sensitive-resource set: in Set.Allows, a * resource entry that is not allActions skips a sensitive resource, so *:read no longer matches it while *:* still does and an explicit audit:read still does. This is the narrow, honest version of the “sensitive permission” idea (distinct from the impersonate call in ADR-0012, where the principal:* resource wildcard legitimately confers principal:impersonate; here it is the global *:read wildcard over a sensitive read). The set is extensible if other sensitive reads appear (it holds only audit today).
  • Closes the gap: issue #116.
  • Superseded by ADR-0015: the carve-out is replaced by consistent topic-pattern matching, where :admin is 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 is resource:action; an admin-sensitive one is resource:action:admin. Because * is a single token, a two-token pattern (*:read, *:*, principal:*) structurally cannot match a three-token :admin permission: 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-token x:y:z, an inconsistency: the second * was silently absorbing a tail. Making matching a real topic match removes every special case, the ADR-0014 sensitiveResources set is deleted. viewer’s *:read misses audit:read:admin because two tokens cannot match three; owner reaches it via >; admin carries audit:read:admin explicitly. It also fixes, for free, a boundary wart from the grant guard: principal:* is now principal:<one token>, so it does not sweep an admin-tier principal:<action>:admin, those stay owner-only unless granted explicitly. Set.Allows matches by token; Set.Covers (the impersonation and grant-escalation guard) becomes pattern subsumption plus the :read floor, staying conservative (a reach covered only by the union of several patterns returns false, deny). The only seed change is owner’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 all resource: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 active flag), 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-sensitive principal:purge:admin, so admin (which carries it explicitly) and owner (>) can purge but a two-token principal:* cannot reach it (ADR-0015). To keep the audit trail through a hard delete, the actor’s human-readable label is denormalized into every audit_log row at write time, and the audit foreign keys become ON 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 --ttl override capped at 365 days; nothing is issued without an expiry. Sessions and API tokens are distinguished by a credential.purpose column, not by whether expires_at is 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_at null), 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 dedicated purpose column 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. AuthenticateBearer already refused a passed expiry, so enforcement needed no change: giving tokens a future expiry is enough, and the list reuses the same expires_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}/avatar gated principal:read, GET /auth/me/avatar on the self lane) that returns { image_base64 }, which the console decodes into a data: URL for the <img>. The write lanes take base64 JSON in (POST /principals/{id}:setAvatar and the /auth/me self lane), and the server-normalized 256x256 JPEG is stored base64 on the human row; the principal read models carry only a has_avatar bool, so no image payload rides a list or the loadPrincipal hot path.
  • Context: The slice design spec proposed a raw image/jpeg read endpoint (with ETag / 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 same authn + 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 over avatar_updated_at is 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 (a secret_type shape 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.path in 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 credential and left it Design. Building it surfaced two calls. First, naming: “credential” collided with the identity credential and undersold the general case (an snmp_community, an API key, an oauth2 blob 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 *:read floor 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 the credential naming and any “references inside the value” reading on the page; the variable and config members stay Design.
  • 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,update is 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: a value_type enum (string | int | float | bool | json) on the row plus a jsonb value validated against it in a pure internal/variable package, not a variable_type shape 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, the template owner scope (the design’s global -> 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_type registry and a shared config/variable cell carrying observed_value and reconcile. Building the member showed those belong to config (the declared-vs-observed member), not the free macro: a variable has no observed side. So variable shipped as its own single table, typed inline, and the page’s Storage section is corrected to match. This diverges from the page’s variable_type-registry and shared-cell sketch; the config member stays Design.
  • 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 tag key vocabulary, the per-entity tag_binding value 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-scope tag:create (broadened to tag:* for admin, covering update and delete of keys), while setting a value is the owner’s ordinary write (component:update and 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 by tag:update. A key carries applies_to (an entity-kind allow-list, empty = universal, checked on bind) and propagates (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 pure internal/tag package, 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 a template-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 own tag_binding cell) 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 an effective_tags map (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 resolves global plus its own location tree; a system resolves global, its own system tree, and the location it is placed at (its location_id tree). A placed system therefore inherits its location’s tags (a system in a PCI building surfaces compliance: pci), consistent with how a component picks up its own location_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 existing rowActions batch. 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 a location_id exactly 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, and principal_group moves from a two-token <resource>:read to the admin-sensitive <resource>:read:admin, so the viewer read floor (*:read) no longer reaches the Users, Roles, and Groups directories. admin carries an explicit principal:read:admin, role:read:admin, and principal_group:read:admin alongside its <resource>:* wildcards, the same shape as the existing principal: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) inherits viewer, whose *:read is a single-token resource wildcard. Because * matches exactly one token, *:read matched principal: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_values set (a new text[] column, empty by default). Empty leaves the key free-text, unchanged; a non-empty set is the enum a bound value must belong to, so environment can be declared as one of prod, staging, dev. The binding write enforces it: SetTagBinding rejects 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 new GET /tags/{name}:values read (a select 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 typed value_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 environment that 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 (one select 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/component entity a secret attaches to on the exclusive arc) gives locality, unchanged. A new per-secret admin_sensitive flag gives same-scope sensitivity: when set, every action on that secret is lifted to the :admin tier, so a scoped two-token grant (secret:reveal) cannot reach it and only admin (secret:>) or owner (>) may see, reveal, update, delete, or create it. The flag defaults from the secret’s secret_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 is true (a secret is admin-only until marked operational). Enforcement is a capability flag computed at the API (canAdmin = the caller holds secret:<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 held secret:create without secret:read; closing it needs a namespace or create-path change and is a tracked follow-up, not a value-disclosure path.) Separately, secret joins 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 literal secret:read, and a secret:* still name it. So viewer (only *:read) reads no secrets at all (not the directory, not the per-component effective-secrets cascade), operator/deploy gain a scoped secret:read,reveal,create,update and see and reveal the operational secrets in their subtree, and admin’s secret:* becomes secret:> so it reaches the admin tier. The /secrets directory, previously all-scope-only, is now scope-filtered. The client can() mirrors both the sensitive-set and the :read floor 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 :admin rule expresses the real distinction without a new matcher concept. Taking secret off the bare * wildcard (rather than promoting secret:read wholesale to :admin, which would deny operators their device secrets) is the one lever that keeps the two-token secret:read operators legitimately hold while stopping viewer’s *:read from reaching it. Negative grants (deny-after-allow) were rejected as a footgun the :admin tier and the sensitive-set already cover. This is Slice B of the same visibility rework as ADR-0023; the IAM directories use the :admin tier (no legitimate sub-admin reader) and are not in the sensitive-set, variable stays 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:read lands) 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: New navigates 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 Users openPrincipalInEdit pattern). 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 /create outranks /:name in the router, so create is a reserved segment. The shared TreeList primitive gains a per-surface edit slot on ListCtx (the full page makes its own slot, since the shared renderDetail must not call useBladeEdit outside a blade provider), plus renderCreate / onNew / onEdit hooks and an optional FormBody, 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 TagAdder rendered 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-page useBladeEdit crash, a FormBody footer 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: rank is dropped from location_type, system_type, and component_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, and ListComponentTypes now order by display_name, id instead.
  • Context: rank was sort-only from the start (the location_type seed comment already said so: “rank does NOT constrain nesting”), never an enforcement mechanism. The upcoming allowed_parent_types placement constraint on location_type needed a clean field to introduce without a stale, unused ordering column sitting beside it, so retiring rank is the mechanical precursor to that slice rather than part of it: this PR only removes the field and switches the sort, allowed_parent_types is 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_types half 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 blob store as a Storage Gateway primitive (a blob.Store seam, default pgblobs backend holding bytes inline, keyed by the sha256 of the bytes, dedup via on conflict do nothing, integrity-verified on read), and a file handle, 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 give file a secret-style owner scope.) (2) Sensitivity reuses the secret mechanism, binary, defaulting off. A per-file sensitive flag 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), and file is 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. DeleteFile drops 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, a collection.failed raw, an attach event, none of which exist yet) stays a later slice; today a file is 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_type from 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_type gains allowed_parent_types (text[], default {}): a set whose members are location_type ids and/or the reserved root sentinel (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 contains root, or the parent location’s type is in the set. root cannot collide with a real type id: CreateLocationType refuses it. Enforcement is forward-only, on CreateLocation and the location move path (UpdateLocation’s new ParentName patch 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 same Show 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). A child.level > parent.level rule 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 separate root_placeable boolean 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-side validatePlacement call 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: UpdateLocation checks 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 the PlacementError (422, naming both types) rather than the generic ErrLocationCycle; the design left the check order unstated, and the more specific, actionable error was chosen to win. Systems and components lose rank too but get no allowed_parent_types this 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 principal of kind='node' with a 1:1 node detail table (keyed by principal_id, alongside human and service), exactly as identity and access describes. Its name is not null unique on 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 bearer credential row on its principal, minted, stored (only as sha256), 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 (a nats credential kind, a signed nonce, a JWT carrying the node’s subject permissions) is deferred; the credential kind CHECK is not widened for it here. (2) Per-node NATS isolation is static per-connection subject permissions: the embedded nats-server runs an in-process CustomClientAuthentication callback 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 own og.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=node principal (rather than the standalone table an earlier checkpoint built) puts it on the shared identity spine from the start: it has a real principal_id so 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 telemetry Event over JetStream is the next checkpoint.
  • Closes the gap: the nkey/JWT node identity (the nats credential 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 + Datapoint messages only, no gRPC service) published to og.v1.telemetry.<node>. This is omniglass’s first protobuf; the wire is generated with protoc + protoc-gen-go via a gen-proto stage on make gen, and the generated event.pb.go is committed. The server hosts a JetStream stream (OG_TELEMETRY over og.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 the datapoint_type registry, writes the surviving typed rows through the checkpoint-1 InsertMetricDatapoints path (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 the task_id; the server binds and confines.
  • Context: The prior (v2) design split telemetry into a hot path that persisted a raw event to a telemetry table 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’s task_id is 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-authored transform_rule (omniglass has none), so label-based multi-owner routing, discovery rules, and node-self binding are a later checkpoint.
  • Closes the gap: raw-Event persistence (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 domain up / down) is a first-class state datapoint, not a metric, seeded as an official datapoint_type at kind=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 an observed state datapoint instanced by the interface; the ingest consumer routes by the registry kind (a metric name to metric_datapoint, a state name to state_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 is time_in_state over 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-side calc_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 make time_in_state a 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 from state_datapoint.
  • Divergence: checkpoint 1 seeded the datapoint_type canon metric-only (the reachability probe metrics), and cp3’s ingest consumer assumed every surviving datapoint was a metric (InsertMetricDatapoints for 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). The state_datapoint table mirrors metric_datapoint (same owner exclusive-arc, same lineage CHECK) with a categorical value text plus an optional value_json.
  • Closes the gap: the availability SLI (time_in_state over interface.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 interface is 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_type is 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 over ssh or telnet), 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: snmp is the transport, a biamp-snmp (or generic-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); the interface instance 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 declarative canonical 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>_type pattern holds: interface_type is the transport (a reachability interface’s type genuinely is its transport), and driver earns 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) with type = 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_type is the transport primitive (icmp, tcp, ssh, http seeded built), each carrying a tcp-connect or ping reachability probe; an interface is named by its protocol and typed by its transport; the dev seed models a lab polaris DSP with a web (http) and a qrc (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 = transport half is built and stable (this slice). The driver / collect layer (the separate driver entity, 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 interface is the only authored collection primitive; the task is derived. Creating an interface derives its one poll task, so the task surface is read-only (GET /tasks, GET /tasks/{id} only): the POST / PATCH / DELETE /tasks routes and the task:create / task:update grants are removed. A task carries no node column; task.node_name is dropped and its placement is projected from interface.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_name and task.interface_id are ON 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_make manufacturer registry (id, display_name, icon, support_phone, website), lands ahead of the rest of the make/model catalog. (1) official boolean, not an origin enum. The design sketch (below) proposed origin official | seed | custom on make and model, matching the model layer’s eventual needs. Slice 1 ships a plain official boolean instead, because component_type and 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 real seed (installed, mutable) tier exists to fill it; origin can still land on component_model if that tier turns out to be real. (2) The in-use / referential delete guard is deferred. component_type, location_type, and system_type all refuse a delete while a location, system, or component still references the row (409). component_make ships no equivalent guard: nothing references a component_make yet (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 when component_model lands 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 renders website as a live anchor; an operator-entered value with no scheme check is a stored-XSS vector (javascript:/data: executing on click). A validWebsiteScheme guard on the API (http/https only, empty allowed, else 422) and a matching safeUrl guard 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.md sketches the full make/model catalog (component_make, a component_type genus tree, component_model, and component.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 | custom enum is not what shipped; official boolean did, per (1) above. The design’s delete-refused-while-referenced rule is not enforced yet; per (2), it is deferred to the component_model slice 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 the authn + require middleware (unchanged enforcement), stamps the operation with an x-omniglass-permission OpenAPI extension, and records the permission in an in-process registry. The required permission for each request is therefore published in the generated api/openapi.json, and the permission universe (the deduped set of every stamp) is derived from the routes, not a hand-kept catalog. GET /roles reports the universe plus, per role, the held subset (resolved by the same rbac.Set.Allows matcher as the effective set), and the console role blade renders it as a net Held / Missing / All view. 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 explicit aheadOfRoutes allow-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 example alarm:*, 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 embedded code defaults and the operator file, 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 a DELETE: 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 UPDATE of 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 to Partial.
  • 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: code is now default (off the axis, the setting’s own declaration) and global is now platform (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 the settings:<action> permission at the route gates them (settings:read admin read with provenance, settings:update write / restore / lock, both admin-tier; the client-safe /settings/me is 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 own user row), 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: code is now default (off the axis, the setting’s own declaration) and global is now platform (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 global lock supersedes a group lock, 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 settings package 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: code is now default (off the axis, the setting’s own declaration) and global is now platform (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-secrets and GET /components/{name}/effective-variables routes (and the generated omniglass effective-secret list / effective-variable list commands 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 field sources model) 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 sources model 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 Settings Go struct (internal/settings/schema.go), and that single declaration is the whole source of truth. Reflection over the struct produces the code defaults layer (Defaults(), from each leaf’s default: tag) and the namespace registry (Namespaces(), from the json and settings: tags), replacing the hand-kept embedded defaults.yaml and the hand-kept Namespaces() slice (both retired). Huma reflects the struct into the OpenAPI schema, so make gen yields the typed SPA client values (a Settings struct, not a free-form object). Writes validate against that same reflected schema on both sides: the server backstops PATCH /settings/{namespace} (unknown namespace to 404, unknown key / wrong type / enum or pattern violation to a 422 naming the namespace.key, null allowed as a delete), and a make gen step slices the field constraints out of api/openapi.json into 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 into Settings, and Go code reads a setting through the EffectiveTyped accessor).
  • Context: Slice-0 shipped the engine with untyped values: a setting lived in two hand-kept places (the Namespaces() slice and the embedded defaults.yaml), the API exposed values as a free-form object, the generated client typed it as Record<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). Making Settings a 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 the defaults.yaml asset and the Namespaces() 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 a Default() 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 location value 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.
  • Date: 2026-07-19 | Status: Accepted | Pages: config, secrets, and variables
  • Decision: The datapoint_type catalog is generalized into a primitive-agnostic property catalog: one typed catalog whose entry (a property) is a canonical, typed name that a datapoint observes and a field declares, identified by a key (its canonical name). The physical table is property; the concept, the API resource (/properties), the Go Property type, and the console all read property, while a property’s identifier (its key, and the field_definition.key reference) stays key. Four shape changes fold datapoint_type in: the unused (scope, name) ladder (org/template never had an operator write path, template_id was a dangling column) collapses to a name primary key plus an official boolean (seed-owned rows are read-only); value_type becomes data_type over the unified set {string, int, float, bool, json} (text backfills to string, bool is added); kind (metric/state/log) becomes nullable, since a declared-only attribute property has no observed kind; and validation is a JSON Schema fragment (pattern/enum/minimum/ maximum and, for a json-typed property, a nested object schema), enforced by Huma’s own validator with the stored schema loaded through yaml.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 so serial_number is the same concept whether a device reports it (observed, a datapoint) or an operator types it (declared, a field). The official boolean chassis is chosen over finishing datapoint_type’s never-built scope-shadow precedence: it is the proven, finished model the *_type registries already use.
  • Values reference the key, not a <primitive>_key layer: the only binding is the type-schema (field_definition gaining a key reference 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.key reference (PR-B); the type-schema editor (how a component_type selects 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 a key reference; 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_make catalog is generalized into a vendor catalog carrying a kind (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: a driver (id, display_name, version, the software that speaks to a component) and a capability (id, display_name, a thing a component can do). Each of the three is a gated CRUD Catalog console page (/vendors, /drivers, /capabilities), reusing the official-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 / :delete permission 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), the product_capability link, and the component.product pointer 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_make was 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 a kind-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, and component.product (the next slice); a referential delete guard on vendor / driver / capability (nothing references them yet, exactly as component_make shipped with no guard until component_model was to land); and an operator shadow of an official row.
  • Refines: ADR-0031 (the component_make registry is renamed and generalized to vendor with a kind; its official-boolean, deferred-delete-guard, and website-scheme-validation calls carry over unchanged).
  • Date: 2026-07-20 | Status: Accepted | Pages: core entities, Products guide
  • Decision: product is 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 stable id and display_name, a kind from a fixed enum (device / app / service / vm, default device, enforced by a DB CHECK and at the API edge), an optional vendor_id (who makes it) and driver_id (what talks to it), an optional parent_product_id (a self-reference: a variant points at its base product), and the official boolean the type and classification registries already use. The capabilities a product provides are a many-to-many set in the product_capability join (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 by product:create / :update / :delete and 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 the component_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 are on delete set null instead (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_type genus; 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 why component.product is a restrict FK, not set 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 its product, not its genus type). Consumes ADR-0044 (product is the product layer that ADR deferred; it references vendor, driver, and capability). One divergence from the leaf catalogs’ prediction: their deferred delete guard lands only as the component.product restrict (409), while a product’s own vendor / driver references null out (on delete set null) rather than blocking the referenced row’s delete.
  • 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 event table is the log-kind sink of the collection pipeline, the counterpart of metric_datapoint / state_datapoint: where a datapoint records a sampled present value, an event records a past occurrence (a device log line, a structured frame). It carries the same datapoint owner exclusive-arc (owner_kind plus component_id / system_id / location_id / node_id, one-set CHECK) and the same provenance vocabulary (observed / calculated / intended / declared, default observed) as the datapoint sinks, plus a message (text) and structured attributes (jsonb). The ingest consumer’s deriveDatapoints now returns metrics, states, and events, and the persistence path calls InsertEvents: a log-kind datapoint that used to be dropped at ingest (it had no sink) is routed to event, riding string_value (its message) or json_value (its attributes), under the same owner-confinement and reject-not-project gates as the metric and state sinks. A boot-seed property syslog.line (kind log) is the canonical log-kind starter. The reserved event_id columns on metric_datapoint and state_datapoint are closed into real foreign keys to event(id) (on delete set null), so an intended-provenance datapoint references the event that produced it. Storage adds InsertEvents (batch, in-tx, provenance observed) and ListComponentEvents(name, since, limit) (newest first); the read route GET /components/{name}/events (operationId list-component-events, gated component: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_id stubs 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 -> sample rename (a naming cleanup that lands in a later slice, so the datapoint tables keep their current names here); property_value and the materialized current-value store (the fold-fields slice); the normalized event_type registry and the promotion of a raw event occurrence into a registered event (events); a scope-wide event read (this ships the per-component read only); and any calculated / intended / declared event producer (the write path is observed collection 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 a log_datapoint table and treats event as a strictly normalized, event_type-registered occurrence promoted from a raw line. The built log sink is the event table directly (the raw occurrence lands there, not in a separate log_datapoint table), and the log_datapoint table plus the promotion ladder stay Design; 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 declared provenance, and the same is true of config (a property with intended provenance). Two tables replace it. product_property is 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_type and validation are not duplicated here; they stay in the property catalog, the single source. property_value is the value store: it carries the same owner exclusive-arc as metric_datapoint and event (owner_kind plus component_id / system_id / location_id / node_id, one-set CHECK), plus property_name, an instance discriminator, a provenance (observed / calculated / intended / declared, default declared), and a jsonb value. Its series key is unique 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 only owner_kind=component with provenance=declared; the rest of the arc and the other three provenances are the seats later producers sit in. The resolver is EffectiveProperties(component, scope), one SQL UNION of two arms: the contract arm (every product_property of 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}/properties and PUT / DELETE /products/{id}/properties/{property} (gated product:read / :update / :delete, an official product read-only 422), and GET /components/{name}/properties plus PUT / DELETE /components/{name}/properties/{property} (gated component: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 the component_type table 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 category component_type used to carry (display, codec) is expressed by the capabilities that product provides. The seeded products ship a starter contract (cisco-room-bar and samsung-qm55 declare serial_number, firmware_version, and model_number with defaults), and roles.yaml drops the now-unclaimed field:* permissions, since property:* 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_value for declared, the datapoint tables for observed, an unbuilt config table for intended).
  • Deferred: standard_property and location_type_property (the other contract owners, each waiting on its owner entity, standard and a location_type schema); the driver access/mode column on product_property (whether a driver can get, set, or only declare a property, which lands with the driver slice); the non-declared provenance producers (intended config writing a desired value, observed materializing a current value out of the datapoint stream, calculated from a rule), which the provenance column seats but nothing writes yet; the multi-owner arc on property_value (only the component arm is written and scope-injected today); and the datapoint -> sample rename, still a later cleanup.
  • Supersedes: ADR-0043’s deferred field_definition.key property 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, and field_definition retires. Also completes ADR-0045’s partial supersession of the component_type-as-shape notion: that ADR repointed shape at the product but left the table standing; this one drops component.component_type and the component_type registry 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, so product_property.default_value (and its two siblings) is the shipped instance of the off-axis default rather than a tier under platform.

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_type is promoted to standard. A standard is the blueprint a system conforms to (huddle room, classroom, auditorium): the system-side counterpart of product, not a label hung off a system. The table is renamed and gains parent_standard_id (a variant points at its base, mirroring product.parent_product_id) and a declared-property contract. system.system_type becomes system.standard_id and is now optional, exactly like component.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 shared type:* registry permission and takes its own standard:read / :create / :update / :delete Catalog resource (read on the viewer *:read floor, the writes at the admin tier, exactly like product:*), and its routes move from /types/system to /standards.

    2. Two more contract tables, and one owner-generic resolver. standard_property and location_type_property join product_property on the identical shape (<classifier>_id, property_name, an optional default_value, a required flag, unique per pair). data_type and validation are never duplicated onto a contract; they stay in the property catalog. The resolver then generalizes: EffectiveProperties(ctx, ownerKind, ownerID, read) resolves component, system, location, and node off one parameterized SQL template driven by an ownerContract table (instance table, classifier column, contract table, contract key, arc column). Component reads its contract through component.product_id, system through system.standard_id, location through location.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, guardOwnerScope now 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: false through seed-if-absent paths (SeedStandard / SeedLocationType, ON CONFLICT DO NOTHING), never the authoritative Upsert*. 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 UPDATE would 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 later command and event_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) and role stay 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}/properties plus PUT / DELETE /standards/{id}/properties/{property} (gated standard:read / :update / :delete); GET /location-types/{id}/properties plus PUT / DELETE .../{property} (gated type:*, since the location type registry is still a type registry); and the value sides GET /systems/{name}/properties plus PUT / DELETE .../{property} (gated system:read / :update) and the same for /locations/{name}/properties (gated location: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_property and location_type_property because neither owner was ready: system_type was 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-side product. Making standard_id optional 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 authoritative official row 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_type plus a disable flag (#318). Also still deferred: a standard’s role set and health composition, the non-declared provenance producers, the cross-owner cascade over property_value, and the datapoint -> sample rename.

  • 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 is official and read-only. That now holds only for the canonical catalogs: the shipped standards and the four shipped location types (campus / building / floor / room) are official: false and 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_role is a slot, declared on the arc. A role (a table microphone, a main display) is declared either on a standard, where every conforming system inherits it live, or directly on one system (ad-hoc, which is how a one-off system gets roles at all). The two owners ride the same exclusive-arc pattern property_value uses: an owner_kind plus standard_id / system_id, a one-set CHECK, and a unique nulls not distinct key over the arc columns and the role name (the default NULLS DISTINCT would let duplicates through the NULL arm). A role carries a quorum: how many components should fill it, at least one, because a role no component need fill is not a role.

    2. role_capability is conjunctive. A role requires a set of capability rows, 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=true adds one the product does not claim, present=false suppresses 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 (marked from_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 serves Assigned() and Understaffed() (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. AssignRole is 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, and role_assignment.component_id is on 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}/roles plus PUT / DELETE /standards/{id}/roles/{role} (gated standard:read / :update / :delete); GET /systems/{name}/roles (the resolved read) plus PUT / DELETE /systems/{name}/roles/{role} and PUT / DELETE /systems/{name}/roles/{role}/assignments/{component} (gated system:read / :update); and GET /components/{name}/capabilities plus PUT / DELETE /components/{name}/capabilities/{capability} (gated component: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 shipped meeting-room standard declares room-mic (microphone + speaker, quorum 2) and main-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 product is 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 the system_member composition table, which stays Design.

  • Supersedes: the system_template_member role-requirement design on templates. That page still describes a role slot frozen into a system_template_version, whose requirement is a set of canonical datapoints and commands and whose instance assignment is a system_member row. 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 in role_assignment. Templates and their frozen BOM stay Design; 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 alarm is component-local (component_id, a severity of info / warning / critical, a message, a raised_at, and a nullable cleared_at), and alarm_capability names 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 declared impact (outage / degraded / none, a column on system_role, defaulting to degraded); 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/health takes 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 reads degraded, so a bad value can never make an impaired role silently harmless. An unrecognized recorded value reads healthy, 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_datapoint is already that primitive (the ingest path writes a row only when the value differs from the last one stored, and StateTransitions reads the ordered flips the reachability availability strip draws), so health reuses it rather than adding a history table: the owner arc, provenance='calculated', and source_rule='health-rollup' (the lineage CHECK requires a non-null source_rule). The first value for an owner is always recorded, even healthy, 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 healthy beside an impaired outage role: 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}/alarms and DELETE /components/{name}/alarms/{id} (gated component:read / :update); GET /systems/{name}/health and GET /locations/{name}/health (gated system: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 reads omniglass component alarms|raise-alarm|clear-alarm, omniglass system health, and omniglass location health. The seed adds a health state-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_datapoint is already the right table and a new health_history would 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 a state_datapoint row from birth, and every rename failed on the owner foreign key, because those FKs address the owner by name and declared no ON UPDATE. Migration 20260721170000 re-adds all four state_datapoint owner FKs with on 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 an event_rule over 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), the unknown verdict with its coverage and staleness reasons, the global estate 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 ok over an ordered ok < degraded < down; the built domain is healthy < degraded < outage, keeping that entry’s reasoning (name the verdict, not the ping) and changing only the words, since outage says what a broken room means to the people in it. (b) Where impact is declared: the design hung an optional health impact on the event_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: the required / redundant / informational member tag on a system_template_member is superseded by quorum plus impact on a system_role, which expresses the same three cases without a fourth vocabulary (required is quorum 1 with impact outage, redundant is a quorum below the number assigned, informational is impact none). It also closes ADR-0049’s deferral of impact and 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_id was a single pointer, set once at create with no path to change it, which no authorization and no health path ever read; role_assignment was 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 displayed 0 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 while is_primary keeps 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_assignment keeps its on delete restrict because 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_id stays 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_member design: the shipped row is the binding alone, without the role column or the pin to a frozen system_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 what is_primary is 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_id is dropped. With nothing reading it, the column, its API field, and its console consumers go. The component body now reports system (the primary, by name) and system_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_chain is 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 name with on 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 by id, 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 by name with on update cascade.
  • Normalized: parent_id and location_id on component and system, parent_id on location, and the redundant owner_id on tag bindings, variables, and secrets, which already carried owner_name beside it. Nine fields, seven of them found by survey and two by the guard test, which caught a SecretBody.owner_id the survey missed.
  • Enforced by contract, not prose. TestResponsesAddressEntitiesByName walks the generated OpenAPI and fails on any field naming another entity by uuid. The failure it prevents is invisible otherwise: a body emitting parent_id still serves 200s, and the cost only appears in the clients.
  • Deliberately not in scope: secret, variable, and tag_binding still 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) through lib/blades; a Drawer form body binds submitLabel / submitIcon / submit / busy / disabled / cancel through lib/formactions. BladeStack and Drawer each compose their own button vocabulary but draw it through the single PanelFooter rail. 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 DrawerFooter helper 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.ts is 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-4 row 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, and secret become text references <entity> (name) on update cascade on delete cascade. The columns keep their _id suffix, matching role_assignment.component_id and state_datapoint.component_id, which are likewise text referencing a name.
  • on update cascade is the load-bearing clause. A name is only safe as a key if a rename carries. TestOwnerArcsSurviveARename is 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_id in the owners CTE is a name, so the final joins that resolve a display name match on name rather than id.
  • 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_id references node.principal_id, a node’s enrollment identity and the only handle it has. tag_binding.tag_id is a genuine instance of the same rule (tag is 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_id for 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 a name. 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.component referencing component (name) and no on update clause, 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. TestResponsesAddressEntitiesByName fails 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.
    1. global becomes platform as the cascade’s least-specific binding tier, on both axes: the estate arc (owner_kind on variable, secret, and tag_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.
    2. code becomes default, and a default is 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_value and its standard_property / location_type_property siblings, read as coalesce(the instance's set value, the contract default) by EffectiveProperties, 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 the property catalog 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 retired field_definition.default_value hanging off a component_type, and it is the stronger one for the rule here, since the coalesce is the fall-through in the code path itself.
    3. There is no root location. The location tree keeps N unparented tops. A tier above today’s tops is a new location_type and a real node, never a magic one, and a top-level location is not a substitute for platform: binding at one top misses every sibling, and a top added later is silently uncovered.
    4. The install-wide tier survives on the estate axis, uniform across kinds: platform | location | system | component.
    5. A write at the tier needs platform:<action>, checked in addition to the resource permission and published per route as an x-omniglass-platform-permission stamp. 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 to admin (and reaches owner through >); operator and deploy hold no platform write, and nothing implies one. “Nothing implies one” is enforced by putting platform in the sensitive-resource set beside secret and settings (ADR-0025), so a bare single-token * never names it: a custom role carrying *:update holds every estate write and still no install-wide authority. Only a literal, a platform:*, or a > names the tier.
    6. root is not used as a tier name, so location_type.allowed_parent_types keeps 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 global was 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 at global, 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 (code for the declaration, global for the install-wide override), so the same distinction existed twice under three names. Separately, global also 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 tier platform and the declaration default gives 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: secretAAD binds a sealed field to its owner arc, ownerKind|ownerID|name|field. A secret sealed at the tier before this rename authenticates against global|global|...; after it, the derivation yields platform|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 generated node group, rather than a top-level node. 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 list resolved 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: members under the principal groups (#326), type list (#319), and now node. 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 create and grant 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: commandWords derives the group from a single path segment, so /principals/{id}/grants and /principal-groups/{id}/grants both become grant. Fixing that renames documented commands and is a naming decision, not a mechanical one.
  • Cost accepted: omniglass node is a documented invocation and it changes. At v0.0.0 that is a docs edit, and a mode reads as a verb anyway, beside node list and node 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}/properties is component property list; /principals/{id}/grants and /principal-groups/{id}/grants are principal grant create and principal-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 list seven 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).
  • nameOverride was 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 /auth family, 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 run and type secret list render as written.
  • omniglass statu shipped. The depluralizer took -s off status. 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. TestNoCommandNameCollisions fails on any duplicate name (its known-collision list is now empty, and its second half forced those entries out once fixed). TestDocsOnlyNameRealCommands walks the guides and fails on a documented command that does not resolve; it immediately found omniglass 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 /types umbrella 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/location while its property contract lived on a flat /location-types/{id}/properties, so one entity had two command groups (type location update and location-type property list) and an operator had to know both. /types/secret had no flat form at all.
  • The umbrella misused nesting. A nested segment says the child belongs to the parent (/principal-groups/{id}/members). location is not owned by types; 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. The type command group disappears and nameOverride needs no entry for any of it.
  • Breaking: three route shapes change. At v0.0.0 that is a regeneration plus a docs pass, and TestDocsOnlyNameRealCommands fails 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. ts records when the value was computed and is for display and history; it does not decide which row is current. For an observed series, ts still orders, because it is the observation time and a late arrival must not displace a newer reading, but id breaks a tie.
  • Context: recordHealth writes select 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 with ts inverted relative to id, and a reader ordering by ts then 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 by id, so the writer and the readers agreed. The health test helper ordered by ts, which is why it reported verdicts the engine never produced. The intermittent failure was in the harness, not the product.
  • LatestState is a real exposure and is fixed here too. It backs the ingest transition guard and ordered by ts alone, 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 on id.
  • 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; SystemHealth recomputes 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 id and a unique, renameable name, the shape tag and every estate entity already have. product and vendor convert 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-pro collide on the primary key itself.
  • name, not slug or key. Six tables and every estate entity already call the human handle name, and the API bodies already say name. A third word for the same concept would be worse than the inconsistency it fixed. Renaming the family to slug later is a separate, mechanical decision.
  • The registries already disagreed with each other, which is worth recording: property and interface_type call their slug name, while capability, driver, location_type, secret_type, and standard call theirs id. So the later slices are a rename for five of them and an addition for two, not one uniform change.
  • node stays the exception. Its primary key is principal_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) and name (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 TestReferencesCarryBothForms guard 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 carried product_id without the product’s name, is fixed). The storage helper collapses too: the per-registry productRefCol / vendorRefCol and the registryHandles set fold into one registryRefCol(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 _type suffix; the bare noun holds the instances. This retires the last confusion left by the datapoint_type to property rename: today’s property registry becomes property_type (kind in {metric, state}), and today’s property_value becomes property, the latest-value cache. event and command gain their own registries and are no longer modeled as a property kind.
  • Context: using bare nouns for registries was the root inconsistency. property named the registry while property_value named the data; event had been folded into the property registry as kind=log, the “false unification” datapoints and events explicitly warn against; and the code said property while the pages still said datapoint_type. Suffixing the registry _type and freeing the bare noun for the data fixes all three at once, and it vindicates the two-registry separation the pages always wanted: property_type and event_type stay 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/property reference property_type; event references event_type; command references command_type. One rule, four tables, no bare-noun registries.
  • A log is a collection of events, so there is no log table. The row is an event (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 table event to log is reversed: the table stays event, and “a component’s log” is a query over its events (observed origin). Component-observed versus platform-derived is an origin on 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, variable all 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.
  • property is a latest-value cache; the firehose stays metric/state. property holds the newest value per series, (owner, property_type, instance, provenance), the same series identity the firehose uses, upserted on intake. metric and state remain 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), so observed=45 and intended=50 are 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.
  • declared resolves on demand; intended is 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 its ts. So the cache’s provenance set is {observed, calculated, intended}.
  • Two different drifts fall out of that split. Command settlement compares observed to intended, is windowed, and is short-lived (“did my last command take?”). Config drift compares observed to declared (resolved live), is ongoing (“is it where config wants it?”). This is exactly why intended is stored and declared is 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 the command_type the driver populates (the driver as a declarative menu of canonical properties, events, and commands), not on the abstract property_type. Settlement is computed, never a stored flag: within now - intended.ts < settle_window the value is pending and drift is suppressed; past the window, observed matching intended is settled and a mismatch is a failed command.
  • Staging. The name foundation is cheap and lands first: property to property_type, property_value to property, and the metric/state FK repoint, a wide but mechanical sweep with no behavior change. The event family (event_type, the origin and causation columns, pulling kind=log out of property_type) rides the calculation and promotion layer, still Design. 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.