Decision log
The architecture pages are written in the present tense as the target design, and each carries a status badge that says how much of it is built (implementation status). Neither axis carries history: why a call was made, when it was reversed, or why the shipped code differs from the page that describes it. That is what this log is for.
A page tells you what the design is and how much is built. This log tells you how it got there: the decisions that bind the design, the ones that were reversed in the open, and the points where the implementation has deliberately (or accidentally) drifted from the prose. It is the project’s architecture decision record (ADR), kept lightweight and append-only.
How it works
Section titled “How it works”- One entry per decision that reverses a prior call, settles an open question, or records a point where the build diverges from a page’s present-tense design.
- Each entry carries a date, a status (
Proposed,Accepted,Resolved, orSuperseded), the decision in one line, the context that forced it, and the page(s) it touches. - A divergence entry is the partner of a page’s inline note: the page says what is true now, this log says why and when it diverged, and which issue tracks closing the gap.
- Entries are never edited away. A reversed decision is marked
Supersededand points at the entry that replaced it, so the trail of reasoning survives. Nothing in this log is deleted when a page changes. - New reversals and divergences are added per slice, as part of the ship gate: if a slice changes a settled call or ships something that differs from its architecture page, the entry lands in the same PR.
This log was seeded on 2026-06-30 from the first architecture-drift review, which backfilled the entries below from the project’s history. From here it grows one slice at a time.
| ID | Date | Status | Decision |
|---|---|---|---|
| ADR-0001 | 2026-06-27 | Accepted | AI acts as a human / service principal; a first-class agent principal is deferred |
| ADR-0002 | 2026-06-27 | Accepted | Authorization is role + scope grants, not a per-principal allow-list |
| ADR-0003 | 2026-06-27 | Superseded by ADR-0050 | The healthy state is named ok, not up |
| ADR-0004 | 2026-06-27 | Resolved | Bearer shipped first; password credentials (argon2id) landed in identity slices 1-2. OIDC / NATS still deferred |
| ADR-0005 | 2026-06-27 | Resolved | omniglass bootstrap <username> [--password]; the password-on-create path shipped, the iam namespace is deferred |
| ADR-0006 | 2026-06-27 | Resolved | The single-owner invariant is now a DEFERRABLE constraint trigger, landed with grant revocation |
| ADR-0007 | 2026-07-01 | Accepted | A principal is not a scope-tree entity; the principal capability confers access only at all-scope |
| ADR-0008 | 2026-07-06 | Accepted | Disable revokes live sessions via the per-request active re-read; no token-version column (nothing consumes it) |
| ADR-0009 | 2026-07-06 | Superseded by ADR-0011 | The deploy “act on the subtree but not the root” capability is an exclude_root grant modifier, not a new scope kind |
| ADR-0010 | 2026-07-06 | Accepted | Impersonation ships view-as + act-as as an impersonation_session (not a credential), guarded by capability-cover, with a real-actor audit column |
| ADR-0011 | 2026-07-06 | Accepted | Generalize the exclude_root boolean into a scope_op operator (subtree / subtree_excl_root / self), a flat enum, not a predicate-expression tree |
| ADR-0012 | 2026-07-07 | Accepted | Owner accounts are un-impersonatable by anyone; impersonate stays swept by principal:*; drop act-as scope intersection (#101) |
| ADR-0013 | 2026-07-07 | Accepted | Grant creation is refused when the granted role’s capabilities exceed the granter’s all-scope capabilities (admin cannot self-promote to owner) |
| ADR-0014 | 2026-07-07 | Superseded by ADR-0015 | The audit trail is admin/owner-only: audit is a sensitive resource that *:read does not confer, only an explicit audit:read or *:* |
| ADR-0015 | 2026-07-07 | Accepted | Permissions match like NATS subjects (* one token, > tail); admin-sensitivity is a deeper :admin token no partial wildcard reaches; owner is > |
| ADR-0016 | 2026-07-09 | Accepted | A principal can be hard-deleted (purge, gated on archival); the audit trail survives via a denormalized actor label and ON DELETE SET NULL, retiring the “never hard-deleted” rule (soft-delete verb: archive) |
| ADR-0017 | 2026-07-09 | Accepted | The access-secret member of the config / credential / variable trio is renamed credential to secret: an encrypted-at-rest typed value resolved most-specific-wins down the cascade |
| ADR-0018 | 2026-07-10 | Accepted | A profile picture is read through a JSON image_base64 endpoint the console renders as a data URL, not a raw image/jpeg handler, so every route stays under the Huma authz middleware |
| ADR-0019 | 2026-07-11 | Accepted | Every credential is time-bounded (reverses tokens-never-expire): session 12h, token / bootstrap 90d default with a --ttl capped at 365d; a credential.purpose column, not the expiry shape, tells session from token |
| ADR-0020 | 2026-07-11 | Accepted | The variable member ships plaintext, typed inline against a value_type enum (no variable_type registry), on the secret owner arc; template scope, groups, the $var: consumer deferred |
| ADR-0021 | 2026-07-12 | Accepted | The tag primitive ships its first slice (governed key registry, per-entity bindings, cascade); minting a key is admin tag:create, setting a value is the entity’s own update |
| ADR-0022 | 2026-07-13 | Accepted | Directory rows carry batch-resolved effective tags; effective resolution extends to systems and locations, and a placed system inherits its location’s tags |
| ADR-0023 | 2026-07-13 | Accepted | The IAM directory reads (principal, role, principal_group) move to the admin tier (<resource>:read:admin), so viewer’s *:read floor no longer reaches Users, Roles, and Groups |
| ADR-0024 | 2026-07-13 | Accepted | A tag key may declare an allowed_values enum (empty = free text), enforced on the binding write; a free key autocompletes its distinct in-use values |
| ADR-0025 | 2026-07-13 | Accepted | secret leaves the bare * wildcard’s reach (direct match and read floor); a per-secret admin_sensitive flag flips a secret to the :admin tier, so operators read operational device secrets in scope while platform credentials stay admin/owner-only at the same scope |
| ADR-0026 | 2026-07-13 | Accepted | Console nav IA: Variables, Secrets, and Config get their own top-level Values group; Inventory holds the estate entities including Nodes; Interfaces and Tasks become facet panels; the Settings group is renamed Admin |
| ADR-0027 | 2026-07-14 | Accepted | Inventory create/edit unify on the detail accordion: New routes to /<entity>/create (a draft) and Save hands off to /<entity>/<id> in edit; view is read-only, edit is the sole writer; the create/edit Drawer is retired |
| ADR-0028 | 2026-07-14 | Accepted | rank is dropped from location_type, system_type, and component_type; the three list operations sort by display_name, id instead |
| ADR-0029 | 2026-07-14 | Accepted | Files slice 1: a content-addressed blob store primitive (pgblobs) and a tenant-wide file handle; no placement arc (a file is 1:many, its locality is a future attachment), a binary sensitive flag reusing the secret :admin tier (defaults off), a delete frees its unreferenced blob synchronously (async mark-sweep GC deferred), base64-in-JSON on the wire |
| ADR-0030 | 2026-07-14 | Accepted | allowed_parent_types constrains where a location may be placed |
| ADR-0031 | 2026-07-14 | Accepted | component_make slice 1: an official boolean (not an origin enum) for consistency with the type registries; the in-use referential delete guard deferred to the component_model slice (nothing references a make yet); website scheme-validated to http/https, client and server, against stored XSS |
| ADR-0032 | 2026-07-17 | Accepted | Every gated route stamps its required permission into the OpenAPI (x-omniglass-permission), so the permission universe is derived from the routes rather than a hand-kept catalog |
| ADR-0033 | 2026-07-17 | Accepted | The settings engine persists only the override level; the code and file base layers are recomputed in memory each boot, so restore is a delete (diverges from scaling.md’s “materialized in Postgres”) |
| ADR-0034 | 2026-07-17 | Accepted | Settings Gateway methods are unscoped: ABAC storage-scope is not applicable to platform / principal config; only the settings:<action> permission gates them |
| ADR-0035 | 2026-07-17 | Accepted | Settings resolve down the principal hierarchy reusing the cascade primitive, with per-key provenance and a top-down broader-wins lock |
| ADR-0036 | 2026-07-07 | Accepted | A node is a principal of kind=node with a 1:1 detail table and a bearer credential row (interim shared secret), and per-node NATS isolation is static per-connection subject permissions via an in-process auth callback; nkey/JWT deferred |
| ADR-0037 | 2026-07-07 | Accepted | Telemetry is a protobuf Event over a JetStream durable consumer; the consumer binds the owner from the task’s interface and confines a node to its own tasks inline (no separate raw-telemetry table or Postgres queue); raw persistence + replay and label-based multi-owner routing deferred |
| ADR-0038 | 2026-07-07 | Accepted | The per-interface reachability verdict interface.reachable is a built-in state (not a metric); availability is time_in_state over it; readiness is interface-type-defaulted and interface-overridable, node-executed, not a calc_rule |
| ADR-0039 | 2026-07-08 | Accepted | An interface is a device API named by its protocol (not a NIC); interface_type = its transport (the reach gate), a driver = the collect layer (protocol handler + transports + normalized menu, what a device CAN do), a template curates (SHOULD), the instance holds what IS there; OIDs/commands live in the driver, not the template |
| ADR-0040 | 2026-07-14 | Accepted | The task is derived read-only plumbing: creating an interface derives its one poll task, so task create/update/delete routes and the task:create / :update grants are dropped; task.node_name is removed and projected from interface.node_name (the worklist and telemetry owner-confinement join the interface), and a node purge cascades its interfaces and their tasks. Reverses the checkpoint-5d task-CRUD build; refines ADR-0039 |
| ADR-0041 | 2026-07-19 | Accepted | A setting is declared once, as a tagged field on a canonical Settings Go struct; reflection produces the code defaults layer and the namespace registry, Huma reflects the struct into the OpenAPI schema so the typed client values is a Settings struct, and both the server PATCH and the generated client validate against that same reflected schema. Closes the slice-0 write-validation thin cut; retires the hand-kept defaults.yaml and Namespaces() slice |
| ADR-0042 | 2026-07-19 | Accepted | A field’s resolved value is deepest-set-wins down product -> location -> system -> component, falling to the field’s type default when nothing is set at any scope; the type default is the floor of the cascade, not a competitor to it. This slice is component-only (resolved = the component’s set value, else the type default); the multi-scope cascade is tracked by #291 |
| ADR-0043 | 2026-07-19 | Accepted | The datapoint_type catalog is generalized into a primitive-agnostic property catalog (the typed set of signals a datapoint observes and a field declares): the unused scope ladder becomes an official boolean, value_type becomes data_type (text to string, add bool), kind is nullable (a declared-only property has none), and validation is a JSON Schema validated by Huma’s own validator (zero new dependencies). Value/source tables key by name (no FK), so the rename is behavior-preserving; the type-schema (field_definition.key) is the only binding and lands in PR-B |
| ADR-0044 | 2026-07-20 | Accepted | The component_make catalog is generalized into vendor (a kind of manufacturer / integrator / developer), and two new leaf catalogs join it, driver (id, display_name, version) and capability (id, display_name), as the component-classification reference data: each a gated CRUD Catalog console page with read-only official seeded rows. product + product_capability + component.product are the next slice. This is PR2 of the estate-model shift toward property / event / command + vendor / product / driver / capability / standard / role / health |
| ADR-0045 | 2026-07-20 | Accepted | product lands as a first-class catalog entity, the concrete SKU that binds a vendor, a driver, a kind (device / app / service / vm), and a capability set via the product_capability join; parent_product_id models variants, and component.product_id (on delete restrict) points a component at the SKU it is, making the product the source of a component’s shape and retiring the component_type-as-shape notion. PR3 of the estate-model shift; consumes the vendor / driver / capability catalogs from ADR-0044 |
| ADR-0046 | 2026-07-20 | Accepted; superseded in part by ADR-0066 | A log-kind observation is no longer dropped at ingest: it lands in a new event table, the log-kind sink (a past occurrence) beside metric_datapoint / state_datapoint (a sampled present value). event carries the same datapoint owner exclusive-arc and provenance, plus message + attributes, and the reserved event_id FK stubs on the two datapoint tables are closed (on delete set null). Scope excludes the datapoint->sample rename (a later cleanup) and property_value / the current-value store (the fold-fields slice). P1 follow-up of the estate-model roadmap |
| ADR-0047 | 2026-07-21 | Accepted; superseded in part by ADR-0085 (the component_type registry returns, above the product) | The standalone fields feature retires and folds into the estate model: a field was only ever a property with declared provenance, never a primitive of its own. product_property is the product’s declared-property contract (product_id, property_name, default_value, required), replacing field_definition; property_value is the value store, carrying the same owner exclusive-arc as metric_datapoint / event plus instance and provenance, replacing field_value. EffectiveProperties unions the contract arm (coalesce(set value, contract default)) with the off-contract arm, so a productless component still resolves. field_definition, field_value, component.component_type, and the whole component_type registry retire. PR5 of the estate-model shift |
| ADR-0048 | 2026-07-21 | Accepted | system_type is promoted to standard, the blueprint a system conforms to and the system-side counterpart of product: it gains parent_standard_id (variants), a declared-property contract, and its own standard:* Catalog resource, and system.standard_id becomes optional. standard_property and location_type_property join product_property, and one owner-generic EffectiveProperties(ownerKind, ownerID) resolves component, system, location, and node off a single parameterized template. A standard and a location type are created by forking an in-code template (one-time, no inheritance), so a shipped row is operator-owned (official: false, seeded if absent), while a system conforms to its standard with live inheritance; only the canonical catalogs keep the authoritative upsert. PR6 of the estate-model shift |
| ADR-0049 | 2026-07-21 | Superseded by ADR-0087 | A system_role is a slot a system needs filled (a table microphone, a main display), declared on a standard (inherited live by every conforming system) or on one system (ad-hoc) over the same exclusive arc property_value uses, requiring a conjunctive role_capability set and carrying a quorum. A component’s capabilities become a resolved set (EffectiveCapabilities = its product’s, plus its own component_capability present=true rows, minus its present=false ones), because product is optional and a strict guard over a product-only fact would lock a productless component out of every role. AssignRole refuses (422) and names the missing capabilities, joining the location placement constraint as a refusal on modeled grounds that names the parties. Quorum ships here (staffing is visible without health); impact and the SLI rollup land in PR8. Supersedes the system_template_member role-requirement design. PR7 of the estate-model shift |
| ADR-0050 | 2026-07-21 | Accepted | Health is recorded as a transition and recomputed at the write, never on read. An alarm is component-local and names the capabilities it degrades; a component satisfies a role only when it provides every required capability and none of them is degraded; a role below its quorum is impaired and contributes its impact (outage / degraded / none); a system takes the worst of its roles, a location the worst of its systems. The verdict domain is healthy / degraded / outage and the judgement is a pure package (internal/health), unit-tested with no database. The recorded carrier is state_datapoint, already transition-only, so the history is edges and only edges; compute-on-read (no history) and write-through-on-read (the edge timestamped when somebody looked) are both rejected. A read never writes, and it computes the verdict it serves from the same rows it shows, so a report cannot contradict its own evidence. PR8 of the estate-model shift, closing epic #266 |
| ADR-0051 | 2026-07-21 | Accepted | Membership is the attachment, and a role is what it does |
| ADR-0052 | 2026-07-21 | Accepted | The cascade resolves through membership, and secrets carry no system band |
| ADR-0053 | 2026-07-21 | Superseded in part by ADR-0056 | A name is the address, a uuid is identity |
| ADR-0054 | 2026-07-21 | Accepted | A panel’s action bar is declared, not laid out: a blade body binds through lib/blades, a Drawer form body through lib/formactions, and BladeStack / Drawer draw the result through the one PanelFooter rail. The opt-in DrawerFooter helper is deleted. A convention a body must remember can be forgotten, and was, by two forms for months while it was copied into six new pages around them |
| ADR-0055 | 2026-07-21 | Superseded by ADR-0056 | The tag, variable, and secret owner arcs key by name |
| ADR-0056 | 2026-07-22 | Accepted; the slug-keyed carve-out is retired by ADR-0062, the health carve-out amended (#717) | Every foreign key stores a primary key. Amended (#717): the health carve-out is gone: the advisory lock hashes health/<kind>/<id> and has since the identity epic (#627) landed its addressing slice, because a name-keyed lock partitions the estate only while names do and that epic scoped a location’s name uniqueness to its placement |
| ADR-0057 | 2026-07-21 | Accepted | The cascade’s least-specific binding tier is renamed global to platform on both axes (same rung, no precedence change); a default is off the axis entirely, a column on a type declaration rather than a tier; there is no root location; a write at the tier needs its own platform:<action> permission. Breaking: a secret sealed at the old tier can no longer be decrypted (the AEAD binds the owner kind) |
| ADR-0058 | 2026-07-22 | Accepted | A run mode is a verb under its noun, and no command may be shadowed |
| ADR-0059 | 2026-07-22 | Accepted | Every collection segment is a command level |
| ADR-0060 | 2026-07-22 | Accepted | A resource is one kebab-case noun; nesting means ownership |
| ADR-0061 | 2026-07-22 | Accepted | A calculated series is current at its highest id, not its newest timestamp |
| ADR-0062 | 2026-07-22 | Accepted | A registry takes a uuid primary key and a renameable handle |
| ADR-0063 | 2026-07-23 | Accepted; superseded in part by ADR-0079 | The telemetry model is typed registries over bare-noun data tables |
| ADR-0064 | 2026-07-23 | Accepted | Placement and classification are mutable after create |
| ADR-0065 | 2026-07-28 | Accepted; superseded in part by ADR-0079 | Property, sample, and current value replace the datapoint |
| ADR-0066 | 2026-07-28 | Accepted | Logs are a raw ingest lane, not events |
| ADR-0067 | 2026-07-28 | Accepted | Bookings are exclusive-arc-owned schedules, reconciled against observed usage |
| ADR-0068 | 2026-07-30 | Accepted | The API error model is Huma’s stock RFC 9457 problem+json (ErrorModel with ErrorDetail {location, message, value}); the custom code plus violations envelope sketched on the API page is retired |
| ADR-0069 | 2026-07-30 | Accepted | Cycle safety is provenance-based: consequence writes carry provenance='calculated' with a source_rule naming the producer, and rules never route on their own consequences; supersedes the “alarms are terminal upstream and never write samples” premise |
| ADR-0070 | 2026-07-16 | Accepted | retire the standalone effective-secrets and effective-variables per-component panels; fields become the component value surface |
| ADR-0071 | 2026-07-31 | Accepted | A template is an example configuration an operator clones: creating from one is a one-time fork with no inheritance and no back-pointer, so templates stay upgrade-safe because nothing remains connected to them. The versioned-shape model retires (component_template, system_template, *_version, channels, the frozen BOM, instance pinning); a component’s shape is its product, a system’s is its standard, and a system conforms to that standard with live inheritance. Reverses ADR-0045’s deferral and ADR-0049’s “templates stay Design” |
| ADR-0072 | 2026-07-31 | Accepted | Two naming rules: a carrier is named for what it carries, never a passenger (the telemetry wire message is TelemetryBatch, since it carries samples, log lines, and later events), and a storage insert struct takes the Write suffix paired with the bare read struct (MetricSampleWrite / MetricSample), the pattern LogLineWrite set. Retires MetricSampleEvent, StateSampleEvent, EventOccurrence, and the proto Event |
| ADR-0073 | 2026-07-31 | Accepted | a driver consumes transports; a transport is code, not a row |
| ADR-0074 | 2026-08-01 | Accepted | loop-executed work rolls up to one PR per approved definition; slices cascade through per-slice gates on an integration branch |
| ADR-0075 | 2026-08-01 | Accepted | alarm gains dedup_key and the one-open-per-condition partial unique index; RaiseAlarm becomes a guarded conditional insert |
| ADR-0076 | 2026-08-04 | Accepted | the name stays renameable and addressable; rename is a custom method, every write returns the uuid, and one validator applies one of two rules |
| ADR-0077 | 2026-08-04 | Accepted | principal_group.name moves to the entity name rule, retiring the looser API-layer pattern |
| ADR-0078 | 2026-08-04 | Accepted | a blade the operator cannot edit contains nothing shaped like a control; BladeField owns the read-or-edit switch |
| ADR-0079 | 2026-08-05 | Accepted; the collective noun revised to signal lanes by ADR-0084 | five telemetry lanes with five names: the catalog splits on data_type, state renames to property, the value store folds into the series (tombstone unset, current values derived), the wire goes per-lane, logs split by origin, and a command records its status; reverses the property-as-genus half of ADR-0063/0065 |
| ADR-0080 | 2026-08-05 | Accepted | retention is provenance-aware: a prune never deletes a declared row and never the latest row of any series, shipped as the PruneSamples primitive before any retention feature exists |
| ADR-0081 | 2026-08-06 | Accepted | the control-plane wire is one subject grammar, og.v1.verb.node with the node name exactly one token: the api.telemetry lane sits in its own segment, per-record subjects are rejected, and the core-NATS consumers (worklist, heartbeat) are singletons by construction with their HA fork named and deferred |
| ADR-0082 | 2026-08-06 | Accepted | the permission resource type renames to location_type on every surface (route stamps, roles seed, console gates, guard fixtures); the generic word retires from the permission vocabulary |
| ADR-0083 | 2026-08-06 | Superseded by ADR-0084 | the Catalog rail is sectioned by the estate noun each registry serves, entries keep the registry’s own word (Types where that is all there is), Telemetry holds what gets recorded and Action what the platform does, and the /catalog hub teaches the map with live counts |
| ADR-0084 | 2026-08-07 | Accepted | Catalog is one rail entry opening a shell: a grouped subrail (Telemetry, Actions, Components, Systems, Locations, Metadata) navigating to the per-registry pages at canonical URLs, with an Overview landing; the organizing axis is direction (Telemetry is what you receive, Actions what you send or run), the lane collective noun becomes five signal lanes, and secret types loses its nav slot |
| ADR-0085 | 2026-08-07 | Accepted; partially reverses ADR-0047 | The component_type registry returns as a nested taxonomy classifying the product, not the component: it carries the identity facts that span products (naming stem, display name, icon, abbrev, default tags), inheriting down the tree with override at any node |
| ADR-0086 | 2026-08-07 | Accepted | Every component is required to name a product (the three seeded generics cover anything unmodeled); product.kind narrows to device / app / service, no default, required at create; vm retires, folded into app |
| ADR-0087 | 2026-08-07 | Accepted | The alarm-capability-role chain retires: an alarm impairs its component’s own verdict wholesale, an occupant satisfies its role whenever its own verdict is not outage, and the typed-slot guard is the only assignment-time check; records the 409-vs-422 refusal line and the choice/alternate boot-seed reconciliation carve-out. Supersedes ADR-0049, amends ADR-0050 |
| ADR-0088 | 2026-08-08 | Accepted | Placement (parent, location) leaves the component/system/location PATCH body and becomes its own :move custom method under its own <resource>:move permission, closing the gap where clearing parent_id to root via PATCH needed no scope check while creating the same root already required an all-scoped grant; MoveLocation deliberately gains no clear-to-root capability |
| ADR-0089 | 2026-08-08 | Accepted | A uuid is the address the platform generates; a dotted path (location segments, a $comp/$sys/$role accessor, plane-local segments) is a human-typed positional lookup, resolved by an allowlist name rule that renders as a CLI argument, a REST path, or a NATS subject with no escaping; dash and bare renders are display-only and never accepted back. Extends ADR-0062, amends ADR-0076 in justification |
| ADR-0090 | 2026-08-08 | Accepted | A derivable value fills at create, tracks live while the platform holds the pen, freezes on the operator’s first edit, and resumes tracking only on an explicit reset; component.name_generated ships DEFAULT false, not the epic’s DEFAULT true, so no pre-existing operator-typed name is silently claimed by the platform |
| ADR-0091 | 2026-08-09 | Accepted | A PATCH body may carry an optional update_mask with AIP-134 semantics exactly (absent is the implied mask of populated fields, present writes exactly what it names so a zero value clears, ["*"] is full replacement, an unknown field is a 422 naming it); it rides in the body, the three-state string sentinel stays, the other 108 PATCH routes are not retrofitted, and the role declarations convert from PUT to PATCH as its first consumer |
| ADR-0092 | 2026-08-09 | Accepted | MoveLocation recomputes health over both ancestor chains (joined and left) inside its own transaction, the second and last member of the exception class ADR-0088 carved out for a system’s relocate; one named row per side seeds the recursive ancestry walk the query already performs, and a no-op move recomputes nothing |
| ADR-0093 | 2026-08-09 | Accepted | Effective tags are scoped by the component the cascade resolves FOR, not per band: a caller who can read the component sees every value that cascades onto it, including from systems and locations it could not list directly; the ?system= seed is a filter over that answer, never a widening of it |
| ADR-0094 | 2026-08-09 | Accepted | Performance has two instruments: round-trip counting gates in make test, wall-clock benchmarks (make bench, two estate sizes, fixtures outside the timed section) are diagnostic and gate nothing; no CI perf job, no stored baseline, no EXPLAIN assertions (deferred), no timing assertion anywhere, and one candidate benchmark was measured as three-quarters transport and dropped rather than shipped. Amended (#725): the EXPLAIN deferral holds for the plan a planner PREFERS and is lifted for the access path a query can REACH; planned under enable_seqscan = off that answer does not move with the fixture’s size, so an access-path assertion on one relation (index name AND index condition, never plan shape, never a duration) becomes a third instrument that gates |
| ADR-0095 | 2026-08-09 | Accepted | An operator’s edit of a shipped (official: true) registry row does not write that row: it forks it into registry_shadow, one registry-agnostic table keyed (registry, row_id) on the shipped row’s OWN uuid, and reads resolve the shadow over the official row; restore is deleting the shadow. One uuid and one name per logical row either way, so no foreign key, walk, audit row or URL learns about the fork. A fork captures the whole mutable row and never the structure, which makes inheritance on a nested registry resolvable per node. component_type is the first adopter. Amended (#709): a fourth fact adopters must keep, that the registry row’s lock is taken in a statement of its OWN before the read that resolves the shadow, because for update on the resolving read serialises the transactions without refreshing what the waiter reads |
| ADR-0096 | 2026-08-09 | Accepted | A nested, universally seeded system_type registry lands beside standard (not inside it): the coarse taxonomy of what kind of space a system is (av / room / {board, class, ...}, av / sign / {...}), with stem, abbrev, and icon inherited down parent_id and overridable at any node, and system.system_type_id nullable for now. The identifier is reused deliberately (it was ADR-0048’s retired column name for standard_id), so its docs-lint denylist entry is removed on ADR-0085’s precedent |
| ADR-0097 | 2026-08-10 | Accepted | The generated ordinal becomes a stored nullable component.ordinal, but sibling allocation does not read it: it reads sibling NAMES and returns the lowest ordinal whose MINTED name is free. An operator can hold a generated-shaped name with no ordinal, and the unique index is on the name, so an ordinal-only allocator would remint a taken name as a 23505. Minting candidates instead of parsing siblings is what makes a stem-less name (a floor called 1) possible, not the column; the column is what every reader DOWNSTREAM of allocation consumes. No scoped-unique index on the ordinal |
| ADR-0098 | 2026-08-10 | Accepted; the placement exclusion reversed by ADR-0100, amended (#729) | A label rule is Go text/template over a closed map[string]string AND a closed grammar: the sandbox is the data map (a secret is absent rather than filtered) plus an allowlist over the parsed tree (a closed set of node types and function names, so printf and friends are refused at rule-edit time; a length cap bounds output, which is not the same as bounding work). The map carries the entity’s own columns and its resolved classification facts, and deliberately no placement: every input then changes on exactly five of the entity’s own acts (create, rename, move, reclassify, reset), which is what makes the stored label’s recompute-and-compare invariant provable. Exposing a location’s name would put a location rename in that set and stale every label under it. The global tier is one row per entity kind with TWO columns, default_template (boot-seed space, authoritative) and template (operator space, nullable). Amended (#729): the key table in the entry is the map as of this decision; each kind’s keys are now declared once in internal/storage/label_keys.go beside the accessor that produces each value, the map is built by ranging that declaration, and the docs render it from docs/src/generated/labeldata.json, so the taught set and the reachable set cannot disagree |
| ADR-0099 | 2026-08-10 | Accepted | The acronym dictionary title consults is ONE key, label.acronyms, in a new platform,client settings namespace; an operator’s list REPLACES the shipped one and provenance tells them apart, rather than a union of shipped plus additions (which would give one key a merge rule no other setting has, make the wire value a fragment rather than the effective dictionary, and make a shipped entry unremovable). The engine resolves the dictionary at render time and caches the compiled engine against the dictionary ITSELF, so a change builds a replacement rather than mutating one and no writer has a generation counter to forget; validation uses a dictionary-less engine, since whether a rule parses is a fact about the rule alone |
| ADR-0100 | 2026-08-10 | Accepted | A label rule reads its entity’s PLACEMENT (a component’s location label and its primary system’s type label, a system’s location label), reversing ADR-0098’s exclusion, and the write paths that keep those honest are derived from the map rather than enumerated, a derivation the epic’s review pass then caught missing the one the DATABASE performs (an ON DELETE CASCADE is a write path with no Go on it, so a system’s delete now releases its memberships explicitly). The line is BLAST RADIUS, not ownership: bounded by a placement (the rows at one location, one system’s members, one component’s membership) it cascades inside the act’s own transaction; bounded only by the estate (a rule at any tier, a classification row’s display_name, the acronym list) it waits for the preview-then-apply verb. A preview is an apply that rolls back, so it lists exactly what the apply changes including the second hop. One audit row per operation, keyed on the rule, never one per changed entity |
| ADR-0101 | 2026-08-10 | Accepted | A generated system name suppresses the ordinal on the first of its stem in a placement bucket (boardroom, then boardroom-2), and the order dependence is accepted: deleting the bare one while the second survives frees the bare name again, and boardroom-1 never exists. Suppression is a field on the MINT rather than a change to the shape (a component still reads display-1), and the ALLOCATOR takes that same mint, so a suppressing mint and a non-suppressing allocator cannot disagree on ordinal 1 and turn the second create into a 23505. A placement bucket becomes a value per entity kind, so a location’s two buckets cannot be written as a system’s three, and the allocation lock loses the stem from its key, since two stems can now mint one name. The pen and both verbs spread to system and location; only a system generates, and a location’s :resetName refuses with the missing fact named. No backfill: the default false is the right value for a row an operator already named. Amended (#696, #691): the component tier’s two guards close too, its :move with the identical bucket comparison and its reclassify on the RESOLVED STEM rather than the classification id, because a component reaches its stem through a product and two products of one component_type mint the same name. Amended (#706): the system tier’s matching residual closes the same way (two system_type rows inheriting one stem from a shared ancestor mint one name), so both tiers compare the resolved stem and the divergence between them is gone |
| ADR-0102 | 2026-08-10 | Accepted | A location_type.name_rule (nullable jsonb) is a type’s opt-in to naming the locations it classifies, and it is a declaration (a stem, possibly empty, plus the first-ordinal suppression flag) rather than the label_rule template beside it. A name has to satisfy validateEntityName, it lands in a scoped-unique index, and other things reference it, so an unrenderable rule has no safe degradation the way a label’s does; a declaration IS a nameMint, so a rule is refused at RULE-EDIT time by minting from it (ordinal 1 and a nine-digit ceiling bound the whole output space), which a template’s output could only be sampled. Null is the opt-out and there is no boolean beside it. A rule change renames nothing: there is no name-side recompute verb, deliberately, because relabelling in bulk is recoverable and renaming is not. A positional type permitted at root allocates 1, 2 across the estate and that is legal, since the bucket is the placement and two positional types under one parent already share an ordinal space. Amended (#657): the entry’s “only floor is genuinely auto-nameable” is now false in both halves, since ADR-0103 was reversed for floor and no shipped type carries a rule; the composed limit is that a withdrawn shipped rule cannot be un-shipped, because insert-when-absent leaves the row alone and the wire cannot spell “clear” | Amended again (#710): the console gets the editor a declaration makes small (a tick box, a stem, the suppression flag), with #692’s clear moved INSIDE it so the affordance can no longer undo a state it cannot create, and the mask naming every field the blade writes since a mask governs the whole write. What the editor shows about what a rule PRODUCES comes from the server: name_rule reads back with examples, the first two names the same nameMint a create allocates from would stamp, so nothing in web/ knows that a counted name is <stem>-<n>. An unsaved rule is shown nothing about its output, deliberately. The mint’s own refusal is unreachable through this body (stem carries maxLength: 90, and 90 plus the widest provable ordinal is exactly the name cap), so the 422s a surface renders are the schema’s, which is why describeError now appends Huma’s errors[]
| ADR-0103 | 2026-08-11 | Accepted | A positional name is allocation order, never a claim about the world, and the entry first kept the dev estate’s divergence (a floor named 1 labelled Level 2) on the argument that a name is an address and a label is what a human reads. Amended (#657) and REVERSED for floor: a floor’s designation is not an integer at all (B2, LG, G, M, 12A), so an ordinal is the wrong KIND of value for it rather than an imprecise one, and the basement objection dissolves with it, since nobody signs a floor -1, they sign it B1, already a legal name. floor becomes nominal, the dev estate’s floors are named level-2 and level-1 for their real designations so ADR-0105’s rule renders those labels and the two pins are released, and the cost is stated rather than hidden: NO shipped location type carries a rule, so location name generation ships dormant, kept covered by a positional type the tests create rather than by a fifth seeded type invented to keep the demo alive. Removing a shipped rule reaches new estates only (insert-when-absent), and no PATCH can clear one, so an estate that already seeded it keeps it. What survives: a stem-less positional name is right where the position is an arbitrary disambiguator (a parking deck, a rack row) and wrong where the number is a real-world fact |
| ADR-0104 | 2026-08-11 | Accepted | A create form shows the stem a generated name will carry, resolved in the browser from the classification the operator just chose, and writes the ordinal as the token n, because the ordinal is allocated against live siblings inside the create’s own transaction and does not exist until the row does. A draft-preview verb that mints and rolls back is refused: its answer is provisional (another create can take the ordinal between the preview and the commit), and the rolled-back mint takes the same advisory lock real creates need, so a form that previewed on every keystroke would serialise the estate’s creates behind a UI affordance. Re-rendering the label rule in TypeScript is refused outright, as the second implementation of an engine slice 3 swept 42 copies of. The label is therefore not previewed at all: its data map carries Name and Ordinal, so it is unknowable for the same reason. The placement bucket is shown beside the field as a PATH and never as a prefix inside it, since names became scoped to placement and a name no longer contains its ancestry. Amended (#699): a RENDER is not a mint, and both refusals were about allocating, so :renderLabel resolves the rule through the same tiers with the same one engine, writes the token where the ordinal goes, and takes no lock; the form now shows both values in LOCKED fields, gated by the entity’s :create with the placement resolved in the caller’s read scope. Amended again (#657): the lock is an inline square icon action in the field’s join, matching Settings’ own Restore to default, and a locked field is readonly rather than disabled, because a disabled input fires no click and leaves the value out of the tab order; focus does not claim the pen, since a locked field is a tab stop and tabbing past would otherwise blank both fields. Amended again (#702): READING the lowest free ordinal is not minting one either, so the form shows display-3 rather than display-n and the token retires; the answer is provisional, so the form posts it back as the create’s expected_ordinal and a create that would land a different number is a 409 naming the one that moved, located on body.expected_name so the form can tell it from a name collision. The NAME’s shape stops being client-side, which closes the naming half of #695. Amended again (#695): the ICON half closes the same way, the listing serving resolved_icon beside the raw icon, so lib/typechain.ts is deleted and no type-chain walk runs in the browser; it costs no read, since the list already loads the whole registry in one query. Amended again (#702 review): the precondition binds the drafted NAME rather than the ordinal, because a name carries the stem and the suppression rule as well as the number and an ordinal claim was met by a create that landed monitor-1 where the form showed display-1; and the draft now REFUSES the parentless bucket its create refuses, reversing this entry’s own “the draft does not rehearse the all-scope gate”, since the previewed ordinal reports which of that bucket’s names are taken and the stem asked about is the caller’s to choose Amended again (#693): the lock is the console’s ONE vocabulary for the pen, so it reaches the EDIT BLADE and the list’s full-text Generated chip retires from both list renderers: an ownership fact belongs beside the field an operator can change it on, not in a cell charging the Name column the width of the word on every platform-labelled row. The NAME’s own chip stays, on the blade beside the name, which is the same rule rather than an exception. The blade gains a state the create form has no equivalent for, a locked field showing a value about to change (the hand-back), because :renderLabel previews a row that does not exist and would answer an existing row with the NEXT sibling’s ordinal; the hint carries it instead. It closes a silent pen theft: every blade posted display() \|\| undefined seeded from the stored label, so saving any unrelated field posted the platform’s own rendering back as an override and took the pen Amended again (#713): “the placement resolved in the caller’s read scope” no longer holds for one reference, the component’s system, which the create binds as a membership under system:update (ADR-0107); the draft resolves it in that same set and carries the same conditional permission, so a preview is never served for a create the platform would refuse |
| ADR-0105 | 2026-08-11 | Accepted | words joins the closed FuncMap (a run of - or _ becomes one space, an edge run is dropped, everything else untouched), which is what finally lets a rule turn a kebab NAME into words: title alone leaves the separator standing, so the acronym dictionary of ADR-0099 could not be reached from a name by any spelling. Adding a function is a THREE-place act (FuncMap, AST allowlist, FuncNames) and the published set is now walked by a test rather than described. Amended (#701): it is ONE place; all three derive from a single declaration and the docs table is a fourth reader of it, so a function in the FuncMap the grammar refuses is unwritable rather than tested for. The global LOCATION rule ships as {{title (words .Name)}}, reversing the seed’s own argument on its restatement half only: a restatement that RE-CASES and runs the operator’s dictionary produces a string the read ladder’s fallback cannot, where an echo could not, and the constant half (“Room” for every room) is still refused. The ladder’s last rung stays verbatim, since this renders and STORES a label rather than prettifying on read; the estate keeps only the pins that say something a name cannot, nine at the time and seven after ADR-0103’s reversal named the two floors for their designations |
| ADR-0106 | 2026-08-12 | Accepted | location_type adopts the registry fork (ADR-0095) rather than growing a third ownership model: the shipped rows seed official: true, the boot seed writes them authoritatively, and an operator’s edit forks into registry_shadow with :restore discarding it. That is what makes a shipped value withdrawable, which insert-if-absent could never be, since it can add a default to every estate and remove one from none. The one-time backfill moves the edits estates already hold ON those rows into shadows first, telling an edit from a shipped value by the audit trail rather than by comparing columns against what this release ships, because a row holding a WITHDRAWN shipped value is indistinguishable from an edit by inspection and preserving it would defeat the withdrawal. A location type’s property and metric contracts stay writable on a shipped row: a contract line is a row in its own table, nothing seeds one, and the official guard was dormant on this registry until the flip would have activated it. On the wire, a nullable OBJECT field clears by being named in update_mask with no value, since an object has no empty value to overload and an explicit null is indistinguishable from an omitted key after decoding; name_rule is the first and the convention is now the API’s, not that field’s. Amended (#703): the discriminator is REMOVED and the backfill is only the official flip, because with no release cut and no operator data there is no edit to preserve; the argument for it stands unchanged for the first release that has estates, which owes them a new migration rather than this one |
| ADR-0107 | 2026-08-12 | Accepted | POST /components accepts a system and INSERTS that system’s primary membership from it, the same row PUT /systems/{name}/members/{component} writes under system:update, while the create asked for no system permission at all: the create was the cheap way around the membership route’s gate. The create now requires system:update when the reference is present and resolves it in that scope, so two paths writing one row cost one permission. The accepted consequence is a live narrowing: operator holds component:create and no system:*, so an operator can no longer create a component INTO a system, which reads as the role line rather than collateral damage (an operator maintains components, a deploy tech builds out systems and their membership). Granting operator the permission was refused as a much larger grant than “may bind a membership while creating”. A second permission conditional on the REQUEST is published like the platform tier’s, as x-omniglass-conditional-permission, and enforced in the handler because middleware cannot see the body; the console hides the picker from a principal that cannot use it and the API’s refusal names the permission, so the narrowing is met before the form is filled in. Amended (#707 review): the console gate read the PERMISSION only, so a principal holding system:update over an empty scope (a location-scoped deploy grant, since the cross-tier expansion is unbuilt, #10) was offered the picker and refused on submit, and the API answered “system not found” for a system that caller could GET; the gate now also requires a system carrying the scope-aware update action, and the bind takes system:read beside system:update so a readable row is refused by AUTHORITY (403) rather than by absence. Amended (#713): the residual this entry accepted is closed, the :renderLabel draft resolving the same reference through the create’s own resolver and carrying its conditional permission, because a preview that resolved it in system:read alone both previewed a refusal and handed back the system’s type label; the LOCATION reference stays location:read on both routes, read and rendered rather than written |
| ADR-0108 | 2026-08-12 | Accepted | Settlement’s two timestamps come from one clock, the database’s: a sample’s ts is default now(), so the now a settle-check judges against is read with select now() inside the same transaction rather than from time.Now() in the server process. Two clocks on one comparison made the verdict a function of skew, and at settle_window_seconds: 0 there is no margin to absorb it, so a command that genuinely failed could be reported pending and never settle on any deployment whose database is on another host. Separately, a zero window is terminal by construction, checked before any arithmetic: it is the documented way to say “settle immediately”, a claim about intent rather than elapsed time, so no timestamp may make it pending. Stamping samples from Go was refused as the larger ripple (every telemetry ts defaults to now() and other readers rely on database ordering), and a tolerance was refused outright as the move that stops a test failing without stopping the behavior depending on skew. Settle stays pure and still takes now; what changed is who supplies it, at the cost of one round trip on each of the two settle paths. Amended (#718): “a check in a later transaction reads a strictly later timestamp” over-claims, since READ COMMITTED admits a concurrent issue committing after a settle-check began, so the delta can be negative; the verdict is unaffected (a negative delta is pending, and the zero case reads no timestamp), and the claim rather than the behaviour is what was corrected. Extended (#719): the same principle reaches the six history reads by a different mechanism and no round trip, since a read needs a BOUND rather than a value: the window travels as a duration and the query filters ts >= now() - make_interval(...), so the instant is never named in Go |
| ADR-0109 | 2026-08-13 | Accepted | An alarm’s raised state belongs to its condition (ADR-0075) and an acknowledgement is a fact about a person, so the acknowledgement is two nullable columns orthogonal to cleared_at, never a status enum, and AcknowledgeAlarm is the one alarm write that does not recompute health: acknowledging is not fixing. Snooze and resolve were refused rather than deferred: snooze suppresses notification and the notification registry is unbuilt (#618), so it would be a column that lies, and resolve is either the existing clear under a second name or an unspecified concept. The permission is alarm:acknowledge, spelled out like every other seeded verb; the alarm:ack,snooze,resolve string that appeared in test fixtures and in the identity-access page was never a design and nothing ever seeded or enforced it. Its scope resolves on the component tier from alarm:acknowledge itself rather than from component:update, and it is granted to operator and not to deploy, because a location-scoped deploy grant reaches no component tier at all (#714) and would hold a capability that acknowledges nothing. A second acknowledgement is idempotent, keeping the first person and the first time and writing no second audit row |
| ADR-0110 | 2026-08-13 | Accepted | principal_label(uuid) is dropped. What names a principal (a human’s username, else a service account’s own identifying column) is declared once in the gateway (internal/storage/principal_ident.go) and rendered into the statements it binds. A READ projects both sources and folds them in Go; the audit insert binds the fold as one expression, because it runs inside the CALLER’s transaction on every operator write and a Go resolution there would cost a second round trip the alarm write path pins as an exact equation. Two shapes of one policy are held together by an invariant test over every principal kind, not by care. A node stayed out of the resolution, exactly as the dropped function had it. Amended (#738): it is the third source, node.name, because preserving the omission preserved a blank actor on an audit row; a node’s identifier is its unique, operator-visible name, never its optional display_name |
| ADR-0111 | 2026-08-13 | Accepted | service.label becomes service.name: it is the username analogue for kind=service, the only handle the row has, so under the identity triad it is a name and it was the one place in the schema where label meant an identifier. The uniqueness question is answered rather than inherited: unique, matching human_username_key and node_name_key, because the string is denormalized as bare text into audit_log.actor_username and into an alarm’s acknowledgement, where a duplicate is unresolvable after the fact. The table’s declared identity shape moves from ShapeIDOnly to ShapeHumanNotAKey, and a new guard refuses any ShapeIDOnly table that carries a name. Breaking wire change: svcBody.label becomes name, and the group roster’s mixed coalesce(h.display_name, s.label, '') splits into name and display_name, two fields each meaning one thing |
| ADR-0112 | 2026-08-13 | Accepted | cmd/cligen derives each body flag’s TYPE from the OpenAPI property: an integer field is an int flag, a boolean a bool flag, a number a float64 flag, so a value the schema refuses is refused at the shell rather than by the server’s 422. Every other shape keeps ONE string flag parsed as JSON (an object, an array, an untyped any, and a nullable number or boolean), because a nested value has no shell-native flag type and null has to stay sendable: it is what clears a field named in update_mask (ADR-0106). A nullable STRING is the exception and stays a plain string flag, since this API clears a string with the empty string. --propagates=false becomes the spelling for a bool flag, and the docs flag check fails on a bool flag handed a space-separated value |
| ADR-0113 | 2026-08-13 | Accepted | A console control carries no required, min, max, pattern or step: a rule is a pure function over the typed value, the surface renders its message inline beside the field, and the binding’s disabled / valid refuses the submit. The audit decided it: 21 attributes on 24 rendered controls and zero could ever fire, because a Drawer’s rail is portaled outside the <form> (ADR-0054), a blade has no form at all, and the four on genuine form paths sit in forms whose submit is disabled in exactly the states native validation would refuse. Wiring form.requestSubmit() instead would have covered the Drawers only, left every blade needing this decision anyway, and meant undoing the disabled gate so an unstyled browser bubble could refuse in place of an inline message. aria-required stays as the honest spelling, type="email" and type="number" stay as input types, and a guard test scans every .tsx |
| ADR-0114 | 2026-08-14 | Accepted | stem, abbrev and icon on component_type and system_type join the three-state string sentinel their label_rule neighbour already honoured: omitted is unchanged, an explicit "" clears the column to NULL so the inheritance walk resumes, a value sets. Not the mask, which ADR-0106 scopes to nullable OBJECT fields because an object has no empty value to overload. The clearing spelling is a pattern alternation, ^([a-z0-9][a-z0-9-]*)?$ with minLength dropped from the PATCH body (the same spelling name_rule.stem ships), so exactly one new string is admitted and every malformed stem is still a 422; CREATE keeps minLength: 1, since a row that does not exist yet has nothing to clear. A root cannot clear its stem, the refusal create already gives moved to the second path that reaches the same broken row, stated as one pure function so the two handlers cannot disagree. The fork leg decodes the sentinel too, since a shadow image is read back as the row. Both blades now send "" for an empty box, so #677’s and #656’s “no '' ever rides the body” guards invert to assert the sentinel rather than being deleted |
| ADR-0115 | 2026-08-14 | Accepted | A type registry’s LISTING serves inherited_stem, inherited_icon and inherited_abbrev beside the raw fields, each with the name of the ancestor it came from, because resolved_icon (ADR-0104’s #695 amendment) answers what a row SHOWS and an edit blade’s placeholder asks what it would show if it stated nothing, which is a different string on every row that states its own: using the shown value would print the string an operator had just deleted back at them as the thing they were about to inherit. The console renders it as the placeholder carrying the VALUE (a placeholder natively means “leave this blank and you get this”) and a mark naming the ancestor, read from the data rather than written as “its parent” because a fact can come from any distance up the chain. The lock is not borrowed: ADR-0104 gives it one meaning, the platform owns this value, and an inherited fact is one an operator MAY set. The source is per FACT, since one type can take its stem from a grandparent and its abbrev from its parent. Amended in the same wave (#716): “no new glyph” is reversed and the read state’s inherited from <ancestor> sentence is REPLACED by one teal provenance dot beside the field’s LABEL, present when the value comes from somewhere that is not this row and absent when the row states it, because the sentence was the third telling of one fact on a line with no room for it and could not appear in the edit state at all, where a grey placeholder in a box that looks empty is the state that needed it; the label is the only placement that holds in both states (beside the VALUE, trailing is right in read and lands 380px from a 407px box’s placeholder in edit, leading is right in edit and reads as a bullet in read), the mark encodes no distance (a segment-per-rung version swung 8px to 28px on estate shape and never lined up across three labels), it carries the whole fact in its accessible name as a focusable tab stop rather than in a hover no keyboard reaches, it agrees with the hint by construction (one predicate over the text the field is showing, so a keystroke moves both), and it is threaded as a NAME rather than as an element, since a JSX.Element prop is a getter that rebuilds the mark under the pointer on every refetch. Amended again in the same wave (#742): the hint’s Inherited from <ancestor>. comes out, since the mark states that in the same field at the same moment, and its conditional twin Empty inherits from <ancestor>. STAYS, since it shows only while the box holds a value, where there is no mark and no visible placeholder and it is therefore the only thing telling an operator how to return the field to inheriting; the two take turns off the one predicate, and the CREATE forms keep their own Leave blank to inherit hints because neither the mark nor InheritedField reaches a row that does not exist yet. Cost measured rather than carried over from #695: the registry read is one statement for a registry twenty levels deeper, so the walk is a pass over rows already in hand. location_type is flat and takes none of it. The blade’s discard action reads Restore default rather than Restore shipped, matching Settings’ own restore-to-default vocabulary. Amended a third time (#743): the registry LIST renders the value a row TAKES instead of an em dash, and the provenance mark stays a BLADE and detail affordance: no mark belongs in a table row, because a table is for scanning values and the blade is where a value’s origin is explained, so a reader running down the Stem column is answering “what is this type’s stem” and a per-row attribution charges them for a question they did not ask. The Stem and Abbrev cells had gone on rendering an em dash on exactly the rows whose blade showed the value they inherit, so the two surfaces contradicted each other about one fact two clicks apart; both registries now render those cells through one InheritedCell primitive off the inherited_* fields the listing already carries, at no new query. A stated value and an inherited one render IDENTICALLY, which is the treatment rather than a consequence and is asserted as such, and it settles the dimming question with it (every value in these columns is text-base-content/60 already). The Icon cell, which has shown resolved_icon undifferentiated since #695, is the PRECEDENT the other two match rather than the odd column out, and the em dash keeps its one meaning for a row that states nothing with nothing above it. The tab-stop and duplicate-accessible-name questions a list mark would have raised do not arise, there being no mark in a row |
| ADR-0116 | 2026-08-14 | Accepted | A refusal on a targeted mutation picks its status from the difference between the read scope and the action scope, not from one set: outside visible_set(P, read) stays the non-disclosing 404, and inside the read scope but outside visible_set(P, action) is a 403 that names the missing authority. Acknowledging existence to a caller who can already GET the row discloses nothing it does not have, and the 404 it replaces was a statement about existence that the caller could see was false. The condition that makes it safe is that the read set is the caller’s own <resource>:read and never a wider convenient one; a route checking a wider set would hand the existence of a row to somebody with no grant to see it, which is the leak the 404 exists to prevent. This was already the contract for the three tree entities’ own CRUD; #736 is its adoption by the seventeen routes that hang off a tree entity (property set/clear on all three tiers, system membership and role writes, component alarm writes), through the existing resolveScoped rather than a second refusal helper, plus one owner-generic seam (ResolveActionTarget) for the routes whose write takes no scope of its own. Two carve-outs hold: resolvePlacementRef’s CROSS-TIER reference keeps its non-disclosing not-found (ADR-0107), and the estate-wide resources (principal, group, tag registry, node) are all-or-nothing and already 403. The conformance matrix asserts all three branches per ROUTE, each entity declaring the routes that hang off it, because those routes are not uniform across the registry |
| ADR-0117 | 2026-08-14 | Accepted | POST /components/{name}/commands:issue fences its write with visible_set(P, command, issue) against the caller’s own component:read, through ADR-0116’s split. It used to resolve all three of its gateway calls with component:read, so a principal holding viewer @ all beside a room-scoped operator grant could command every component in the estate: not a wrong refusal but a wrong REQUEST, since a command records an intent a driver acts on. The load-bearing half is that command becomes a component-tier resource: it was absent from applicableKinds, where every scoped grant resolves to the empty set, so fencing on that set without registering it would have denied every scoped issuer instead of fencing them. The component tier alone, not the arc the command table’s owner columns allow, because the only route that issues addresses a component and a location- or system-tier root can never match a component’s ancestor chain. A command:issue grant at those tiers now reaches nothing, the same tier rule alarm:acknowledge lives under (ADR-0109, #714), and the anomaly was that it ever commanded. The target is resolved once and bound by id, so IssueCommand and CommandSettlement take no scope: a split cannot be applied from one parameter, and two of the route’s three name resolves go with it |
| ADR-0118 | 2026-08-14 | Accepted | display_name becomes label on all twenty-three tables that carry it, and display_name_generated becomes label_generated on the three that carry a pen. The rename finishes a word rather than introducing one: internal/label/ is the rule engine, label_rule is a column on five tables plus a table of its own, and the API already says :renderLabel / :previewLabels / :recomputeLabels, so until now a route called :recomputeLabels wrote a column called display_name. The column is nullable and unset is SQL NULL and nothing else, normalized in Go at the gateway write path (labelOrNull / labelPatch) rather than by a constraint. This slice first shipped the opposite, NOT NULL DEFAULT '', and reversed it before merge on the strength of its own diff: the floor forced all seven registry orderings to be spelled order by nullif(label, '') nulls last, and a read that has to convert the stored representation back is the schema saying which value it should have stored. The argument for the floor had a hole worth recording: it claimed collapsing unset on a nullable column needs a CHECK, but this repo puts that class of rule in Go (ADR-0110 had just moved principal_label out of the database), and a Go rule maps empty to NULL as cheaply as to ''. The migration is the smaller half; what makes NULL the ONLY spelling is the Go normalization, proved by a sweep that drives every write path with a WHITESPACE label and reads the raw column, complete by construction (it caught CreateStandard still binding raw). A PATCH carries a set flag beside the value, since clear and leave-alone both arrive as NULL. Reads coalesce, so the wire keeps a plain string and no generated artifact moves but the schema facts. Orderings are order by label nulls last, name; the two shadow-resolved registries resolve on jsonb key presence, so a fork that cleared its label sorts at the end rather than under the official one. stem and abbrev are NOT swept (their NULL means inherit-from-ancestor). registry_shadow.image is the one place the old word survived as DATA; audit_log’s images are left as the record they are |
| ADR-0119 | 2026-08-14 | Accepted | The identity declaration (internal/storage/identity_shape.go) grows the third column of the triad: a table an operator NAMES carries a label, or declares in TableIdentity.NoLabel why it does not, and a guard checks the claim against the generated schema both ways (a declared label the schema lacks fails; a reason on a table that has the column fails as stale). A table nobody names carries none at all, there being no name for an unset one to fall back to. Written RED it named exactly tag, variable, secret and interface, the four key-bearing tables that had gone without one, which is why the declaration is the fix rather than the four columns: the gap existed because the shape said what the identifier was and nothing about the friendly string beside it. All four gain the column (text, nullable, no default, unset is SQL NULL per ADR-0118), with no backfill and no uniqueness, pattern or reserved words. interface is included (D2) and is the strongest of the four: its name is SERVER-derived from its type, so an interface’s only string says which protocol it speaks and nothing about what it is FOR, and the label is settable at create rather than only on a following patch, an interface labelled by a following call being unlabelled at the moment it is made. The premise D2 was argued from is corrected here and pinned by a test: a component holds at most ONE interface per protocol today (the unique index is (component, name) and the name IS the protocol), so the three-ssh-interfaces case is not reachable, and the narrower fact carries the decision on its own, every SSH interface in the estate reading ssh. Its declared name exemption in KeyProvedElsewhere STAYS: the name really is derived, which is the argument for the label rather than something the label replaces. Four exemptions are declared with reasons: interface_type (retires with the interface.type FK, ADR-0073), file (its name is already the label; #755 may revisit), service (principalIdent resolves a service principal to service.name, which a label may not be) and blob (no operator surface of its own). Lists order by the rendered label, order by label nulls last, name (D4), matching the console’s one comparator; the two CASCADE projections keep ordering by name, where the name is the cascade key grouping a winner with the candidates it shadows rather than a display order. LabelledTables() makes the declaration the only copy of the list the schema guard and the unset sweep read |
| ADR-0120 | 2026-08-14 | Accepted | ?edit=1 beside a detail address (or a blade’s id param, ?u=<id>&edit=1) is how the console expresses edit mode: deep links, refresh, and the create/row-pencil handoffs all carry the mode in the URL, behind the same <resource>:update the footer Edit is behind, and leaving edit strips the param via history replace. The one-shot in-memory handoffs (pendingedit, openPrincipalInEdit) are retired for one hook (web/src/lib/editurl.ts); the param is a consume-once intent (deriving the mode would re-enter edit in the Cancel gap), a blade honors it only when the URL also names it, and the name-to-uuid redirect keeps its query string. The groups blade keeps its one-shot until it gains an id deep link |
| ADR-0121 | 2026-08-15 | Accepted | The console serves IBM Plex Sans and JetBrains Mono from its own origin (vendored under web/public/fonts/, embedded in the binary, declared by a generated web/src/fonts.css) instead of linking a font CDN: rendering correctly stops depending on reaching a third party, which is the deployment this product targets and was also the cause of a docs capture writing fallback-font PNGs the zero-tolerance freshness gate reported as UI drift. Every script the CDN served is vendored (54 files, 1,005,028 bytes) so no operator string renders differently than before; font-display: block replaces swap; the capture now aborts rather than photograph a fallback render; DOCS_SHOTS_PROXY retires |
| ADR-0122 | 2026-08-09 | Accepted | The verdict domain gains a fourth value: a role short because its hardware was never installed reads incomplete (ranked between healthy and degraded) rather than its declared impact, which now describes failure only; impact: none stays healthy when empty and a losing alternate’s roles contribute nothing. No migration. Amends ADR-0050 |
| ADR-0123 | 2026-08-14 | Accepted | The totality-of-managed-things noun becomes fleet, everywhere at once (code identifiers, routes, docs prose, glossary, scope grammar, nav): a fleet of SYSTEMS, uniform units maintained at scale against a standard, stationed across the location tree. The old noun retires to the docs denylist. Ruled all-in rather than surface-only: two words for one referent is a permanent translation tax |
| ADR-0124 | 2026-08-15 | Superseded | A healthy dot on the fleet canvas wears its system’s identity hue (the .og-system-dot OKLCH recipe over hueFor); incomplete, degraded and outage dots wear the semantic verdict colours; an unnarrowed verdict wears --og-unknown. Healthy is the wallpaper and the wallpaper is identity, so failures are the only status-coloured pixels. Diverges from the prototype, which had no per-system hue |
| ADR-0125 | 2026-08-15 | Accepted | A fleet band’s verdict chip renders the location’s server-recorded verdict, the same row its detail page reads, never the console’s fold over in-scope clusters; the fold remains as a named derivation. A band disagreeing with its own detail one click apart is a visible contradiction, and the fold covers only what the caller may read |
| ADR-0126 | 2026-08-15 | Accepted | The deeper zooms render at the identity routes behind a query param (/locations/{id}?zoom=1), the inventory detail staying the default face: ADR-0120’s mode-rides-the-URL precedent applied to the fork where “the zoom is a function of which entity the URL names” and “the four tables stay live and untouched” collided at one address. The param may become the default later, and the table face may retire once the medium is judged |
| ADR-0127 | 2026-08-20 | Accepted | Operator surfaces carry no inline explanatory prose (explainers ride the label’s tooltip: InfoTip, Eyebrow); the system body renders components-first, role as a badge, role chrome only where it earns it (quorum > 1, short, or unstaffed) |
| ADR-0128 | 2026-08-20 | Accepted | A standard may declare a room map: one validated jsonb value (aspect + normalized 1-based role positions) on standard, display-only, rendered by every conforming system’s Map tab; JSON null clears; the visual editor stays out of v1 |
| ADR-0129 | 2026-08-21 | Accepted | The zoom/workspace/leaf render at the identity routes by default (?zoom=1 tolerated, never written); the classic detail face survives at ?view=detail only until edit-in-blade lands; the ruled target: one altitude rule, edit in blades, tables as a list-density toggle |
| ADR-0130 | 2026-08-21 | Accepted | A read that promises write order orders by a bigint identity the database assigns at insert, never by created_at or a uuidv7 id: wall-clock steps (reproduced on WSL2 under load) invert clock-derived keys across transactions; audit and principals converted, the rest tracked by #801 |
| ADR-0131 | 2026-08-24 | Accepted | The component_type tree mirrors the OpenAVCloud AV Device Taxonomy v1.1 (category roots, subcategory types, form-factor mic subtypes); the seed catalog goes fictional (the omniglass-lab brand universe, AV-iQ as the realism source); standards become integrator-style room chains with size-serialized variants, and the impossible shared bar yields to the divisible pair’s shared DSP and amplifier rack |
| ADR-0132 | 2026-08-25 | Accepted | Every fleet workspace carries a Configure tab as the ONE deep editor; the blade stays a quick face whose rows jump into it; ?edit=1 retargets to Configure and the classic detail face retires with an explicit miss face (#800, diverging deliberately from ADR-0129’s edit-in-blade target) |
| ADR-0133 | 2026-08-27 | Accepted | A <select> whose options come from a collection the server answers for takes its value from bindSelectValue(value, ...options) (web/src/lib/selectvalue.ts) used as the element’s ref, never from a value= prop: the control keeps no value it has no option for, and a value binding does not re-run when the OPTIONS are what arrived. Thirteen controls convert, the workspace Configure face’s four among them. Two shapes stay on value= and the exemption is deliberate: a hard-coded or generated option list has no async gap, and a control whose value starts empty and only moves because the operator moved it has nothing stored to lose |
Entries
Section titled “Entries”ADR-0001: AI acts as a user; the agent principal is deferred
Section titled “ADR-0001: AI acts as a user; the agent principal is deferred”- Date: 2026-06-27 | Status: Accepted | Pages: identity and access, AI
- Decision: An AI tool authenticates over OAuth as an ordinary
humanorserviceprincipal and acts with exactly that principal’s grants. A dedicated first-classagentprincipal kind is not in the initial architecture. - Context: A separate
agentidentity would need its own authN, its own grant semantics, and its own audit treatment before any AI surface exists to use it. Treating AI as a scoped, audited user reuses the whole identity machinery and keeps the audit trail honest (the acting principal is the human or service). - Note: The schema’s
principal.kindCHECK already reserves theagentvalue so a later slice adds the kind without editing the applied auth migration; noagentidentity is issued today. If and when a first-class agent identity is built, that is a new entry that supersedes this one.
ADR-0002: Roles carry requirements, not an allow-list
Section titled “ADR-0002: Roles carry requirements, not an allow-list”- Date: 2026-06-27 | Status: Accepted | Pages: identity and access
- Decision: Authorization is built from additive
(role x scope)grants, where a role is a capability set of<resource>:<action>permissions. An earlier sketch attached a per-principal allow-list of permitted actions directly. - Context: A per-principal allow-list does not compose: the same operator role at two scopes, or a role inherited and extended, would be re-listed by hand per principal. Roles plus scope make the common case (the same role at different places) a single reused definition, and keep permissions additive and positive (no negative entries). It is also what makes the per-grant binding (an action and its scope bind in the same grant) expressible.
ADR-0003: Health reads ok, not up
Section titled “ADR-0003: Health reads ok, not up”- Date: 2026-06-27 | Status: Accepted | Pages: health
- Decision: The healthy state of a component or system is named
ok. An earlier draft usedup. - Context:
upreads as reachability (the device answers), which is only one input to health. Health is a rollup verdict (“is this system working?”) that can be unhealthy while every device is reachable, or healthy while a redundant member is down.oknames the verdict rather than the ping, so the word does not promise something narrower than the model delivers. - Superseded by ADR-0050
on the word only. The reasoning holds and the built domain still names the verdict rather than the ping;
it spells the members
healthy/degraded/outage, becauseoutagesays what a broken system means to the people in the room wheredownsays what a device is doing.
ADR-0004: Credentials ship bearer-only
Section titled “ADR-0004: Credentials ship bearer-only”- Date: 2026-06-27 | Status: Resolved (identity slices 1-2) | Pages: identity and access
- Resolved: Password credentials shipped in identity slice 1 (#35) and slice 2 (#70):
credential.kindnow allowsbearerorpassword(argon2id, PHC-encoded, one password per principal), and a human signs in with a username and password behind an httpOnly session cookie. Theoidc/natsmethods and the full(method, identifier)lookup key remain deferred (future slices). - Decision (divergence): The shipped
credentialtable carrieskind = 'bearer'only, stored as the token’s sha256 with a non-secretogp_locator prefix. The design’s fuller model (thepassword,oidc, andnatsmethods, and the(method, identifier)lookup key) is deferred, not yet built. - Context: The auth foundation slice needed exactly one working authN method to prove the capability and
scope seams end to end. Bearer tokens are the thinnest honest cut: a service credential the bootstrap and
the CLI can both carry. Password login is the first slice of the identity tier epic (#27)
(slice #28), which adds
passwordto thecredential.kindCHECK in a new migration (never editing the applied one). OIDC and the NATS node credential follow with their own surfaces. - Closes the gap: epic #27.
ADR-0005: The first owner is omniglass bootstrap
Section titled “ADR-0005: The first owner is omniglass bootstrap”- Date: 2026-06-27 | Status: Resolved (identity slice 1) | Pages: identity and access
- Resolved:
omniglass bootstrap <username> --password <pw>shipped in identity slice 1 (#35): bootstrap now installs a password credential on create (plus--email/--display-name), so the owner can sign in to the console without a separate step. Theog iamadmin command namespace is still deferred (it lands with the admin user surface, slice 3). - Decision (divergence): The first owner is created by
omniglass bootstrap <username>, which mints anowner@allgrant plus a bearer credential in one transaction. The design page describes the eventualog iam create-owner --username ... --email ...password path under aniamcommand namespace; that namespace and the password credential are deferred. - Context: Bootstrap has to work before any login surface exists, so it pairs with the bearer-only
credential decision (ADR-0004): one trusted, idempotent command that produces a token the operator pastes
into the console or CLI. The
iamcommand family (and the password-on-create path) lands with the identity-tier admin surfaces. - Closes the gap: epic #27.
ADR-0006: The owner invariant is enforced by bootstrap for now
Section titled “ADR-0006: The owner invariant is enforced by bootstrap for now”- Date: 2026-06-27 | Status: Resolved (identity slice 3c) | Pages: identity and access
- Resolved: The
DEFERRABLE INITIALLY DEFERREDconstraint trigger (principal_grant_owner_guard) shipped with grant revocation (issue #82): it refuses to leave zeroowner @ allgrants atCOMMIT, so revoking the last owner is a clean 409 while a swap (grant a new owner + revoke the old in one transaction) still passes. The gateway maps its custom SQLSTATEOG001toErrLastOwner. - Decision (divergence): “At least one active
owner@allgrant exists at all times” is upheld today by the bootstrap path (it always creates one) and the absence of any grant-revocation surface. The design’s deferrable Postgres constraint trigger that enforces it atCOMMIT(so the swap-owners-in-one-txn pattern works) is not yet built. - Context: With no API to revoke a grant or delete a principal, the last-owner removal the trigger guards against is not yet reachable, so the trigger is not load-bearing until grant CRUD ships. It is required before the admin user-management slice exposes grant revocation (epic #27, slice 3).
- Closes the gap: epic #27.
ADR-0007: Principals are gated at all-scope, not scope-tree
Section titled “ADR-0007: Principals are gated at all-scope, not scope-tree”- Date: 2026-07-01 | Status: Accepted | Pages: identity and access
- Decision: A
principalis not a scope-tree entity: it is not “under” a location, system, or component, so theprincipal:<action>capability confers access only at all-scope. A grant scoped to a location or system carries no principal access, and the Storage Gateway refuses a non-all scope on the principal directory with a 403 (ErrPrincipalForbidden) rather than silently returning an empty list. This falls out of the scope resolver:applicableKinds("principal")is empty, so only anallgrant resolves to a non-empty set. - Context: The admin principal directory (slice 3a, issue #77)
is the first surface to gate on
principal:*. Modelling users as scope-tree entities would be wrong (there is no “users under HQ”), and returning an empty list to a mis-scoped admin would hide a misconfiguration, so making all-scope explicit keeps the capability honest and surfaces the error. The same rule governs the later principal-mutation and grant surfaces. - Closes the gap: n/a (a design decision, not a divergence).
ADR-0008: Disable is hard revocation; no token-version column
Section titled “ADR-0008: Disable is hard revocation; no token-version column”- Date: 2026-07-06 | Status: Accepted | Pages: identity and access
- Decision: Disabling a principal revokes its live sessions immediately, achieved by the authn path
re-reading
principal.activeon every request, not by a session-version / epoch column.AuthenticateBearerandAuthenticatePasswordboth filterand pr.activein the credential lookup on every call, with no caching anywhere in the authn path, so the very next request on an already-issued bearer or session cookie after a disable gets zero rows and a 401.SetPrincipalActiveflips the flag in one statement: disable is revocation, atomically. Notoken_versioncolumn is added. - Context: Issue #94 asked for “hard session
revocation on disable”, assuming disable was soft (a propagation delay). It is not: the per-request active
check already is the hard-revocation mechanism, proven end to end by
TestDisableRevokesLiveSessionAPI(a live token is 401 on its next request the moment it is disabled) andTestDisablePrincipal. Atoken_versioncolumn would matter only as an invalidation signal for an authn-result cache, which does not exist; adding it now would be a dead column with no reader, against the primitive-first and meaningful-migration disciplines. Revisit if any cache/memoization is introduced in the authn path (an epoch bump would then be its invalidation signal). - Closes the gap: issue #94, closed as already satisfied.
ADR-0009: Root exclusion lives on the grant, not a new scope kind
Section titled “ADR-0009: Root exclusion lives on the grant, not a new scope kind”- Date: 2026-07-06 | Status: Accepted | Pages: identity and access
- Decision: The “act on the subtree but not the root” capability (the deploy / integrator case, issue
#87) is a boolean
exclude_rootmodifier onprincipal_grant, not a newscope_kind(e.g.location_descendants) and not a role-level flag. It narrows only the modify actions (update, delete) to the root’s descendants; read and create-placement keep the root. An inclusive grant on the same root wins over an excluding one. - Context: A new scope_kind would fork the kind handling three ways (location / system / component) and
grow the scope vocabulary; a role-level flag could not vary per grant (the same deploy role granted
root-inclusive in one place and root-excluded in another). The grant modifier composes with the
additive-grant model and confines the change to one predicate (
inScopeTree) shared by all three tree entities. Keeping read and create-placement inclusive means aPATCHon the root is the existing readable-but-out-of-write-scope 403, soexclude_rootreuses the three-way status split rather than adding a fourth case. Shipped with a newdeployofficial role (create + update on the three tree tiers, read via the viewer floor). The grant-builder toggle to set it from the console is a fast-follow (#99). - Closes the gap: issue #87.
ADR-0010: Impersonation is a session, not a credential; guarded by capability cover
Section titled “ADR-0010: Impersonation is a session, not a credential; guarded by capability cover”- Date: 2026-07-06 | Status: Accepted | Pages: identity and access
- Decision: Admin/owner impersonation ships with both modes (view-as read-only, act-as full) in one
slice. An impersonation token is an
impersonation_sessionrow (its own table: target, real actor, mode, expiry, revoke), not acredential(which authenticates a principal as itself). Authorization to impersonate is the escalation guardactor.Covers(target)(the caller’s capabilities must cover the target’s) plus theprincipal:impersonatecapability at all-scope. Capability cover applies to both modes; scope is where the modes diverge: view-as is cross-scope (read-only grants no write authority, and seeing another scope is the troubleshooting case), but act-as additionally requires the caller’s all-scope grants alone to cover the target: a capability held only through a narrower grant does not count. Without that, act-as would let a split-grant admin (all-scope user management, but infra scoped to campus X) impersonate a campus-Y admin and gain write in Y, since an impersonated request resolves its ABAC scope from the target: a scope escalation. Because the rule is capability-cover against the caller’s all-scope grants (not a hardcoded list of scoped resources), it closes non-tree escalation too: a user-admin who holds grant authority only through a scoped grant (empty effective scope, cannot create a grant directly) cannot launder all-scope grant authority by acting-as a grant admin. Accountability is a nullableaudit_log.real_actor_principal_idwritten on the row directly, not reconstructed from a time-window join (clock skew and concurrent sessions make that unreliable for an accountability record), and the self-service mutations (/auth/meprofile and password) audit too so an act-as edit is never untracked. - Context: view-as is enforced by refusing every non-read action when the request carries a view-as
claim; act-as threads the real actor through the audit writer via a request-scoped context value
(
storage.WithRealActor), so no mutating gateway signature changes.authntries the impersonation session on a bearer-hash miss, so the sameAuthorization: Bearerpath serves both. Disabling either party kills the session via the per-requestactivere-read (ADR-0008). The console ships an Impersonate action (view-as / act-as) and an acting-as banner. Deferred: re-checking the escalation guard on every request (bounded instead by a short TTL plus revoke), and act-as within a scoped admin’s own scope by intersecting the target’s scope with the caller’s (#101), rather than the current all-scope-only act-as rule. - Closes the gap: issue #85.
ADR-0011: Grant scope is an operator, not a boolean modifier
Section titled “ADR-0011: Grant scope is an operator, not a boolean modifier”- Date: 2026-07-06 | Status: Accepted | Pages: identity and access
- Decision: Generalize the
exclude_rootboolean (ADR-0009) into ascope_opoperator onprincipal_grant(issue #102):subtree(root + descendants, the default, == oldexclude_root=false),subtree_excl_root(descendants only for update/delete, root kept for read/create, == oldexclude_root=true), andself(the root row only for read/update/delete, no descendants and no create-placement, a leaf-lock, net-new). The operator is a flat enum column, not a full predicate-expression tree or a per-grant tuple list. It is part of a grant’s identity: the dedup index includesscope_op, so the same role at the same root with a different operator is a distinct grant. - Context: Grant scope wants one composable axis, not a growing pile of booleans; the grant builder is
already a filter-bar-style operator UI, so the operator vocabulary is the natural fit. The flat enum was
chosen over a predicate-expression scope and a per-grant tuple list (negation, multi-root
in): those buy expressiveness the boolean’s two states never needed, at the cost of a much larger blast radius on the two authorization invariants (permission-on-every-route, scope-on-every-query).selfis the cheap third value (a scalar= any()arm, no new recursive CTE) that turns a boolean rename into a real operator, and grant on exactly one node is a frequently-wanted capability the boolean could never express. The purescope.Resolvegains aSelfIDsset; the three gateway walks (inScopeTree,InScopeIDs,scopedListSQL) gain a self arm. The migration also recreates the dedup index to includescope_op, fixing a latent collision, and threadsscope_opthroughRevokeGrant’s audit SELECT (previously dropped). The operator model does not subsume the act-as scope intersection (#101): that blocker is plumbing (carry the real actor’s grants and intersect two Sets per row), unchanged by how a Set is expressed. A future tuple model (negation, multi-root) stays a documented path if a real carve-out requirement lands. The console grant builder gains an operator stage (role -> kind -> entity -> operator), so #99 (setting the modifier from the console) ships here too. - Supersedes: ADR-0009 (the boolean is retired for the operator).
- Closes the gap: issue #102.
ADR-0012: Owner accounts are un-impersonatable; impersonation stays capability-gated, not scope-intersected
Section titled “ADR-0012: Owner accounts are un-impersonatable; impersonation stays capability-gated, not scope-intersected”- Date: 2026-07-07 | Status: Accepted | Pages: identity and access
- Decision: Harden the impersonation authorization model on tiers, not scope. (1) A principal holding
owner @ allcannot be impersonated by anyone, including another owner, in either mode (issue #106): a target-side check in the:impersonatehandler, before the mode branch. (2) Theprincipal:impersonatecapability stays swept by theprincipal:*wildcard (admin) and*:*(owner); it is not carved out as a sensitive action, because holdingprincipal:*already lets a caller create and use its own principals, so impersonate confers no new reach there. (3) Drop act-as scope intersection (#101): act-as stays all-scope-only. - Context: The escalation guard (
Covers) already blocks a lesser admin from impersonating an owner, butowner.Covers(owner)is true, so owner-impersonates-owner was possible. An owner is the highest-trust account and impersonating one is a full-takeover vector, so the explicit owner-protection rule removes it entirely and reads more clearly than relying on cover arithmetic. Owner detection reuses the samerole='owner' and scope_kind='all'lane as the owner invariant, so it is not new role-name branching. Scope intersection (a scoped admin acting-as within its own subtree by intersecting two scope Sets per row) was dropped as complexity for a narrow case; the tier model plus all-scope-only act-as is simpler and safe. The impersonated-vs-direct distinction an operator needs in the audit trail is already recorded byaudit_log.real_actor_principal_id(ADR-0010); surfacing it is a later auth-event audit slice. - Refines: ADR-0010.
- Closes the gap: issue #106; closes #101 as dropped.
ADR-0013: A grant cannot confer capabilities the granter lacks
Section titled “ADR-0013: A grant cannot confer capabilities the granter lacks”- Date: 2026-07-07 | Status: Accepted | Pages: identity and access
- Decision: Grant creation is refused (403) when the granted role’s capabilities are not covered by the
granter’s all-scope capabilities (
rbac.Set.Covers, the same primitive as the impersonation escalation guard). So no caller can promote anyone, including itself, to a tier above its own: an admin cannot grantowner(*:*), because admin is an enumerated role that does not cover the global wildcard. Issue #109. - Context:
CreateGrantpreviously checked only that the granter held all-scopeprincipal_grant:create(action.All), not that the granter covered the granted role, so an admin could grant itselfowner@alland log in as a superuser, leaving the admin/owner distinction unenforced. The check lives in thecreate-granthandler (capability is a route/handler concern; ABAC scope stays the gateway’s), mirroring the impersonation guard. Only the caller’s all-scope grants count, so a capability held through a narrower grant cannot be conferred estate-wide (the same reason act-as requires all-scope cover). The consequence is a deliberate stance: admin is bounded on purpose, the top management role, never the superuser, and does not auto-gain future resources;owner(*:*) is the break-glass superuser and the owner-invariant anchor. The same cover rule must extend to role editing when it lands (you cannot edit a role above your own tier); tracked with that slice. - Refines: ADR-0010 (reuses its capability-cover primitive on the grant path).
- Closes the gap: issue #109.
ADR-0014: The audit trail is a sensitive read, not reached by a partial global wildcard
Section titled “ADR-0014: The audit trail is a sensitive read, not reached by a partial global wildcard”- Date: 2026-07-07 | Status: Accepted | Pages: identity and access
- Decision: Reading the audit trail requires the
audit:readcapability, andauditis a sensitive resource: a partial global wildcard (*:<action>, e.g. theviewerrole’s*:read) does not confer it. Only an explicit grant on the resource (audit:read, held byadmin) or the full*:*superuser wildcard (held byowner) reaches it. So the audit trail is admin/owner-only; a read-only user does not see logins, impersonations, and access changes (issue #116). - Context: The
:readfloor and the*:readviewer role mean “read everything,” which is right for the estate but wrong for the security audit trail: exposing who impersonated whom and every access change to any read-only operator leaks security posture. Rather than gate the route with a non-read action (a hack),rbacgains a small sensitive-resource set: inSet.Allows, a*resource entry that is notallActionsskips a sensitive resource, so*:readno longer matches it while*:*still does and an explicitaudit:readstill does. This is the narrow, honest version of the “sensitive permission” idea (distinct from the impersonate call in ADR-0012, where theprincipal:*resource wildcard legitimately confersprincipal:impersonate; here it is the global*:readwildcard over a sensitive read). The set is extensible if other sensitive reads appear (it holds onlyaudittoday). - Closes the gap: issue #116.
- Superseded by ADR-0015: the
carve-out is replaced by consistent topic-pattern matching, where
:adminis a deeper token no partial wildcard reaches.
ADR-0015: Permissions are topic patterns (single-token and tail wildcards)
Section titled “ADR-0015: Permissions are topic patterns (single-token and tail wildcards)”- Date: 2026-07-07 | Status: Accepted | Pages: identity and access
- Decision: Permissions match like NATS subjects (which the node path already uses, so the stack shares
one wildcard convention): a colon-delimited token path where a literal matches itself,
*matches exactly one token, and>matches one or more tokens and must be last. A normal permission isresource:action; an admin-sensitive one isresource:action:admin. Because*is a single token, a two-token pattern (*:read,*:*,principal:*) structurally cannot match a three-token:adminpermission: admin-sensitivity is a deeper token, not a special case. The whole-estate superuser is>(issue #118). - Context: The prior ad-hoc wildcard let a two-token
*:*match a three-tokenx:y:z, an inconsistency: the second*was silently absorbing a tail. Making matching a real topic match removes every special case, the ADR-0014sensitiveResourcesset is deleted.viewer’s*:readmissesaudit:read:adminbecause two tokens cannot match three;ownerreaches it via>;admincarriesaudit:read:adminexplicitly. It also fixes, for free, a boundary wart from the grant guard:principal:*is nowprincipal:<one token>, so it does not sweep an admin-tierprincipal:<action>:admin, those stay owner-only unless granted explicitly.Set.Allowsmatches by token;Set.Covers(the impersonation and grant-escalation guard) becomes pattern subsumption plus the:readfloor, staying conservative (a reach covered only by the union of several patterns returns false, deny). The only seed change isowner’s*:*becoming>; every other permission keeps its meaning because*already meant a single token. A closed grammar also makes “what does this pattern set grant” exactly enumerable against a permission catalog (the set of allresource:action[:admin]the routes declare), the basis for a future custom-role preview. - Supersedes: ADR-0014.
- Closes the gap: issue #118.
ADR-0016: A principal can be purged, and the audit trail is denormalized to survive it
Section titled “ADR-0016: A principal can be purged, and the audit trail is denormalized to survive it”- Date: 2026-07-09 | Status: Accepted | Pages: identity and access
- Decision: A principal gains a full lifecycle: disable (reversible, the
activeflag), archive (a soft delete,archived_at, hidden from the directory and unable to authenticate, reversible), and purge (an irreversible hard delete of the row). Purge is gated on prior archival (archive-before-delete) and on the admin-sensitiveprincipal:purge:admin, soadmin(which carries it explicitly) andowner(>) can purge but a two-tokenprincipal:*cannot reach it (ADR-0015). To keep the audit trail through a hard delete, the actor’s human-readable label is denormalized into everyaudit_logrow at write time, and the audit foreign keys becomeON DELETE SET NULL: a purge nulls the id link but the text survives, so “who did X” outlives the principal. The read side coalesces the live join to the snapshot. - Context: ADR-0006’s single-owner invariant
meant accounts were disabled, never hard-deleted, since audit rows referenced them (
RESTRICT). But operators need to remove accounts created by mistake, a common task, without erasing history or orphaning the trail. Denormalizing the actor label decouples the audit record from the principal row, so the row can be purged while the history stays legible; the archive gate prevents an accidental one-click hard delete, and the last-active-owner guard (extended to archive) means a purgeable account is never the last owner. This retires the “never hard-deleted” statement in the identity-access page. - Naming: the soft-delete verb was renamed deactivate to archive (and reactivate to restore) when
the console UI landed (#146): “disable” and
“deactivate” read as synonyms, blurring two distinct operations. The ladder is now a suspend (disable,
reversible, still listed) then an offboard (archive, soft delete, hidden, recoverable) then a destroy
(purge), so the labels read pause to remove to destroy, matching the industry suspend-vs-delete pair. The
column, endpoints (
:archive/:restore), capability (principal:archive), and list param (include_archived) all follow the verb. - Closes: issue #143 (backend), #146 (console + rename).
ADR-0017: credential is renamed secret; the cascade is the reuse mechanism
Section titled “ADR-0017: credential is renamed secret; the cascade is the reuse mechanism”- Date: 2026-07-09 | Status: Accepted | Pages: config, credentials, and variables
- Decision: The access-secret member of the config / credential / variable trio
is renamed credential to secret, and its first slice is built: a typed, encrypted-at-rest value owned on the
exclusive arc (
global | location | system | component) and resolved most-specific-wins down the cascade. A secret is an encapsulated typed cell (asecret_typeshape with per-field secrecy and origin), not a bag of references: the reuse a tool like Windmill gets from variable references, the cascade already provides here (define once at a broad scope, inherit it below), so composition solves a non-problem. Interpolation references live at the consumption site ($sec:name.pathin an interface input or a function arg), never inside a secret’s own fields. Crypto is envelope AES-256-GCM behind a pluggable KEK provider (env / file / fallback), the value sealed under a per-value DEK wrapped by the KEK, with(owner, name, field)bound as AAD; the provider seam lets a KMS or Vault drop in without a model change. “credential” is retained for the authentication credential (a principal’s bearer or password), a distinct resource; only the collection-side access secret is renamed. - Context: The written variables page named this member
credentialand left itDesign. Building it surfaced two calls. First, naming: “credential” collided with the identity credential and undersold the general case (ansnmp_community, an API key, anoauth2blob are all just sensitive cascaded values); “secret” is the Cloudflare-style vars-and-secrets pair and reads correctly. Second, shape: Windmill’s resource-references-variables split was considered and rejected, because our cascade is the sharing mechanism and an atomic one-form typed cell (doctrine 4) suits an operator better than composing references. Reveal (plaintext decrypt) ships as an audited,secret:reveal-gated endpoint that the*:readfloor does not reach, so only admin and owner may decrypt; the interpolation consumer (splicing a value into a live request) is deferred to the collection-driver slice that first needs it. This reverses thecredentialnaming and any “references inside the value” reading on the page; thevariableandconfigmembers stayDesign. - Closes: issue #155 (secret slice 1).
ADR-0018: The avatar read endpoint is JSON, not raw image bytes
Section titled “ADR-0018: The avatar read endpoint is JSON, not raw image bytes”- Date: 2026-07-10 | Status: Accepted | Pages: identity and access
- Decision: A human principal’s profile picture is read through a JSON endpoint
(
GET /principals/{id}/avatargatedprincipal:read,GET /auth/me/avataron the self lane) that returns{ image_base64 }, which the console decodes into adata:URL for the<img>. The write lanes take base64 JSON in (POST /principals/{id}:setAvatarand the/auth/meself lane), and the server-normalized 256x256 JPEG is stored base64 on thehumanrow; the principal read models carry only ahas_avatarbool, so no image payload rides a list or theloadPrincipalhot path. - Context: The slice design spec proposed a raw
image/jpegread endpoint (withETag/Cache-Control/304) so a browser<img src>could load it directly. But a raw-bytes handler would be a chi-native route sitting outside the Huma authz middleware, breaking the two-layer invariant that a<resource>:<action>capability is checked on every route, and a bare<img src>cannot send a bearer header, so a token-only (non-cookie) session could not authenticate the image. Keeping the read as a Huma JSON route puts it under the sameauthn+require("principal","read")(admin) or authn-only (self) path as every other route, and the typed client (session cookie or bearer, both work) fetches the JSON and builds the data URL. The one normalized size is small (roughly 30 to 50 KB base64), so per-request payload is not a concern, and HTTP caching overavatar_updated_atis a later refinement if it is ever needed. This supersedes the spec’s raw-bytes read decision; the write transport (base64 JSON) is unchanged.
ADR-0019: Every credential is time-bounded; token purpose, not expiry shape
Section titled “ADR-0019: Every credential is time-bounded; token purpose, not expiry shape”- Date: 2026-07-11 | Status: Accepted | Pages: identity and access
- Decision: All credentials are time-bounded (reverses the earlier tokens-never-expire choice). A
web-login session keeps a 12h absolute lifetime; CLI/API tokens and the bootstrap token get a 90-day
default expiry with a
--ttloverride capped at 365 days; nothing is issued without an expiry. Sessions and API tokens are distinguished by acredential.purposecolumn, not by whetherexpires_atis set. Expiry is enforced lazily at authentication; there is no background sweep, and session/token lists show only live credentials. Deferred: a sliding idle timeout, a housekeeping sweep of long-expired rows, and nearing-expiry notifications. - Context: The credential-expiry slice (#157)
bounded only the web-login session and left the CLI/API token unbounded (
expires_atnull), overloading “has an expiry” to mean “is a session”. That left an eternal secret in the field, against the every-secret- rotates principle, and coupled the session-vs-token distinction to a nullable column that both kinds now populate. A dedicatedpurposecolumn names the concept directly, so the list and the console read the discriminator rather than inferring it, and the default 90-day / 365-day-cap window keeps a minted token usable for real automation without becoming permanent.AuthenticateBeareralready refused a passed expiry, so enforcement needed no change: giving tokens a future expiry is enough, and the list reuses the sameexpires_at is null or expires_at > now()filter so a dead row is never shown. - Reverses: the tokens-never-expire behavior introduced with #157.
- Closes: issue #172 (self-service sessions and the every-credential-expires model).
ADR-0020: variable slice 1 types inline and mirrors the secret arc
Section titled “ADR-0020: variable slice 1 types inline and mirrors the secret arc”- Date: 2026-07-11 | Status: Accepted | Pages: config, secrets, and variables
- Decision: The variable member of the trio ships its first slice: a typed, cascade-resolved plaintext
value owned on the exclusive arc and resolved most-specific-wins down the cascade, with a
Variables directory and a per-component effective-variables panel, mirroring the secret
member minus crypto, masking, and the reveal.
variable:create,updateis granted to operators (delete stays admin and owner), the same split secret got. Three parts of the written design are deferred to keep the slice one vertical cut. First, typing is inline: avalue_typeenum (string | int | float | bool | json) on the row plus a jsonbvaluevalidated against it in a pureinternal/variablepackage, not avariable_typeshape registry. A scalar needs no governed vocabulary, and the page itself calls variables the “operator-defined, not curated” member, so a registry would contradict the model. Second, thetemplateowner scope (the design’sglobal -> template -> instance) is out: slice 1 mirrors the secret arc (global | location | system | component), and template scope plus cascade groups land together in #184, because they touch the shared resolver once for both members. Third, the$var:consumer and the secret-flagged variable are deferred (the consumer has no live interpolation site yet, as with$sec:). - Context: The written variables page sketched a
variable_typeregistry and a shared config/variable cell carryingobserved_valueandreconcile. Building the member showed those belong to config (the declared-vs-observed member), not the free macro: a variable has no observed side. Sovariableshipped as its own single table, typed inline, and the page’s Storage section is corrected to match. This diverges from the page’svariable_type-registry and shared-cell sketch; theconfigmember staysDesign. - Closes: issue #183 (variable slice 1).
ADR-0021: tag slice 1, a governed key registry with entity-update-gated bindings
Section titled “ADR-0021: tag slice 1, a governed key registry with entity-update-gated bindings”- Date: 2026-07-12 | Status: Accepted | Pages: tags, config, secrets, and variables
- Decision: The tag primitive ships its first slice on its own tags page: the governed
tagkey vocabulary, the per-entitytag_bindingvalue cell owned on the exclusive arc (global | location | system | component), and a resolver that unions keys and overrides values most-specific-wins down the cascade. Two permissions, not one: minting a key is a tenant-wide governance action gated by an all-scopetag:create(broadened totag:*for admin, covering update and delete of keys), while setting a value is the owner’s ordinary write (component:updateand friends), so an operator who may edit an entity may tag it with no new grant; a global binding, having no owning entity, is gated bytag:update. A key carriesapplies_to(an entity-kind allow-list, empty = universal, checked on bind) andpropagates(a flag that toggles cascade inheritance versus a flat per-entity set, the shape a file will reuse). Key names are validated as lowercase identifiers in a pureinternal/tagpackage, keeping the vocabulary normalized. Four parts of the written design are deferred to keep the slice one vertical cut. First, the operator console surface (a Tags directory and a per-entity tag editor) is out; the slice ships over the API and the generated CLI, matching the files-first ordering the estate chose. Second, binding through groups and atemplate-scoped default are out, landing with the shared-resolver work in #184 that the variable member also waits on. Third, value-domain governance (a key constraining or normalizing its values) stays the page’s open question; slice 1 ships free-text values. Fourth, binding a tag onto a file waits on the files primitive. - Context: The tag design lived inside the config, secrets, and variables page as the
fourth cascade user. Building it earned tags a page of its own, because its governance model is distinct: unlike a
variable (one free value, one
variable:*permission), a tag splits a curated key vocabulary (admin-minted) from routine value binding (operator-open via the entity’s own write), and it resolves with a union-on-key combinator rather than a single value. The exclusive-arc scope and the cascade walk are shared with the variable and secret resolvers; the combinator and the two-permission split are what make it its own primitive. This diverges from the variables page’s single-table sketch (the binding is its owntag_bindingcell) and its “bindable via groups” note (deferred); the variables page’s tag section now frames the shared cascade and points at the tags page. - Closes: issue #188 (tag slice 1). The deferrals are filed: the console surface #189, value-domain governance #190, and binding onto a file #191; groups and template scope ride #184.
ADR-0022: effective tags resolve onto systems and locations; a placed system inherits its location
Section titled “ADR-0022: effective tags resolve onto systems and locations; a placed system inherits its location”- Date: 2026-07-13 | Status: Accepted | Pages: tags
- Decision: The directory Tags column shows a row’s effective (resolved-cascade) tags, not its direct
bindings, so the list routes (
GET /components,/systems,/locations) carry aneffective_tagsmap (key to winning value, winners only) per row, resolved for the whole page in one batched query per kind (Gateway.EffectiveTags(kind, ownerIDs), three per-kind recursive-CTE resolvers that thread a target id through the ancestor chains and rank per(target, key)). This required defining effective tags for systems and locations, which previously only components resolved: a location resolvesglobalplus its own location tree; a system resolvesglobal, its own system tree, and the location it is placed at (itslocation_idtree). A placed system therefore inherits its location’s tags (a system in a PCI building surfacescompliance: pci), consistent with how a component picks up its ownlocation_id. A component is unchanged (the full four-band arc). The resolver is scopeless by contract: the list query has already filtered the ids to the caller’s read scope, so the batch adds no per-id check, matching the existingrowActionsbatch. Winners only in the column; provenance (which scope a value came from) stays in the per-entity effective-tags detail view. - Context: The tag-apply UI needs each directory row to show what tags actually apply to it. The cheaper option was
to embed a row’s direct bindings (a flat, non-recursive
where owner_id = any($1)lookup); the architect chose effective so the column reflects inherited values, not just locally-set ones. That choice moved real work to the backend (a batched recursive cascade versus a flat index scan) and forced the systems-and-locations effective definition, whose one genuine call was whether a system inherits its location: yes, because a system carries alocation_idexactly as a component does, so treating it as placement-that-inherits is the consistent reading. The added cost is a small bounded per-row recursion over the shallow estate trees, one round-trip, and is capped by the directory page size. This is the first (backend) slice of the tag-apply UI; the Tags column, the type-to-add editor, and tag search consume it in later slices. - Closes: issue #201 (batch effective-tags resolver); part of #189.
ADR-0023: the IAM directory reads (principal, role, principal_group) are admin-tier
Section titled “ADR-0023: the IAM directory reads (principal, role, principal_group) are admin-tier”- Date: 2026-07-13 | Status: Accepted | Pages: identity and access
- Decision: The read (list and get) of
principal,role, andprincipal_groupmoves from a two-token<resource>:readto the admin-sensitive<resource>:read:admin, so theviewerread floor (*:read) no longer reaches the Users, Roles, and Groups directories.admincarries an explicitprincipal:read:admin,role:read:admin, andprincipal_group:read:adminalongside its<resource>:*wildcards, the same shape as the existingprincipal:purge:admin;owner’s>is unaffected. Create, update, and the lifecycle verbs stay two-token: they were never reachable by a non-admin, so only the directory read needed promoting. The console gates the three Settings tabs on the same three-token permission and the route guard reads it from the shared nav map, so the sidebar and the server never diverge. - Context:
deploy(an integrator or field tech) inheritsviewer, whose*:readis a single-token resource wildcard. Because*matches exactly one token,*:readmatchedprincipal:read/role:read/principal_group:read, and the read floor shares that reach, so a field tech could enumerate every user, role, and group over the API (a real 200, not just a visible menu). Promoting the directory reads reuses ADR-0015’s deeper-token rule rather than adding a matcher special case: admin-sensitivity is a third token*cannot reach. Secrets are a separate concern (an operator legitimately reads device secrets in scope), handled by a forthcoming slice that combines placement scope with a per-secret admin-sensitive flag; this ADR is the IAM directories only. - Closes: issue #197.
ADR-0024: a tag key may constrain its values to an enum
Section titled “ADR-0024: a tag key may constrain its values to an enum”- Date: 2026-07-13 | Status: Accepted | Pages: tags
- Decision: A tag key gains an
allowed_valuesset (a newtext[]column, empty by default). Empty leaves the key free-text, unchanged; a non-empty set is the enum a bound value must belong to, soenvironmentcan be declared as one ofprod,staging,dev. The binding write enforces it:SetTagBindingrejects a value outside a key’s non-empty allowed set with a dedicated 422 (ErrTagValueNotAllowed), so the constraint is a real server gate, not a UI hint. The Tags directory create and edit forms carry a value-domain control (a checkbox that turns the key into an enum plus a value-list editor), and the TagAdder value stage renders a strict dropdown for an enum key. A free key instead offers value autocomplete from the distinct values already bound for it, through a newGET /tags/{name}:valuesread (aselect distinct value), so an operator reaches for an existing value without the key having to declare a set up front. Only the enum (a string set) ships; a typedvalue_type(int, bool, date) and input normalization (lowercase, trim, fold) stay the page’s open question. - Context: The tags page left value-domain governance an open question, with the enum, a
typed value_type, and normalization all on the table. Operators asked first for the plain case, a key like
environmentthat should only ever be one of a short list, so that shipped: a string enum on the key, enforced on write, with a strict picker. The distinct-in-use autocomplete is the free-key counterpart, cheap (oneselect distinct) and immediately useful, so the two ship together. This resolves the enum half of the page’s open question; the value_type and normalization halves remain deferred. - Closes: issue #190 (tag value-domain governance, enum).
ADR-0025: secret is a sensitive resource; a per-secret admin_sensitive flag flips a secret to the :admin tier
Section titled “ADR-0025: secret is a sensitive resource; a per-secret admin_sensitive flag flips a secret to the :admin tier”- Date: 2026-07-13 | Status: Accepted | Pages: identity and access, variables
- Decision: Two orthogonal axes now decide who reaches a secret. Placement scope (the
global/location/system/componententity a secret attaches to on the exclusive arc) gives locality, unchanged. A new per-secretadmin_sensitiveflag gives same-scope sensitivity: when set, every action on that secret is lifted to the:admintier, so a scoped two-token grant (secret:reveal) cannot reach it and onlyadmin(secret:>) orowner(>) may see, reveal, update, delete, or create it. The flag defaults from the secret’ssecret_type(secret_type.default_admin_sensitive: an SNMP community defaults operational, an OAuth2 client secret defaults admin-sensitive) and the row’s own value is authoritative; the column default istrue(a secret is admin-only until marked operational). Enforcement is a capability flag computed at the API (canAdmin= the caller holdssecret:<action>:admin) and passed to the Storage Gateway alongside scope: the gateway hides admin-sensitive rows from a lister/resolver without it, and returns a non-disclosing 404 (not a 403) to a revealer/updater/deleter without it, so a platform credential’s existence and field names are not disclosed through the read, reveal, list, or cascade paths. (One residual: because a secret name is unique per owner, an operator with create scope at the same owner can distinguish a create-collision 409 from a 201, a narrow existence-and-name oracle, no field values. It predates this slice, since operators already heldsecret:createwithoutsecret:read; closing it needs a namespace or create-path change and is a tracked follow-up, not a value-disclosure path.) Separately,secretjoins a sensitive-resource set that a bare single-token*does not reach, in both places*grants read (the direct topic match and the read floor);>(owner), a literalsecret:read, and asecret:*still name it. Soviewer(only*:read) reads no secrets at all (not the directory, not the per-component effective-secrets cascade),operator/deploygain a scopedsecret:read,reveal,create,updateand see and reveal the operational secrets in their subtree, andadmin’ssecret:*becomessecret:>so it reaches the admin tier. The/secretsdirectory, previously all-scope-only, is now scope-filtered. The clientcan()mirrors both the sensitive-set and the:readfloor so the console hides exactly what the server denies. - Context: A field tech setting up a site must create and read back that site’s device secrets (an SNMP
community, a device login), but the platform integration credentials (a Zoom or Microsoft client secret the
collection engine consumes) must never be revealed below admin. A device secret and a platform credential can sit
at the same scope (both global), so placement alone cannot separate them, and a low/medium/high sensitivity
ladder was rejected as arbitrary and hard-fixed to three tiers. A per-secret binary flag reusing
ADR-0015’s
third-token
:adminrule expresses the real distinction without a new matcher concept. Takingsecretoff the bare*wildcard (rather than promotingsecret:readwholesale to:admin, which would deny operators their device secrets) is the one lever that keeps the two-tokensecret:readoperators legitimately hold while stoppingviewer’s*:readfrom reaching it. Negative grants (deny-after-allow) were rejected as a footgun the:admintier and the sensitive-set already cover. This is Slice B of the same visibility rework as ADR-0023; the IAM directories use the:admintier (no legitimate sub-admin reader) and are not in the sensitive-set,variablestays viewer-visible by decision and is not in the set. The move of Secrets, Variables, and Config out of Settings into Catalog is a separate branch, not this slice. - Closes: issue #210.
ADR-0026: Console nav IA: estate values get their own top-level group; the Settings group becomes Admin
Section titled “ADR-0026: Console nav IA: estate values get their own top-level group; the Settings group becomes Admin”- Date: 2026-07-13 | Status: Accepted | Pages: ui
- Decision: The operator console left nav is reorganized around five genera: Catalog (the reusable,
estate-agnostic model), Inventory (the estate instances: locations, systems, components, and nodes), Values
(the operator-set values resolved down the scope cascade: variables, secrets, config), the observed surfaces
(Explore, Alarms, Dashboards, Learn), and platform Admin. Secrets, Variables, and Config are values operators
set on estate entities, so they move from the Settings menu into a Values group of their own, standing
beside Inventory rather than nested inside it as a band. Config’s meaning is fixed as the CI store:
operator-set desired component and system configuration, optionally observed back from the device to detect
drift and reconcile, distinct from platform Settings and from Variables. Inventory gains Nodes (the
collection daemons, a monitored, scope-controlled entity, ungated “soon” until
node:readlands) alongside Locations, Systems, and Components; Interfaces and Tasks are dropped from the nav entirely, since an interface is a facet of a component and a task a facet of a node, not a directory of their own. The Settings group is renamed Admin (Users, Roles, Groups, Audit) and gains an ungated “soon” Settings leaf that reserves the platform-settings-table page. - Context: Settings had become a junk drawer mixing platform governance, platform config, and estate-attached
values. Those three values attach to a single estate entity on the scope cascade (the same genus as a tag
assignment) but are not estate entities themselves, so they earned a home of their own, not Settings, not
Catalog, and not a band folded inside Inventory. This supersedes the “into Catalog” line of ADR-0025 above:
the earlier same-day plan named Catalog, and the decision is a dedicated Values group. Interfaces and Nodes were
first sketched as Inventory children alongside the estate entities; Nodes stayed (a node is monitored and
scope-controlled exactly like a location, system, or component, so it belongs with them, not under Admin), but
Interfaces and the Tasks a node runs were cut from the nav once it was clear each is a facet of one owning
entity’s detail page (a component’s device endpoints, a node’s collection assignments), not a set an operator
browses on its own. The relaxed whole-group-drop (an ungated Settings “soon” stub keeps the Admin group visible
to a viewer, showing only that greyed placeholder while every data-bearing child stays admin-gated and hidden)
is deliberate until the platform-settings backend ships and the leaf is gated on
setting:read:admin. Design:docs/superpowers/specs/2026-07-13-operator-console-nav-ia-design.md. - Closes: issue #222.
- Update (2026-07-14): Files joins the Values group. The files slice (ADR-0029) first shipped the Files directory under Inventory, but a file is not part of the monitored estate (no health, not polled): it is operator-uploaded content. So the Values group broadens from “operator-set values resolved down the cascade” to operator-set values and content, with the (deliberately non-cascading, flat) file as its content member alongside the cascaded variables, secrets, and config (#249).
ADR-0027: create is a route; inventory create and edit unify on the detail accordion
Section titled “ADR-0027: create is a route; inventory create and edit unify on the detail accordion”- Date: 2026-07-14 | Status: Accepted | Pages: design system, core entities
- Decision: The inventory entities (component, system, location) drop the create/edit Drawer. Creating one
is now a route:
Newnavigates to/<entity>/create, a draft accordion where Identity and Placement are writable and the binding sections (Tags, and later Secrets/Variables) are shown locked until the entity exists; Save commits the row and hands off to/<entity>/<id>in edit mode (a one-shot pending-edit flag consumed when the detail resolves, the UsersopenPrincipalInEditpattern). The detail is one accordion, read-only in view and the sole writer in edit: no in-body field or binding mutation control renders while not editing (the footer’s Edit / Delete chrome and the read-only effective-secrets/variables panels are exempt). This is the Users inline-blade-edit model generalised to inventory, and it holds on both the docked blade and the addressable full page. No new routes: the static/createoutranks/:namein the router, socreateis a reserved segment. The sharedTreeListprimitive gains a per-surface edit slot onListCtx(the full page makes its own slot, since the sharedrenderDetailmust not calluseBladeEditoutside a blade provider), plusrenderCreate/onNew/onEdithooks and an optionalFormBody, so a page opts into the model without breaking the others. - Context: Creating an inventory entity returned you to the list, so setting a tag meant find, reopen, edit; and
TagAdderrendered a write control in view. A drawer that opened in edit after create would need a fragile cross-surface hand-off (the code-grounded review of the drawer design surfaced a full-pageuseBladeEditcrash, aFormBodyfooter collision, and a pending-edit gap). Framing create as its own URL dissolved the “create is blade-only” false dilemma: a draft with an address is deep-linkable full-page and dockable as a blade, and Save is a route hand-off, not a surface hop. Own-field edits commit on Save (Cancel reverts them); tag bindings keep their immediate per-binding write, so Cancel does not roll a tag back, and the tag control sits apart from the Save/Cancel form. Slice 2 (a shared cross-page form shell) and slice 3 (moving Users onto it) are deferred. - Closes: issue #231.
ADR-0028: rank retired from the type registries; sort is alphabetical
Section titled “ADR-0028: rank retired from the type registries; sort is alphabetical”- Date: 2026-07-14 | Status: Accepted | Pages: core-entities, Types guide
- Decision:
rankis dropped fromlocation_type,system_type, andcomponent_type: the column (a new idempotent migration), the three API bodies and create/update inputs, the boot-seed YAMLs, the generated client and CLI, and the Types catalog page (no Rank column, no Rank field on create or edit).ListLocationTypes,ListSystemTypes, andListComponentTypesnow order bydisplay_name, idinstead. - Context:
rankwas sort-only from the start (the location_type seed comment already said so: “rank does NOT constrain nesting”), never an enforcement mechanism. The upcomingallowed_parent_typesplacement constraint onlocation_typeneeded a clean field to introduce without a stale, unused ordering column sitting beside it, so retiringrankis the mechanical precursor to that slice rather than part of it: this PR only removes the field and switches the sort,allowed_parent_typesis a separate slice. Alphabetical is the obvious default with no enforcement semantics to preserve; an operator who wants a specific browse order can still rely on the id or display name they chose. - Closes: part of issue #239 (the
allowed_parent_typeshalf continues in a follow-up PR against the same issue). Design:docs/superpowers/specs/2026-07-14-type-placement-constraints-design.md.
ADR-0029: files slice 1, a content-addressed blob store and a tenant-wide file handle
Section titled “ADR-0029: files slice 1, a content-addressed blob store and a tenant-wide file handle”- Date: 2026-07-14 | Status: Accepted | Pages: files and blobs, storage, identity and access
- Decision: The files subsystem ships its first slice: a content-addressed
blobstore as a Storage Gateway primitive (ablob.Storeseam, default pgblobs backend holding bytes inline, keyed by the sha256 of the bytes, dedup viaon conflict do nothing, integrity-verified on read), and afilehandle, searchable metadata (name, content_type, size, sha256, sensitive) that points at a blob by hash, with CRUD over the API, the generated CLI, and the typed client, plus the Files directory (under Values; see the ADR-0026 update). Four calls shape it. (1) No placement arc on a file. A file is tenant-wide, not on the exclusive arc a secret sits on, because a file relates 1:many (to entities and types) rather than 1:1; that locality is a future many-to-many attachment, not an owner column, so the gateway injects no ABAC tree scope on a file query. (This reverses an in-design proposal to givefilea secret-style owner scope.) (2) Sensitivity reuses the secret mechanism, binary, defaulting off. A per-filesensitiveflag reuses ADR-0025’s:admin-tier rule (hidden from a lister without the tier, a non-disclosing 404 to a reader without it, admin-only to create), but defaults false (a file is shared unless marked, where a secret defaults sensitive because it is a credential), andfileis not added to the sensitive-resource set, so the viewer floor (*:read) reads ordinary files. (3) A delete frees its blob synchronously; async GC deferred.DeleteFiledrops the handle and, in the same transaction, frees the blob when no other handle references it (a dedup-aware refcount: a deleted file reclaims its bytes rather than leaking storage, but a blob shared by another handle is kept). The general async mark-sweep GC (for blobs referenced by other things, an aged large log body, acollection.failedraw, an attach event, none of which exist yet) stays a later slice; today afileis the only referencer, so the synchronous check is complete. (4) One backend, base64-in-JSON on the wire. Only the pgblobs backend ships (S3 and disk behind the same seam later); upload and download carry the bytes base64 in JSON, reusing the avatar precedent (ADR-0018) so the whole surface stays under the Huma authz middleware and generates a uniform client and CLI. content_type lives on the file, not the blob: content-addressing is about the bytes, so identical bytes are one blob regardless of declared type. - Context: files.md specified the two-layer model (handle plus content-addressed blob) and an index-probe GC; its open questions (inline-versus-blob threshold, chunking, the grace floor) are untouched here. The 1:many insight is what separated a file’s locality (attachment, deferred) from its access (permission plus sensitivity), and is why the file does not copy the secret owner arc. A full classification + clearance lattice (an ordered ladder on the resource, a clearance on the principal, an external-principal class) was considered for the sensitive axis and split into its own epic (#243) rather than inflating this slice; the binary flag is a 2-rung subset it will subsume. Multipart streaming for very large blobs is deferred with the S3/chunking slice.
- Divergences logged: files.md moved
content_typefrom the blob to the file; the in-design file owner/scope arc was dropped (a file is off the placement arc). Both are reflected in the page. - Lands: epic #242, #244.
ADR-0030: allowed_parent_types constrains where a location may be placed
Section titled “ADR-0030: allowed_parent_types constrains where a location may be placed”- Date: 2026-07-14 | Status: Accepted | Pages: core-entities, Types guide, Work with an entity
- Decision:
location_typegainsallowed_parent_types(text[], default{}): a set whose members arelocation_typeids and/or the reservedrootsentinel (a placement at the top, no parent). An empty set is unconstrained (the default, and every existing custom type until an operator opts in); a non-empty set is enforced: a placement is valid iff the parent is null and the set containsroot, or the parent location’s type is in the set.rootcannot collide with a real type id:CreateLocationTyperefuses it. Enforcement is forward-only, onCreateLocationand the location move path (UpdateLocation’s newParentNamepatch field, added this slice so the “grandfathered until moved” guarantee is real and testable, not merely a claim); an existing placement a type’s set no longer allows is untouched until something tries to move it. The four seeded types get their sets:campus={root},building={root,campus},floor={building,campus},room={floor,building,campus}. Re-parent ships operator-usable this slice: the location edit form’s Placement section makes Parent editable, a picker built on #240’s inventory edit model (the sameShow when={editing()}field/fact split every other editable field on the accordion uses), narrowed to the set and excluding the location’s own subtree; moving back to root is not offered (the move primitive does not support it this slice). - Context:
rank(ADR-0028) was sort-only and never expressed the estate’s real hierarchy rule (a floor does not belong above a room). Achild.level > parent.levelrule was rejected: it does not generalize past locations (systems and components have no total order), while a type-level allowed-parent set expresses both the general “may skip a level” case and the specific “may never be root” or “may never nest under this particular type” cases with one field. A separateroot_placeableboolean was rejected in favor of folding root into the set as a sentinel, keeping one field and one validation path. Enforcing retroactively was rejected: seeding a type’s set must never invalidate an existing estate. Locations had no move or re-parent capability at all before this slice (create-time placement only); the storage/API primitive was originally scoped without a UI trigger (the console’s placement fields render read-only in every edit context today, on all three inventory pages), but the decision changed once #240’s create-as-route edit model landed as the concrete field pattern to hang a reparent picker off: one PR ships the enforcement point and a real way to use it, rather than a primitive an operator cannot reach. The picker’s candidate list is narrowed client-side (a UX nicety); the server-sidevalidatePlacementcall is the actual gate, so a stale or bypassed client filter still gets an inline 422, not a silently-accepted violation. One divergence from the design surfaced while building the move primitive:UpdateLocationchecks placement before the cycle guard, not after, so a move that is simultaneously a placement violation and a structural cycle (moving a location under its own descendant, where the descendant’s type also does not allow this child) reports thePlacementError(422, naming both types) rather than the genericErrLocationCycle; the design left the check order unstated, and the more specific, actionable error was chosen to win. Systems and components loseranktoo but get noallowed_parent_typesthis slice, and keep their existing read-only-in-edit Parent field: a leaf or must-nest constraint there is closer to a boolean than an ordered set, deferred until a concrete need names the shape, and extending the same editable-Parent pattern to two more pages is a follow-up, not bundled here. - Closes: issue #239. Design:
docs/superpowers/specs/2026-07-14-type-placement-constraints-design.md.
ADR-0031: component_make registry slice 1, an official boolean, a deferred referential guard, and website scheme validation
Section titled “ADR-0031: component_make registry slice 1, an official boolean, a deferred referential guard, and website scheme validation”- Date: 2026-07-14 | Status: Accepted | Pages: core entities, Vendors guide
- Decision: Three calls on the first slice of the
component_makemanufacturer registry (id, display_name, icon, support_phone, website), lands ahead of the rest of the make/model catalog. (1)official boolean, not anoriginenum. The design sketch (below) proposedorigin official | seed | customon make and model, matching the model layer’s eventual needs. Slice 1 ships a plainofficialboolean instead, becausecomponent_typeand the other registries already distinguish seed-owned from operator rows with a boolean, and a two-value distinction gains nothing from a three-value enum until a realseed(installed, mutable) tier exists to fill it;origincan still land oncomponent_modelif that tier turns out to be real. (2) The in-use / referential delete guard is deferred.component_type,location_type, andsystem_typeall refuse a delete while a location, system, or component still references the row (409).component_makeships no equivalent guard: nothing references acomponent_makeyet (component_model, the referencing entity, does not exist), so a custom make deletes unconditionally (an official row is still refused, 422, the seed-owned rule). The guard is added whencomponent_modellands and gives the registry something to be in-use by, rather than building an unused check now. (3) Website URL scheme validation, client and server. The create/edit form renderswebsiteas a live anchor; an operator-entered value with no scheme check is a stored-XSS vector (javascript:/data:executing on click). AvalidWebsiteSchemeguard on the API (http/httpsonly, empty allowed, else 422) and a matchingsafeUrlguard on the console (render a live link only when safe, else plain text, never a dead or unsafe anchor) close it in both places: server-side so a non-browser caller (CLI/curl) cannot persist a dangerous scheme, client-side so a value written before the server-side check existed (or by any path that bypassed it) still renders safely. - Context:
docs/superpowers/specs/2026-07-14-component-make-model-catalog-design.mdsketches the full make/model catalog (component_make, acomponent_typegenus tree,component_model, andcomponent.model_id) as four independent vertical slices; this is slice 1, make alone, with no dependency on the tree or the model layer. A review pass on the first cut of the console page (Task 4) found the missing website-scheme check as a stored-XSS gap before this shipped, closed in the same slice rather than carried as a follow-up. - Divergences logged: the design sketch’s
origin official | seed | customenum is not what shipped;official booleandid, per (1) above. The design’s delete-refused-while-referenced rule is not enforced yet; per (2), it is deferred to thecomponent_modelslice that gives it something to check. - Lands: epic #254, issue
#255. Design:
docs/superpowers/specs/2026-07-14-component-make-model-catalog-design.md. Plan:docs/superpowers/plans/2026-07-14-component-make-registry.md.
ADR-0032: the required permission is published per route, and the permission universe is route-derived
Section titled “ADR-0032: the required permission is published per route, and the permission universe is route-derived”- Date: 2026-07-17 | Status: Accepted | Pages: identity and access, API, Access guide
- Decision: Every capability-gated route registers through one helper,
gated(op, tokens...), which sets theauthn+requiremiddleware (unchanged enforcement), stamps the operation with anx-omniglass-permissionOpenAPI extension, and records the permission in an in-process registry. The required permission for each request is therefore published in the generatedapi/openapi.json, and the permission universe (the deduped set of every stamp) is derived from the routes, not a hand-kept catalog.GET /rolesreports the universe plus, per role, the held subset (resolved by the samerbac.Set.Allowsmatcher as the effective set), and the console role blade renders it as a netHeld / Missing / Allview. Two build-time guards keep it honest: a published-gate guard (every gated route is stamped, allow-listed routes are not, so “gated” and “published” are the same set) and a seed-drift guard (every seed-role grant resolves into the universe or sits in an explicitaheadOfRoutesallow-list). - Context: the authz contract already existed (
require(...)enforced a permission on every route) but lived only in Go middleware, invisible to the spec, the clients, and any reader; and a role blade could show only what a role granted, never the capabilities it lacked. Three options were weighed for the universe source: a hand-kept catalog YAML (drifts), a runtime-only set (invisible in diffs), and the route-derived stamp (self-maintaining, reviewable in the committed spec, exactly the enforced surface). The stamp won because it makes the universe fall out of the API-first pipeline with no second source to drift. Held is resolved server-side so the single rbac matcher is not duplicated in the SPA. Grants that resolve to nothing (for examplealarm:*,interface:*before those subsystems have HTTP surfaces) are legitimate but ahead of their routes; they show as held-nothing and are allow-listed until the route lands. - Lands: issue #272 under epic
#27. Design:
docs/superpowers/specs/2026-07-17-net-permissions-role-blade-design.md.
ADR-0033: settings persist only the override level; base layers are recomputed in memory
Section titled “ADR-0033: settings persist only the override level; base layers are recomputed in memory”- Date: 2026-07-17 | Status: Accepted | Pages: settings, scaling and deployment
- Decision: The settings engine stores only the override level in Postgres (
setting_override). The two base layers, the embeddedcodedefaults and the operatorfile, are recomputed into memory on every boot and never written to the table, so the effective document is the in-memory base layers merged with the live DB override. Restore is therefore aDELETE: dropping a namespace’s override row (or truncating the scope) re-exposes the base defaults, with no separate reset column and no re-seed of the file into the store. - Context: The scaling page sketched a single settings store “materialized in
Postgres … seeded declaratively from a settings file reconciled on every boot” (an
ON CONFLICT DO UPDATEof the file into the table). Building the engine showed that materializing the file into the DB is the wrong shape: it duplicates the GitOps source into a second authoritative copy that can drift, and it conflates ship-with defaults (a compile-time asset) with operator changes (the only thing worth persisting). Keeping the base layers in memory makes the file always-fresh (a ConfigMap change lands on restart), keeps the store lean (it holds only what an operator actually changed), and makes restore fall out of the model as a delete rather than a re-seed. This diverges from the scaling page’s “materialized in Postgres” wording; the settings page carries the corrected model and the scaling page moves toPartial. - Closes: issue #271 (settings engine slice-0), under
epic #270. Design:
docs/superpowers/specs/2026-07-17-settings-engine-design.md. - Amended by ADR-0057: the model is unchanged, the level names are not:
codeis nowdefault(off the axis, the setting’s own declaration) andglobalis nowplatform(the install-wide rung).
ADR-0034: the settings Gateway is unscoped; only the permission gates it
Section titled “ADR-0034: the settings Gateway is unscoped; only the permission gates it”- Date: 2026-07-17 | Status: Accepted | Pages: settings, storage, identity and access
- Decision: The Storage Gateway methods for settings (
GetSettingOverrides,UpsertSettingOverride,DeleteSettingOverride,DeleteAllSettingOverrides) are unscoped: no ABAC storage-scope predicate is injected. Only thesettings:<action>permission at the route gates them (settings:readadmin read with provenance,settings:updatewrite / restore / lock, both admin-tier; the client-safe/settings/meis authn-only). This is a deliberate carve-out from the “scope on every applicable query” invariant, recorded so it reads as intentional. - Context: The two authorization layers (identity and access) are a
<resource>:<action>permission on every route and an ABAC scope injected on every applicable query. Platform and cascade settings describe the platform and its principals, not the estate, so there is no location / system / component subtree to scope them by, exactly as with the registry-type reads (GET /types/...), which are also unscoped. Forcing a scope predicate here would be meaningless (there is nothing to filter on) and would misrepresent settings as estate data. The carve-out is narrow: it applies only because the data is platform config. When the group and user override rungs land, override reads and writes will be constrained by the acting principal (a user edits only their ownuserrow), but that is a per-principal ownership check, a different mechanism than estate ABAC, not a return of tree scope. - Closes: issue #271 (settings engine slice-0).
- Amended by ADR-0057: the model is unchanged, the level names are not:
codeis nowdefault(off the axis, the setting’s own declaration) andglobalis nowplatform(the install-wide rung).
ADR-0035: settings resolve as a cascade over principals with a broader-wins lock
Section titled “ADR-0035: settings resolve as a cascade over principals with a broader-wins lock”- Date: 2026-07-17 | Status: Accepted | Pages: settings, cascade
- Decision: A setting’s effective value resolves down the principal hierarchy (global to group to user),
reusing the same cascade primitive the estate uses down location to system to
component: ordered layers deep-merged in JSON map-space (most-specific-wins by key presence), with per-key
provenance (the winning level) reported alongside the value. Layered on top is a top-down lock: an admin
locks a key at a level, pinning that level’s value and forbidding any more-specific level from overriding it, and
when two levels lock the same key the broader level wins (a
globallock supersedes agrouplock, so top-down admin authority is absolute). Slice-0 ships the global rung; group and user are a fast-follow. - Context: Omniglass already had one cascade resolver (the estate’s secrets / variables / tags / config,
config and credentials). Rather than write a second resolver for settings, the engine
points the same primitive at the identity axis (doctrine 5, primitive-first): a value defined once at a broad
scope inherits below, which is exactly the reuse a variable-reference model (Windmill-style) would buy, provided
here by inheritance for free. The lock is the piece the estate cascade did not need: settings are governance
(an admin enforcing an org default a user cannot escape), so the engine adds a per-key lock with a broader-wins
conflict rule, the inverse of the most-specific-wins value rule, applied to the enforcement axis. Provenance
reuses the estate’s effective-values vocabulary (the winning level per key), extended from three estate bands to
five principal levels plus a lock chip. The pure
settingspackage is the primary unit-test target; the DB override is supplied through a narrow function seam so the package never imports storage. - Closes: issue #271 (settings engine slice-0), under epic #270.
- Amended by ADR-0057: the model is unchanged, the level names are not:
codeis nowdefault(off the axis, the setting’s own declaration) andglobalis nowplatform(the install-wide rung).
ADR-0036: A node is a kind=node principal with an interim bearer credential and static per-connection NATS subject permissions
Section titled “ADR-0036: A node is a kind=node principal with an interim bearer credential and static per-connection NATS subject permissions”- Date: 2026-07-07 | Status: Accepted | Pages: nodes, identity and access
- Decision: A node is a first-class
principalofkind='node'with a 1:1nodedetail table (keyed byprincipal_id, alongsidehumanandservice), exactly as identity and access describes. Itsnameisnot null uniqueon the detail table and stays the estate address the collection FKs (interface.node_name,task.node_name,metric_datapoint.node_id) reference. The node runtime ships with two deliberate calls that diverge from the present-tense design, both reversible in a later hardening slice. (1) The node’s credential is a bearercredentialrow on its principal, minted, stored (only assha256), and verified through the same helpers a service bearer token uses (AuthenticateBearer), and the enrollment token doubles as the node’s NATS password (a shared secret), rather than being a single-use bootstrap exchanged for a distinct long-lived credential. The decentralized nkey/JWT operator-account model that identity and access describes for nodes (anatscredential kind, a signed nonce, a JWT carrying the node’s subject permissions) is deferred; thecredentialkind CHECK is not widened for it here. (2) Per-node NATS isolation is static per-connection subject permissions: the embeddednats-serverruns an in-processCustomClientAuthenticationcallback that resolves each connecting node by name, verifies its bearer credential, and registers a user whose publish/subscribe grants are scoped to that node’s ownog.v1.*.<node>subjects, so a node cannot publish or pull as another. - Context: Checkpoint 2 of the reachability slice needed a real, negatively-tested per-node isolation
mechanic against an embedded server, without carrying the full JWT/nkey machinery a single slice should not.
The auth-callback path adds per-node users dynamically at enrollment time with no config reload, which is
the simplest mechanism that keeps the isolation invariant real: the negative test proves node A cannot use
node B’s subjects (and a confused-deputy reply cannot forge another node’s liveness), and a wrong credential
is rejected at connect. The subject encodes the node name in its last token and the callback grants only that
node’s subjects, so the subject is the transport isolation boundary (the payload-owner admission fence is
a later checkpoint). Modeling the node as a
kind=nodeprincipal (rather than the standalone table an earlier checkpoint built) puts it on the shared identity spine from the start: it has a realprincipal_idso it can be an audit actor, its credential rides the audited human/service machinery, and only the credential scheme (interim bearer vs nkey/JWT) remains to tighten. JetStream is enabled on the server now (it boots and shuts down cleanly), but the control-plane messages (worklist, heartbeat) are JSON over core NATS; the protobuf telemetryEventover JetStream is the next checkpoint. - Closes the gap: the nkey/JWT node identity (the
natscredential kind and the signed-nonce admission) and the single-use enrollment token are tracked with the node-identity hardening slice.
ADR-0037: Telemetry is a protobuf Event over JetStream with an inline owner-confining consumer
Section titled “ADR-0037: Telemetry is a protobuf Event over JetStream with an inline owner-confining consumer”- Date: 2026-07-07 | Status: Accepted | Pages: collection, datapoints
- Decision: A node ships each collected batch as a protobuf
Event(proto3,proto/og/v1/event.proto, since renamed toTelemetryBatchinproto/og/v1/telemetry.proto, #424,Event+Datapointmessages only, no gRPC service) published toog.v1.telemetry.<node>. This is omniglass’s first protobuf; the wire is generated withprotoc+protoc-gen-govia agen-protostage onmake gen, and the generatedevent.pb.gois committed. The server hosts a JetStream stream (OG_TELEMETRYoverog.v1.telemetry.*) and a single durable consumer (og-telemetry-worker, AckExplicit) whose handler, per Event, derives and writes inline: it decodes the batch, resolves the owner as the task’s interface component, confines the node to its own tasks, applies reject-not-project against thedatapoint_typeregistry, writes the surviving typed rows through the checkpoint-1InsertMetricDatapointspath (owner_kind=component,provenance=observed), and acks. A permanent condition (an undecodable payload, or an orphan the confinement fence drops) is terminated/acked so it is not redelivered; only a transient failure (a DB error) is left unacked so JetStream redelivers. The node stamps no component identity: its only assertion is the publishing subject (its own name) plus thetask_id; the server binds and confines. - Context: The prior (v2) design split telemetry into a hot path that persisted a raw event to a
telemetrytable and an async Postgres queue worker that derived from it. Checkpoint 3 deliberately collapses that split: the JetStream durable consumer is the at-least-once worklist, so there is no raw-telemetry table and no Postgres queue in this checkpoint; the handler derives, confines, writes, and acks in one place. This keeps the reachability slice small while keeping the two invariants real and negatively tested: a node cannot land a datapoint for a component it holds no task for (an Event carrying another node’stask_idis orphan-dropped, no row written), and an unregistered datapoint name is dropped, not projected. Owner binding is the interface-prebind path only (task -> interface -> component); there is no separately-authoredtransform_rule(omniglass has none), so label-based multi-owner routing, discovery rules, and node-self binding are a later checkpoint. - Closes the gap: raw-
Eventpersistence (backfill/replay) and the raw -> admission -> trusted two-lane topology, plus label-based multi-owner resolution, are tracked with a later collection checkpoint.
ADR-0038: The reachability verdict is a built-in state
Section titled “ADR-0038: The reachability verdict is a built-in state”- Date: 2026-07-07 | Status: Accepted | Pages: datapoints, collection
- Decision: The per-interface reachability verdict
interface.reachable(value domainup/down) is a first-class state datapoint, not a metric, seeded as an officialdatapoint_typeatkind=state,value_type=text,validation: {values:[up,down]}. It is gated per interface: the verdict is the AND of that interface’s applicable probe results (for the inline tcp/icmp interfaces this is degenerate, one probe drives the verdict; it generalizes to an interface with several probes). The node computes it after running the interface’s probe(s) and emits it as anobservedstate datapoint instanced by the interface; the ingest consumer routes by the registry kind (a metric name tometric_datapoint, a state name tostate_datapoint) after the same owner-confinement and reject-not-project, so a foreign or unregistered state is dropped identically to a metric. The series is transition-only: the node remembers the last verdict per interface and emits only on a flip or first observation, and the ingest side re-guards by skipping a write whose value equals the latest stored value (the net for a node restart). Availability istime_in_stateover this state (health’s primitive one tier down), a later slice; the raw probe metrics (tcp.open,icmp.reachable, the rtts) keep emitting unchanged. Readiness config (an ssh command + regex, an snmp OID) is an interface-type default, interface-overridable concern executed on the node, not a server-sidecalc_rule; 5a builds no readiness-config column, its verdict is the inline probe result. - Context: Reachability history is only honest if the verdict is a dwell-measurable signal: availability
is time-in-state, which needs a categorical state with transitions, not a numeric sample per tick. Modelling
the verdict as a metric would conflate the raw per-probe reading (
tcp.open, a firehose sample) with the interface-level judgement (an availability substrate), and it would maketime_in_statea re-derivation over a numeric series rather than a read over the state’s own transitions. Making it a state, and computing it at the node as the AND of the interface’s probes, keeps the verdict where the probe results are, keeps the raw metrics untouched, and lets the read side reconstruct the availability strip directly fromstate_datapoint. - Divergence: checkpoint 1 seeded the
datapoint_typecanon metric-only (the reachability probe metrics), and cp3’s ingest consumer assumed every surviving datapoint was a metric (InsertMetricDatapointsfor all). This entry records the divergence: 5a adds the first state to the seed and makes the ingest consumer route by kind (the cp3-deferred “route by kind, not assume metric” note now come due). Thestate_datapointtable mirrorsmetric_datapoint(same owner exclusive-arc, same lineage CHECK) with a categoricalvalue textplus an optionalvalue_json. - Closes the gap: the availability SLI (
time_in_stateoverinterface.reachable) and the operator surfaces that render the transitions are a later slice (5b); readiness config as an interface-type default is a later interface-type concern.
ADR-0039: An interface is a device API; the interface type is its transport, not its driver
Section titled “ADR-0039: An interface is a device API; the interface type is its transport, not its driver”- Date: 2026-07-08 | Status: Accepted | Pages: collection, nodes
- Decision: An
interfaceis an API endpoint we intend to call on a component, identified by the protocol it speaks (web,qrc,ttp,snmp), not a network interface; a host or IP is a variable it consumes, not its identity. It is named by that protocol and is unique within its component (unique(component, name)), never a hand-typed label. Two axes are decoupled: the transport (how bytes move) and the driver (the protocol handler that produces the normalized functions and datapoints).interface_typeis the transport (ssh,tcp,http,snmp,udp,telnet,icmp): a node-side wire capability that also carries the default reachability probe (tcp/ssh/http open the port, icmp pings). Reachability is the first gate of a ladder (reach to auth to responds to collecting) and needs only the transport. A driver is the collect layer: a protocol handler plus the transport(s) it can run over plus the normalized catalog (functions and datapoints, how to fetch them as commands/OIDs/paths, parse, a version). The same handler can run over several transports (a CLI oversshortelnet), so the driver declares its transports and the instance picks one; a genuinely different grammar over a different transport (an ssh CLI vs a tcp JSON-RPC) is a different driver producing the same catalog. Device-specific fetch detail lives in the driver, never the template:snmpis the transport, abiamp-snmp(orgeneric-snmp) driver holds the OID map. The entities then split on CAN / SHOULD / IS: the driver owns what a device family CAN do and how (transports, catalog, normalization, discovery rules, version); a template (per model) owns what an operator SHOULD watch and how it looks (curate the driver’s menu to a default subset, thresholds and event rules, an icon); theinterfaceinstance owns what IS actually there (transport, host, credentials, a driver when it collects, the discovered subset, per-device overrides). Discovery is a driver rule whose result lands on the instance; filtering-for-choice is a template default plus an instance override; capability is the driver. The reusable driver is data on one generic engine (a declarativecanonical datapoint <- fetch <- parse), official or org-custom via the(namespace, id)shadow registry, with a pluggable-Go escape hatch only for a wire the engine cannot express; a “device pack” bundles a driver plus a template, and a template declares its driver deps (version-pinned) so a missing or shadowed driver surfaces, never silently misbinds. The house<entity>/<entity>_typepattern holds:interface_typeis the transport (a reachability interface’s type genuinely is its transport), anddriverearns its own registry (SNMP and multi-transport protocols prove it folds into neither transport nor template). - Context: The 5a build named interfaces by a hand-typed string (
boardroom-tcp) withtype= the probe (tcp/icmp), which conflated identity with transport and implied operators name and wire-configure devices by hand. The reframe: operators are not programmers, so the value is a driver that normalizes a device family into a pick-from menu, which makes the template a light curation, policy, and presentation layer and means the operator never authors a protocol. You cannot cleanly split “how you talk” from “what you say” (the command is both), so the seam is elsewhere: the transport is the reusable connection, the driver is the reusable normalized menu over it, and the template is a selection plus policy. Keeping the driver as data (not Go per family) is what makes it community-shippable; growing the canonical menu device-by-device (not a universal ontology up front) is what keeps it honest; separating menu-of-types from discovered-instances is what fits programmable devices (a DSP’s blocks are per-install); versioning the driver is what lets a template’s picks resolve as the menu matures. - Scope now (tier-0): this slice (#114) ships only the first gate.
interface_typeis the transport primitive (icmp,tcp,ssh,httpseededbuilt), each carrying a tcp-connect or ping reachability probe; aninterfaceis named by its protocol and typed by its transport; the dev seed models a lab polaris DSP with aweb(http) and aqrc(tcp) interface, the “two APIs on one device” story. The driver catalog, normalization, discovery, templates, versioning, and the shadow-resolved device pack are later slices of the collection epic (slices 2 to 4 realize this model). - Refines: ADR-0038 (the reachability verdict is the first rung of the gate ladder this ADR names).
- Status note (2026-07-08): the
interface = API/interface_type = transporthalf is built and stable (this slice). The driver / collect layer (the separatedriverentity, the normalized menu, and the driver-centric split itself) is under active design: it departs from the original template-centric architecture (where protocol handling lived in the template), which is a serious enough change to redesign deliberately rather than on momentum. Recorded here as the current-best direction, not a locked gate; driver-centric vs template-centric is re-examined, and this ADR revised or superseded, in a later ADR before the collect layer is built.
ADR-0040: The task is derived read-only plumbing, projected from its interface
Section titled “ADR-0040: The task is derived read-only plumbing, projected from its interface”- Date: 2026-07-14 | Status: Accepted | Pages: collection, api
- Decision: The
interfaceis the only authored collection primitive; thetaskis derived. Creating an interface derives its one poll task, so the task surface is read-only (GET /tasks,GET /tasks/{id}only): thePOST/PATCH/DELETE /tasksroutes and thetask:create/task:updategrants are removed. A task carries no node column;task.node_nameis dropped and its placement is projected frominterface.node_name, so the worklist and the telemetry owner-confinement join the interface rather than reading a task-local node. A node purge cascades its interfaces and their derived tasks (interface.node_nameandtask.interface_idareON DELETE CASCADE). - Context: The checkpoint-5d build gave both primitives a full CRUD surface and a node placement of their own. That let an operator author a task divorced from its interface, and left a task’s node and its interface’s node as two independently-set fields that could disagree. The reframe makes the interface the one thing an operator authors (an API on a component, ADR-0039): a reachability check is an interface, its poll task is the plumbing that runs it, and placement is a property of where the interface is reached from, stated once. This is the honest shape for the reach tier; the richer driver-authored collection surface (multiple functions over one interface) is a later slice and does not reintroduce operator task CRUD.
- Refines: ADR-0039 (the interface is the authored API; this ADR settles that its task is derived, not co-authored).
ADR-0041: settings are a reflected typed struct with generated client and server validation
Section titled “ADR-0041: settings are a reflected typed struct with generated client and server validation”- Date: 2026-07-19 | Status: Accepted | Pages: settings
- Decision: A setting is declared once, as a tagged field on a canonical
SettingsGo struct (internal/settings/schema.go), and that single declaration is the whole source of truth. Reflection over the struct produces thecodedefaults layer (Defaults(), from each leaf’sdefault:tag) and the namespace registry (Namespaces(), from thejsonandsettings:tags), replacing the hand-kept embeddeddefaults.yamland the hand-keptNamespaces()slice (both retired). Huma reflects the struct into the OpenAPI schema, somake genyields the typed SPA clientvalues(aSettingsstruct, not a free-form object). Writes validate against that same reflected schema on both sides: the server backstopsPATCH /settings/{namespace}(unknown namespace to 404, unknown key / wrong type /enumorpatternviolation to a 422 naming thenamespace.key,nullallowed as a delete), and amake genstep slices the field constraints out ofapi/openapi.jsoninto a committed client artifact (web/src/api/settings.schema.gen.ts) that drives inline form validation (enum-as-select, Save blocked while invalid). The cascade merges partial generic maps as before; typing lives only at the edges (the effective read unmarshals intoSettings, and Go code reads a setting through theEffectiveTypedaccessor). - Context: Slice-0 shipped the engine with untyped values: a setting lived in two hand-kept places (the
Namespaces()slice and the embeddeddefaults.yaml), the API exposedvaluesas a free-form object, the generated client typed it asRecord<string, unknown>, and the PATCH write accepted any namespace, key, or value and stored it as-is (the documented write-validation thin cut). That is the one surface that dodged doctrine 1 (API-first, typed, generated). MakingSettingsa reflected struct pulls the default, the schema, the typed client, both validators, and the typed accessor from a single declaration, so adding a setting is one tagged field and there is no second place to drift. The cascade keeps merging partial maps because a Go struct cannot express “unset” versus a zero value; typing is applied only at the edges. This closes the slice-0 write-validation thin cut and retires thedefaults.yamlasset and theNamespaces()list. - Deferred: the declarative operator-file machinery (a generated JSONSchema for the operator
settings.json, validation of the file layer at boot, and letting the file layer take precedence over the database, the GitOps-wins / read-only lever) is a future slice on the same epic, as are operator-open namespaces (a typed map with aDefault()method) and the group and user cascade rungs; none is built here. - Closes: issue #288 (settings engine slice-1), under epic #270.
ADR-0042: Field cascade and the type-default floor
Section titled “ADR-0042: Field cascade and the type-default floor”- Date: 2026-07-19 | Status: Accepted | Pages: config, secrets, and variables
- Decision: A field’s resolved value is deepest-set-wins down the field arc
product -> location -> system -> component: a value set at any scope beats every broader scope. When nothing is set at any scope, the value falls to the field’s type default. The type default is therefore the floor of the cascade, not a competitor to it: a value set at any higher scope always beats the default, and no cascade rule is bent to make that true. Raised during design as “does a value set higher in the cascade beat the type default?”, the answer is yes, and it costs the model nothing, because the default is simply the bottom rung. - Context: The override-rendering slice needed the resolution rule pinned before the renderer could say
what “inherited” means. Modelling the type default as a competing scope would force an ordering
question at every read (does a
locationvalue or the type default win?); modelling it as the floor removes the question: any set value at any scope wins, and the default is what remains when the arc is empty. This slice is component-only: resolved = this component’s set value, else the type default, so the deeper arc (product,location,system) is drawn in the model but not yet walked. The multi-scope cascade itself lands later. - Closes the gap: the multi-scope cascade is tracked by #291; this ADR settles only the resolution rule and the type-default floor.
- Amended by ADR-0057: the resolution outcome is unchanged (any value set at any scope beats the default, and the default is what remains when the arc is empty), but the default is off the axis, a column on the definition row, rather than the cascade’s bottom rung. The vocabulary moved; the rule did not.
ADR-0043: The property catalog
Section titled “ADR-0043: The property catalog”- Date: 2026-07-19 | Status: Accepted | Pages: config, secrets, and variables
- Decision: The
datapoint_typecatalog is generalized into a primitive-agnosticpropertycatalog: one typed catalog whose entry (a property) is a canonical, typed name that a datapoint observes and a field declares, identified by akey(its canonical name). The physical table isproperty; the concept, the API resource (/properties), the GoPropertytype, and the console all readproperty, while a property’s identifier (itskey, and thefield_definition.keyreference) stayskey. Four shape changes folddatapoint_typein: the unused(scope, name)ladder (org/templatenever had an operator write path,template_idwas a dangling column) collapses to anameprimary key plus anofficialboolean (seed-owned rows are read-only);value_typebecomesdata_typeover the unified set{string, int, float, bool, json}(textbackfills tostring,boolis added);kind(metric/state/log) becomes nullable, since a declared-only attribute property has no observed kind; andvalidationis a JSON Schema fragment (pattern/enum/minimum/maximumand, for a json-typed property, a nested object schema), enforced by Huma’s own validator with the stored schema loaded throughyaml.v3, so there is no new dependency. Value and source tables continue to key by the name string (no foreign key, reject-not-project at ingest exactly as before), so the rename is behavior-preserving: the collection registry, the reachability BFF, and the metric/state sinks keep working unchanged. - Context: Datapoints already had a typed canonical-key catalog (
datapoint_type: name, value_type, display_name, unit, validation, kind), while fields, variables, secrets, and tags had no catalog at all, an operator typed a key and it was registered nowhere. Rather than build a parallel table, this slice makes the one catalog primitive-agnostic soserial_numberis the same concept whether a device reports it (observed, a datapoint) or an operator types it (declared, a field). Theofficialboolean chassis is chosen over finishingdatapoint_type’s never-built scope-shadow precedence: it is the proven, finished model the*_typeregistries already use. - Values reference the key, not a
<primitive>_keylayer: the only binding is the type-schema (field_definitiongaining akeyreference so a field draws from the catalog), which is PR-B, not this slice. Provenance rides the value (an observed metric versus a declared field value share the key), so reconciliation of declared-versus-observed sources needs no middle table; it is deferred. - Deferred: the
field_definition.keyreference (PR-B); the type-schema editor (how acomponent_typeselects properties); reconciliation (the declared-versus-observed drift signal); a console editor for the validation JSON Schema (set via the API for now); variables/secrets/commands/tags adopting akeyreference; and an operator shadow of an official property. - Closes: issue #297 (the field catalog, expanded to the property catalog), under epic #266.
ADR-0044: The component classification catalogs
Section titled “ADR-0044: The component classification catalogs”- Date: 2026-07-20 | Status: Accepted | Pages: core entities
- Decision: The
component_makecatalog is generalized into avendorcatalog carrying akind(manufacturer/integrator/developer), so the one organization that makes, integrates, or writes for a component is a single reference entity rather than a make-only registry. Two new leaf catalogs join it as the rest of the component-classification reference data: adriver(id,display_name,version, the software that speaks to a component) and acapability(id,display_name, a thing a component can do). Each of the three is a gated CRUD Catalog console page (/vendors,/drivers,/capabilities), reusing theofficial-boolean chassis the type and property registries already use: seed-owned official rows are read-only (an official row refuses update and delete, 422), a custom row is full CRUD gated by the resource’s<resource>:create/:update/:deletepermission and audited in the same transaction. The official rows are seeded at boot. This is a pure classification slice:product(the specific model an organization sells), theproduct_capabilitylink, and thecomponent.productpointer that binds a component to its product are the next slice, not this one. - Context: The estate model is shifting from the make/model catalog sketch toward a fuller classification
vocabulary: property / event / command on the signal side (property landed in
ADR-0043) and vendor / product / driver / capability / standard / role /
health on the component side. This is PR2 of that shift.
component_makewas manufacturer-only, but the same organization concept covers an integrator who installs the estate and a developer who writes a component’s software, so generalizing make into akind-tagged vendor is the honest widening rather than three parallel organization registries. Driver and capability are leaf catalogs (no tree, no cross-references yet), so they ship as the plain seeded-plus-CRUD pattern the registries already prove; they gain their bindings (a driver to an interface / product, a capability to a product) when the product slice gives them something to reference. - Deferred:
product,product_capability, andcomponent.product(the next slice); a referential delete guard on vendor / driver / capability (nothing references them yet, exactly ascomponent_makeshipped with no guard untilcomponent_modelwas to land); and an operator shadow of an official row. - Refines: ADR-0031
(the
component_makeregistry is renamed and generalized tovendorwith akind; itsofficial-boolean, deferred-delete-guard, and website-scheme-validation calls carry over unchanged).
ADR-0045: The product catalog
Section titled “ADR-0045: The product catalog”- Date: 2026-07-20 | Status: Accepted | Pages: core entities, Products guide
- Decision:
productis a first-class catalog entity, the concrete SKU (a Cisco Room Bar, a Samsung QM55) that ties the ADR-0044 leaf catalogs together. A product carries a stableidanddisplay_name, akindfrom a fixed enum (device/app/service/vm, defaultdevice, enforced by a DB CHECK and at the API edge), an optionalvendor_id(who makes it) anddriver_id(what talks to it), an optionalparent_product_id(a self-reference: a variant points at its base product), and theofficialboolean the type and classification registries already use. The capabilities a product provides are a many-to-many set in theproduct_capabilityjoin (a video bar provides microphone, speaker, camera, codec); setting capabilities on an update replaces the whole set. It is a gated CRUD Catalog console page (/products) on the same chassis as the leaf catalogs: seed-owned official rows read-only (update and delete 422), custom rows full CRUD gated byproduct:create/:update/:deleteand audited in the same transaction, official rows seeded at boot. Crucially,component.product_id(on delete restrict) now points a component at the product it is: the product is the source of a component’s shape (its vendor, driver, and capability set), replacing thecomponent_type-as-shape notion. The restrict FK is the referential guard the leaf catalogs deferred: a product still referenced by a component cannot be deleted (409). The vendor, driver, and parent FKs areon delete set nullinstead (deleting a vendor nulls a product’s pointer, it does not block). - Context: The estate model is shifting toward property / event / command on the signal side and vendor /
product / driver / capability / standard / role / health on the component side.
ADR-0044 landed vendor, driver, and capability as leaf
catalogs with nothing to reference them; product is PR3, the layer they were built for and the first
consumer of all three. A component’s shape used to be a job for a
component_typegenus; binding a component to a product instead makes the shape data-driven from the SKU (the same product supplies the same vendor, driver, and capabilities to every component that is one), which is whycomponent.productis arestrictFK, notset null: a product in use is load-bearing for its components. - Deferred: a product’s own template or field-schema binding; and the remaining component-side catalogs (standard, role, health).
- Supersedes: the
component_type-as-shape notion (a component’s shape now comes from itsproduct, not its genus type). Consumes ADR-0044 (product is theproductlayer that ADR deferred; it references vendor, driver, and capability). One divergence from the leaf catalogs’ prediction: their deferred delete guard lands only as thecomponent.productrestrict (409), while a product’s own vendor / driver references null out (on delete set null) rather than blocking the referenced row’s delete.
ADR-0046: The event log-kind sink
Section titled “ADR-0046: The event log-kind sink”- Date: 2026-07-20 | Status: Accepted; superseded in part by ADR-0066 (the log-to-event ingest promotion and the seeded
log.linetype were removed) | Pages: core entities, datapoints, data collection, API, Nodes and reachability guide - Decision: A collected log-kind observation now has a durable home. A new
eventtable is the log-kind sink of the collection pipeline, the counterpart ofmetric_datapoint/state_datapoint: where a datapoint records a sampled present value, aneventrecords a past occurrence (a device log line, a structured frame). It carries the same datapoint owner exclusive-arc (owner_kindpluscomponent_id/system_id/location_id/node_id, one-set CHECK) and the same provenance vocabulary (observed/calculated/intended/declared, defaultobserved) as the datapoint sinks, plus amessage(text) and structuredattributes(jsonb). The ingest consumer’sderiveDatapointsnow returns metrics, states, and events, and the persistence path callsInsertEvents: a log-kind datapoint that used to be dropped at ingest (it had no sink) is routed toevent, ridingstring_value(its message) orjson_value(its attributes), under the same owner-confinement and reject-not-project gates as the metric and state sinks. A boot-seed propertylog.line(kindlog) is the canonical log-kind starter. The reservedevent_idcolumns onmetric_datapointandstate_datapointare closed into real foreign keys toevent(id)(on delete set null), so an intended-provenance datapoint references theeventthat produced it. Storage addsInsertEvents(batch, in-tx, provenanceobserved) andListComponentEvents(name, since, limit)(newest first); the read routeGET /components/{name}/events(operationIdlist-component-events, gatedcomponent:read, non-disclosing 404 out of scope) returns the last 24 hours, capped at 200, and is the log-kind mirror of the reachability read. The console component detail page gains an Events panel over it. - Context: The estate model is shifting toward property / event / command on the signal side; property
landed in ADR-0043. Log was already a first-class datapoint kind in the
registry, but the ingest consumer had no sink for it: a log-kind datapoint was silently dropped after the
metric/state route split (ADR-0038), a checkpoint gap
rather than a design choice. This is the P1 follow-up of the estate-model roadmap: give the log kind a
durable home so the third sink flows like the other two, and close the
event_idstubs the datapoint tables reserved for exactly this. Reusing the datapoint owner-arc and provenance (not a bespoke shape) keeps a log occurrence owned, addressed, and traced identically to the values beside it, the primitive-first move. - Deferred: the
datapoint->samplerename (a naming cleanup that lands in a later slice, so the datapoint tables keep their current names here);property_valueand the materialized current-value store (the fold-fields slice); the normalized event_type registry and the promotion of a raweventoccurrence into a registered event (events); a scope-wideeventread (this ships the per-component read only); and anycalculated/intended/declaredevent producer (the write path isobservedcollection only). - Supersedes: the checkpoint behavior where a log-kind datapoint had no sink and was dropped at ingest
(recorded in ADR-0038); the log kind now persists to
event. Divergence from datapoints: that page’s present-tense design routes the log kind to alog_datapointtable and treatseventas a strictly normalized,event_type-registered occurrence promoted from a raw line. The built log sink is theeventtable directly (the raw occurrence lands there, not in a separatelog_datapointtable), and thelog_datapointtable plus the promotion ladder stayDesign; the pages carry an inline note pointing here until the two models are reconciled in the fold-fields / rename cleanup.
ADR-0047: The fields fold: product_property and property_value
Section titled “ADR-0047: The fields fold: product_property and property_value”- Date: 2026-07-21 | Status: Accepted; superseded in part by
ADR-0085 (the
component_typeregistry returns, reshaped above the product) | Pages: core entities, config, secrets, and variables, API, Properties guide, Products guide - Decision: The standalone fields feature is retired and folded into the estate model, because a
field was never a primitive: it was a property with
declaredprovenance, and the same is true of config (a property withintendedprovenance). Two tables replace it.product_propertyis the product’s declared-property contract:(product_id -> product, property_name -> property, default_value jsonb, required bool), unique per(product, property), so what a product’s instances expose is data on the SKU rather than a catalog hung off a genus type.data_typeandvalidationare not duplicated here; they stay in thepropertycatalog, the single source.property_valueis the value store: it carries the same owner exclusive-arc asmetric_datapointandevent(owner_kindpluscomponent_id/system_id/location_id/node_id, one-set CHECK), plusproperty_name, aninstancediscriminator, aprovenance(observed/calculated/intended/declared, defaultdeclared), and a jsonbvalue. Its series key isunique nulls not distinct, since the arc leaves three owner columns NULL and Postgres’s default NULLS DISTINCT would let duplicate rows through. This slice writes onlyowner_kind=componentwithprovenance=declared; the rest of the arc and the other three provenances are the seats later producers sit in. The resolver isEffectiveProperties(component, scope), one SQL UNION of two arms: the contract arm (everyproduct_propertyof the component’s product, value =coalesce(the component's declared value, the contract default),from_contract=true) and the ad-hoc arm (declared values the contract does not declare,from_contract=false), so a productless component still resolves, to its ad-hoc set alone. Six routes carry it:GET /products/{id}/propertiesandPUT/DELETE /products/{id}/properties/{property}(gatedproduct:read/:update/:delete, an official product read-only 422), andGET /components/{name}/propertiesplusPUT/DELETE /components/{name}/properties/{property}(gatedcomponent:read/:update, ABAC-scoped with a non-disclosing 404 out of scope, audited). The console renames the operator word from Fields to Properties: a Properties panel on the component detail (contract rows, plus a dashed-bordered off contract group for the ad-hoc ones, an override toggle with an accent dot, a required property blocking Save) and a Declared properties contract editor on the product detail (declare, edit, withdraw, read-only for an official product). Retired with the feature:field_value,field_definition,component.component_type, and thecomponent_typetable itself with its routes (/types/component), its console registry section, and its seed. A component’s shape now comes from its product (ADR-0045), still optional: a productless component simply has no contract, and the categorycomponent_typeused to carry (display, codec) is expressed by the capabilities that product provides. The seeded products ship a starter contract (cisco-room-barandsamsung-qm55declareserial_number,firmware_version, andmodel_numberwith defaults), androles.yamldrops the now-unclaimedfield:*permissions, sinceproperty:*already covers the tier. - Context: ADR-0043 made the catalog primitive-agnostic and deferred the
one binding it needed,
field_definition.key, so a field could draw its type from the catalog. Building that binding forced the realization that the binding was the wrong shape: once a field’s name, type, and validation all come from a property, a “field” is a property the operator declares rather than the device observes, and the only thing left that was field-specific was where the schema hangs. The answer was already on the table: ADR-0045 made the product the source of a component’s shape, so the per-type field catalog becomes the per-product contract over the property catalog, and the field value becomes an arc-owned, provenance-tagged property value beside the samples and occurrences it sits next to. Folding is primitive-first: one value store the cascade, reconciliation, and the current-value read can all be built on, rather than three parallel ones (field_valuefor declared, the datapoint tables for observed, an unbuiltconfigtable for intended). - Deferred:
standard_propertyandlocation_type_property(the other contract owners, each waiting on its owner entity,standardand alocation_typeschema); the driver access/mode column onproduct_property(whether a driver can get, set, or only declare a property, which lands with the driver slice); the non-declared provenance producers (intendedconfig writing a desired value,observedmaterializing a current value out of the datapoint stream,calculatedfrom a rule), which the provenance column seats but nothing writes yet; the multi-owner arc onproperty_value(only the component arm is written and scope-injected today); and thedatapoint->samplerename, still a later cleanup. - Supersedes: ADR-0043’s deferred
field_definition.keyproperty binding. This is that binding, done differently: rather than a field definition gaining a key reference, the field catalog itself became the product contract over the property catalog, andfield_definitionretires. Also completes ADR-0045’s partial supersession of thecomponent_type-as-shape notion: that ADR repointed shape at the product but left the table standing; this one dropscomponent.component_typeand thecomponent_typeregistry outright. - Tracked under epic #266. This is PR5 of the estate-model shift toward property / event / command plus vendor / product / driver / capability / standard / role / health.
- Amended by ADR-0057:
the contract default is unchanged, its vocabulary is.
coalesce(the instance's set value, the contract default)is the fall-through to a declaration, not the bottom rung of a cascade, soproduct_property.default_value(and its two siblings) is the shipped instance of the off-axis default rather than a tier underplatform. - Partially reversed by ADR-0085:
this ADR’s retirement of
component.component_typeand thecomponent_typeregistry stands; naming and rendering (a generated component name needs a device-class stem the product’s SKU cannot supply) forced the registry’s return, deliberately reshaped: above the product (product.component_type_id), not beside the component and not a second classifier the component itself carries. What this ADR actually decided aboutproduct_propertyandproperty_valueis untouched; only the “and the wholecomponent_typeregistry retire” clause is reversed.
ADR-0048: The standard blueprint and the template-fork seed model
Section titled “ADR-0048: The standard blueprint and the template-fork seed model”-
Date: 2026-07-21 | Status: Accepted | Pages: core entities, API, identity and access, storage, Standards guide, Types guide, Properties guide
-
Decision: Three moves land together, because each one only makes sense with the others.
1.
system_typeis promoted tostandard. A standard is the blueprint a system conforms to (huddle room, classroom, auditorium): the system-side counterpart ofproduct, not a label hung off a system. The table is renamed and gainsparent_standard_id(a variant points at its base, mirroringproduct.parent_product_id) and a declared-property contract.system.system_typebecomessystem.standard_idand is now optional, exactly likecomponent.product_id: a one-off system that conforms to no standard is first-class and carries only its own ad-hoc values. The seeded rows carry over unchanged. Because a standard now owns a contract, it leaves the sharedtype:*registry permission and takes its ownstandard:read/:create/:update/:deleteCatalog resource (read on the viewer*:readfloor, the writes at the admin tier, exactly likeproduct:*), and its routes move from/types/systemto/standards.2. Two more contract tables, and one owner-generic resolver.
standard_propertyandlocation_type_propertyjoinproduct_propertyon the identical shape (<classifier>_id,property_name, an optionaldefault_value, arequiredflag, unique per pair).data_typeandvalidationare never duplicated onto a contract; they stay in thepropertycatalog. The resolver then generalizes:EffectiveProperties(ctx, ownerKind, ownerID, read)resolves component, system, location, and node off one parameterized SQL template driven by anownerContracttable (instance table, classifier column, contract table, contract key, arc column). Component reads its contract throughcomponent.product_id, system throughsystem.standard_id, location throughlocation.location_type; a node has no classifier, so it resolves ad-hoc values only. The query shape is unchanged from ADR-0047: a contract arm (coalesce(the instance's value, the contract default),from_contract=true) UNION an ad-hoc arm. This is the primitive-first move: three classifier/instance pairs, one resolver, so they cannot drift. Alongside it,guardOwnerScopenow scope-checks every owner arc on a value write (it previously returned nil for everything but the component arc), so an out-of-scope system or location is a non-disclosing 404 on the write path as well as the read.3. The seed model: templates live in code, not the database. A standard (and a location type) is created by forking an in-code template. The fork is one-time, with no inheritance, so nothing in any tenant ever points back at a template and templates can be improved in any release. That dissolves the shipped-defaults-versus-local-edits problem at the root: the thing the vendor updates (the template) and the thing the operator owns (the row) are never the same object. Four consequences follow.
- Forking applies to template -> standard, not standard -> system. A system does not fork its standard, it conforms to it, with live inheritance: the standard’s contract default resolves for every conforming system until that system overrides it, and revising the default moves every system that has not.
- Therefore a shipped standard or location type is operator-owned, not official. Both seed with
official: falsethrough seed-if-absent paths (SeedStandard/SeedLocationType,ON CONFLICT DO NOTHING), never the authoritativeUpsert*. They are freely editable and deletable from the moment they land. - An authoritative upsert here would be a bug, not a policy.
ON CONFLICT DO UPDATEwould silently revert an operator’s edit on the next boot, which is the exact failure this model avoids. A regression test edits a seeded standard, re-runs the seed, and asserts the edit survived. - The canonical catalogs are the exception and keep the authoritative upsert with
official: true:property(and latercommandandevent_type) is the shared vocabulary a driver maps onto, so a release must be able to correct it. The classification catalogs (vendor,driver,capability,product,interface_type,secret_type) androlestay on that same authoritative path for now.
Four route groups carry the contracts and the values, all regenerated into the OpenAPI document, the cobra CLI, and the typed client:
GET /standards/{id}/propertiesplusPUT/DELETE /standards/{id}/properties/{property}(gatedstandard:read/:update/:delete);GET /location-types/{id}/propertiesplusPUT/DELETE .../{property}(gatedtype:*, since the location type registry is still atyperegistry); and the value sidesGET /systems/{name}/propertiesplusPUT/DELETE .../{property}(gatedsystem:read/:update) and the same for/locations/{name}/properties(gatedlocation:read/:update). The value routes are scope-injected, so an out-of-scope system or location is a non-disclosing 404. -
Context: ADR-0047 deferred
standard_propertyandlocation_type_propertybecause neither owner was ready:system_typewas a bare label registry with no contract to hang anything on. Building that contract forced the promotion, since a registry that declares what its instances expose is a blueprint, and a blueprint is the system-sideproduct. Makingstandard_idoptional followed immediately: a productless component was already first-class, and a system that matches no blueprint has the same claim. The seed question surfaced during the build and is the harder half. Shipping a room standard as an authoritativeofficialrow would make it read-only (an operator could not tune “Huddle Room” to their own estate) and would revert local edits on every boot if it were writable. Both failures come from one mistake: treating example content and canonical vocabulary as the same kind of thing. Splitting them (a template in code that is forked once, versus a catalog row that is upserted authoritatively) lets the release improve its examples forever without ever touching an estate’s data, and keeps the one thing that genuinely must stay identical install to install, the property vocabulary, under release control. -
Deferred: the in-code template mechanism itself and its create-from-template console affordance (#317); this slice ships the seed-if-absent behavior and the operator-owned rows that the mechanism will produce, with the shipped starter set still declared as seed YAML. The official / community / private catalog tiering for
product/driver/property/event_typeplus a disable flag (#318). Also still deferred: a standard’s role set and health composition, the non-declaredprovenance producers, the cross-owner cascade overproperty_value, and thedatapoint->samplerename. -
Supersedes: the
system_type-as-label notion (a system’s blueprint is a first-class Catalog entity with its own contract and its own permission, and it is optional), completing on the system side what ADR-0045 and ADR-0047 did on the component side. Also supersedes the assumption running through ADR-0044, ADR-0045, and ADR-0047 that everything the seed ships isofficialand read-only. That now holds only for the canonical catalogs: the shipped standards and the four shipped location types (campus/building/floor/room) areofficial: falseand fully editable, so any prose promising a read-only seed-owned row for those two registries is stale. -
Tracked under epic #266. This is PR6 of the estate-model shift toward property / event / command plus vendor / product / driver / capability / standard / role / health.
ADR-0049: The system role: capability-gated staffing and the resolved capability set
Section titled “ADR-0049: The system role: capability-gated staffing and the resolved capability set”-
Date: 2026-07-21 | Status: Superseded by ADR-0087 | Pages: core entities, API, glossary, templates, health, Standards guide, Capabilities guide, Work with an entity
-
Decision: A system says what it needs filled, and the platform refuses a component that cannot fill it. Four tables and two resolvers carry that.
1. A
system_roleis a slot, declared on the arc. A role (a table microphone, a main display) is declared either on astandard, where every conforming system inherits it live, or directly on onesystem(ad-hoc, which is how a one-off system gets roles at all). The two owners ride the same exclusive-arc patternproperty_valueuses: anowner_kindplusstandard_id/system_id, a one-set CHECK, and aunique nulls not distinctkey over the arc columns and the role name (the default NULLS DISTINCT would let duplicates through the NULL arm). A role carries aquorum: how many components should fill it, at least one, because a role no component need fill is not a role.2.
role_capabilityis conjunctive. A role requires a set ofcapabilityrows, and a component must provide every one of them. Requiring nothing admits anything, which is the honest reading of an empty requirement, not a special case.3. A component’s capabilities become a resolved set.
component_capability(component_id,capability_id,present) is the component’s own capability facts, layered over its product’s:present=trueadds one the product does not claim,present=falsesuppresses one it does.EffectiveCapabilities(component)is then the product’s set UNION the additions MINUS the suppressions, and a productless component resolves to just its own declarations. This is the single definition of “what this component can do” for the whole platform, and it is the set the guard checks.4.
EffectiveRoles(system)merges both arms. The roles the system’s standard declares (markedfrom_standard) UNION those declared directly on it, each with its required capabilities, its quorum, and the components filling it here. A one-off system has only the ad-hoc arm. The resolver servesAssigned()andUnderstaffed()(quorum minus assignments, floored at zero) rather than leaving arithmetic to each surface, so staffing reads the same way everywhere.5. The guard refuses, and the refusal names the gap.
AssignRoleis a 422 when the component’s resolved capabilities do not cover every capability the role requires, and the message names the missing ones (component "panel-1" cannot fill role "table-mic": missing microphone, speaker), sorted so the same gap always reads the same way. It joins the location placement constraint as a refusal on modeled grounds, and follows the same rule that one set: name the parties. A bare “no” leaves the operator nothing to do, and the whole value of modeling capability is that the refusal is actionable. Assignment is otherwise idempotent, androle_assignment.component_idison delete restrict, so a component staffing a role cannot be deleted out from under the system.Eight routes carry it, regenerated into the OpenAPI document, the cobra CLI, and the typed client:
GET /standards/{id}/rolesplusPUT/DELETE /standards/{id}/roles/{role}(gatedstandard:read/:update/:delete);GET /systems/{name}/roles(the resolved read) plusPUT/DELETE /systems/{name}/roles/{role}andPUT/DELETE /systems/{name}/roles/{role}/assignments/{component}(gatedsystem:read/:update); andGET /components/{name}/capabilitiesplusPUT/DELETE /components/{name}/capabilities/{capability}(gatedcomponent:read/:update). Every system and component route resolves its owner within the caller’s scope first, so an out-of-scope target is a non-disclosing 404. The shippedmeeting-roomstandard declaresroom-mic(microphone + speaker, quorum 2) andmain-display(flat-panel-display, chosen so the shipped Samsung QM55 can actually fill it), seeded if absent on the operator-owned lane, so an operator’s quorum retune survives a re-seed. -
Context: The strict refusal was decided first: a role that names a requirement and then lets anything fill it is decoration. But a component’s capabilities came only from its product, and
productis deliberately optional on a component (ADR-0047), so under a strict guard a productless component could have filled no role at all. Three ways out, and only one of them keeps both halves: make product mandatory (reverses a call made one slice ago for good reasons), soften the guard to a warning (throws away the point of the model), or let a component declare its own capabilities over its product’s. Layering resolves the tension without touching either commitment, and it is not a new shape: it is exactly the contract-plus-override the declared properties already use, where a product declares a default and an instance overrides it, applied to capabilities instead of values. Quorum lands in this slice and impact does not, because staffing is visible without health at all: a role wanting 2 with 1 assigned is under-staffed today, on data the operator entered, with no engine reading anything. -
Deferred:
impact(outage/degraded/none, what an unfilled or failing role does to its system) and the whole SLI rollup, which land in PR8 with the engine that reads them; the console surfaces for both arcs (the standard’s role editor, the system’s roles panel, the component’s capability editor); role-scoped config, a value declared against a role slot and resolving onto whichever component fills it (templates describes it, nothing builds it); a cap at quorum (a role may be over-staffed, and nothing refuses the extra assignment, because “more than enough” is not an error); and thesystem_membercomposition table, which staysDesign. -
Supersedes: the
system_template_memberrole-requirement design on templates. That page still describes a role slot frozen into asystem_template_version, whose requirement is a set of canonical datapoints and commands and whose instance assignment is asystem_memberrow. What shipped puts the slot on the standard / system arc (a standard is the blueprint now, so the role belongs with it), states the requirement as a capability set (a coarser, operator-legible vocabulary that exists as a catalog today, where canonical commands do not), and records the assignment inrole_assignment. Templates and their frozen BOM stayDesign; the two models are reconciled when template pinning is built, and until then the built role model is the one on core entities. Also supersedes the reading, running through ADR-0044 and ADR-0045, that a capability is a product-only fact: a capability is now a fact about a component, which its product supplies a default for. -
Tracked under epic #266. This is PR7 of the estate-model shift toward property / event / command plus vendor / product / driver / capability / standard / role / health.
ADR-0050: Health is a recorded transition, computed from the alarm-capability-role chain
Section titled “ADR-0050: Health is a recorded transition, computed from the alarm-capability-role chain”-
Date: 2026-07-21 | Status: Accepted | Pages: health, core entities, API, glossary, Standards guide, Work with an entity
-
Decision: Health is a verdict on a system or a location, derived from what is wrong with the components staffing it and recorded as a transition at the moment it changes. Five calls carry that.
1. Capability is the routing key, and an alarm is how a component loses one. An
alarmis component-local (component_id, aseverityofinfo/warning/critical, amessage, araised_at, and a nullablecleared_at), andalarm_capabilitynames the capabilities it degrades. Clearing keeps the row, so the record of what was wrong and when survives the fix. The chain from there is one sentence per hop: a component satisfies a role only when it provides every required capability and none of those is currently degraded; a role with fewer satisfying components than its quorum is impaired; an impaired role contributes its declaredimpact(outage/degraded/none, a column onsystem_role, defaulting todegraded); a system takes the worst contribution among its roles, and a location the worst among the systems placed anywhere beneath it. That chain is why a capability is flat and why a role requires a set of them: capability is the only vocabulary shared by the thing that breaks (a component) and the thing that cares (a slot in a room), so it is the only honest place to route through. Impact lives on the role, not on the alarm or the component, because the same broken box matters differently depending on the slot it was filling: a dead confidence monitor is not a dead main display.2. The judgement is a pure package.
internal/healthtakes resolved inputs and returns a verdict, with no database:Component.Satisfies, the quorum boundary, worst-wins at both levels, and the impact mapping are unit tests, not SQL. Two of its defaults are deliberate safety calls in opposite directions. An unrecognized impact readsdegraded, so a bad value can never make an impaired role silently harmless. An unrecognized recorded value readshealthy, so one stray row cannot paint an estate broken. The rule behind both: fail loud about a judgement, fail quiet about a record.3. Health is recorded as a transition-only state, on
state_datapoint. The requirement this whole design serves is an accurate history of the edges: exactly when a system stopped working, answerable weeks later.state_datapointis already that primitive (the ingest path writes a row only when the value differs from the last one stored, andStateTransitionsreads the ordered flips the reachability availability strip draws), so health reuses it rather than adding a history table: the owner arc,provenance='calculated', andsource_rule='health-rollup'(the lineage CHECK requires a non-nullsource_rule). The first value for an owner is always recorded, evenhealthy, so a reader can tell “healthy since we started watching” from “never evaluated”.4. Recompute happens at the writes that can change health, in the same transaction. Every mutation that can move a verdict recomputes the affected chain before it commits: raising or clearing an alarm, assigning or unassigning a component, declaring or withdrawing a role, changing a role’s quorum or impact, changing a component’s capabilities or its product, creating a system, and changing the standard it conforms to or the location it sits in (recomputing both the old and the new location, since the one it left may have just improved). A read never writes. Two alternatives were considered and rejected, and both fail the same requirement. Compute-on-read keeps no history at all, so “when did this break” is unanswerable by construction. Compute-and-write-through-on-read keeps a history that is sampled by whoever opens a page: the edge timestamp becomes the moment somebody looked, not the moment the estate changed, and an estate nobody watched over a weekend has no weekend. A transition is only worth recording if it is recorded where the change happened.
5. A report computes the verdict it serves from the evidence it shows. The health report originally served the last recorded verdict while resolving the contributing roles live, which let a system with nothing recorded yet report
healthybeside an impairedoutagerole: the report contradicted itself. The served verdict is now derived from the same resolved rows the report displays, so the headline and the reason can never disagree. This is not self-healing on read: nothing is written, and the recorded transitions remain the source for history. A missing trigger can therefore cost an edge in the history, but it can never make a report lie about the present.Routes, regenerated into the OpenAPI document, the cobra CLI, and the typed client:
GET/POST /components/{name}/alarmsandDELETE /components/{name}/alarms/{id}(gatedcomponent:read/:update);GET /systems/{name}/healthandGET /locations/{name}/health(gatedsystem:read/location:read, scope-injected, an out-of-scope owner a non-disclosing 404), each returning the verdict, the contributing roles (with the degraded capabilities and the causing alarms) or the systems beneath, and the recorded transitions over the last 30 days. The CLI readsomniglass component alarms|raise-alarm|clear-alarm,omniglass system health, andomniglass location health. The seed adds ahealthstate-kind property (healthy/degraded/outage) so the recorded series is typed like any other. -
Context: The architect’s requirement was stated plainly: “The most important thing about health is that we have a real, accurate history of the edges. We need to know exactly when a system went from healthy to unhealthy, and be able to look back at it weeks later.” Every call above falls out of taking that literally. Once the history has to be accurate, the write side is the only correct place to compute from, and once the carrier has to be edges,
state_datapointis already the right table and a newhealth_historywould have been a second, worse copy of it. Recording an opening verdict at system creation then surfaced a latent bug the rest of the schema had been quietly carrying: every system now had astate_datapointrow from birth, and every rename failed on the owner foreign key, because those FKs address the owner by name and declared noON UPDATE. Migration20260721170000re-adds all fourstate_datapointowner FKs withon update cascade, which is what name-as-address always meant: the history follows the entity rather than pinning its old name. Health did not create that bug, it made it reachable for every system, which is the useful kind of forcing function. -
Deferred: the same FK gap on
metric_datapoint,event,property_value,alarm, and the role tables’ name-addressed columns, tracked in #314. An alarm today is written by an operator or an API caller, not produced by anevent_ruleover datapoints; the rule that opens and clears one automatically is the next tier. Also deferred: system- and location-owned alarms (the alarm arc is component-only today), theunknownverdict with its coverage and staleness reasons, theglobalestate top, the SLI / SLO / SLA family and the KPI set, an alarm’s interaction with operational mode (maintenance suppressing a contribution), and dependency suppression. -
Supersedes: three earlier calls on health. (a) The value vocabulary: ADR-0003 named the healthy state
okover an orderedok < degraded < down; the built domain ishealthy<degraded<outage, keeping that entry’s reasoning (name the verdict, not the ping) and changing only the words, sinceoutagesays what a broken room means to the people in it. (b) Where impact is declared: the design hung an optionalhealthimpact on theevent_rule, so an alarm moved its owner’s health directly. Impact now lives on the role, and an alarm reaches a system only through the capabilities it degrades. An alarm on a component that fills no role moves that component’s own verdict and nothing above it, which is the correct answer and was previously an accident of tagging. (c)health_role: therequired/redundant/informationalmember tag on asystem_template_memberis superseded by quorum plus impact on asystem_role, which expresses the same three cases without a fourth vocabulary (required is quorum 1 with impactoutage, redundant is a quorum below the number assigned, informational is impactnone). It also closes ADR-0049’s deferral ofimpactand its “quorum ships without health” note. -
Tracked under epic #266. This is PR8 of the estate-model shift toward property / event / command plus vendor / product / driver / capability / standard / role / health, and the slice that closes the epic: it is the one that consumes what the previous seven built.
-
Amended by ADR-0087: chain item 1 above (capability as the routing key: an alarm names the capabilities it degrades, a component satisfies a role only when it provides every required one) retires with the whole capability registry (#626). An alarm now impairs its component’s own verdict wholesale, and a role’s occupant satisfies it whenever that verdict is not
outage. Items 2 through 5 (the pure judgement package, the transition-only record onstate_datapoint, recompute-at-the-write, and a report computing what it serves) are unchanged.
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”- Date: 2026-07-21 | Status: Accepted | Pages: core entities
- Context: a component’s relationship to a system was two unrelated facts that could silently
disagree.
component.system_idwas a single pointer, set once at create with no path to change it, which no authorization and no health path ever read;role_assignmentwas many-to-many and carried what the component actually does. Nothing reconciled them, and the console rendered the first under the heading “Components” while the panel directly below listed the second, so a fully staffed system displayed0 components. The contradiction was visible to operators before it was understood by us. - Decision: membership is a first-class binding,
system_member (system_id, component_id, is_primary), and a role attaches to it. Staffing a role creates the membership, because a component filling a job in a system that the system does not count as a member is a contradiction. The reverse is not symmetric: giving up a role leaves the membership, because the device is still in the room, and a member carrying no role (a power conditioner, a spare) is ordinary. - Why membership cannot simply replace the pointer: the cascade seeds its system band from one row
and ranks with
row_number() over (partition by ... order by band desc, depth asc), which has no tiebreaker after depth. A many-valued seed would make an effective tag, variable, or secret resolve nondeterministically for precisely the shared-device case. Membership is therefore many-valued whileis_primarykeeps a single answer for callers with no system in hand. It is a default, not a resolution rule: anything naming a system resolves against that system, and a component’s first membership takes the default with nobody asking, so the single-system case never meets the concept. - Cascade from both ends, and deliberately no restrict on the component. A binding is meaningless once
either side is gone.
role_assignmentkeeps itson delete restrictbecause deleting a component that fills a job would silently break a system’s health; duplicating that restrict on membership would add a step to every component removal while protecting nothing new. - Backfill reads both of the old places. The role table alone drops every component that belonged to a
system without filling a declared role; the pointer alone drops the shared device’s other systems. The old
pointer seeds
is_primary, since answering which system chain feeds a component’s config is exactly what it used to do. A component left with several memberships and no pointer gets no default, because there is no honest way to guess which one was meant. - Thin cut: resolution behaviour does not move in this slice.
component.system_idstays and keeps feeding the four cascade resolvers unchanged, so this ships and is verified on its own. - Supersedes: core-entities’s “a truly shared device skips the system
layer”, which was the best available answer while the only binding was a single pointer. A shared device
is now a member of every system it serves. It also narrows that page’s
system_memberdesign: the shipped row is the binding alone, without the role column or the pin to a frozensystem_template_version, so a member can exist without a role. - Tracked under epic #324, slice #325.
ADR-0052: The cascade resolves through membership, and secrets carry no system band
Section titled “ADR-0052: The cascade resolves through membership, and secrets carry no system band”- Date: 2026-07-21 | Status: Accepted | Pages: cascade
- Context: ADR-0051 made
membership explicit but deliberately left resolution alone: the tag, variable, and secret cascades
still seeded their system band from
component.system_id, the write-once pointer. That left the pointer alive for one reason only, and left the “config differs per system” case unanswerable. - Decision: the system band is seeded from
system_member. Tag resolution takes the system to resolve against, and resolves against it only if the component is a member: naming a system it has no binding to must not lend it configuration. With no system given it falls back to the primary membership, which is the entirety of whatis_primaryis for.GET /components/{name}/effective-tags?system=exposes the first case. - The seed stays single-valued, as a correctness requirement. The rank orders by band then depth with no tiebreaker after that, so two seeds in one band resolve nondeterministically. Membership is many-valued; the chain it feeds is not. This is the same fact that made the pointer worth keeping under ADR-0051 and is now satisfied without it.
- Secrets lose the system band entirely, on ownership rather than determinism: an interface belongs to a component, a shared device has one password, and the room it serves is the wrong owner for a credential. It also removes the one case where an ambiguous inheritance would have been dangerous rather than merely wrong.
component.system_idis dropped. With nothing reading it, the column, its API field, and its console consumers go. The component body now reportssystem(the primary, by name) andsystem_count, which also retires one of the three places the API emitted a raw uuid for a field it accepts by name (#328).- Written test-first because the failure mode is silent. A mis-seeded
sys_chainis still valid SQL that returns fewer rows: a system-owned tag would simply stop reaching its components, with no error and no 500, and the resolution blade would show the location winner as though the system band never had a candidate. - Supersedes cascade’s “the primary-system pointer is the single system chain that feeds the cascade”, which described the mechanism when a pointer was the only binding available.
- Tracked under epic #324, slice #327.
ADR-0053: A name is the address, a uuid is identity
Section titled “ADR-0053: A name is the address, a uuid is identity”- Date: 2026-07-21 | Status: Superseded in part by
ADR-0056. Its API half stands: a reference is
addressed by name. Its schema half (“a new table references an estate entity by
namewithon update cascade”) is reversed, and responses now carry the id beside the name rather than instead of it. - Context: the pattern was real and dominant but never applied to the original entities.
Eleven tables keyed their estate references by
name, six byid, split by age. Worse, the API accepted names on write and returned uuids on read: a component created with{"parent": "rack", "location": "hq-b1"}read back as{"parent_id": "0198f...", "location_id": "0198f..."}. The body did not round-trip, so every client fetched a second collection and joined by uuid to render one label. The console carried exactly that map until it was deleted in #329. - Decision: every request and response addresses another entity by its name. A uuid appears
only as an entity’s own
id. Two exceptions: an entity with no name (an interface, a stored value, an audit row, a grant, a principal) and a slug-keyed catalog, whose id already is a name. A new table references an estate entity bynamewithon update cascade. - Normalized:
parent_idandlocation_idon component and system,parent_idon location, and the redundantowner_idon tag bindings, variables, and secrets, which already carriedowner_namebeside it. Nine fields, seven of them found by survey and two by the guard test, which caught aSecretBody.owner_idthe survey missed. - Enforced by contract, not prose.
TestResponsesAddressEntitiesByNamewalks the generated OpenAPI and fails on any field naming another entity by uuid. The failure it prevents is invisible otherwise: a body emittingparent_idstill serves 200s, and the cost only appears in the clients. - Deliberately not in scope:
secret,variable, andtag_bindingstill key their owner arcs by uuid in the schema, and the cascade resolvers compare those uuids directly. Converting them is a data migration plus a rewrite of resolution SQL reworked in ADR-0052. The API contradiction is what operators saw and is fixable without touching resolution. The rule binds new tables; the stragglers convert when something else needs to touch them. - Breaking. Response shapes change. At v0.0.0 this is the right moment, since the cost only grows.
- Tracked as #334, following #328.
ADR-0054: The shell owns a panel’s action rail; the body registers and never draws
Section titled “ADR-0054: The shell owns a panel’s action rail; the body registers and never draws”- Date: 2026-07-21 | Status: Accepted | Pages: design system
- Decision: A panel’s action buttons are declared as data, not laid out as markup. A blade body
binds
destructive/secondary/primary(plus the Edit -> Save cycle) throughlib/blades; a Drawer form body bindssubmitLabel/submitIcon/submit/busy/disabled/cancelthroughlib/formactions.BladeStackandDrawereach compose their own button vocabulary but draw it through the singlePanelFooterrail. A form body renders no footer markup at all. - Context: The blade already worked this way. The Drawer did not: its rail was an opt-in
DrawerFooterhelper that every form body had to remember to import and wrap its buttons in. Fourteen forms remembered. Two did not, and hand-rolled their own right-aligned row instead, where they survived nine merged PRs unchanged while the helper was copied into six newly added pages around them. The cost was not only the two misses: among the forms that did use it, some rendered a Cancel and some did not, some spun a spinner and some swapped the label to “Creating…”. A rail reached by convention drifts in both directions at once. - Why a slot rather than a lint rule: A lint rule finds the miss after it is written. A slot makes
it unwriteable: there is no exported helper to forget, and a body that wants a button has exactly
one way to ask for one. The enforcement is the deleted export, and
rail-ownership.test.tsis the belt to that braces. - Scope, honestly: This converges two of the three rails. Full-page create forms
(Locations, Systems, Components) still draw an inline
border-t ... pt-4row of their own. That rail is inline in a scrolling page rather than pinned to the viewport, so it is a different layout problem, and it converges when the CRUD form primitive lands and owns both form factors. - Deliberate convergences: submit labels no longer change while in flight (the shell’s spinner says it), and the new-interface blade lost its Cancel button, since a blade already dismisses two ways and no other blade in the stack carries one.
- Tracked under #332.
ADR-0055: The tag, variable, and secret owner arcs key by name
Section titled “ADR-0055: The tag, variable, and secret owner arcs key by name”- Date: 2026-07-21 | Status: Superseded by ADR-0056, which converts these nine columns back to uuids along with every other name-keyed foreign key. Kept in full because the reasoning below is a worked example of the mistake: it is internally consistent, it shipped green, and it is wrong at the premise.
- Context: ADR-0053 fixed what operators saw and deliberately left the schema alone. Three tables still keyed their owner arcs by uuid while every table from the collection era onward keyed by name, so the two conventions met inside single queries: the cascade resolvers walked chains of uuids purely to match these three, and a component’s name had to be carried alongside its id to bridge them.
- Decision: the nine arc columns on
tag_binding,variable, andsecretbecometext references <entity> (name) on update cascade on delete cascade. The columns keep their_idsuffix, matchingrole_assignment.component_idandstate_datapoint.component_id, which are likewise text referencing a name. on update cascadeis the load-bearing clause. A name is only safe as a key if a rename carries.TestOwnerArcsSurviveARenameis the proof and is mutation-checked: with the clause removed the rename is refused outright with a foreign-key violation, so the test cannot pass vacuously. It also could not have failed before this change, since the arcs held uuids that a rename never touches.- The resolvers now project names. Each chain still recurses on
parent_id, which stays a uuid, and only what it projects changed.owner_idin theownersCTE is a name, so the final joins that resolve a display name match onnamerather thanid. - The scope walk still uses ids, resolved from the name at the point of the check. Identity stays internal, and the walk is the only place that needs it, which is the rule working as intended rather than an exception to it.
- Not converted, deliberately:
tag_binding.node_idreferencesnode.principal_id, a node’s enrollment identity and the only handle it has.tag_binding.tag_idis a genuine instance of the same rule (tagis uuid-keyed with a unique name) but is the binding’s subject rather than its owner, and it touches the tag CRUD surface rather than resolution; tracked as #340. Removing it from the guard test’s slug-keyed allow-list, where it had been listed on a false claim that the tag catalog is slug-keyed, is part of this change. - No
migrate:down. Reversing would have to resolve names back to uuids the forward migration no longer records, and any rename since would make that resolution wrong rather than merely absent. - Tracked as #339.
ADR-0056: Every foreign key stores a primary key
Section titled “ADR-0056: Every foreign key stores a primary key”- Date: 2026-07-22 | Status: Accepted; the slug-keyed carve-out below is retired by ADR-0062 | Pages: storage, api-first
- Decision: every foreign key stores the target’s primary key: a uuid for an estate entity,
principal_idfor a node, and the slug itself for a slug-keyed catalog (product,standard,property,interface_type) where the name already is the key. No column references aname. In exchange, the API accepts either form wherever a reference is written (a path segment or a join field in a body), trying the uuid first, and every response carries both: the name an operator reads and the id it resolves to. - Context: this reverses a direction set two ADRs ago. ADR-0053 found eleven tables keyed by name and six by id, and resolved the split by declaring the majority correct. ADR-0055 then converted the six to match, and the follow-on work was converting the rest. The premise was never examined: a friendly, renameable key is valuable because it can change, which is the one thing a foreign key must not do.
- The tell was
on update cascade. ADR-0055 called it “the load-bearing clause” and was pleased that removing it made the test fail. That machinery exists only to fund the wrong choice: it is write amplification across every referencing row, on a rename, to protect a key that did not need to be a name. A uuid arc needs no clause, and the equivalent test passes because there is nothing to rewrite. - A rename was not merely inefficient, it was refused. With
interface.componentreferencingcomponent (name)and noon updateclause, renaming a component that owned any interface failed outright with a foreign-key violation. The name-keyed convention had spread past the point where its own cascade covered it, and the operator-facing symptom was a rename that returned an error. - Scope: all 30 name-keyed foreign keys, converted across five slices grouped by subsystem rather
than by table, so each file changed once: the estate arcs, then health and roles, then the
collection tier (
metric_datapoint,interface,node) and every node reference. - What stays a name. The columns whose target is slug-keyed are already conformant and were not
touched. Health passes names internally on purpose: its advisory lock hashes
health/<kind>/<name>, and a mixed currency would hash two keys for one owner and silently stop serializing. (That sentence stopped being true at #627; see the amendment below. It is left standing because the log is append-only and because its argument is the one that reversed it.) (The slug-keyed targets themselves later took uuid keys too, so those columns moved to the uuid; see ADR-0062.) - Amended (#717): the health carve-out above is no longer true, and what unmade it is the reason it
was a carve-out at all. The lock hashes
health/<kind>/<id>, and has since the identity epic (#627) landed its addressing slice (#647); health resolves a reference to the row’s id once, before any lock is taken. A name-keyed lock partitions an estate only while names partition it, and that epic scoped a location’s name uniqueness to its placement, so two rooms under different buildings may both be415a. One key for two unrelated owners is a silent loss of concurrency; the mixed currency this bullet warned about is a silent loss of the serialization the compare-then-act recompute needs for correctness. Both halves of the argument survive, and only the conclusion moved. The same fact moved the lock ORDER a slice later, because a comparison that leaves two owners tied is not an order at all (#670); the health page has saididsince. This entry was the last page in the corpus describing a keying scheme that would be a live defect if implemented from it, so the key now lives in one named function (healthLockKey) with a unit test asserting that two same-named rooms do not share a lock. - Guarded both ways.
TestResponsesAddressEntitiesByNamefails on a response that names an entity by uuid alone; the per-tier rename tests fail if an arc stops following a rename. Each conversion was mutation-checked rather than trusted: breaking the projection had to turn the suite red. - Tracked as #343.
ADR-0057: The cascade’s least-specific tier is platform, and a default is not a tier
Section titled “ADR-0057: The cascade’s least-specific tier is platform, and a default is not a tier”- Date: 2026-07-21 | Status: Accepted | Pages: cascade, settings, config, secrets, and variables, tags, identity and access, scaling and deployment
- Decision: Six calls, one vocabulary.
globalbecomesplatformas the cascade’s least-specific binding tier, on both axes: the estate arc (owner_kindonvariable,secret, andtag_binding) and the settings level (setting_override.scope). It occupies exactly the rung it occupied before (segment_rank 0). It is a decision like every other rung, what an admin set for the whole install, not a floor beneath the chain.codebecomesdefault, and adefaultis off the axis on both engines. A default is what a value is absent any decision: a column on a definition row, beside the unit, the kind, and the validation rule. It is not a rung, it shadows nothing, and nothing shadows it; the fold falls through to it when no rung bound anything, and the resolve view reports it as a declaration rather than as a winning source. A default is a column on a declaration row, so a kind with no declaration row has no default: a setting has one (its struct tag) and a property has one on its classifier’s contract (product_property.default_valueand itsstandard_property/location_type_propertysiblings, read ascoalesce(the instance's set value, the contract default)byEffectiveProperties, ADR-0047), while a variable, a secret, and a tag have none. Absent means absent. Note the property default sits on the contract, not on thepropertycatalog entry: the catalog declares what a name means, a classifier declares what it is for the things that conform to it. That is a narrower claim than the one this ADR was drafted against, when the precedent was the retiredfield_definition.default_valuehanging off acomponent_type, and it is the stronger one for the rule here, since the coalesce is the fall-through in the code path itself.- There is no root location. The location tree keeps N unparented tops. A tier above today’s tops is a
new
location_typeand a real node, never a magic one, and a top-level location is not a substitute forplatform: binding at one top misses every sibling, and a top added later is silently uncovered. - The install-wide tier survives on the estate axis, uniform across kinds:
platform | location | system | component. - A write at the tier needs
platform:<action>, checked in addition to the resource permission and published per route as anx-omniglass-platform-permissionstamp. This separates full-estate scope from install-wide authority: a senior operator may hold an all-scope grant without being able to change the value that applies to the whole install.platform:*is seeded toadmin(and reachesownerthrough>);operatoranddeployhold noplatformwrite, and nothing implies one. “Nothing implies one” is enforced by puttingplatformin the sensitive-resource set besidesecretandsettings(ADR-0025), so a bare single-token*never names it: a custom role carrying*:updateholds every estate write and still no install-wide authority. Only a literal, aplatform:*, or a>names the tier. rootis not used as a tier name, solocation_type.allowed_parent_typeskeeps its reserved"root"sentinel meaning “top, no parent”, unchanged.
- Context: One word named two unrelated things, in two engines, with three spellings. On the estate axis
globalwas both a tier an operator writes at and, in the prose, a floor where ship-with policy supposedly lived: cascade read “Ship-with default policy lives atglobal, the floor of the chain”, three lines under a heading that says the registry is outside the cascade. That was drift, not design:internal/seed/writes eight YAML files and every one defines a type; none writes a binding, and there has never been a ship-with row at the tier. Meanwhile the settings engine had already split the two ideas and picked different words for them (codefor the declaration,globalfor the install-wide override), so the same distinction existed twice under three names. Separately,globalalso names the singleton estate owner where health and KPIs roll up (a different concept that keeps the name), which made “global” ambiguous in the one place ambiguity is most expensive. Naming the binding tierplatformand the declarationdefaultgives each idea one word, and it drops an assumption the estate never had: that “everything” and “the planet the sites are on” are the same thing. - What does not change: no precedence change (every row keeps its rung under a new name, so no deployment resolves differently), no new rows (the migration renames a value, inserting nothing and adding no column), no reordering of the segment ranks or the comparison key, and no capability removed.
- Breaking change, accepted deliberately:
secretAADbinds a sealed field to its owner arc,ownerKind|ownerID|name|field. A secret sealed at the tier before this rename authenticates againstglobal|global|...; after it, the derivation yieldsplatform|platform|..., the AEAD check fails, and that ciphertext never opens again. Only the renamed tier is affected: a scoped secret carries a real owner id and is untouched. Accepted because no deployment holds tier secrets yet, and each alternative (freezing the AAD at the legacy string, a Go-side re-seal backfill, or a reveal-time fallback) buys compatibility nothing currently needs at the price of a permanent legacy branch. Recorded here rather than discovered later by a reader. - Amends: ADR-0033, ADR-0034, ADR-0035, and ADR-0042, and ADR-0047. Each keeps its model; only the level names and the default’s place in the vocabulary move.
- Closes: issue #316.
ADR-0058: A run mode is a verb under its noun, and no command may be shadowed
Section titled “ADR-0058: A run mode is a verb under its noun, and no command may be shadowed”- Date: 2026-07-22 | Status: Accepted | Pages: CLI guide
- Decision: the edge run mode becomes
omniglass node run, a leaf under the generatednodegroup, rather than a top-levelnode. A guard test walks the assembled command tree and fails on any duplicate name, so the hand-written and generated command sets can no longer collide silently. - Context: the hand-written run mode and the generated API group both registered as
node. Cobra does not treat that as an error: both are added and lookup returns the first, so every generated node command was unreachable.omniglass node listresolved to the daemon and failed asking for--token, while the CLI guide documented it as working. - Why a guard rather than a rename alone. This is the third instance:
membersunder the principal groups (#326),type list(#319), and nownode. Each was found by a person typing it. The two command sets compose on one root, so no single file owns the namespace and no review of either set can catch it. The tree is the only place they meet, so it is the only place the check can live. The guard was written first and found two more nobody had reported:grant createandgrant delete, where the principal-group variants shadow the principal ones, so granting a role to a principal has no CLI path at all (#357). - The known collisions are an explicit list that may only shrink. The guard fails on any name not on it, so a new collision cannot land, and it also fails on an entry that has stopped colliding, so a fix must delete its entry rather than leave that name unwatched. It is a ratchet, not an allow-list.
- Root cause, left for #357:
commandWordsderives the group from a single path segment, so/principals/{id}/grantsand/principal-groups/{id}/grantsboth becomegrant. Fixing that renames documented commands and is a naming decision, not a mechanical one. - Cost accepted:
omniglass nodeis a documented invocation and it changes. At v0.0.0 that is a docs edit, and a mode reads as a verb anyway, besidenode listandnode enroll. - Tracked as #354.
ADR-0059: Every collection segment is a command level
Section titled “ADR-0059: Every collection segment is a command level”- Date: 2026-07-22 | Status: Accepted | Pages: api-first, CLI guide
- Decision: the CLI command path is derived from the whole route: every collection segment
contributes a level and the verb is last, so a subresource is always addressed under the resource
that owns it.
/components/{name}/propertiesiscomponent property list;/principals/{id}/grantsand/principal-groups/{id}/grantsareprincipal grant createandprincipal-group grant create. - Context: the old rule used only the collection nearest the leaf, so it could not tell two
parents apart. Across 195 operations it produced 24 collisions,
property listseven ways. Cobra does not treat a duplicate name as an error: it registers both and returns the first, so the second was unreachable and the only symptom was a command that ran the wrong thing. Granting a role to a principal had no CLI path at all (#357). nameOverridewas the rule, written out by hand fifty times. It had grown to 53 entries, and the comment on nearly every one said the same thing: “the leaf-noun heuristic would collapse both into one group.” Each was added after somebody typed a broken command. It is now 14 entries, all of them the genuinely non-AIP/authfamily, and none of them about a collision.- A name depends only on its own route. This is the property worth having: under a disambiguate-only-when-ambiguous rule, adding a route could rename an existing command. Here it cannot, so the naming is stable as the API grows.
- The grouping had to become a tree. Fixing the derivation alone was not enough: the generator
bucketed commands by their first word and used only the last word as the leaf name, so a
three-word path rendered as two and collided again. It now builds an N-level tree, which is also
what makes
node runandtype secret listrender as written. omniglass statushipped. The depluralizer took-soffstatus. A small irregular set is declared instead; the route vocabulary is ours, so this is a known list, not an English problem.- Cost accepted: 67 of 202 commands are renamed, 135 unchanged. At v0.0.0 that is a docs edit, and the guides are corrected mechanically from the route map in the same change.
- Two guards, because regeneration does not fix prose.
TestNoCommandNameCollisionsfails on any duplicate name (its known-collision list is now empty, and its second half forced those entries out once fixed).TestDocsOnlyNameRealCommandswalks the guides and fails on a documented command that does not resolve; it immediately foundomniglass secret-type list, which had never existed in any build, and two commands with no API route behind them (#359). - Supersedes the naming half of ADR-0058, whose guard this keeps and whose exception list this empties.
- Tracked as #357.
ADR-0060: A resource is one kebab-case noun; nesting means ownership
Section titled “ADR-0060: A resource is one kebab-case noun; nesting means ownership”- Date: 2026-07-22 | Status: Accepted | Pages: api-first, API, types
- Decision: a resource is addressed by one kebab-case noun, and a nested path segment means the
nested thing is owned by it. The
/typesumbrella is retired:GET/POST /location-types,PATCH/DELETE /location-types/{id},GET /secret-types. - Context: the location type registry was addressed two ways. Its CRUD lived under
/types/locationwhile its property contract lived on a flat/location-types/{id}/properties, so one entity had two command groups (type location updateandlocation-type property list) and an operator had to know both./types/secrethad no flat form at all. - The umbrella misused nesting. A nested segment says the child belongs to the parent
(
/principal-groups/{id}/members).locationis not owned bytypes; it is a registry that happens to be one of several. Grouping by category is a documentation concern, not an addressing one. - Two mechanisms, now unambiguous. A hyphen joins a noun that happens to be two words
(
principal-group,location-type,audit-log,effective-tag); a space means the thing beneath it (location-type property,principal-group member). Before this, the same registry used both, which is what made the rule unstateable. - Found by asking what the rule was, not by a failure. The CLI naming fix (ADR-0059) made the two spellings sit next to each other in one command tree, where the contradiction was obvious. The generator was correct throughout; the routes disagreed with themselves.
- Addressing only. Same handlers, same
<resource>:<action>gates, same scope injection, no storage change. Thetypecommand group disappears andnameOverrideneeds no entry for any of it. - Breaking: three route shapes change. At v0.0.0 that is a regeneration plus a docs pass, and
TestDocsOnlyNameRealCommandsfails on any guide left teaching the old names. - Tracked as #361.
ADR-0061: A calculated series is current at its highest id, not its newest timestamp
Section titled “ADR-0061: A calculated series is current at its highest id, not its newest timestamp”- Date: 2026-07-22 | Status: Accepted | Pages: datapoints
- Decision: for a calculated series (health, and anything else the engine derives), the current
value is the row with the highest id.
tsrecords when the value was computed and is for display and history; it does not decide which row is current. For an observed series,tsstill orders, because it is the observation time and a late arrival must not displace a newer reading, butidbreaks a tie. - Context:
recordHealthwritesselect clock_timestamp(), ..., so the timestamp is evaluated in the SELECT list while the id comes from the identity sequence applied when the row is inserted: the clock is read before the id is assigned. Two concurrent inserts can therefore commit withtsinverted relative toid, and a reader ordering bytsthen disagrees with the writer about which row is current. - Production was already right, the test was not. Every production reader of a recorded verdict
(
recordHealth’s own transition check,subtreeSystemHealth) orders byid, so the writer and the readers agreed. The health test helper ordered byts, which is why it reported verdicts the engine never produced. The intermittent failure was in the harness, not the product. LatestStateis a real exposure and is fixed here too. It backs the ingest transition guard and ordered bytsalone, so a poll cycle stamping several rows in one instant resolved to an arbitrary one and the guard could compare against a row that is not current. It now tie-breaks onid.- Reproduced deliberately rather than waited for. The failure needs contention: it never appeared in nine consecutive full-suite runs on an idle machine, and appeared within one or two attempts when six copies of the storage package ran at once. Under that same load the fix held for 24 runs.
- The regression test writes the inversion directly rather than racing it into existence, and asserts
the two orderings genuinely disagree before asserting the outcome, so it cannot pass vacuously. It
reads through
LocationHealth, which reports the recorded verdict;SystemHealthrecomputes live and cannot witness the defect. - Tracked as #356.
ADR-0062: A registry takes a uuid primary key and a renameable handle
Section titled “ADR-0062: A registry takes a uuid primary key and a renameable handle”- Date: 2026-07-22 | Status: Accepted | Pages: storage, api-first
- Decision: a registry has a uuid
idand a unique, renameablename, the shapetagand every estate entity already have.productandvendorconvert first; the remaining seven follow, slice by slice, tracked as #262. - Context: ADR-0056 says every foreign key stores
its target’s primary key, and epic #343 made that true everywhere except the slug-keyed registries,
where the name is the key. That exception was the last place a foreign key referenced a mutable,
human-authored string. A product id was a typo or a rebrand away from being wrong forever, and two
device packs both defining
cisco-room-kit-procollide on the primary key itself. name, notslugorkey. Six tables and every estate entity already call the human handlename, and the API bodies already sayname. A third word for the same concept would be worse than the inconsistency it fixed. Renaming the family tosluglater is a separate, mechanical decision.- The registries already disagreed with each other, which is worth recording:
propertyandinterface_typecall their slugname, whilecapability,driver,location_type,secret_type, andstandardcall theirsid. So the later slices are a rename for five of them and an addition for two, not one uniform change. nodestays the exception. Its primary key isprincipal_id, because a node is the detail row of a principal and its key IS that foreign key. It is deliberate and it is not changing.- The API carries both and accepts either, as the estate entities do:
id(uuid) andname(handle) on every body, and a path or reference resolves whichever form it is given. A name can never look like a uuid, so the two cannot collide. - The rename test is written first, each slice. It renames a handle and asserts every reference still resolves and now reads the new one. That is the capability the epic buys, so it is what the slice proves.
- The exception is retired, not just the tables. With all nine registries converted, a closing slice
removes the slug-keyed carve-out from the doctrine: the api-first rule now
states every foreign key stores a uuid with no exception, ADR-0056’s
carve-out is marked retired, and the
TestReferencesCarryBothFormsguard drops its slug-keyed allow-list (the registry references move into the both-forms rule, and the one that surfaced a real gap, a component response that carriedproduct_idwithout the product’s name, is fixed). The storage helper collapses too: the per-registryproductRefCol/vendorRefColand theregistryHandlesset fold into oneregistryRefCol(ref), since every registry now behaves the same. - Extended by ADR-0089:
this decision’s dual-accept clause already said a reference “resolves whichever form it is given”;
ADR-0089 makes a third form, a dotted path, a real one for
location,system, andcomponent, resolved structurally to a uuid before the ordinary name-or-id lookup runs.
ADR-0063: The telemetry model is typed registries over bare-noun data tables
Section titled “ADR-0063: The telemetry model is typed registries over bare-noun data tables”- Date: 2026-07-23 | Status: Accepted | Pages: datapoints, events, variables, storage, glossary
- Decision: every component interaction normalizes to one of three registries, each suffixed
_type(property_type,event_type,command_type), over bare-noun data tables (metric,state,property,event,command). A registry is a classification, so it takes the_typesuffix; the bare noun holds the instances. This retires the last confusion left by the datapoint_type to property rename: today’spropertyregistry becomesproperty_type(kindin{metric, state}), and today’sproperty_valuebecomesproperty, the latest-value cache.eventandcommandgain their own registries and are no longer modeled as apropertykind. - Context: using bare nouns for registries was the root inconsistency.
propertynamed the registry whileproperty_valuenamed the data;eventhad been folded into the property registry askind=log, the “false unification” datapoints and events explicitly warn against; and the code saidpropertywhile the pages still saiddatapoint_type. Suffixing the registry_typeand freeing the bare noun for the data fixes all three at once, and it vindicates the two-registry separation the pages always wanted:property_typeandevent_typestay distinct catalogs, they were never one universal registry. - The reusable pattern. A registry (
<noun>_type) defines canonical entries. A realization (the bare<noun>) records data referencing one registry entry by FK, over the same exclusive owner arc (component / system / location / node) the estate already uses, tagged with a provenance or origin.metric/state/propertyreferenceproperty_type;eventreferencesevent_type;commandreferencescommand_type. One rule, four tables, no bare-noun registries. - A log is a collection of events, so there is no
logtable. The row is anevent(one occurrence); a log is the stream of them, the way a registry is a collection of keys. So the earlier plan to rename the occurrence tableeventtologis reversed: the table staysevent, and “a component’s log” is a query over its events (observed origin). Component-observed versus platform-derived is anoriginon the row, not a separate table; promoting a raw line into a typed event is enrichment of the row, not a move between tables. - The owner arc stays; owner-prefixed tables (
component_metric,system_metric, …) are rejected. The exclusive arc already carries a component’s and a system’s metrics in one table, and it is the estate’s established primitive (property,tag_binding,secret,variableall use it). Splitting by owner would multiply the firehose tables fourfold, fragment the hot path, and force a UNION for the query that matters most: a system’s health rolling up its components’ metrics wants one table. The only gain is a non-null single FK, which the arc’s check already enforces logically. propertyis a latest-value cache; the firehose staysmetric/state.propertyholds the newest value per series,(owner, property_type, instance, provenance), the same series identity the firehose uses, upserted on intake.metricandstateremain the append-only samples. The cache exists to answer “what is it now” and “what did we last tell it” without scanning the firehose.- Provenance in the cache is rows, not columns. Each provenance is its own series row (
observed,calculated,intended), soobserved=45andintended=50are two rows, not two columns. Columns would put device-intake, command, and config all updating the same row (lock contention, lost updates on the hot path) and bake the provenance set into the schema. The “want / told / is” one-liner is the right read shape, delivered as a pivot view over the rows: read-shape is not write-shape. declaredresolves on demand;intendedis stored. The config setpoint (declared) is resolved live from the cascade and never rows into the cache, because it is always current and needs no history. The last commanded value (intended) is stored, because settlement needs the fact plus itsts. So the cache’s provenance set is{observed, calculated, intended}.- Two different drifts fall out of that split. Command settlement compares
observedtointended, is windowed, and is short-lived (“did my last command take?”). Config drift comparesobservedtodeclared(resolved live), is ongoing (“is it where config wants it?”). This is exactly whyintendedis stored anddeclaredis not. - The settle window is a driver fact, carried on
command_type. How long a command takes to actuate (an input switch is near-instant, a lamp warmup is tens of seconds) is device-physical, so it lives on the driver, on thecommand_typethe driver populates (the driver as a declarative menu of canonical properties, events, and commands), not on the abstractproperty_type. Settlement is computed, never a stored flag: withinnow - intended.ts < settle_windowthe value is pending and drift is suppressed; past the window,observedmatchingintendedis settled and a mismatch is a failed command. - Staging. The name foundation is cheap and lands first:
propertytoproperty_type,property_valuetoproperty, and themetric/stateFK repoint, a wide but mechanical sweep with no behavior change. The event family (event_type, theoriginand causation columns, pullingkind=logout ofproperty_type) rides the calculation and promotion layer, stillDesign. The command pillar (command_type,command, the settle window, command settlement) is greenfield. Each architecture page is rewritten to this model in the slice that builds its part, per docs with everything; until then the pages carry an inline note pointing here.
ADR-0064: Placement and classification are mutable after create
Section titled “ADR-0064: Placement and classification are mutable after create”- Date: 2026-07-23 | Status: Accepted | Pages: core entities, api
- Decision: a component’s product, location, and parent, and a system’s location
and parent, are patchable after create, not fixed at creation. Each follows the house
three-state convention on
PATCH: an omitted field is unchanged, an explicit empty string clears (a productless one-off, an unplaced entity, a root), and a name sets. A re-parent is cycle-guarded (refused when the new parent is the entity itself or one of its own descendants, the same recursive walklocationalready uses) and scope-injected (the new parent must sit inside the caller’s update scope). The existing per-transaction audit and health recompute rules are unchanged. - Context: the create body accepted these fields, the update body did not, so a component classified wrong on import or a display that physically moved rooms had no path back except delete and recreate, which destroys its telemetry history. The gap was invisible from any single body; it only showed up as the set difference between the create and update schemas, which is what surfaced it (#342).
- Why product is the sharp one: ADR-0047
made
productthe carrier of the property contract, so a wrong product resolves the wrong property defaults. A swap therefore keeps every explicitly-set value (they key by component and property_type, independent of product) and lets only the unset defaults follow the new product, so re-classifying is never a silent data loss. - What is deliberately NOT carried over from the location pattern: location’s reparent also runs an
allowed-parent-type placement check, because a
location_typeconstrains its parents. Products and standards are not placement-typed, so a component and a system carry the cycle guard and scope injection only, no placement-type validation. A component and a system reparent also, unlike a location this slice, support clearing to a root, since a root component and a root system are ordinary. - Health does not move on a placement change. The rollup runs component to systems-it-staffs to locations-over-those-systems, so a component’s own location and parent, and a system’s parent, sit outside the chain; only a product swap (capabilities) and a system relocate (which rung it rolls into) recompute, both already wired.
- Storage was already half-built:
ComponentPatch.ProductNameandSystemPatch.LocationNamewere wired but unexposed; this slice adds the two reparent paths and the component relocate, and opens all of it on the API. It unblocks the CRUD form primitive, whose generated edit form reads mutability off the create-minus-update schema difference and would otherwise render these fields read-only.
ADR-0065: Property, sample, and current value replace the datapoint
Section titled “ADR-0065: Property, sample, and current value replace the datapoint”- Date: 2026-07-28 | Status: Accepted | Pages: properties, storage
The one word “datapoint” conflated two things: the signal (what is measured) and the observation (a single reading of it). Splitting them removes the ambiguity that let “the datapoint’s current value” and “a datapoint arrives” name different nouns.
- A property is the canonical signal on one owning entity (the key a sample observes and config declares); its registry is
property_type. - A sample is one timestamped observation of a property, a row in
metric/state(the kind). - The current value is the latest sample per series, held in the
propertycache.
No tables were renamed (metric, state, event, property, property_type stay); the shift is vocabulary across the code (the proto Sample message, the *Sample Go types, deriveSamples) and the docs (the value-model page is now properties, with a redirect from the old slug).
It also settles the built shape the earlier design left open: the property cache is the architecture-of-record for current values, a table upserted from the persistence sink with an out-of-order guard, not the “view over metric” once sketched on storage. The intended value a command opens is written in the command’s Postgres transaction; the data-lane re-entry of intended and the adaptive-poll reconciliation described under intended are the deferred actuation evolution, not the built path. Refines ADR-0063.
ADR-0066: Logs are a raw ingest lane, not events
Section titled “ADR-0066: Logs are a raw ingest lane, not events”- Date: 2026-07-28 | Status: Accepted
Pulling log out of property_type into event_type (#395) also promoted every raw log line, at
ingest, into a caught log.line event. Reviewing the seeded event types showed that conflates two
different things: a log line that arrived versus an occurrence that happened. It is the same
category slip as naming a type after its transport (the syslog.line to log.line fix): arrival is
not a happening.
Three ingest shapes, not two. A property (metric or state) is a sampled value with a current value. An event is a semantic, typed happening. A log line is raw arrival: untyped text off a firehose. A log line is neither of the other two, it is the substrate some events are derived from.
Two lanes meet at event. (1) The raw firehose lands in a log_line ingest table; derivation
rules read it and emit semantic event rows. (2) A component with a native event model (an xAPI
xEvent, an SNMP trap, a webhook) publishes straight to event. Neither lane is a subset of the other:
most log lines never become events (they are searched, retained, and aged out having produced
nothing), and native events never touch a log. That asymmetry is why log_line is its own table, not
a flag on event.
origin names the producer. derived (a rule produced it, from a log line or another event),
caught (a component published it natively), scheduled. The retired model’s “a log line is a caught
event” collapses into: the log line is a log_line row, and if it matters a rule derives an event
from it.
Lineage lives on the event row. A derived event carries source_event_id (unified: the source is
the cause, so this replaces the separate caused_by_event_id), source_log_line_id, and
derived_by_rule_id, all null for a natively-caught event. The derivation engine keeps its own
execution history (which rule version fired, when, over what inputs), but that is a separate
observability lane and lineage must not depend on it: rotating the execution log cannot be allowed to
orphan an event’s provenance. derived_by_rule_id is the bridge into that history when the full story
is wanted, without being the only home for the basic fact.
A log line is untyped but classifiable by labels, not a registry. There is deliberately no
log_type registry: the log lane’s job is to swallow the firehose without pre-declaration, the
exact opposite of the reject-not-project contract that justifies the property_type and event_type
registries, and log classes (system, app, firmware, kernel) are device- and OS-specific and
open-ended. Classification is descriptive: a source channel plus freeform labels and attributes,
which an operator can extend without a schema change. The one exception is severity (and a coarse
facility), promoted to indexed columns because retention and routing policy keys on them (keep
firmware and error lines longer, drop debug faster). The shape is
log_line { ts, owner, source, severity?, facility?, message, attributes, labels, correlation_id }.
Build gate. The log_line table, retention, the derivation engine, the lineage columns, and
native-event producers are their own slice (#410),
worth building when logs are a real firehose to search and age out separately. This ADR records the
target; it is not built all at once.
Consequence for #395 (the event_type family slice). The bits that encode the retired model come
out now: the ingest log-to-event promotion and the seeded log.line event type. The slice keeps the
event_type registry, the richer event (origin, causation, correlation), log leaving
property_type, and a native caught example (call.started). The caused_by_event_id to
source_event_id rename and the new lineage columns land together in the log-lane slice, not
piecemeal here.
ADR-0067: Bookings are exclusive-arc-owned schedules, reconciled against observed usage
Section titled “ADR-0067: Bookings are exclusive-arc-owned schedules, reconciled against observed usage”- Date: 2026-07-28 | Status: Accepted
Tracking how systems and spaces are used needs the scheduled side, not only the observed telemetry. A booking (a room reservation, an equipment checkout, a virtual-room reservation) is what a calendar system holds, and it is a different shape from a telemetry event: an interval (a start and an end) with a lifecycle (created, moved, cancelled), and it is declarative, someone reserved something.
A booking is its own entity, owned through the exclusive arc. It uses the same owner_kind plus
one-of (component_id / system_id / location_id / node_id) plus the CHECK that exactly one is set,
the ownership model the rest of the estate uses. The arc is the binding, so no separate resource
layer is needed: a room booking owns to a location, a mobile-equipment checkout owns to a
component, a virtual meeting room owns to a system (the bridge modeled as a virtual conferencing
system), and a bookable space with no AV owns to a location that has no systems. The heterogeneity
flagged during design review (not every reservation is a physical room) is exactly what the arc
already expresses.
A booking is an observed intended schedule. Its provenance is observed: the platform did not
declare it, it caught it from an external calendar. Its meaning is intended: a declaration of how the
owner is meant to be used. A booking therefore carries observed provenance of intended use, and it is the
intended-schedule side of usage. Shape: booking { id, owner arc, source, external_id, subject, organizer, start, end, status, attendee_count, ... }, deduplicated and updated on (source, external_id).
Sourcing is a calendar driver on one platform-level integration, not a per-room mailbox fanout. A
per-room-mailbox model fails operationally at scale (one subscription and one credential per room), and a
calendar is a source, not a device. Instead there is one platform-scoped integration component per
calendar tenant, holding the tenant application credential, exposing a calendar interface whose
interface_type is a driver (graph-calendar, google-calendar), consistent with the
interface-is-a-driver model. A server-side worker runs the driver, enumerates the tenant’s bookable
resources, and fans out internally, upserting each booking with its owner bound through the arc. The
operator’s only seam is mapping a resource to its owner (auto-matched by resource address, operator-confirmed).
Bookings drive usage reconciliation and utilization SLIs. The booking is the intended schedule;
telemetry (call.started, occupancy, an active input) is the observed actual. Reconciled per owner and
window: booked and observed is real utilization, booked and not observed is a no-show, observed and not
booked is ad-hoc use. These are SLI slices in the health layer (utilization percent, no-show rate,
ad-hoc rate, per location / system / component / window). An owner with no observability (a booked space
with no systems) degrades gracefully to a booked-only SLI.
Deferred, each its own slice. The resource-to-owner binding (auto-match versus operator-set); privacy, since booking subjects and organizers are more sensitive than device telemetry, so store the minimal busy/free plus booked-by by default and make the full subject opt-in; the platform order (Microsoft 365 Graph first, Google Workspace second, Teams and Zoom Rooms scheduling as a later layer); and recurrence (expand a series to instances versus store the series) with the sync window. Nothing here is built. This ADR records the target so the booking slice (#412) inherits a decided model.
ADR-0068: The API error model is the stock RFC 9457 shape
Section titled “ADR-0068: The API error model is the stock RFC 9457 shape”- Date: 2026-07-30 | Status: Accepted | Pages: API
- Decision: the API’s error model is Huma’s stock RFC 9457
application/problem+jsonshape: theErrorModel(title,status,detail), carrying for validation anerrorsarray ofErrorDetailentries, each{location, message, value}. The custom envelope sketched on the API page (a stable machinecodeplus aviolationsarray of{field, message}) is retired. - Context: the custom envelope was designed before any route existed. Today 141 routes serve the
stock model, and the generated SPA client and the CLI already render it uniformly. A bespoke envelope
would be cost without a driving consumer: no caller keys on an error
code, and Huma’sErrorDetailalready names the failing field throughlocation. - Reversible: additive. If a consumer ever needs a stable machine code, it can be added as an extension field on the stock model without breaking the shape.
ADR-0069: Cycle safety is provenance-based, not topology-based
Section titled “ADR-0069: Cycle safety is provenance-based, not topology-based”- Date: 2026-07-30 | Status: Accepted | Pages: alarms and actions, health
- Decision: the guarantee that automation cannot feed back into itself rests on provenance, not
on a topological “alarms are terminal upstream and never write samples” rule. A consequence write
carries
provenance='calculated'with asource_rulenaming its producer (today: the health rollup’sstatesample,source_rule='health-rollup', written on alarm raise and clear), and rules must never route on their own consequences: the routing layer refuses to re-trigger a rule off a sample whosesource_ruleis that rule. - Context: the alarms page argued cycle safety from the premise that alarms never write samples.
The build falsified it: raising or clearing an alarm recomputes health in the same transaction
(
internal/storage/alarms.go), and the rollup records the verdict transition as a calculated-provenancestaterow (internal/storage/health.go). That write is correct, it is the recorded-transition model of ADR-0050; the premise was too strong. The real invariant is lineage-based and survives future consequence writers (calculations, action side effects) that a topology rule would forbid. - Supersedes the terminal-upstream cycle-safety argument on alarms and actions; the page now derives safety from provenance.
ADR-0070: retire the standalone effective-secrets and effective-variables per-component panels; fields become the component value surface
Section titled “ADR-0070: retire the standalone effective-secrets and effective-variables per-component panels; fields become the component value surface”- Date: 2026-07-16 | Status: Accepted | Pages: config, secrets, and variables, identity and access, API
- Decision: The standalone per-component Effective secrets and Effective variables panels are removed,
along with their
GET /components/{name}/effective-secretsandGET /components/{name}/effective-variablesroutes (and the generatedomniglass effective-secret list/effective-variable listcommands and the matching typed-client methods). A component’s value surface is the field primitive: a component’s values are its fields, each resolving override-versus-type-default and shown in the Effective fields panel. A secret or a variable reaches a component by being sourced into a field (the deferred fieldsourcesmodel) or bound to a collection interface input, not through a per-component cascade-browse panel. Kept unchanged: the storage cascade resolvers (ResolveSecrets/ResolveVariables) as the internal primitive the future$sec:/$var:interpolation consumer will call, and the Secrets and Variables directories (browse, create, edit, reveal) with all their routes and CLI. - Context: The per-component effective-* panels predated the field primitive and listed every
cascade-resolving cell that reached a component, which at any real depth is mostly inherited noise (a global SNMP
community, a location poll interval) rather than anything set on that component. The
field primitive
(#266) is the schema-over-cells consumer the design always
intended: a component carries a typed set of fields, each resolving to a set literal or its type default, and the
intended
sourcesmodel lets a field draw its value from a variable, a secret, a datapoint, or a file. Once fields are the value surface, a second per-component cascade browser over the raw cells is redundant and misleading (it reads as though the cells attach to the component when they only resolve onto it). Retiring the panels narrows the component detail to its fields and keeps the cells’ own management on the Secrets and Variables directories, where the cascade is authored. The resolvers stay because the interpolation consumer ($sec:/$var:) still needs them; only the browse-panel surface retires. - Closes: issue #281 (retire the per-component effective-secrets / effective-variables panels), under the field epic #266.
ADR-0071: a template is a clonable example, not a versioned shape an instance pins
Section titled “ADR-0071: a template is a clonable example, not a versioned shape an instance pins”- Date: 2026-07-31 | Status: Accepted | Pages: templates, core entities, cascade, glossary, collection
- Decision: A template is an example configuration an operator clones. Creating from one is a one-time
fork with no inheritance and no back-pointer, so a template can be improved in any release without migrating
anyone: it is upgrade-safe precisely because nothing stays connected to it. What an operator instantiates from
a template is an ordinary row they then own, whether that is a
location_type, astandard, or a whole system. The versioned-shape model is retired:component_template,system_template, their*_versionrows, thestable/betachannels, the frozen BOM, and “an instance pins a version” or “tracks latest” are gone. A component’s shape is itsproduct(with theproduct_propertycontract), and a system’s shape is itsstandard, to which a system conforms with live inheritance. Forking applies template to row; conformance applies row to instance. The word template survives; its meaning is inverted. - Context: The decision log and the code had drifted into disagreement, and neither said so. ADR-0045 deferred
“a product’s own template or field-schema binding” and ADR-0049 stated “Templates and their frozen BOM stay
Design; the two models are reconciled when template pinning is built”, so the log asserted the pinned model was merely deferred. Meanwhile ADR-0047 retiredcomponent_type, ADR-0048 promotedsystem_typetostandard, and the shipped schema pointed a component atproduct_idand a system atstandard_id. A 2026-07-30 vocabulary audit foundtemplates.mdstill teaching version pinning across 29 lines, with the retired nouns reaching search results through the page’s own frontmatter description, and no denylist entry anywhere because the estate-model ADRs had each introduced a new noun without retiring the old one. The two models are not variants of one idea, they are opposites. The pin existed so a template could not change under an instance; the fork exists so a template can change freely because no instance is watching. Holding both was what made the docs unresolvable: every page had to hedge. This generalizes the fork-seed model ADR-0048 already shipped for standards and location types (tracked for build in #317) and extends it to instantiating a system from a template, which is the operator-facing half. It also removes two rungs from the structural cascade: a template cannot be a resolution tier when nothing points at it. Rejected: keeping pinning for components while forking systems (two mental models for one word), and retiring the word template entirely (it is the right word for a clonable example, and the fork model is what an operator already expects from “start from a template”).
ADR-0072: an envelope is not named after its passengers, and an insert struct takes the Write suffix
Section titled “ADR-0072: an envelope is not named after its passengers, and an insert struct takes the Write suffix”- Date: 2026-07-31 | Status: Accepted | Pages: storage, glossary
- Decision: Two naming rules, both general. A carrier is named for what it carries, never for one of
its passengers. The telemetry wire message is a
TelemetryBatch(proto/og/v1/telemetry.proto): it carries samples and raw log lines and will carry natively caught events, so naming itEventnamed it after one passenger. And a storage insert struct takes theWritesuffix, paired with the bare read struct:MetricSampleWrite/MetricSample,StateSampleWrite/StateSample,EventWrite/Event,LogLineWrite/LogLine. AWriteis the shape a caller hands the gateway; the bare noun is the row that comes back. - Context: “Event” had come to mean four different things: the wire batch, the two sample insert structs
(
MetricSampleEvent,StateSampleEvent), the event insert struct (EventOccurrence), and the event read struct. Three of the four collided in a single signature,deriveSamples(ev *ogv1.Event, ...) ([]MetricSampleEvent, []StateSampleEvent, []EventOccurrence), where only the last had anything to do with an event as the platform defines one (anevent_type-registered occurrence carrying anorigin, ADR-0066). The batch had already outgrown its name once when the log lane added raw log lines to it, and the push route (#423) would have made the collision visible on the wire asEvent.events[]. The rename was free, which is why it happened before the payload grew rather than after: protobuf encodes field numbers and never message names, so the same payload marshals byte-identically before and after (verified by encoding one on each side and diffing the hex), and a node and a server of different vintages still interoperate. No migration, no dual-write, no deploy ordering. TheWritehalf is not a new idea, only a newly stated one: the log lane set the pattern withLogLineWritein #414 and the older types simply predated it, so the codebase carried two spellings of the same concept. Recorded here because it is a rule for every future insert struct, and a convention that lives only in a commit message is one nobody applies. ADR-0037’s title still reads “a protobuf Event”, correctly: that is what was decided on 2026-07-07, the log is append-only, and its heading generates the anchor other entries cite. It carries a forward pointer to the rename instead.
ADR-0073: A driver consumes transports; a transport is code, not a row
Section titled “ADR-0073: A driver consumes transports; a transport is code, not a row”- Date: 2026-07-31 | Status: Accepted | Pages: collection, glossary
- Decision: The collect layer is driver-centric, closing the question
ADR-0039 left open.
Three things follow. A transport is code, not an operator-editable row.
interface_typeis a low-level primitive (icmp,tcp,ssh,http,snmp, …), one per package, discovered through a code registry the way collection primitives already are; theinterface_typetable, its FK frominterface.type, and the hand-written dispatch switch ininternal/node/probe.goall retire together in a later slice. An operator cannot author a transport, because a transport is a wire implementation, not configuration. A driver consumes transports and is never one. A driver declares which transports it can run over and the instance picks one; the driver owns the normalized catalog (what to fetch, how to parse), the transport owns only how bytes move. Theinterface_typeis a driver clause in ADR-0067 is retracted: a calendar integration is anhttpstransport plus agraph-calendarorgoogle-calendardriver, the same shape as every other device, not aninterface_typenamed after a vendor API. - Context: ADR-0039 recorded the driver-centric split as “the current-best direction, not a locked gate” and
named its own successor: “driver-centric vs template-centric is re-examined, and this ADR revised or superseded,
in a later ADR before the collect layer is built.” This is that ADR, and it confirms rather than reverses the
direction, because the alternative has been tried in this tree and lost twice. Protocol handling in the template
makes every operator a programmer, which the product cannot ask for; and a transport as a row produced exactly
the drift it invites, an ADR sourcing calendars through an
interface_typenamed for a vendor API, which reads as reasonable precisely because a table accepts any string. A code registry cannot be extended by an operator, so it cannot silently absorb the driver’s job. The estate model above this line is built (a component points at a product, a product carries a capability set and a property contract, roles staff a standard), while below itdriverholds a name and a version and nothing a node could act on, so nothing is being unbuilt here: the decision authorizes work that the scope gate has been blocking. - Deliberately not decided here, each with a home so none of it rides on this entry: whether a product may bind
more than one driver (
product.driver_idis a single nullable uuid today, and role-addressed driver output is the recommended alternative to a join table, but it is a recommendation, not a ruling); whether a product is a versioned artifact instances pin or stays the live classifier it is today (#491); where cadence lives; and how a component resolves its effective driver set (theEffectiveCapabilitiesplus-minus pattern ininternal/storage/roles.gois the obvious candidate to reuse rather than invent a second mechanism). - Supersedes: the status note on ADR-0039 (the driver-centric split is no longer provisional) and, in part, ADR-0067 (its calendar sourcing clause only; the booking entity and its arc ownership stand).
- Enables: the collect layer epic, whose slices were unauthorized without it. Nothing in this entry is built; it is a decision, and the code lands in that epic.
ADR-0074: An approved definition rolls up to one PR; slices cascade on an integration branch
Section titled “ADR-0074: An approved definition rolls up to one PR; slices cascade on an integration branch”- Date: 2026-08-01 | Status: Accepted | Pages: slice workflow, feature loops
- Decision: For loop-executed work (a body of slices defined and approved as one Epic or Feature issue), the
PR granularity moves from one-slice-per-PR to one-PR-per-approved-definition: sub-issue slices are built
test-first on cascade branches (or serial commits) merged into one integration branch, each passing the
per-slice gates before merging inward, and the definition ships as a single rollup PR whose ship-review covers
the whole diff. The slice lifecycle inside each sub-issue is unchanged, and merge to
mainremains the architect’s call. Hand-driven single slices keep the original one-slice-per-PR shape. - Context: Long agent loops ship several related slices per body of work. Per-slice PRs would put the
architect back in the approval loop once per slice (the touchpoint cost the loop exists to remove), while
letting unreviewed slices land on
mainwould gut the ship gate. The cascade keeps every per-slice gate and gives the architect one reviewable boundary: approve the prose definition at the front, merge one rollup PR at the end. The feature loops page is the contract; built for the AI-driven feature loops epic.
ADR-0075: An alarm’s condition identity is a raiser-supplied dedup key
Section titled “ADR-0075: An alarm’s condition identity is a raiser-supplied dedup key”- Date: 2026-08-01 | Status: Accepted | Pages: workers, alarms and actions
- Decision:
alarmgains adedup_keycolumn (the condition identity; it defaults to the message when the raiser supplies none) and the partial unique indexalarm_open_condition_keyon(component_id, dedup_key) WHERE cleared_at IS NULL, so one open alarm per condition per component is a database fact.RaiseAlarmis a guarded conditional insert: a losing raise returns the existing open incident instead of a duplicate, writing no audit row and recomputing nothing. The rule engine’sevent_rule_idkeying joins alongside the dedup key with its own slice; a per-component unique index was refused (it would forbid two unrelated conditions on one component, contradicting the capability-degradation model). - Context: workers.md reasoned from a “one-open index” that did not exist:
RaiseAlarmwas an unguarded insert,alarm_active_idxis non-unique, and the liveness sweep was idempotent only because it happens to run as a singleton. The table had no column naming WHICH condition was open, so the documented(event_rule, owner)key was unrepresentable before the rule table exists; the raiser-supplied key works today and does not block on the engine. Shipped by #465 in the #431 loop.
ADR-0076: A renameable, human-typed identifier stays in the URL, and the write returns the uuid
Section titled “ADR-0076: A renameable, human-typed identifier stays in the URL, and the write returns the uuid”- Date: 2026-08-04 | Status: Accepted | Pages: core entities, api, storage, tags, config, secrets, and variables
- Decision: An entity is addressed by its
name, a renameable identifier an operator types, as well as by its immutableid. A rename is an explicit custom method (POST /<collection>/{ref}:rename, gated by<resource>:rename) rather than a field write, and a write returns theidso a client can store the stable handle and stop depending on the name it used. References inside the platform store theidonly (ADR-0056), andaudit_log.resource_idkeys on it, so a rename moves exactly one column. Two response bodies do not yet carry the id,NodeBodyandSystemRoleBody, so a client of those two has the name and nothing else to diff; closing that is a follow-up, not a change of direction. - Decision (what the name rule became): there is one validator,
storage.ValidateName(table, name), which picks the rule from the table’s declared identity shape instead of from the call site; the platform’s two other name rules are deleted, not renamed. Two rules survive rather than four, and they share one character set, differing only in the dot and the ceiling: an entity name is one segment of lowercase letters, digits, and hyphens, at most 100 characters (hq-boardroom-dsp); a keyspace name is a dot-joined path of those same segments, at most 128 (icmp.rtt-avg). Neither may be uuid-shaped. Onlyproperty_type,event_type, andcommand_typeare keyspace.tag,variable, andsecrethad been declared keyspace on a claim nothing exercised, and move to the entity rule, since none of them ever carries a dot: a behaviour change on three shipped surfaces, socost_centerand$var:crestron.sshstop being creatable and the docs that taught them saycost-centerand$var:crestron-ssh. - Context: this is a deliberate departure from prevailing practice, recorded so it is not mistaken for an
oversight. Prior art runs the other way almost without exception: AIP-180 states that a resource must not
change its name, Kubernetes
metadata.nameis documented “Cannot be updated”, GCPprojectIdand TagshortNameare Immutable, and Azure’s own guidance is that most resource names cannot change after creation and that details belong in tags instead. More pointedly, systems in this domain retreated from it after shipping: Grafana deprecated its title-derived dashboard slug in v5.0 and addeduidbecause name-based references broke dashboards, and PagerDuty frozeCustomField.nameat creation and routes all renaming pressure todisplay_name. None migrated the other way. The case for keeping it is operator ergonomics in an estate whose entities are named after rooms and racks that genuinely get renamed, and the mitigation is Linear’s: the typed identifier is renameable, the uuid is returned on every write, and a client that kept using the name can detect the move by diffing the id it holds. The cost is real and accepted: an external reference held as a name breaks on rename, and nothing on the server can repair it. Alias and redirect machinery is deliberately not built yet; it is the first thing to reach for if that cost shows up. - Amended by ADR-0089 in
justification, not shape:
:renamestays exactly this custom method, gains a second job as the operator’s pen-taking act (ADR-0090), and the accepted cost above is repaired rather than removed. Once the uuid is the address, an external reference held as a name no longer breaks on rename in the way this entry describes: an integration holding the id survives every rename, and one holding a dotted path is a positional lookup honestly reporting whatever occupies that position now. What remains, and stands on its own, is the permission split this entry already named.
ADR-0077: A group name obeys the entity name rule, tightening a pattern the code had excused
Section titled “ADR-0077: A group name obeys the entity name rule, tightening a pattern the code had excused”- Date: 2026-08-04 | Status: Accepted | Pages: identity and access
- Decision:
principal_group.nameobeys the ordinary entity name rule (^[a-z0-9][a-z0-9-]*$, 100 characters, a uuid refused), enforced by the Storage Gateway on create and rename and carried in the OpenAPI contract as apattern. The previous rule (^[a-z0-9][a-z0-9._-]*$, 200 characters, checked only by the request schema) is retired. - Context: this reverses a position that was written down in code.
KeyProvedElsewhereexcusedprincipal_groupfrom the behavioural validation sweep with the reason that tightening it “is a behaviour change for existing groups, tracked with the rename work”. This is that work, so the excuse is spent. The looser pattern admitted.and_, the two characters the address grammar reads as separators, and the gateway itself validated nothing at all, so a group could be created with a uuid-shaped name through any caller that was not the HTTP route. There are no releases and no operator data, so the tightening costs nothing now and would cost a migration later. Shipped by #567 in the #545 loop.
ADR-0078: A read-only field renders as a fact, not as a box that refuses typing
Section titled “ADR-0078: A read-only field renders as a fact, not as a box that refuses typing”- Date: 2026-08-04 | Status: Accepted | Pages: UI and the design system
- Decision: a field that is not being edited renders as a fact (
KVStacked: an eyebrow label above a value), never as a bordered control.BladeFieldowns the read-or-edit switch, the read-only treatment, the free-text shape, and the identity label pairing, so each is decided once instead of at every field. A blade the operator cannot edit therefore contains no element shaped like a control. - Context: the blade shell was a primitive and the blade contents were not. Eleven pages defined
a byte-identical local
Field, four more went through positionalctx.field(...)/ctx.fact(...)helpers, and the read-only rendering (input input-bordered flex items-center) was hand-rolled 24 times, so every blade defect was an N-place defect:display_namewas labelled “Name” on 11 blades at once, and a description that would not wrap was one bug in 24 fields (#573). The seed-owned vendor blade made the treatment question concrete: five fields rendered as bordered boxes on a panel with no pencil, two of them holding a placeholder glyph, directly below three plain facts. The same read-only state had two appearances on one panel, and the boxed one signalled an editability that did not exist. A box that rejects typing reads as broken; a fact reads as a fact. The rule also fixes the read half of #573 by construction, because text in a fact wraps. Shipped by #575 in the #574 loop.
ADR-0079: Five telemetry lanes, and property stops being the genus
Section titled “ADR-0079: Five telemetry lanes, and property stops being the genus”- Date: 2026-08-05 | Status: Accepted | Pages: samples, storage, core entities, collection, commands, events, data model, health, glossary
- Decision: the telemetry model is five lanes with five names and no overlap: a metric
is a quantity (numeric, aggregates, carries unit and precision), a property is a value (what
something is, including a number used as a name; values have duration, not averages), an
event is a typed happening, a command is an instruction with a target, and a log line
is raw arrival. “Property” stops being the genus of every reading and becomes one of the five,
and the word “state” retires as a table name: a health state is a concept, not a table. Shipped
as epic #584 in seven slices:
- The catalog splits on
data_type, the lane key (#587):int/floatrows becomemetric_type(taking the numeric facts: unit, precision),string/bool/jsonrows stayproperty_type(keeping the value domain: validation), and the per-keykindcolumn retires, since catalog membership routes a sample and partitions every row cleanly wherekinddid not (four kind-less declared names were properties by ruling, anddata_type='string'already put them there). Each classifier contract gains a metric sibling (product_metric,standard_metric,location_type_metric). The three ingest catalogs share one resolution namespace: a create is refused when a sibling holds the name. - The value store folds into the series (#591): a declared value is an ordinary series row
(
provenance='declared', no lineage), an edit appends, and the unset is a tombstone (an appended declared row whose value is JSON null, resolved as absence by every reader). The current value of every provenance is derived: the latest series row per(type, owner arc, instance, provenance), never a maintained cache. The separatepropertyvalue store and its upsert-on-intake cache retire outright. - The
statetable becomesproperty(#588): the record takes the bare noun and the catalog keeps the_typesuffix, matchingevent_type/event; the two value columns converge into onevalue jsonb NOT NULL. The word “state” was overloaded (a health verdict is a state, a reachability verdict is a state, and neither was that table); the lane is a value over time, and its name now says so. - A log line belongs to a component, and node self-logs split into
node_log(#589):log_line’s four-armed arc narrows to component-only, and a node’s self-logs move to an origin-truenode_logtable keyed to the node, the amended ruling recorded on the slice issue. - A command records its status, and an intended value names its command (#590):
commandgains a recordedstatus(issueduntil a terminalsettled/failed/timed-out, withsettled_atstamping the terminal moment) beside the still-computed settlement verdict, andcommand_typegains the two-armed target (target_property_type_idortarget_metric_type_id, never both), because a metric is commandable. An intended sample’s lineage moves from the command’s caused event to the command itself (command_id): the value points at its cause, not at a derivation of it. - The push wire goes per-lane (#594):
TelemetryBatchcarriesmetrics,properties,events, andlogsarrays, each entry validated against its own catalog at ingest; the polymorphic samples array retires. - One name rule, no dots (#586): a name is a single kebab token, at most 100 characters, on
every table; the dot-joined keyspace rule and its 128-character ceiling retire, and the seeded
dotted names backfill to their hyphenated forms (
icmp.rtt-avgtoicmp-rtt-avg).
- The catalog splits on
- What this reverses, and why. ADR-0063 and ADR-0065 recorded the previous taxonomy as the
settled model: property as the canonical signal over every sample, one
property_typecatalog with akindcolumn spanning numeric and categorical names, and a maintainedpropertylatest-value cache as the architecture-of-record for current values. This ADR reverses that recorded call in part. The root defect was the property-as-genus overreach: one word carried the signal, the store, and the whole sample family, so “property” meant three things on one page, the catalog carried facts (unit, precision) that only half its rows could use, and the cache duplicated a fact the series already held. The registry-over-bare-noun pattern of ADR-0063 survives intact; what changes is that property becomes one lane among five rather than the genus of all of them. Recorded as the current-best model on the evidence above, and like every entry in this log it is revisable if the evidence changes. - Context: verified against the live schema before building, which produced one mid-loop stop:
the epic was first drafted against the pre-July init-dump names, where
propertywas the catalog andproperty_valueheld current values, and three of its claims pointed at tables that no longer existed under those names. The definition was corrected against the running database before any slice built on it, and two rulings rode the correction: the partition key isdata_typerather thankind, and a current value is derived from its series rather than stored. The retired vocabulary (stateas a sample table,property_value,StateSampleWriteand its siblings) joins the docslint denylist with this entry.
ADR-0080: Retention is provenance-aware: never declared, never the latest row per series
Section titled “ADR-0080: Retention is provenance-aware: never declared, never the latest row per series”- Date: 2026-08-05 | Status: Accepted | Pages: storage, samples
- Decision: any retention pass over the sample tables obeys two invariants: it never deletes
a
declaredrow (an operator’s assertion is the whole truth however old, not a sample that ages out), and it never deletes the latest row of any series(type, owner arc, instance, provenance)(a prune must not erase a current value). The rule ships as thePruneSamplesStorage Gateway primitive, tested for both invariants, before any retention feature exists; no caller wires it yet, and any future retention feature calls the primitive rather than writing its own delete. - Context: a blanket “delete older than N days” is the obvious first retention feature, and it would silently erase a declared value set two years ago, because for declared provenance the single row is the record itself, not one sample among many. With current values now derived from the series (ADR-0079), a naive prune would also delete the newest row of a quiet series and blank a current reading. Both traps are cheap to close before the feature and expensive to discover after a purge, so the floor shipped ahead of the feature (#591).
ADR-0081: The control-plane wire is one subject grammar, node-anchored and batch-granular
Section titled “ADR-0081: The control-plane wire is one subject grammar, node-anchored and batch-granular”- Date: 2026-08-06 | Status: Accepted | Pages: messaging, scaling
- Decision: every control-plane subject is
og.v1.<verb>.<node>, the node name the last token and exactly one token, so the server subscribes per-verb single-token wildcards and a node’s credential is an explicit allow-list of its own subjects plus its private_INBOX.<node>reply namespace. The verb family isworklist,heartbeat, andtelemetry, withworklist-changedreserved for the re-pull nudge andog.v1.command.<node>the committed future per-node command queue. The trusted push lane isog.v1.api.telemetry, its own segment; the rejected alternative was a reserved node name underog.v1.telemetry.*, where the single-token wildcard would hand a node namedapithe trusted subject, so its own segment makes the forgery structurally impossible rather than dependent on nobody choosing an awkward name. Addressing is node-anchored and batch-granular: a record’s name is payload, never topic, and per-record subjects (the MQTT-style topic tree) are rejected. One recorded correction rides the entry: the one-token name rule (#586) was never justified by names as topic tokens; it stands on its own grounds. One consequence is named and deferred: the core-NATS verbs’ server-side consumers (worklist, heartbeat) are singletons by construction, and their HA fork (queue groups versus worklist reassignment) is a scaling decision for the day a second server exists. Telemetry does not face that fork: its ingest is a named durable JetStream consumer a second server joins. - Context: the topics conversation (recorded until now only in #584’s issue body) weighed a
user-facing MQTT-style topic tree against the internal contract and split the two concerns: the
internal bus follows KISS (NATS subjects carrying batches between the node and the server), and
any future user-facing subscription surface is its own design with its own grammar, reachable
over an MQTT bridge if wanted. Wire constants live in
internal/collection/wire.go; the grammar section on messaging is the page-of-record.
ADR-0082: The type resource renames to location_type
Section titled “ADR-0082: The type resource renames to location_type”- Date: 2026-08-06 | Status: Accepted | Pages: identity and access, API
- Decision: the permission resource
typerenames tolocation_typeon every surface: the route stamps, the roles seed, the console gate strings, and the authz guard fixtures. The location-type property contract routes follow the same resource (location_type:updatedeclares,location_type:deletewithdraws). The generic wordtyperetires from the permission vocabulary; no route stamps it and no role grants it. - Context: one word hid two registries: the Types console page held location types and secret
types behind one nav word while
type:*gated only the location registry, and the contract routes gatingtype:*besideproductandstandardgating their own nouns was a live asymmetry. The same one-word-hides-two-things shape the catalog arc exists to end, renamed pre-release while the rename is cheap; the Types page split carried it (#598, the epic #601).
ADR-0083: The Catalog rail is sectioned by the estate noun each registry serves
Section titled “ADR-0083: The Catalog rail is sectioned by the estate noun each registry serves”- Date: 2026-08-06 | Status: Superseded by ADR-0084 | Pages: UI
- Decision: the Catalog nav cluster renders non-folding section headers under one naming rule:
a section is named for the estate noun it serves and an entry keeps the registry’s own
word, collapsing to plain Types where the registry has no other word (Locations > Types,
Secrets > Types). The sections, mirroring Inventory’s order: Components (products, vendors,
drivers, capabilities), Systems (standards), Locations, Secrets, Telemetry (metrics, properties,
events, and the future log catalog as a soon entry), Action (rules, commands, and the future
notifications), General (tags). The organizing line: Telemetry is what gets recorded, Action
is what the platform does; Commands left Telemetry on the observed-versus-issued split, and
Events stays in Telemetry because the shipped lane records happenings (caught from the estate,
caused by the platform) and never sends them. Headers render from the permission-filtered entry
list, so a fully gated section disappears with its entries; the palette tags sectioned entries
Catalog · <section>; a visible Overview entry opens the/cataloghub, one card per visible section with live registry counts. Templates leaves the rail until the registry is real. Routes, tables, resources, and API surfaces are untouched: the rule governs presentation only. - Context: fourteen flat entries hid five real clusters: products, vendors, drivers, and capabilities serve components with nothing saying so, and standards served systems invisibly. Rejected along the way: a flat rail with hub-only teaching (daily wayfinding regresses to the soup), hover flyouts (hiding is the disease being treated, hostile to touch and keyboard, and a “Types” flyout bucket recreates the one-word-hides-many shape ADR-0082 retired), and a uniform Types suffix (reverses the lane-noun labels the five-lane epic settled). The nav word for the rule registry is Rules; the vocabulary beneath it (table, API, resource words for rules and the alarm rows they raise) is unsettled and tracked in #606, out of this decision’s scope.
ADR-0084: The catalog shell, and five signal lanes
Section titled “ADR-0084: The catalog shell, and five signal lanes”- Date: 2026-08-07 | Status: Accepted | Pages: UI, glossary
- Decision: Catalog is a single rail entry opening a shell: a grouped subrail whose entries navigate to the real per-registry pages, rendered in the pane at their canonical flat URLs, with an Overview landing of teaching cards; the subrail and the Overview derive from one group table, judged through the same permission filter the rail uses. The groups, ordered: Telemetry (metrics, properties, events), Actions (commands, with rules and notifications as tracked stubs), Components (vendors, products, drivers, capabilities, a templates stub), Systems (standards, a templates stub), Locations (types, a templates stub), Metadata (tags). The organizing axis is direction, not genus: Telemetry is what you receive, Actions what you send or run; a command targeting a property or metric is a form dependency, not a menu adjacency. Consequences ruled with it: secret types loses its nav slot (the URL stays reachable and gated; the table’s retirement is the schema phase’s call); Logs stays out of Telemetry until a log_type exists; the Systems entry reads Standards until the system_blueprint rename lands with the schema phase, because splitting operator vocabulary across surfaces to ship a label early is drift by construction; and the blade model holds on every field (read facts until the pencil, per the epic’s approval caveat). The lane collective noun becomes five signal lanes, four inbound and one outbound: a command is an instruction you issue, not a telemetry reading, so “five telemetry lanes” retires as prose while ADR-0079’s structure (a command is a genuine peer lane: type table, instance table, registry) stands unrevised.
- Context: the sectioned rail (ADR-0083) shipped its sections as rail geography and a hub that restated the rail; four design rounds against the live console replaced it: a single-surface browse table (identity-first rows, rejected for parallel-table drift against the real pages), a filter subrail (rejected because facets that filter one merged table cannot host each page’s own search and create flows), and finally the shell, which keeps the IA as wayfinding and the pages as the single surfaces. Grouping by subject entity rather than artifact class survived every round: an operator arrives knowing the entity, not the taxonomy, and a component template and a location template share only a word. ADR-0083 is superseded; its estate-noun instinct survives in the group names. The genus-naming discussion that produced the direction axis also queued the schema phase (system_blueprint, location contract removal, secret_type retirement, the four-class taxonomy), deliberately sequenced ahead of the #379 migration collapse and deferred from this decision.
ADR-0085: The component_type registry returns as the device-class genus
Section titled “ADR-0085: The component_type registry returns as the device-class genus”- Date: 2026-08-07 | Status: Accepted | Pages: core entities, storage, Products guide
- Decision: The
component_typeregistry returns: a seeded-plus-custom device-class taxonomy (display,projector,screen,presentation-switcher,video-bar,dsp,amplifier,mic,camera,codec,control-processor,touch-panel, …) that nests byparent_id(micoverwireless-mic,ceiling-mic,boundary-mic), on the same official-and-custom pattern as the other classification catalogs, operator-graftable at any node. It classifies the product (product.component_type_id, required), so a component inherits its type through the product it is; it is not a second classifier on the component. The row carries exactly the identity facts that genuinely span products, inheriting down the tree with override at any node:name, the naming stem (a subtype names components by its inherited stem unless it overrides),display_name,icon(the console glyph, replacing the too-coarse derivation fromproduct.kind),abbrev(the two-to-three character hostname stem), and default tags. The seed discipline: a subtype exists only where a standard’s slot would name it; a fact like panel technology stays on the product. It is not a shape-definer: contracts, declared properties, and drivers stay on the product. The division of labour: the type says what a component is (one, via its product); the role says what a system needs (a typed slot, a separate decision the roles epic owns); the capability axis is untouched by this decision. - Context: ADR-0047 retired the component-level
component_typewhen the fields folded into the product contract, and this partially reverses it, deliberately differently shaped: above the product rather than beside the component. What forced the return was naming and rendering. A generated component name needs a stem in the device-class vocabulary (display-1, thefp1hostname convention), and the platform had no table that speaks it: the role is positional (display-frontsays where it sits), the product is a SKU (qm55),product.kindis three values wide, and capabilities are many-valued. Every identity fact the console kept reaching for (icon, abbreviation, base tags, name stem) turned out to live at the same missing level, which is the tell that the level is real. The economics confirm it: a thousand components, a few dozen products, a couple dozen types; each identity fact is authored once at the level it spans. - Tracked under epic #614.
ADR-0086: The product classification floor, and the kind split
Section titled “ADR-0086: The product classification floor, and the kind split”- Date: 2026-08-07 | Status: Accepted | Pages: core entities, storage, Products guide
- Decision: Every component is required to name a product:
component.product_idisNOT NULL. Three seeded generics (generic-device,generic-app,generic-service, each pointed at the matching genericcomponent_type) cover anything not yet modeled as a real SKU, so the floor is total from the first migrated database onward: no component ever exists with no kind, no declared contract, and no driver path.product.kindnarrows todevice | app | service, drops its column default, and is required at create: an operator states a product’s class explicitly rather than reading a silent fallback todevicethat let a mislabeled cloud service pass as correct forever.vmretires, folded intoapp, because nothing forks on a virtual machine that does not fork the same way on any other app: a virtual appliance is a different SKU, not a different kind. The three that remain are the who-owns-what split:device(the box is yours),app(the runtime is yours),service(only the account is yours). - Context: ADR-0049’s own Context named the optional product as the forcing function behind
capability-gated staffing’s resolved-capability layering: a role could not check a product’s
capabilities directly because a component might not have one. Making product required closes that
gap at the root rather than compensating for it one layer up, and does the same for the naming
epic (ADR-0085): a
generated component name needs a
component_type, which needs a product, so “product required” is what makes the naming generator total rather than a fallback-to-hand-authoring special case. Thevmretirement follows the same audit that found the kind default silently absorbing a mistake: a fixed four-value enum with a default reads as validated when it is merely unset, and the fourth value had no code path that branched on it the other three did not already cover. - Tracked under epic #614.
ADR-0087: Capability-gated staffing retires; an alarm impairs its component, not a named capability
Section titled “ADR-0087: Capability-gated staffing retires; an alarm impairs its component, not a named capability”-
Date: 2026-08-07 | Status: Accepted | Pages: core entities, health, API
-
Decision: The alarm -> alarm_capability -> degraded-capabilities -> role chain ADR-0049 and ADR-0050 built retires. The replacement is three calls shorter: an alarm impairs its component’s own verdict wholesale, no longer routed through a named capability; a role’s occupant satisfies it whenever the occupant’s own verdict is not
outage(Occupies()isVerdict != Outage, so a merely degraded occupant still occupies its slot, since severity is how loudly to page somebody, not a second staffing threshold); and the typed-slot guard (system_role_type,system_role_product: a filling component’s product must be classified within an acceptedcomponent_type, and, if pinned, be one of the named products) is now the only assignment-time check and plays no further part in health.impact,quorum, the worst-wins fold, and health as a recorded transition recomputed at the write are all unchanged; only the routing key changed, from a capability set to a component’s own verdict.Two refusal rulings that fell out of the same rebuild, generalized here because nothing else names them: a role-write refusal is 409 when it depends on rows other than the one being written (double-staffing a component across roles, a capacity below the currently assigned count: “the declaration or assignment request is not invalid on its own, it conflicts with the estate’s current state”), and 422 when the declaration alone is invalid regardless of other rows (capacity below quorum, an unresolvable typed-slot reference). And a boot-seed carve-out:
choice_alternate(notrole_choice, which keeps the ordinary insert-if-absent rule and no set-level reconcile; a choice row itself is never deleted by a reseed) reconciles to its declared YAML set on every boot, deleting a stored alternate that dropped out of the set (refusing instead, withChoiceInUseShortfall, if a role still points at it) rather than leaving it in place. This is a deliberate departure from the platform’s usual boot-seed rule (insert-if-absent,ON CONFLICT DO UPDATE, an operator’s row never touched by a reseed): it is safe here only becausechoice_alternatehas no operator write path (nothing but the seed ever writes one) and itspositionis a packed 1..n sequence within a choice, where a leftover orphan does not sit inert, it collides with the position a renamed or reordered entry now wants. It is a narrow exception for a position-ordered seeded child of a table nothing else writes, not a precedent: a seeded entity with an operator write path, or without a packed ordering an orphan can collide on, keeps the ordinary insert-if-absent rule. -
Context: Task 5 of the identity-model epic (#626) shipped the retirement (
ca78bd3,dbfa284) without filing this entry at the time; it is recorded now, against the gap, by Task 9 of the same epic. The forcing function was the typed-slot guard the prior slice had just landed (c006c62): once a component fills a role because its product classifies within an acceptedcomponent_type, gating the same assignment on a second, independent capability set was two guards asking the same question in different vocabularies, and the capability registry (capability,alarm_capability,component_capability,product_capability,system_role_capability, five tables) added a maintenance surface, an editing UI, and a resolved-set computation (EffectiveCapabilities) that nothing left standing needed. Health’s own reasoning for routing through capability (ADR-0050, “capability is the only vocabulary shared by the thing that breaks and the thing that cares”) no longer holds once assignment routes through type instead: the component itself, not a named fact about it, is what a role now cares whether is up. The 409/422 line and the boot-seed carve-out are recorded here rather than left as implicit code comments because both are the kind of local call a later slice reads out of context and either contradicts by accident or copies somewhere it does not fit; the boot-seed carve-out in particular must not be read as license to delete a shipped row for any other seeded entity, which is why its two preconditions are stated explicitly. -
Supersedes: ADR-0049 in full:
role_capability,component_capability, andEffectiveCapabilitiesare gone, not merely superseded in wording, and the typed-slot guard is what a role now requires. Amends ADR-0050: its chain item 1 (capability as the routing key) retires; items 2 through 5 (the pure judgement package, the transition-only record, recompute-at-the-write, and a report computing what it serves) are undisturbed and this entry changes nothing about them. -
Tracked under epic #626.
ADR-0088: A placement change is an authorization act, so a move is its own verb
Section titled “ADR-0088: A placement change is an authorization act, so a move is its own verb”-
Date: 2026-08-08 | Status: Accepted | Pages: core entities, API, identity and access
-
Decision:
parentandlocationleave thePATCHbody ofcomponent,system, andlocationentirely (both the API input structs and the storagePatchstructs) and become a newPOST /<collection>/{ref}:movecustom method, carrying{location?, parent?}on component and system (at least one required, 422 otherwise) and{parent}only on location.:moveis gated by a new, single-word permission token,<resource>:move, distinct from<resource>:updatethe same way:renameis distinct from it, seeded ininternal/seed/roles.yamlbesiderenamein the same slice its route lands (Operator and Deploy getcomponent:move; Deploy also getssystem:moveandlocation:move; Administrator’ssystem/locationgrants gainmove).:movewrites a DISTINCT audit verb,move, not the genericupdatea PATCH wrote (a loosely worded “the move is auditable” acceptance criterion was already satisfied by the old row, whose JSON happened to containparent_id, without forcing anything).:movenever callsRecomputeHealth: a component’s or a system’s own reparent never has, and a component’s relocate never has either (a component’s verdict is purely its own active alarms, unaffected by placement). The one exception, stated so it is not mistaken for an oversight: a system’s relocate (itslocationfield, notparent) keeps recomputing health at both the location it left and the one it arrived at, exactly asUpdateSystem’s combined patch already did (TestHealthMovesOnRelocationis the load-bearing proof); the health rollup runs system -> location, and a system’s own location is a direct input to that rollup the way a component’s placement no longer is post-#626, so “a placement move never recomputes health” holds for every reparent and every component relocate but not for this one field. This is a correctness statement, not a coverage claim: a second, known-and-tracked gap sits beside it.locationVerdictalso rolls up recursively through the location tree (a system’s location resolves upward to every ancestor, and a location’s own verdict folds every system in its subtree downward), so a location with placed descendants that moves to a new parent leaves both the old and new ancestors’ recorded verdicts stale, exactly the shape a system relocate closes but nothing closes for a location move. This is not new here:UpdateLocation’s old reparent branch never recomputed health either, soMoveLocationcarries the gap forward rather than introducing or closing it, per the same “:movedoes not add new recompute calls” ruling. Tracked as #642, not fixed in this task. -
Decision (the gap this closes):
UpdateComponent’s andUpdateSystem’s old reparent branches guarded a rejected reparent only on the non-empty case (if patch.ParentName != nil && *patch.ParentName != ""); an explicit empty string skipped the guard entirely and setparent_idtoNULLwith no scope check at all, whileCreateComponent/CreateSystemalready refused a root placement (create.Allrequired) to a caller without an all-scoped grant. A component- or system-scoped (not all-scoped) principal, who could already write anything inside its own subtree, could clear a row’s parent and walk it out of every subtree scope it had ever been placed under, with no check the create path itself would have refused.MoveComponentandMoveSystemnow requireaction.Allon the same branch, closing it;TestScopedPrincipalCannotLiftToRootand its system twin are written from scratch, since every existing lift-to-root test ran with the all scope and none would have caught the gap or would turn red from the fix.The argument is written on
parent_id, notlocation_id, because it is the true, checkable one: component and system scope is own-tier only today (a component’s scope tree is its own ancestor chain, unrelated to a location’s), so alocation_idclear cannot lift a row out of a scope that never covered it in the first place, and the cross-tier cascade that would make a location-scope argument meaningful is a tracked later slice (#10). The durable framing that also covers that latent hole once it lands: a placement change is an authorization act, whatever tier it crosses, not merely a field write that happens to touch two more columns than a rename touches one. -
Decision (the deliberate asymmetry):
MoveLocationdoes not gain a clear-to-root capability.UpdateLocation’s reparent branch never had one either (an explicit emptyParentNameresolved nothing and was already a 422,ErrParentNotFound, before this split), so there was no gap to close there, and adding clear-to-root now would be a new product capability nobody asked for, not a security fix riding along with this task. The asymmetry with component and system, which DO gain a guarded clear-to-root, is intentional and stated here so a future reader does not read it as a missed spot. -
Decision (the transaction split):
MoveComponentandMoveSystemare separate gateway functions with their own transaction and their own audit row, not a shared statement withUpdateComponent/UpdateSystem.UpdateComponent’s old UPDATE carried a three-stateCASEacross four columns (display_name,product_id,location_id,parent_id) in one statement; splitting placement out splits that statement, so an operator gesture that used to change both a label and a placement in one PATCH now costs two requests and writes two audit rows if the caller wants both. This is the same tradeoffRenameComponent/RenameSystem/RenameLocationalready established for name-plus-other-field edits (ADR-0076), chosen deliberately here for the same reason rename earned its own act: a placement change is an authorization act, not a label edit, so it deserves its own grant and its own audit trail entry rather than riding along with whatever else a PATCH happened to touch. -
Amended by ADR-0092: the known-and-tracked second gap this entry records (a location move leaving both ancestor chains’ recorded verdicts stale, #642) is closed. The ruling is unchanged in principle and gains a second member of the same exception class it already carved out for a system’s relocate:
:moverecomputes health exactly where the rollup genuinely depends on the placement being changed, which is a system’slocationand a location’sparent, and nowhere else. -
Context: Task 13 of the identity-model epic (#627). The two storage placement test files (
components_placement_test.go,systems_placement_test.go) and the placement end-to-end tests moved to the new verb rather than being deleted; the compiler found every storage caller once thePatchstruct fields were removed, the HTTP end-to-end callers (map[string]anybodies, resolved only at request time) did not, and were found by re-reading everyPATCH .../parentandPATCH .../locationcall site by hand.location:checkNameis unaffected: it is the advisory placement-availability precheck, unrelated to:move, and was not touched. -
Tracked under epic #627.
ADR-0089: A uuid is the address, a dotted path is a positional lookup
Section titled “ADR-0089: A uuid is the address, a dotted path is a positional lookup”-
Date: 2026-08-08 | Status: Accepted | Pages: core entities, storage, identity and access, API, glossary
-
Decision: An entity has two resolvable references, different guarantees, both legitimate and labelled as such. The uuid is the address: immutable, surviving rename and move, the only reference the platform itself ever generates or persists. The dotted path is a positional lookup: human-typed, resolving to whatever occupies that position now (
boi.17c.415a.$comp.display-1after a panel swap resolves to the replacement, which is the point of a positional reference, not a defect of one). The containment rule: a reference the platform generates is always a uuid; a reference a human types may be a uuid, a bare name, or a dotted path. The console persists and addresses every read and write byn().raw.id(web/src/pages/Components.tsx,Systems.tsx,Locations.tsx), so a stale path can only ever exist where an operator typed one, into a CLI, a runbook, or a hand-built request. Because a platform-owned name recomputes at every:move(ADR-0090), a path built entirely from platform-owned segments cannot itself go stale between the read that produced it and the write that consumes it; only an operator-owned segment can drift, and only because an operator chose to rename it.This does not reopen ADR-0079’s one-name-rule collapse (#586). That decision killed a NAME that could itself contain a dot (
icmp.rtt-avg, a keyspace catalog key); today every name, keyspace or entity, is exactly one kebab segment, no dots, at most 100 characters, on the one rulestorage.ValidateNameapplies everywhere (internal/storage/name_rule.go). What this decision adds is a reference, syntactically distinct from a name, that concatenates several individually-valid single-segment names with.and a$accessor($comp,$sys,$role) into one positional path.icmp.rtt-avgwas the last name a table’snamecolumn held with a dot in it, backfilled dot-free by #586 itself (icmp.rtt-avgtoicmp-rtt-avg); no name column has held one since, and this epic adds none: a dotted value is never stored, only resolved.The allowlist is the load-bearing fact. The entity name rule,
^[a-z0-9][a-z0-9-]*$(internal/storage/name.go), is an allowlist, not a denylist of characters someone remembered to exclude. A name can never contain a separator or wildcard from any protocol, chosen or not yet chosen:.(path segments),$(accessors),*and>(NATS),/and#(MQTT),%(URL escaping), or:(reserved by the router for a custom method’s verb suffix,POST /components/{ref}:rename: a name admitting:would makerm215a:renameambiguous between the entityrm215a:renameand the entityrm215awith the:renameverb, so the allowlist excludes it for the same reason it excludes everything else on this list). That single property is what lets one grammar render as a CLI argument, a REST path segment, or a NATS subject with no escaping anywhere, and lets most segments render as a DNS label or an email localpart, since the character set alone is a subset of both: what the allowlist does not guarantee is a segment’s fit under DNS’s own 63-octet label ceiling (the 100-character entity limit is wider) or DNS’s ban on a trailing hyphen (^[a-z0-9][a-z0-9-]*$legally admitsabc-); both are a render-time concern for whichever segment eventually feeds a hostname, not a naming rule this decision enforces. An allowlist still composes across every namespace it has not met yet, where a denylist has to be re-audited for each new one.A percent-encoded slash arrives already decoded. The HTTP handler decodes a path parameter before the address parser ever sees it (verified against the router, not assumed, before Task 12 built on it), so a caller cannot smuggle an extra path level past
ParseAddressby encoding a/or a.. Every segment, in the location root, the plane tail, and the role name alike, passes the entity name rule insideParseAddressitself (internal/storage/path.go:74-77) before any of it reaches a query: validation is structural, not a property of what happens to already be in the database.A dash render and a bare render are display-only, and neither is a form the resolver ever treats as a path.
RenderDash(boi-17c-216b-display-1) strips the accessor;RenderBare(boi17c216bfp1) further compacts the final segment to the component type’sabbrevplus its ordinal and drops every separator, hyphens included (internal/storage/render.go). Both exist for labelling only (a cable tag, an asset sticker, a compact row sub-line): accessor-stripping is lossy for the dash form and stem-compaction is lossier still for the bare form, so neither round-trips throughParseAddress/resolvePath. Both also still satisfy the entity name rule on their own (letters, digits, and hyphens, no dot or$), so a dash or bare string handed back to the resolver is not refused as malformed:ParseAddressreports it is not an address at all and it falls through to the ordinary bare-name path, almost always matching no row, an unremarkable 404 rather than a distinguishable error. -
Decision (six edges, recorded rather than hidden): the read path (
scopedByNameInScope,refPolicyHide) and the write path (resolveScopedRef,refPolicyForbid) share one primitive,resolveRef(internal/storage/scopedcrud.go:521-546), but they do not share every guarantee, and the gaps below are shipped as stated limits, not silently left for the next reader to discover.resolveRef’s write-path policy proves “writable here,” not “readable here.”resolveScopedRef(scopedcrud.go:633-649) narrows candidates by the caller’s create- or action-scope, not its read scope. A resolved reference is one the caller may place a binding against, which is a narrower claim than the scope ruling’s own wording (“scope decides before ambiguity does”) suggests on its own. Amended (#700): that claim was too weak for a CROSS-TIER placement reference, and the amendment narrows which references the edge covers rather than changing the policy itself. A create’s or a move’s location and system references (CreateComponent,CreateSystem,MoveComponent,MoveSystem) resolved existence-only, because the caller’s create scope is resolved for the entity being written and can never match the referenced tier’s own ancestor chain. Once ADR-0100 put placement into the label data map, the label those writes stamp and hand back is rendered from the referenced row’s label, so “writable here” stopped being the right question and “readable here” became it. They now go throughresolvePlacementRef, which applies the READ path’s policy (scopedByNameInScope,refPolicyHide) using the caller’s scope on the REFERENCED tier, matching what:renderLabelalready did for the identical references (ADR-0104) so a preview and the create it previews cannot disagree.resolveScopedRefkeeps every same-tier parent and owner reference, where this edge still reads as written. Amended again (#705):CreateNodeandUpdateNodebind a location the same way and were the one caller-supplied placement reference left resolving existence-only, so they take the same seam. The node tier reaches it by the plain invariant rather than by the label argument above: no node label rule exists, so the write stamps nothing from the location it names and discloses nothing about it, but ABAC scope is injected on every applicable query and a reference resolved without it is an exception to that rather than a carve-out anybody decided on. The refusal is the same non-disclosingErrLocationNotFoundan absent location gives.- A bare-name
forbiddenis a name-existence oracle. A name matching at least one row, none of them in the caller’s action scope, iscfg.forbidden(403), not the read path’s non-disclosing 404 (scopedcrud.go:469-475). A caller can learn a name exists somewhere in the estate from the status code alone. This predates the epic and is tested (interfaces_scope_test.go:82), and it is now asymmetric with the read path this branch tightened (scopedByNameInScopefolds the same two cases into one 404), by design: several routes’ contracts already depend on the 403/404 split a caller supplied its own reference into, which is not the new disclosure a read’s uuid would be. - An ambiguous
?system=on a component’s tags read is a new, narrower oracle, preferred over what it replaced.ResolveTags(internal/storage/tags.go:449-459) now 409s acomponent:readcaller whoseforSystemfilter matches more than one system, redacting candidates (withoutCandidates). A caller with nosystem:readgrant learns two systems share that name. This is a deliberate improvement: the alternative silently seeded the tag cascade from whichever system a bare-name lookup happened to resolve to, returning a wrong answer with no signal at all. - The
$roleaccessor parses but resolves nowhere.AddressRole(internal/storage/path.go:35-42) is syntactically real:boi.17c.$sys.av.$role.primary-dspparses to a well-formedAddress.addressKindTable(scopedcrud.go:247-263) has no table for it, soresolvePathreports the sameErrPathNotFoundit would for any other plane mismatch, a non-disclosing 404 that does not distinguish “role addressing is not built” from “this role does not exist.” Reserving the grammar ahead of its resolution is a stated decision here, not a gap found later: the day a system-role read exists, it inherits a grammar it never has to renegotiate. - The boot-seed reconcile-and-delete carve-out is a separate pattern, cross-referenced, not
restated.
choice_alternate’s every-boot reconciliation (ADR-0087, storage) is about which seeded rows a reboot may remove, not about how a reference resolves or who holds the pen on a value; it shares no mechanism with this decision or with ADR-0090 and is named here only so a reader does not conflate the two governance patterns. - The tier guard inside
resolveRefis forward insurance, not proof.resolveRefpanics if the caller’s scope was resolved for a resource that does notCoverthe config being checked (scopedcrud.go:521-524). Every one of the 29 non-test call sites ofscopedByNameInScopeandresolveScopedReftoday passes aresourcelabel already derived from the same config it is checked against, so the guard cannot fire on any input the current code produces, and a green suite running with it live proves nothing beyond that (scopedcrud.go:507-517, in the code, says so explicitly). It is insurance for the next call site that copies a pattern without updating the label. It also inherits a blind spot fromscope.Covers: forsecret,variable,field, andtelemetry, every tier is admissible, so the guard tells a right family from a wrong one but not a right tier from a wrong one within that family, exactly the shape a real cross-tier regression took in this epic’s own review before a scoped test, not this guard, caught it.
-
Extends ADR-0062: that decision’s dual-accept clause already said a reference “resolves whichever form it is given”; this decision makes the third form literal rather than aspirational.
loadByRef(internal/storage/scopedcrud.go:158-202) tries a uuid, then a parsed dotted address, then a bare name, in that order, for every scoped tree entity. -
Amends ADR-0076 in justification, not shape:
:renamestays exactly the gated custom method ADR-0076 built, and it earns a second job under ADR-0090: it is now precisely the boundary where an operator takes the pen from the platform’s name-tracking. What changes is why the ceremony still earns its keep. ADR-0076 accepted “an external reference held as a name breaks on rename, and nothing on the server can repair it” as the renameable identifier’s cost. Once the uuid is the address that argument is repaired: an integration holding the id survives every rename, and one holding a path is a positional lookup honestly reporting whatever occupies that position now, not a broken link. What remains, and is sufficient on its own, is the permission split (an operator trusted to editdisplay_nameis not thereby trusted to rewrite the identifier colleagues type) and the pen-taking act itself. -
Context: every estate name was globally unique before this epic, so operators hand-encoded the building into every name and the encoding went stale on every move, twice per room (location and system). ADR-0076 already made the id the durable reference and the name the renameable display key; this decision (identity-model epic #627, Tasks 10 through 12 and 15) makes the id literally an address a caller composes, types, and resolves, and gives a human a second, positional way to reach the same row without inventing a third identity field. The path grammar was reserved, syntax-only, in
internal/storage/name.gosince ADR-0076; Task 10 made every internal owner-resolve id-based ahead of the placement-scoped uniqueness DDL (Task 11,db/migrations/20260808090000_names_scope_to_placement.sql), Task 12 built the parser and resolver (internal/storage/path.go,resolvePath), and Task 15 put the resolved path, its segments, and both renders on the wire and pointed the console at uuids exclusively. Task 13’s:moveverb (ADR-0088) and Task 14’s name generation (ADR-0090) are what make a platform-owned segment of a path trustworthy rather than merely typeable. -
Tracked under epic #627.
ADR-0090: A derived value is a default that tracks until touched
Section titled “ADR-0090: A derived value is a default that tracks until touched”-
Date: 2026-08-08 | Status: Accepted | Pages: core entities, storage
-
Decision: when the platform can compute a value an operator would otherwise type, it follows one rule with four binding clauses, and none of the four make the value a constraint: a derived value can prefill a field but it can never cause a write elsewhere to be refused.
- The platform fills. A component’s
nameis minted at create when an operator leaves it blank:<component_type stem>-<n>, the ordinal the smallest positive integer no sibling matching that stem already claims in the same placement scope, a sibling on a different stem never blocking it (generateComponentName,internal/storage/namegen.go:129-168). - A platform-owned value tracks its facts. While
component.name_generated = true, the name recomputes inside the same transaction as whatever changed its inputs: a:moveto a new placement (internal/storage/components.go:632-655) or an ordinary productPATCHthat reclassifies the component to a new type (components.go:470-496). Both ride the causing write’s own audit event, old and new name in its payload; a platform-driven recompute is never itself a:rename. - The operator owns it on first touch.
:rename(components.go:706-727) clearsname_generatedtofalseunconditionally, whether or not the row was already operator-owned, and from that write the platform never recomputes the name again no matter how the facts move afterward. - The operator can hand it back.
:resetName(components.go:744-774, gated by the samecomponent:renametoken:renameuses, since both change the identifier) regenerates the name from the component’s current type and placement and setsname_generated = trueagain, whether or not it already was.
The contrapositive is the other half of the rule: a value the platform must own unconditionally (a health verdict, a resolved effective-tags set) is computed on every read, never stored as a default an operator could edit into a lie. The boundary between the two is whether the operator is allowed to disagree with the platform’s answer.
- The platform fills. A component’s
-
Decision (the migration default deviates from the epic’s own wording):
component.name_generated boolean not null default false(db/migrations/20260808090000_names_scope_to_placement.sql:39-46) shipsDEFAULT false, not theDEFAULT truethe epic issue specified. This is deliberate: every component row that exists before this column lands was operator-typed under the pre-#627 model, where no generator existed to have picked a name for it.DEFAULT truewould hand the platform a pen it never earned over real, pre-existing operator data and let the very first:moveagainst one of those rows silently rename it. The gateway writes the flag explicitly on every insert from Task 14 onward,trueon a generated create andfalseon an operator-typed one; the column default describes only a row this migration found already sitting in the table, never a row created after it. -
Context: the same shape resolved independently twice on this branch before it was named as one principle, which is what promotes it from a per-feature habit to a rule to check new work against. A component’s name (Task 14) is the built case above.
createIdentity(web/src/lib/entities.ts) already ran clauses 2 and 3 client-side for a catalog entity, deriving a slug from a display name live and freezing it on first edit, before this decision gave the server-side pattern a name. The product classification floor (ADR-0086) is the contrapositive’s own evidence: a silent schema default onproduct.kindlet a mislabeled cloud service read as correct forever, exactly the failure clause 1’s explicit fill and clause 3’s no-silent-default guard against, which is whykindis required at create rather than defaulted. A system’s ownlocation_id(internal/storage/systems.go) is a live counterexample to clause 2, worth naming because a reader could otherwise assume every platform-computable field tracks live: it is authored at create and changed only through:move(ADR-0088), never derived fromsystem_memberrows, so clause 2’s live tracking is confirmed here as a per-field policy choice this decision’s own build makes differently for a system’s placement and a component’s name, not a mandate every derived field must take. The boot-seed reconcile-and-delete carve-out (choice_alternate, ADR-0087, storage) is a related but distinct pattern, about which seeded rows a reboot may remove rather than who holds the pen on an operator-facing value; it is cross-referenced here, not restated. -
Tracked under epic #627.
ADR-0091: An update_mask says which fields a PATCH writes
Section titled “ADR-0091: An update_mask says which fields a PATCH writes”-
Date: 2026-08-09 | Status: Accepted | Pages: api
-
Decision: a
PATCHbody may carry an optionalupdate_mask, with AIP-134’s semantics exactly: an absent mask is the implied mask of the fields the body populated (a non-empty value), which is what everyPATCHin the tree already did; a present mask writes exactly the fields it names, populated or not, so a named field carrying its zero value clears;["*"]is full replacement, the equivalent of aPUT, and cannot be combined with named fields; and a mask naming a field the resource does not patch is a 422 naming the field, never a silent no-op. Top-level field names only, spelled as the wire body spells them. It rides in the request body, not the query string. It is built as a primitive (internal/updatemask, a pureResolveover three lists) and consumed by one caller, the role declarations, which are converted fromPUTtoPATCHin the same change. -
Context: the API could set a field and could narrow one, but for anything that is not a string it could never unset one.
system_role.capacityis an integer with no empty-string sentinel available, so once an operator set a cap, raw SQL was the only way back to unbounded (#638). That is not acapacityquirk, it is a property of every non-string optional field the API will ever have, and AIP-134 has no answer for it through the implied mask, whose whole definition is “fields that are populated”. The mechanism it gives is the explicit mask.Why the body, not the query. Google puts the mask in the query because gRPC transcoding binds the request body to the resource itself, leaving nowhere else for it. There is no gRPC here, Huma models a body as a typed struct, and a body field generates cleanly into the OpenAPI document, the typed SPA client, and a CLI flag with no hand editing. Recorded because it is expensive to reverse once clients exist.
The three-state string sentinel stays. An omitted field unchanged, an explicit
""clears, a value sets (emptyPtrToNil,internal/api/products.go) is already the house convention for an optional string reference, taught by the CLI reference on:moveand the system patch. The mask generalizes clearing to everything that is not a string; it does not retire the sentinel, which is a larger ripple across the docs and the CLI reference and should not ride along with the mechanism that would eventually replace it. Where the two meet, the sentinel wins the definition of “populated”: a pointer to""counts as populated (the caller said something, and what they said was “clear it”), which is what keeps the role alternate’s explicit detach path working.No retrofit. The other 108
PATCHregistrations accept no mask and are not changed. They stay correct for free: an absent mask IS the implied mask, so their behavior is byte-identical, proven by the suite staying green with no expectation edited. Retrofitting is a per-route decision about which fields are patchable, not a mechanical sweep, and doing it under one slice would have made the primitive impossible to review.The roles conversion is the proof, and it changes wire behavior. The role declarations were a
PUTcarrying two semantics at once:capacityandalternatepreserved on omit whiledisplay_name,quorum,impact,position_labels,accepted_typesandpinned_productsreplaced wholesale, so a write carrying onlydisplay_namesilently reset the role’s impact todegradedand dropped its labels and its typed slot (#639). Under the implied mask the whole body reads one way. Two consequences are deliberate rather than incidental: an empty list is not a populated field, so[]now means “unchanged” where it used to clear, and clearing a list means naming it in the mask (the console’s role editor therefore names every field it owns, and nothing it does not); and the declaration routes becomePATCH, which AIP-134 requires and which matters becausePUT“becomes a backwards-incompatible change to add fields to the resource”, and fields are about to be added. ThePATCHstill CREATES the role when it is absent, which AIP-134 would gate behindallow_missing: there is no other create path for a declaration (the role is addressed by name within its owner, so declaring and revising are the same idempotent write), and a flag whose only legal value istrueteaches nothing. The binding-stylePUTroutes (members, the*_propertiesand*_metricsassociation routes) are untouched: they set an association wholesale rather than updating a resource.
ADR-0092: A location move recomputes both ancestor chains
Section titled “ADR-0092: A location move recomputes both ancestor chains”- Date: 2026-08-09 | Status: Accepted | Pages: health, API
- Decision:
MoveLocationrecomputes health when it changes a location’sparent, inside the transaction the move already opens, over BOTH ancestor chains: the one the location joined and the one it left. This is the second and last member of the exception class ADR-0088 carved out for a system’s relocate, not a reversal of its “a placement move never recomputes health” ruling::moverecomputes exactly where the rollup genuinely depends on the placement being changed, which is a system’slocationand a location’sparent, and nowhere else (a component’s or a system’sparentstays health-inert, and so does a component’slocation, since a component’s own verdict is purely its active alarms per ADR-0087). The trigger names ONE row per side, the moved location and the parent it left, not a walked chain:locationsOveralready takes its named locations as the seed of a recursive ancestry CTE, so each named row carries every ancestor above it, and walking either chain in Go first would reimplement in a second place the walk the query performs. Resolving the old chain after the write is equally safe, because the only parent edge the write touches is the moved row’s own, so the old parent’s ancestry reads the same before and after. A:movethat changes no parent (the documented no-op) recomputes nothing, the same guard the system relocate applies. - Context: #642, filed by ADR-0088’s own
review round and closed here. The gap predates the
:movesplit:UpdateLocation’s old reparent branch never recomputed either, so nothing has ever recorded this edge. It matters because a location’s verdict is the one rollup that genuinely depends on where the row sits:locationVerdictfolds every system in the location’s own subtree, walked downward by a recursive CTE, so a location with placed descendants moving to a new parent really does change what its old and new ancestors should read. Left open, an operator reorganizing a campus would see the abandoned branch frozen at the verdict of a room that is no longer in it, and the new branch reading healthy over a room that is. The reads never lied (both health reads compute the verdict they serve), so the cost was confined to the recorded history, which is exactly the “a missing trigger is a hole in the history” cost the health page enumerates its trigger list to avoid.
ADR-0093: The tag cascade follows the component it resolves for
Section titled “ADR-0093: The tag cascade follows the component it resolves for”- Date: 2026-08-09 | Status: Accepted | Pages: tags, identity and access
- Decision: Effective tags are authorized by the component the cascade resolves for, and by
nothing else. A caller who can read that component sees every value that cascades onto it,
including values owned by a system or a location the caller could not have listed directly. The
?system=parameter is a filter over that answer, choosing which membership seeds the system band, and never a widening of it:seed_sysalready requires the named system to be one the component belongs to. Per-band scope checks are deliberately NOT applied. - Context: #641 proposed resolving
?system=through the caller’s read scope. Three findings closed it as the posture we already hold rather than a defect. First, the obvious implementation is the exact change #627 made and reverted as a critical:ResolveTagsresolves its scope for component (the route is gated oncomponent:read), andinScopeTreewalks the target table’s own chain, so a component-tier id can never appear in a system’s ancestor chain and every non-all caller’s band silently vanished, a wrong answer with no error rather than a refusal.TestResolveTagsSystemBandSurvivesScopedCallerstands as that regression and asserts the band IS present for a scoped caller. Second, the check would not close the disclosure it named: the same values reach the same caller through the primary-membership default seed, throughsys_chain’s ancestor walk above the seeded system, and through the deliberately scopeless batch resolver behind the directory’s Tags column. Third, and decisively, the cascade is a property OF the component: a value that resolves onto a component the caller may read is part of that component’s configuration, and withholding it would report a resolved configuration that no longer resolves to what the platform actually applies. The alternative, a per-band scope-filtered cascade, is a coherent design and a much larger one (per-tier scopes threaded from the API, a rule for the default seed and the ancestor walk, redaction of shadowed candidates, a batch contract, and that regression rewritten). It is not ruled out forever; it is ruled out as a patch, and it would need a threat model that wants it.
ADR-0094: Benchmarks are the second performance instrument, and they gate nothing
Section titled “ADR-0094: Benchmarks are the second performance instrument, and they gate nothing”- Date: 2026-08-09 | Status: Accepted | Pages: test-driven
- Decision: Performance has two instruments, and only one of them gates. Round-trip counting
(
internal/storage/storagetest/querycount, asserted inlist_cost_test.go) stays the deterministic net that runs inmake testand blocks a merge. Wall-clock benchmarks (make bench, ten benchmarks over the real Gateway at two estate sizes) are diagnostic only: run deliberately before and after a change that claims a performance effect, and compared withbenchstat. Counting catches the N+1 exactly; benchmarks catch what counting is blind to, which is everything inside a single statement (a dropped index, a plan flip to a sequential scan, a recursive CTE that stops being bounded, a predicate that stops being sargable). Every benchmark runs at a small estate and a larger one, because one size cannot tell a constant apart from a linear cost, and every fixture is built outside the timed section, because a benchmark that provisions inside its own loop measures the harness. - Not chosen, and why each was refused rather than deferred by accident:
-
No CI job and no merge gate on a duration. A wall-clock threshold on a shared runner either sits so loose it catches nothing or it flakes and gets muted. A perf job everybody ignores is worse than none, because it reads as coverage. A Go benchmark is inert without
-bench, so this is the natural shape rather than a compromise. -
No stored baseline artifact.
benchstatcomparison needs a baseline per commit or per release plus a policy for when a regression blocks a merge, which is real infrastructure that should be built because it is needed, not because benchmarks felt like diligence. A number committed to the tree is a number from a different machine on a different day. -
No
EXPLAINplan assertions, deliberately deferred rather than dropped. They are the sharpest tool for the dropped-index class and cost no timing at all, but a plan depends on table statistics, so a fixture too small plans differently from production and the assertion pins the wrong shape. Revisit when a fixture is realistic enough for the planner to agree with production, or when a specific plan regression proves the benchmarks insufficient.Amended (#725): the deferral stands for the plan a planner prefers and is lifted for an access path a query can reach, which is a different question with a different dependence on statistics. Neither revisit condition above is met (no fixture here is production-sized, and nothing regressed a plan) and neither is claimed: what ships is narrower than what was deferred. Planned with
set enable_seqscan = off, which prices the sequential path out rather than forbidding it,EXPLAINanswers “which index is this relation reachable by”, and that answer does not move with the fixture’s size: measured over the health reads on an empty database, on a 45-row fixture with no statistics at all, and on the same fixture analyzed, the join shapes around the scan differed in all three (hash join to merge join, a sort appearing and disappearing) while the scan ofpropertywas the same index scan carrying the same index condition in every one. The instrument isinternal/storage/storagetest/accesspath, it gates inmake testas counting does, and three rules bound it. Assert the access path of one relation, never the plan’s shape. Assert the index condition as well as the index name, because a coerced predicate or a leading column dropped from the filter leaves the index NAMED in the plan and walked rather than searched, which reads as a pass. And never assert a duration or a preference, which is this entry’s standing invariant, untouched. It earns its place on the class no other instrument here can see: a partial index can sit inpg_indexesand be unreachable by the read it was built for, because its predicate stopped being provable from the query’s own clauses, and the statement count is identical either way. -
No timing assertion anywhere, in a benchmark or a test. This is a standing invariant, not a property of this slice.
-
- What the first run measured, because a benchmark set is only as honest as its floor. One pool acquire and one empty statement costs about 265us on the dev box, and every other number contains one copy of that per statement the call issues. The reads land between 0.7ms and 5.7ms with a run-to-run spread near 5%, so a plan regression in them is visible. The health RECOMPUTE chain does not: a raise-and-clear pair costs about 22ms across 58 statements, so roughly three quarters of it is transport that no planner decision can move, and its spread is three times the reads’. It was built, measured, and deliberately not shipped, because a benchmark that cannot detect the regression it appears to watch is worse than none. A path that round-trip-bound is counted, not timed, which is the same reasoning that made counting the first instrument.
- Context: #651, filed out of Fred’s question during #643: “how do we know if performance decreased or increased”. #650 answered the counting half. This is the half counting cannot answer, and it was deliberately sequenced after #649 replaced per-test migration with a template-database copy: while provisioning was about 90% of a storage run, timing anything on top of it measured the harness.
ADR-0095: An operator forks a shipped registry row instead of the platform writing it
Section titled “ADR-0095: An operator forks a shipped registry row instead of the platform writing it”-
Date: 2026-08-09 | Status: Accepted | Pages: storage, core entities, API
-
Decision: An operator’s edit of a shipped (
official: true) registry row never writes that row. It writes a shadow: the operator’s version of the row’s mutable columns, stored in one registry-agnosticregistry_shadowtable keyed(registry, row_id)whererow_idis the shipped row’s own uuid. Every read resolves the shadow over the official row, and restore-to-defaults (POST /component-types/{id}:restorefor the first adopter) is deleting the shadow. The official row is neither updated nor deleted by any operator action, so a release can improve it without stomping anyone, and a delete of a shipped row stays refused whether or not it is forked: a fork is an overlay, not ownership.component_typeis the first adopter; the other registries carryingofficialadopt as their slices land, needing no schema of their own.Three sub-decisions carry the shape, and an adopter must keep all three.
1. The shadow is keyed on the shipped row’s uuid, in one generic table, not by a
namespacecolumn on the registry. The rejected alternative wasnamespace textwith the unique relaxed from(name)to(namespace, name). It fails on addressing, which is the thing this repo has already settled twice. Relaxing the unique makes every name-keyed lookup on the registry return two rows, andQueryRowtakes an arbitrary one; that is eight call sites oncomponent_typealone, several of them in helpers shared with twelve other registries (guardTypeMutable,registryAuditImage,deleteTypeRow,requireRegistryRow), so a single missed site is a silent wrong-row bug rather than a failure. Worse, a same-table shadow needs its own uuid, giving one logical row two, whileproduct.component_type_idandrole_component_type.component_type_idare foreign keys tocomponent_type(id)that would keep naming the official one: the fork would not take effect for the rows that matter, and theida write echoes back would change on fork, which is the namespace leaking into the URL that #655 forbids. The literal(namespace, id)composite primary key the issue sketched makesidnon-unique and forces dropping all three inbound foreign keys, including theON DELETE RESTRICTbought by #507. Keying the shadow on the shipped row’s uuid keepscomponent_type_name_keya global unique, keeps every foreign key and every existing lookup matching exactly one row, and reduces the change to what a read resolves to rather than what anything addresses. Generic rather than one shadow table per registry because twelve more registries adopt later and the per-type rule columns of #657 add columns to them: a typed shadow table would double the DDL cost of every future column on every adopter, and this costs none. The polymorphic(registry, row_id)reference is the shapeaudit_log’s(resource, resource_id)already uses.2. A fork captures the WHOLE mutable row, not only the edited column. So a later release’s improvement to a column the operator did not touch does not reach a forked row: the operator took the row over, and restore is how they hand it back. The alternative, a sparse overlay where unedited columns keep tracking the release, is not merely a different trade here, it is unrepresentable:
stem,iconandabbrevare nullable and null already means “inherit from the nearest ancestor”, so a sparse overlay would need null to mean both “not overridden” and “inherit”, i.e. a third state and a separate overridden-column set. That is the merge and three-way-diff machinery the issue’s thin cuts rule out. The image is therefore stored as a whole-row jsonb of the mutable columns, each nullable column written as an explicit null rather than dropped, and the resolve overlays the keys the image carries. That last distinction is what makes a column added to a registry after a fork resolve to its official value instead of to nothing, which is the only sane answer for a column the fork could not have had an opinion about.3. Inheritance across the shadow boundary resolves PER NODE, in official structure space. The split that makes it decidable is structure versus facts. Structure is the uuid, the name, the
officialflag andparent_id; a shadow never carries any of them, so a fork restates what a node says and never moves, renames, or re-provenances it. Facts (display_name,stem,icon,abbrev,default_tags) resolve shadow-over-official at each node independently. The walk therefore visits the same node sequence it always did, and at each node reads that node’s effective row before applying the existing first-non-null-wins rule. Forking an ancestor reaches every descendant that does not override the field; forking a leaf does not cut it off from the ancestors it inherits from; a chain with some nodes forked and some not is not a special case, because there is no such thing as a forked chain, only forked nodes. The rejected alternative, per chain (“a forked leaf walks only forked ancestors”), would make forking a leaf silently drop every inherited fact, and it names an object the schema does not have:parent_idis per row. The ruling survivesparent_idbecoming patchable later, which it is not today (ComponentTypePatchhas no parent field and there is no reparent leg): the walk follows the effective row’s parent, so a reparenting shadow would diverge the chain from that node up, which is still per node.Amended (#709): a FOURTH fact, and the one an adopter is most likely to get subtly wrong. Every path that reads a row in order to write its shadow takes the registry row’s lock in a statement of its OWN, before the read that resolves the shadow over it. Sub-decision 2 is what makes the lock necessary: a fork writes the whole mutable row back, so two operators editing different fields of one shipped row at once both compute an image from the same starting point, and the second silently discards the first’s field with no error and no audit anomaly, both writes in the log. What the amendment adds is that
for updateON the resolving read does not do the job. At READ COMMITTED a statement’s snapshot is taken when the statement begins, which is before it blocks; the waiter then unblocks on a row that was locked rather than updated, so there is no EvalPlanQual recheck, and the left join it had already evaluated still reports the shadow as its stale snapshot saw it. That serialises the two transactions without making the second read what the first wrote. It is not a hypothetical:location_typeshipped exactly that lock in #703, and a paired-fork test still lost an edit against it, which is how the shape was found. Locking FIRST makes the resolving read a new statement taking a new snapshot, after the lock is held, so it reads the shadow the previous holder committed. Cost: one round trip on the patch and restore paths of an adopting registry, the same trade ADR-0108 states for the settle paths. -
Context: #655, the first prerequisite of #657. Thirteen tables carry
officialand enforcement was a flat refusal (ErrTypeOfficialon any patch), which is correct as far as it goes and leaves an operator nowhere to go. It also produced an inversion #657 runs into:component_typeandproductseedofficial: trueand are closed, whilelocation_typeandstandardseedofficial: false(example content an estate owns) and are open, so a per-type rule column would be writable on two registries and unwritable on the other two, backwards from where the machinery lives. The alternative on the table was a per-column carve-out from the official lock, which trades one inconsistency for a worse one: some columns of an official row writable and others not, with the rule living in code rather than in the model.component_typeis the first adopter precisely because it is nested, so it forces sub-decision 3 rather than deferring it. -
Open: what happens to a shadow when a later release removes the official row it shadows. The emergent behaviour today is “orphan it, inertly”: resolution is driven by the official row left joining the shadow, so an orphan is invisible rather than corrupting, and the polymorphic key carries no foreign key that would have blocked or cascaded the removal. Surfacing it as a conflict an operator resolves is the alternative, unbuilt.
ADR-0096: The system_type name returns as the coarse space taxonomy
Section titled “ADR-0096: The system_type name returns as the coarse space taxonomy”-
Date: 2026-08-09 | Status: Accepted | Pages: core entities, storage, API, glossary
-
Decision: A new
system_typetable lands: a nested, universally seeded registry saying what kind of space a system is (av / room / {board, class, meeting, training, conference, huddle},av / sign / {video-wall, interactive-sign}), exactly parallel to the waycomponent_typeclassifies a product. It carriesname,display_name,stem,abbrev,icon,parent_id, andofficial;stem,abbrev, andiconare nullable and inherited from the nearest ancestor that sets one, resolved by walkingparent_idin Go.system.system_type_idpoints at it, nullable for now; a floor waits until the shipped tree has proven out. Both foreign keys areON DELETE RESTRICT, and the delete path pre-counts both sides, so a type that still parents another type or still classifies a system is refused with the registry’s own in-use error rather than a raw constraint failure.It does not carry
default_tags, the one columncomponent_typehas that this does not: a product’s instances start from its type’s tag set, while a system’s effective tags come from the platform, its location, and its own system tree, so the column would have no reader.standardis untouched. The two answer different questions and both stay: the type is what a system is, the standard is the blueprint it is built to. -
Decision (the identifier reuse):
system_typewas the old column name for what ADR-0048 promoted tosystem.standard_id, and it sat on the docs-lint denylist. A table by that name, with asystem.system_type_idpointing at it, is a different object from the retired column, so the reuse is safe at the schema level; the collision is in prose, where a reader who greps finds both. The denylist entry is therefore removed, not exempted, on exactly the precedent ADR-0085 set forcomponent_type: an entry cannot express “banned, except in its own reintroduced meaning”, and every sentence teaching the new registry would otherwise need an escape. The retired sense survives in ADR-0048’s own prose under the standing retirement-marker exemption. One fossil is left in place deliberately and now labelled:internal/storage/systems.gomaps the constraint namesystem_system_type_fkey(the pre-rename name of the standard foreign key) alongsidesystem_standard_id_fkey, and the new key issystem_system_type_id_fkey, a distinct string that maps to its own error. -
Context:
standardis already nested (parent_standard_id), so using standard inheritance as the taxonomy was on the table and is the wrong axis. Standard inheritance expresses design forks, two ways to build the same kind of room; the coarse classifier is a different question, it wants a universal shipped seed, and one estate can hold ten signage standards and six classroom standards under a single coarse type. That makes the system side exactly parallel to the component side, wherecomponent_typeis the coarse nested taxonomy with a universal seed andproductis the specific artifact.The forcing function is naming. A generated system name needs a stem in the space vocabulary (
boardroom-2) and a label render needs a compact form (br), and nothing in the estate model spoke either: a standard is a blueprint name (meeting-room-v2), a location type is where the room sits rather than what it is for, and neither is universally seeded. Every identity fact the console reached for turned out to live at the same missing level, the same tell that made ADR-0085’s case. The shipped tree is deliberately not filler: its display names, stems, and abbrevs are the strings the whole naming arc will render from.
ADR-0097: Allocation tests the name it would mint, rather than reading the ordinal it stored
Section titled “ADR-0097: Allocation tests the name it would mint, rather than reading the ordinal it stored”-
Date: 2026-08-10 | Status: Accepted | Pages: core entities, storage, glossary
-
Decision: The number a generated name is minted from becomes a stored, nullable
component.ordinal, written in the same advisory-locked transaction that already computed it. Nullable is the load-bearing part: a name an operator typed, and a name an operator renamed (:renameclears the column in the same statement it clearsname_generated), have no ordinal the platform owns, and absent is how that is written down.Sibling allocation does not read that column. It reads sibling names and returns the lowest ordinal whose minted name no sibling in the placement bucket already holds. One pure function (
mintName) owns the name’s shape, and the allocator asks it for candidates instead of picking siblings apart. -
Decision (no scoped-unique index): The ordinal gets no unique constraint of its own, only a
>= 1domain check. A “one ordinal per (bucket, stem)” index cannot be expressed, since the stem is resolved from thecomponent_typechain rather than stored, and it would be redundant if it could: for a platform-named row the name is the stem and the ordinal formatted together, so two rows colliding on both already collide on the name, which the scoped-name unique indexes (ADR-0088’s placement buckets) refuse. A redundant constraint would add a second23505to map and a second way for a race to surface as a 500. -
Context: #657 proposed that storing the ordinal would let sibling allocation “stop being a string-prefix scan and become a lookup keyed on type and placement”. Building it exposed two problems with reading the column, and one thing the column was being credited with that it does not actually do.
An ordinal-only allocator is unsound. The rows holding a NAME are not the rows holding an ordinal. An operator can type
display-1by hand: that row owns the name and, correctly, no ordinal. An allocator consulting stored ordinals sees an empty set, picks 1, mintsdisplay-1, and hits the scoped-name unique index. The generator deliberately takes an advisory lock rather than retrying a23505(retry was rejected when the generator landed, since the abort takes the whole transaction with it), so that collision is a 500 on an ordinary create. Mutating the allocator to read only rows with a stored ordinal reproduces it exactly, and the test that catches it is now part of the suite.Keying on the type would be worse than keying on the stem. A stem is inherited, so two sibling component types can resolve the same one; giving each its own ordinal space would have both mint
display-1. The uniqueness that matters is the name’s, so the space that matters is the name’s.The column is not what unblocks a stem-less name. The prefix scan could not count a stem-less sibling because its filter was a bare
-that no name matched, which is a property of parsing, not of storage. Inverting the loop fixes that on its own:mintName("", 1)is1, and the allocator tests it like any other candidate. So a floor whose ordinal genuinely is its name (#657 slice 7) is now expressible, and it is asserted at the storage layer against a real database ahead of its first caller.What the stored ordinal does buy is everything downstream of allocation, which is what the rest of the epic needs: the bare render reads a fact instead of re-deriving one from the string it is about to replace (finishing what #654 left half-done, and making its never-restamp-an-operator’s-name guarantee structural rather than defensive), a label rule can read
.Ordinalwithout parsing it back out, and a recompute-and-compare invariant test can prove no stored ordinal has drifted from what allocation would produce. It also generalizes: when a name comes from an operator-editable rule rather than<stem>-<n>, the allocator changes its mint and nothing else, where a parser would have needed a matching second implementation.
ADR-0098: A label rule reads what an entity IS, never where it sits
Section titled “ADR-0098: A label rule reads what an entity IS, never where it sits”-
Date: 2026-08-10 | Status: Accepted | Pages: core entities, storage, glossary
-
Decision: A label rule is an operator-authored Go
text/templateevaluated over a closed, flatmap[string]stringand a closed FuncMap (title,upper,lower,slug). The map carries the entity’s own columns and the facts resolved from its classification chain, and carries nothing about its placement: not its location, not its parent, not the system it staffs.entity keys component Name,Ordinal,TypeName,TypeAbbrev,Stem,ProductName,VendorNamesystem Name,TypeName,TypeAbbrev,Stem,StandardNamelocation Name,TypeName -
Decision (the sandbox is the data map AND the grammar): Two allowlists, the same argument twice.
The DATA half filters nothing. The value type is
string, so field traversal and a method call fail at execution rather than reaching further; a key the map does not carry renders as the empty string (missingkey=zero, set explicitly rather than relied on); a secret, a credential and a token are absent structurally, never put in. That is why a general template engine beat a custom interpolation DSL: the security property a label rule needs is not a syntax that cannot express dangerous things, it is an environment that contains nothing dangerous, and the environment is ours entirely. Adding a key is the only way to widen what a rule can read, so the key set is pinned by a test.The GRAMMAR half is an allowlist over the parsed tree, checked at parse time, admitting a closed set of node types (text, action, pipeline, command, field, dot, identifier, literal,
{{if}}, comment) and a closed set of function names (title,upper,lower,slug, plusand/or/notand the six comparisons). Everything else text/template offers is refused:printf,print,println,call,index,slice,len,html,js,urlquery, variable declarations,{{range}},{{with}},{{template}}and{{define}}.A superseded claim, corrected here: an earlier draft of this entry said
printf’s width verb was “the one way a closed map of short strings still produces output far larger than its inputs” and that the ceiling therefore lived in a cap on rendered length. Both halves were wrong. The cap bounds bytes reaching the WRITER, and a value built inside a pipeline is materialized byfmtand never written, so the cap never sees it: 437 bytes of operator-authored rule allocates 85 MB and writes 8, with every further doubling 35 more bytes of rule for twice the memory, which OOMs the single binary and re-triggers on every later write to any entity of that type. Refusing:=does not fix it (the nested-pipeline form needs no assignment) and neither does removing a FuncMap entry (printfis a builtin the FuncMap never granted). A closed grammar does, and it does it at rule-edit time so nothing is ever stored. The length cap stays as a ceiling on OUTPUT, which is now reachable only by literal text an operator typed. -
Context (why placement is excluded): The label is stored, so a stored value must equal what its rule produces, and every input to a rule is therefore something a write path has to re-render on. The keys above change on exactly five acts, all of them the entity’s own: create, rename, move (which re-mints the ordinal), reclassify, and reset. That closed set is what makes the recompute-and-compare invariant provable rather than hopeful.
Adding the location’s name would add a sixth, and it would not be the entity’s own: renaming a location would silently stale every label under it, and the invariant would be right to fail. The epic’s acceptance text (“a component created with a product and a location gets a label”) reads either way, and is satisfied by the classification alone. The cascade that would make placement facts safe belongs with the slice that owns bulk recompute (#685), which can then add the keys and the cascade together. Ancestry is not lost meanwhile: it is already the entity’s
pathand its two renders, beside the label rather than inside it. -
Decision (the global tier is two columns, not one): The global rule is one row per labelled entity kind in
label_rule, carryingdefault_template(boot-seed space, rewritten authoritatively on every start) andtemplate(operator space, nullable), resolved ascoalesce(template, default_template). Clearingtemplateis restore-to-defaults.One column could not be both. An authoritative boot seed over a single column stomps an operator’s rule on the next restart; a seed-if-absent freezes the shipped default at whatever the first boot wrote and no release can improve it. That is the problem ADR-0095 solves for a shipped registry row, and this is the same answer at the smallest scale that expresses it: shipped values and operator values live apart and reads resolve one over the other. It is two columns rather than a
registry_shadowrow because this table is keyed on an entity kind rather than a uuid, and three rows do not earn an overlay of their own. -
Decision (a rule inherits per node, like every other type fact): On a nested registry a
label_ruleis nullable and resolves by the first-non-null walkstem,iconandabbrevalready follow, crossing the fork boundary per node with the chain always the official one (ADR-0095 decision 3). So forking a shipped ancestor’s rule reaches every descendant that declares none, and a descendant that declares one overrides for itself alone. The analogous question ADR-0095 settled for facts has the same answer for rules, because a rule is a fact of the node that declares it. -
Decision (an empty render stores NULL and keeps the pen): A rule with nothing to say about a row stores SQL
NULL, not a blank string, anddisplay_name_generatedstays true. Marking the row operator-owned because a rule once rendered nothing would exclude it forever from the recompute that exists to fix exactly that. -
Decision (the pen is backfilled, claimed only where there is no label): The migration adding
display_name_generateddefaults it false, and a one-time backfill then claims it on every existing row whosedisplay_nameisNULLor empty, leaving it false where the operator typed something. A row with no label has nothing to protect and is the majority case; left unclaimed it is inert forever, because the write-path stamp returns immediately when the pen is false and a bulk recompute only touches rows the platform already owns. The backfill writes the PEN and not the label, since rendering needs the rule engine and logic lives in Go, so those rows take their labels from the next write that touches them or from the recompute that can now see them. -
Consequence:
label_rulecolumns land oncomponent_type,system_type,location_type,productandstandard, but onlycomponent_typecarries the fork today, so a rule on a shipped row of the other four is not operator-editable until each adopts ADR-0095’s primitive. That is the pre-existing state of those registries rather than a new restriction, and the tier that matters most for components (component_type) is forkable now. -
Amended (#729): the key table above is the map as of this decision, and it is a historical snapshot rather than the live set: ADR-0100 added the placement keys and #686 gave a system its
Ordinal. The map is no longer typed out anywhere. Each kind’s keys are declared once ininternal/storage/label_keys.go, beside the accessor that produces each value,labelDatabuilds the map by ranging that declaration, anddocs/src/generated/labeldata.json(make gen, viacmd/labelgen) renders the per-kind tables core entities publishes. So “the key set is pinned by a test” above is now pinned by CONSTRUCTION, and what the tests hold is the artifact against the declaration and the twolabel_ruleAPI descriptions against it too. That last one was already wrong: both enumerated the map as it stood before ADR-0100, so the console’s field help and the CLI reference taught seven keys where a rule could read nine. -
Tracked under #682, the second slice of epic #657, amended by #729.
ADR-0099: The acronym list is one replaceable setting, not a shipped list plus operator additions
Section titled “ADR-0099: The acronym list is one replaceable setting, not a shipped list plus operator additions”- Date: 2026-08-10 | Status: Accepted | Pages: settings, core entities, glossary
- Decision: The acronym dictionary a label rule’s
titleconsults is one key,label.acronyms, in a newplatform,clientsettings namespace. An operator’s list replaces the shipped one; the two are told apart by the settings engine’s existing provenance (sources["label.acronyms"]readsdefaultorplatform), not by keeping them as separate values and unioning them at read time. - Why not shipped-plus-additions: The epic’s scope text reads as a union, and a union is what an
operator adding one word wants. It was rejected because of what it costs everywhere else. Merge in
this engine is presence-based over generic maps and a non-map value overrides wholesale, so a
union would be a merge rule this one key does not share with any other setting, and reading
label.acronymswould no longer tell you the effective dictionary: the value would be a fragment and the engine would hold a list nothing on the wire reports. It also makes a shipped entry unremovable, which matters more than it sounds, since a word we ship that an operator’s estate spells differently would then be uncorrectable. The union’s one benefit, not retyping the list to add a word, is a console problem, and the console already read-modify-writes the effective value for every other setting. - Consequence, stated rather than discovered: an operator who overrides the list stops receiving later releases’ additions to it, exactly as overriding any other setting freezes it. The console badges the key as overridden, and restoring the namespace returns the shipped list.
- Decision (the engine’s lifecycle): The dictionary is resolved from the settings cascade at
render time, and the compiled engine is cached against the dictionary itself
(
label.EngineCache). Parsing binds a template’s FuncMap, so a dictionary change produces a new engine rather than mutating one, and a rule compiled against the old engine keeps the old casing for as long as it lives; the storage path re-parses on every render, so the longest a stale dictionary survives is one render and an operator’s edit needs no restart. A generation counter bumped by the settings write path was rejected for having a failure mode a content key cannot have: a second write path that forgets to bump it. - Consequence: a render costs one read of the override table, on a write path already several round trips deep. A caller rendering many rows (the bulk recompute, #685) resolves the engine once and passes it down, which is why the render functions take an engine rather than reaching for one.
- Decision (the gateway reads that override in the caller’s transaction, not off the pool): A
stamp runs inside a transaction, so it is already holding a connection; issuing the settings read
against the pool acquires a second one, and a pool whose connections are all held by writers each
waiting for a second connection is deadlocked, not slow. It needs no unusual code to reach (a
bulk import of the estate sizes this epic exists for is enough) and it fails as a hang. So
Service.ResolveOverridetakes an override level the caller has already read, over the same level stackResolvebuilds, and the gateway reads it on its own transaction; the dictionary and the row being stamped then come from one snapshot as well. A one-connection pool is the whole population of that race, which is how the regression test reaches it deterministically rather than under load. - Decision (parse and render need different engines): Rule validation uses a
dictionary-less engine, because
label.Newbinds the same four function names whatever dictionary it is given and the grammar check walks a static allowlist: whether a rule parses is a fact about the rule alone. Only execution differs, so only the render path carries the current engine, and a template compiled by the validator is discarded rather than reused. - Tracked under #684, the fourth slice of epic #657.
ADR-0100: A label cascades where the blast radius is a placement, and waits for the verb where it is the estate
Section titled “ADR-0100: A label cascades where the blast radius is a placement, and waits for the verb where it is the estate”- Date: 2026-08-10 | Status: Accepted | Pages: core entities, storage, identity and access, glossary
- Decision (placement is in the data map): A component’s label rule reads
LocationLabel(the label of the location it sits at) andSystemTypeLabel(the label of its primary system’s type); a system’s readsLocationLabel. This reverses the narrowing in ADR-0098, whose title is now false of the code and is left standing as the record of what was believed. The worked example is{{.SystemTypeLabel}} {{.LocationLabel}} {{.TypeName}}renderingBoardroom 204B Display. - The keys are flat, never dotted, because the map is a closed
map[string]stringand that flatness is half the sandbox argument ADR-0098 rests on: a string cannot be traversed through, so{{.Location.Label}}would be a handle on another row where{{.LocationLabel}}is a fact copied out of one. - Which location, precisely: the row’s OWN
location_id. Not an ancestor of it, and not the location its plane root sits at. A component’s label says where that component is, so it reads the column that says so; a nested component with no location of its own reads placement as absent. The alternative would make a label depend on an ANCESTOR COMPONENT’s placement, so moving a parent would stale every descendant, and it costs a recursive walk per row on a path that runs on every create.LocationLabelis the location’s READ LADDER (the label an operator typed, else the location’s own name), not the raw column, because a shipped estate has no location labels at all and reading the column alone would render placement as blank for every row in it. - Consequence, and the reason this is one slice rather than two: ADR-0098’s completeness argument
is void. It could enumerate five write paths and call the set complete precisely because every
fact its map held was the labelled row’s own. That argument was already thinner than it read
(
TypeName,ProductName,VendorName,Stem,TypeAbbrevand the rule itself are columns on registry rows), and placement ends it: the set of acts that stale a label is no longer the set of acts on the labelled row. The write paths are therefore derived from the map and, more importantly, the invariant that guards them stopped being an enumeration at all: it is now an estate-wide question the gateway answers (PreviewLabelRecomputereturning nothing), so a write path nobody thought of fails it. - Decision (the line is blast radius, not ownership): An act whose blast radius is bounded by a
PLACEMENT cascades eagerly, inside its own transaction, so the estate is never observably stale: a
location’s rename, relabel or reclassify restamps what is placed at it; a system’s reclassify
restamps its member components; every act that moves a component’s primary membership (
AddMember,RemoveMember,SetPrimaryMember,AssignRole’s implicit bind, a create naming a system, and a system’s DELETE, which releases every membership it holds) restamps that component; a system’s:movestamps its own label, which it never did before. An act whose blast radius is bounded only by the ESTATE restamps nothing and waits for the verb: a rule changing at any tier, acomponent_type’s orproduct’s orvendor’sdisplay_namechanging, and the acronym list changing. That is the epic’s own argument applied consistently: editing a shared classification must not silently rewrite fifteen thousand rows any more than editing a rule may. - Consequence, added by the epic’s review pass: a write path can be one the DATABASE performs.
Deriving the write paths from the map caught every explicit mover of a primary membership and
missed the implicit one.
system_member_system_id_fkeyisON DELETE CASCADE, so deleting a system has always taken its memberships with it, in the parent’s ownDELETEwhere the gateway can attach nothing to the loss. A component in a boardroom under[{{.SystemTypeLabel}}]went on reading[Boardroom]after the boardroom was gone, and the invariant this decision rests on (PreviewLabelRecomputereturning nothing) said so, which is the property it was built for. The fix is not another restamp bolted to the delete: the memberships are now released explicitly, one step ahead of the row’s own delete, so a delete is the same actRemoveMemberperforms and gets the same two consequences (the sole survivor is promoted to default, the released components restamp in one recompute rather than one apiece). The promotion half was a pre-existing divergence between the two doors intosystem_member, and it is fixed here rather than filed because the label the delete leaves behind depends on which membership answers afterwards. The generic scoped-CRUD delete grew abeforeDeletehook to hold it, since the rows a hook needs are unreadable by the time anafterDeleteruns. The completeness lesson generalizes past labels: anON DELETE CASCADEis a write path with no Go on it, and the estate’s other two (system_role_assignmenton bothsystem_idandrole_id) feed nothing into a rule today. - Consequence: a location rename is bounded by what is placed AT that location, not by its subtree, because a component reads the label of the room it is in and never that room’s building. A campus rename is free. That is a property of the data map, so if a later slice gives a location an ancestry fact (a positional type’s generated name is the obvious candidate) the cascade owes it a subtree arm, and the test that pins the current answer is the one that fails first.
- Decision (a preview is an apply that rolls back): The read-only implementation of a preview
cannot list exactly what the apply changes. Recomputing locations moves their labels, which stales
the components and systems placed at them, so the honest answer includes rows of two other kinds
that exist only as a consequence of writes a read-only pass never made. Simulating those writes
would be a second implementation of the cascade for the two to drift apart on. So a preview runs
the apply, collects the change set, and rolls back: exact by construction rather than by argument.
The cost is stated rather than hidden: a preview is not a pure read. It takes the operation lock
and
FOR UPDATEon the rows it visits and produces WAL that is discarded, so it is an operator gesture, not something to put behind a keystroke. It still does not promise atomicity ACROSS the pair, and deliberately: holding a lock between two HTTP requests would let an operator who opened a preview and wandered off block every write on the tier. The apply’s own returned set is what closes that gap. - Decision (epic D1, one audit row for the operation): A bulk recompute writes one audit row,
verb
recompute, resourcelabel_rule,resource_idthe entity kind, carrying the affected count and its per-kind split. Per changed entity was the alternative and it loses on both halves of the question it claims to answer better: it writes fifteen thousand rows for one click, in one transaction, on a table every other write shares, and the per-entity trail it buys is a restatement, since a generated label is DERIVED and “why does this row read what it reads” is already answered by the rule and the row’s own facts. “Who changed the estate’s labels and when” is answered by exactly this row and by nothing in a per-entity trail. The nearest precedent agrees: a health recompute cascades across a whole ownership chain and audits nothing, because it is a consequence of an act that is itself audited. Which is also why a CASCADE writes no row of its own: the rename or reclassify that triggered it already has one. The row is keyed on the rule rather than on the entities becauselabel_rule’s primary key genuinely is the entity kind, so it is the one key a rename cannot orphan. - Decision (a cascade is not scope-filtered; the verb is): The verb selects on the caller’s read
scope AND their update scope, both injected into the one query, so an operator can neither preview
nor apply outside what they may already see and change. A cascade is not scope-filtered, because it
is not a query anyone asked for: it is the rest of a write the operator already made, and leaving a
row stale because it sits outside the grant that let them rename the location would break the
invariant the stored label rests on, silently. The health recompute crosses scope boundaries for
the same reason. No new permission either way: a recompute is gated by the entity’s own
:update, and so is the preview, because a preview is half of an edit rather than a report. - Decision (lock order, written down rather than discovered): membership, then label, then
health. A bulk recompute takes one coarse advisory lock for the whole operation plus
FOR UPDATEon the rows it visits; the single-row stamps take neither, because each already holds its row’s lock from theUPDATE ... RETURNINGit rides behind. - Consequence, measured: the recompute and the location cascade are both flat in row count, held there by #650’s counting instrument rather than by intent, with the classification resolved once per distinct product or classifier pair and the placement facts, the global rule and the acronym dictionary each read once per operation. A placed, system-bound, generated component create costs nineteen statements, of which exactly one is this decision’s.
- Tracked under #685, the fifth slice of epic #657.
ADR-0101: The first of its stem in a bucket carries no ordinal, and the mint that says so is the one allocation tests
Section titled “ADR-0101: The first of its stem in a bucket carries no ordinal, and the mint that says so is the one allocation tests”-
Date: 2026-08-10 | Status: Accepted | Pages: core entities, storage, glossary
-
Decision (epic D3, ordinal suppression): A generated system name suppresses the ordinal on the first of its stem in a placement bucket: a room’s only boardroom is
boardroomand the second isboardroom-2. The order dependence this buys is accepted rather than mitigated, and it is worth stating in full rather than in the abstract. Allocation is lowest-free (ADR-0097 preserved it), so deletingboardroomwhileboardroom-2survives lets the next create takeboardroomagain, andboardroom-1never exists at any point in that sequence. The same rule therefore yields different names for the same estate depending on the order it was built in. The operator-facing string is what this epic exists to improve, and a room calledboardroom-1when there is only one of it is the defect the epic was filed about. -
Decision (suppression is a property of the MINT, not of the shape function):
mintNamebecame anameMintvalue: a resolved stem plus abareFirstflag, with the shape as a method on it. It is NOT a branch inside the shape function, and it is not a global change to the shape, because a component that suppressed at ordinal 1 would rename every generated component that already exists (display-1is the shape #681 has been minting) and would break the no-expected-value-changes rule this epic runs under. Components are counted things in a rack, wheredisplay-1besidedisplay-2is what an operator writes on the label; a system is a room, and a room with one boardroom in it does not call itboardroom-1. -
Decision (the allocator takes the mint, not the stem):
pickOrdinaltakes thenameMintand testsmint.name(n)for each candidate. This is the load-bearing half. A mint that suppressed while the allocator still tested<stem>-<n>would disagree on exactly ordinal 1, so the first create would takeboardroomand the second would test a freeboardroom-1, mintboardroom, and hit the scoped-name unique index as a23505the transaction cannot recover from. Passing one value to both is what makes that disagreement unrepresentable rather than merely unlikely: there is no second spelling of the shape to keep in step. -
Decision (where the choice comes from, and the seam for the next slice):
bareFirstis a field on the mint, resolved per entity kind today (componentMintfalse,systemMinttrue) and intended to be resolved per TYPE. #687 giveslocation_typea nullable name rule, and a rule that produces a positional name fills this same field from a per-type fact: it consumes the seam rather than replacing it. A per-kind default is where the fact lives until a type carries one, not a competing mechanism. A stem-less mint ignores the flag outright, since suppressing the ordinal of a name that IS its ordinal (a floor called1) would leave nothing at all. -
Decision (the allocation lock is keyed on the bucket, and the stem comes OUT of that key): The advisory lock was keyed on the table, the stem and the bucket, which was sound only because the mint was always
<stem>-<n>, making two stems’ name spaces provably disjoint. Suppression ends that: stemwallat ordinal 2 and stemwall-2at ordinal 1 are the same name, and both stems pass the rule a stem is validated with. Keyed on the stem, those two concurrent creates in one bucket take different locks, read the same siblings, mint the same name, and the loser gets a23505on a create that supplied no name at all. So the lock now guards what the unique index guards, the bucket, because the bucket is the only partition of the name space a mint cannot cross. The cost is that two creates in one room serialize whatever they are classified as, which is the price of the shape being per-type rather than fixed. -
Decision (a placement bucket is a value, not a pair of pointers): The lock key and the sibling filter became one
nameScopeper entity kind, because the kinds do not agree on how many buckets there are.componentandsystemhave three (a parent, else a location, else unplaced) andlocationhas TWO (a parent, else root), since a location carries no located-at column. The location constructor takes no location id, so pairing a location with the three-way shape is not a mistake to avoid at each call site, it is a value that cannot be constructed. The key carries the table too, so a system and a component sharing a parent uuid never serialize against each other. -
Decision (the pen spreads to both trees, generation to one):
name_generatedand both verbs (:renamefreezes it false,:resetNamereturns it) land on system and location; only a system generates.location_typecarries no stem to mint from, so:resetNameon a location refuses with a typed error naming the missing fact (422) rather than reporting a reset that did not happen. The pen still ships on that tier ahead of its generator, and that is the point: a location an operator names today is already frozen when #687’s rule arrives, where a pen added later would have to decide retroactively who had named every existing row. -
Decision (no backfill, unlike the label pen): The column defaults
falseand no one-time migration touches it, which is the argument the component column already records: every row that exists when the migration runs was named by an operator, so claiming the pen retroactively would let the first:movesilently rename real estate. That is the opposite of #682’s label pen, which WAS backfilled, and the difference is the field rather than a change of mind: “no label” is a state the platform may safely claim, while a row always has a name and somebody typed it. -
Decision (the system bare render stays unwired, and this is not an oversight): Both halves of the compact render’s substitution now exist for a system (
system_type.abbrevfrom ADR-0096, and a stored ordinal from this slice), andattachSystemPathsstill passes neither.RenderBaresubstitutes whenever it is given both, with no shape check, which is what makes #654’s guarantee structural for a component. A suppressed name carries no digits while owning ordinal 1, so wiring it would printbrd1for a room namedboardroom: a number on a physical label that appears nowhere in the entity’s name, the exact defect the ordinal column’s own migration cites. Correcting it means teaching the render which mint produced the name, which is a rendering decision, not a naming one. -
Consequence (the write paths were derived, not listed): The acts that must re-mint a platform-owned system name are the ones that change an INPUT to the mint, and the mint reads exactly two things: the
system_typechain’s stem and the placement bucket. That yields create,:resetName,:move(both the relocate and the reparent arm, since a parent wins over a location) and thesystem_typehalf of a reclassify, and it also settles two cases a list would have missed. Un-classifying a platform-named system reaches the generator with no type and is REFUSED there rather than silently keeping the name and handing back the pen. And astemedited on a sharedsystem_typerow is bounded by the estate rather than by a placement, so it does not cascade, which is ADR-0100’s line applied to the name side. The database agrees:system.location_idandsystem.parent_idare bothON DELETE RESTRICT, so no placement changes without one of these acts running.The same derivation settles the shape of the reclassify guard, which review caught the code getting wrong. The trigger is the classification changing, not the
system_typefield being present in the patch: the console sends that key on every save so an unclassify can clear, so a presence test re-mints on an edit to the label alone, and with a lower ordinal freed by an earlier rename that re-mint MOVES the name (boardroom-2becomesboardroom) undersystem:update, with no rename requested and possibly nosystem:renamegrant held. A patch that re-states the type changes neither input to the mint.The epic’s review pass found the same defect on
:move, and it is fixed here rather than filed. The move arm was gated on the row being platform-named alone, so a:movethat re-stated the location the system already sat at, or supplied neither field (this verb’s documented no-op), re-minted and moved the name by exactly the sequence above, undersystem:move. It was left wide deliberately, on the stated belief that narrowing it would move an existing expected value; that premise was false, since systems had only had generated names since this same unmerged branch. The guard is now the bucket changing, compared as anameScoperather than as a pair of pointers, because a parent wins over a location: relocating a parented system leaves it in the same parent bucket and must not rename it either. No test expectation moved.MoveComponenthas the identical wide shape, is reachable the same way, and is genuinely pre-existing, so it is left for its own issue rather than changed here.Amended (#696, #691): both component-tier guards are now closed, and one of the two is spelled DIFFERENTLY here rather than copied.
MoveComponenttakes the identicalnameScopebucket comparison, since the two kinds have the same three buckets and a parent wins over a location on both. The reclassify does not. A system reads its stem from asystem_typechain, while a component reads it from a PRODUCT that points at acomponent_typechain, one hop further, so the value an operator picks is not the value the mint reads: two products classified under one type (or under two types inheriting one stem) mint identical names. Keying the component guard on the product id would therefore leave a real reclassify moving the name onto a freed lower ordinal, which is the defect this entry refused for the presence test wearing a third set of clothes. It compares the RESOLVED STEM instead, andstemForProductis the first half ofgenerateNameForProductlifted out so a guard can ask what a name would be minted from without minting one. A destination with no stem at all is never equal to anything, so it still reaches the generator and is still refused there. The system tier kept the type-id comparison and the same residual with it (twosystem_typerows inheriting one stem), a real case filed as #706 rather than fixed inside a component-tier slice: the two tiers agree on the RULE (re-mint when an input to the mint moves) and disagreed only on how far each had to walk to read one. That divergence closes with #706: the system reclassify compares the resolved stem too,stemForSystemTypeis the first half ofgenerateNameForSystemTypelifted out exactly asstemForProductwas, andsystemTypeStemMovedisproductStemMoved’s twin over the shorter walk. Both tiers now spell one rule, and each keeps its own path to a stem: a system’s chain, a component’s product then chain. An un-classify and a destination that resolves no stem are still never “the same stem”, so both still reach the generator and are still refused there rather than by a branch in the guard. The claim that the component reclassify was “not reachable the same way today” survived checking and is narrower than it sounds: the console never sendsproducton a save (web/src/pages/Components.tsxedits the name and the label only), but the API takes it and the generated CLI exposes it, so both defects were reachable by the paths the issues named. -
Consequence:
Ordinaljoins the system label data map, widening ADR-0098’s closed map by one key, and the shipped global system rule deliberately does not use it: for a suppressed first name the number and the name disagree, so whether a label says “Boardroom” or “Boardroom 1” is an authoring choice an operator makes, not a platform default.Amended (#693): the shipped system rule reads the ordinal, and the key it reads is the number the NAME carries. The decline above is reversed, and the case that reverses it is the one AV estates are full of: a divisible boardroom is two
boardsystems in one room, so both rendered “Boardroom”, and the operator reading the console had less information than the platform holdingboardroomandboardroom-2. The rule is now{{.TypeName}}{{if .Ordinal}} {{.Ordinal}}{{end}}, the component’s verbatim.The reversal is TWO changes and only one of them was named when the ruling was made, which is worth recording because the other one is where the argument lives. Adding
{{if .Ordinal}}to the rule alone renders “Boardroom 1” for the only boardroom in a room, because{{if}}on a string is false for the EMPTY string and a suppressed first name still owns the stored ordinal 1. That is the defect this entry’s decline was protecting against, and a rule change on its own walks straight into it. So the map’s value changes with it:Ordinalis now the ordinal the row’s name SHOWS, which is empty for a suppressed first exactly as it is empty for a row an operator named. The two states read alike to a rule because they mean the same thing to a reader: this row’s name carries no number.The suppression is asked of the MINT (
nameMint.suppresses, the branchnameitself takes) rather than read off the name string, which keeps this entry’s central decision true on the label side: a name’s shape has one implementation, and a second reading of it could disagree at exactly ordinal- It also survives the seam above, where
bareFirstbecomes per-TYPE: a rule hardcodingne .Ordinal "1"would have been a per-kind default baked into a template, wrong for the first type that chose differently, and unwritable by an operator who should never have to know which mint named a row.
What the reversal costs is stated rather than buried: a rule can no longer render “Boardroom 1” for a system named
boardroom, because the number is not reachable from the map any more. That was the authoring choice the decline preserved, and it is the one this entry already calls the defect the epic was filed about, so it is refused as a rule rather than offered and warned about. An operator who wants those exact words still types them, which takes the pen (#682) and is the honest way to say a label is not derived from anything.A key was NOT added beside
Ordinalto keep both readings available. Two spellings of one number in a closed map is a difference for a rule author to misinterpret, and the wrong pick reintroduces the defect silently, which is the same argumentNameRule.normalizedmakes about two spellings of one rule.The component tier reads its ordinal through the same helper, and the value there is unchanged for every row:
componentMintdoes not suppress (a rack’sdisplay-1besidedisplay-2is what an operator writes on the label), so the helper’s answer is the stored number. One meaning of the key on both tiers, rather than two tiers that happen to agree today.The write paths were re-derived and grew by none. Every act that moves a system’s ordinal is an act that re-runs the mint or hands the pen back: create,
:rename(which clears it),:resetName,:movewhere the bucket changes, and thesystem_typehalf of a reclassify, which is the same set this entry derived for the NAME. Each already restamps the label unconditionally in its own transaction, and unconditionally is what matters rather than the set being the same: a reclassify between two stems can leave the name identical while the ordinal moves (wall-2at ordinal 2 is the same string as the suppressed first of stemwall-2), so a stamp gated on the name having changed would have missed it. A delete frees a lower ordinal and re-mints nothing, by this entry’s own allocation rule, so no surviving row’s label goes stale behind it. The completeness invariant (TestNoActLeavesALabelStaleAnywhere) now runs a system rule that READS the ordinal, where the one it ran before could not have seen a hole in any of this. - It also survives the seam above, where
-
Tracked under #686, the sixth slice of epic #657, and amended by #693.
ADR-0102: A name rule is a declaration a type opts in with, and a rule change renames nothing
Section titled “ADR-0102: A name rule is a declaration a type opts in with, and a rule change renames nothing”-
Date: 2026-08-10 | Status: Accepted | Pages: core entities, storage, glossary
-
Decision (the opt-in):
location_typegains a nullablename_rule, and its presence IS the opt-in: null means an operator names every location of that type. There is no boolean beside it, so “this type generates” and “this is what it generates” cannot disagree. A campus, a building and a room each have a real-world name an operator holds and the platform does not, so generating one would be guessing.17cis ground truth, not a default.This entry shipped saying “of the shipped place vocabulary only floor is genuinely auto-nameable, and it ships positional”, so three of the four shipped types stayed on the opt-out. Amended (#657): that is now false in both halves. ADR-0103 was reversed for
floorand the rule was dropped from the seed, on the argument that a floor’s designation (B2, LG, G, M, 12A) is not an integer at all, so all four shipped types are on the opt-out and no shipped type is auto-nameable. The opt-in itself is unchanged, and it is reached by an operator declaring a positional type of their own. What that costs, and why no fifth type was seeded to replace it, is recorded on ADR-0103 rather than repeated here. -
Decision (a declaration, not a template): The rule is
{"stem": "...", "bare_first": <bool>}, decoded straight into thenameMintADR-0101 already made the one place a generated name’s shape lives. An empty stem is a positional type, whose ordinal genuinely is its name (a floor called1).The alternative, and it was the more consistent-looking one, was the
label_ruletemplate beside it on the same table: the sametext/templateengine, the same allowlist (ADR-0098), reusing a parser rather than growing a second dialect. It was rejected on three counts, and the first is decisive.A name has no safe failure. A label rule that fails to render degrades: the row keeps the pen, the read ladder falls through to the entity’s name, and nothing is lost. A name has no next rung. It is
NOT NULL, it is in a scoped-unique index, it is the address an operator types, and it is what a runbook outside this system stores. A template that rendersFloor-1for a type whose display name isFloorproduces an illegal name, and the honest options at that point are a failed create or a silently different name, neither of which is a degradation.The edit-time refusal is provable for a declaration and only sampleable for a template. #686’s acceptance already promised that a rule which would render an illegal name is refused when the RULE is edited, not when a location is created, and nothing had built it. A declaration’s whole output space is
{stem}and{stem-n : n >= 1}, sovalidateNameRulemints ordinal 1 and a nine-digit ceiling and checks both: every candidate between them shares a character class and has a length between the two, so a rule legal at both ends is legal everywhere in it. The ceiling has to exist rather than being waved at, because<stem>-<n>grows withnand the name rule has a length cap: a 97-character stem mints legally at 1 and illegally at 100. A template could only have been probed against a sample row, which is a different and weaker promise.The expressiveness would have bought nothing here. A location’s data map (ADR-0098) carries
NameandTypeName.Nameis what a name rule is producing, so it is circular;TypeNameis the type’s display name, whose slug IS the stem. The one useful template a location could write is the declaration, spelled longer. The FuncMap would have diverged too (slugandlowermatter,titleand the acronym dictionary are actively wrong for a kebab name), so “reuse the engine” would have meant a second configuration of it, not a second use of it.Where a template earns its place is a rule reading facts a declaration cannot name, which is the shape a future component or system name rule might have (a vendor, a product, a role). That is a reversal to make when there is a fact to read, on the tier that has one, with an argument about failure that this tier cannot make.
-
Decision (the rule IS the mint, so nothing restates the shape):
NameRule.mint()returns anameMintwith no reshaping, which carries ADR-0101’s guarantee onto this tier for free: the allocator testsmint.name(n), the caller mints from the same value, and there is no second spelling of the shape to keep in step. Validation mints from it too, so the check and the generator cannot disagree about what a rule produces. A stem-less rule normalizesbare_firstto false, since suppressing the ordinal of a name that IS its ordinal would leave nothing: two spellings of one rule would compare unequal while minting identically. -
Decision (a rule change renames nothing, and there is no name-side recompute): #687’s own acceptance said a rule change renames nothing “except through the explicit recompute” of #685. That verb is the label cascade, and there is no name-side equivalent. Editing a rule changes no stored fact about any existing row: the name, the ordinal and the pen all stand, and the new rule decides how the NEXT nameless create,
:resetName, move or reclassify names a row.That is ADR-0100’s blast-radius line, which ADR-0101 already applied to a
system_type’s stem, and it is stronger on the name side rather than merely the same. A bulk relabel is recoverable: a label is display, the pen says who owns it, and the preview shows the blast radius before the apply. A bulk rename is not: it breaks bookmarks, runbooks and integration config outside this system, which is the entire reason a rename is its own verb under its own permission (ADR-0088).:resetNameis the deliberate, one-row-at-a-time way to bring a row onto a new rule, and it is gated bylocation:renamebecause it IS a rename. -
Decision (a positional type at root is legal):
allowed_parent_typescan permit a positional type at root, where the bucket is every parentless location, so it allocates1,2across the whole estate. That is allowed, and the argument is that the root bucket is not a special case, it is a large one. The bucket is the PLACEMENT, never the placement and the type, so two positional types under one parent already share an ordinal space; refusing at root would refuse the estate-sized instance of a rule that holds everywhere else, and bucket size is not a property the platform can police (a building with two hundred floors is the same shape). An operator asks for it by making one type both positional and root-placeable.The real consequence is not allocation, it is ADDRESSING: a positional type makes a bare name ambiguous by design, since every building has a floor
1. That is answered by machinery this epic did not have to build, the dotted address (ADR-0062’s addressing and#627’s placement-scoped uniqueness):boi.17c.1resolves where1cannot, and an ambiguous bare name is already a named refusal listing its candidates rather than a silent first match. -
Consequence (two limits recorded rather than papered over): A rule cannot be CLEARED on the wire, only replaced, because an omitted JSON key and an explicit
nullboth decode to a nil pointer and an object field has no third state to read. That is the limitcomponent_type.stem’s patch already carries (its wire field isminLength: 1), and closing it means either an untypedjson.RawMessagefield, which costs the generated client its shape, or a custom method for one field; both are wire-contract choices rather than this slice’s. And the shippedfloorrule reaches NEW estates only:SeedLocationTypeis insert-when-absent, the forked-template half of the seed model, so an upgraded estate opts its ownfloorin with onePATCH. Deliberately no backfill migration, since the rule it would install could not then be removed, and alocation_typerow is operator space rather than platform space. Both limits are written into the operator guide with the exact call.Amended (#657): the two limits compose worse than either does alone, which is only visible when a shipped rule has to be WITHDRAWN, as ADR-0103’s reversal withdraws this one. Removing the line reaches new estates only, by the same insert-when-absent argument read backwards, and the estate that already has the rule cannot drop it with a
PATCHeither, because the wire has no spelling for clearing one. So aflooropted in by an earlier release stays opted in until someone writes the column directly. ThePATCHabove is still the whole of the opt-IN story; there is no opt-out story, and the operator guide now says that rather than leaving it to be discovered. -
Consequence (a location label rule still cannot read
.Ordinal, and that is not an oversight): ADR-0098’s closed map gains no key here, although this slice gives a location an ordinal. It would be redundant where it helps and wrong where it does not: a positional location’s name IS its ordinal, so{{.TypeName}} {{.Name}}already rendersFloor 3, and the only case a separate key serves is a stemmed type with the first ordinal suppressed, where printingWing 1for the only wing is the defect the epic was filed about. ADR-0101 declines.Ordinalin the shipped SYSTEM rule for the same reason. -
Consequence (the fork primitive does not reach this registry, and does not need to): #655’s
registry_shadow(ADR-0095) has exactly one adopter,component_type, so an operator cannot fork a rule onto an officiallocation_typerow. They do not have to: the shipped location types are seededofficial: falseand inserted only when absent, so they are ordinary operator-owned rows the registry’s ownPATCHedits, and a restart never reverts the edit. That is the forked-template half of the seed model rather than the canonical-catalog half, and it is the same reason this registry has needed no fork forlabel_ruleeither. -
Consequence (the write paths, derived again): A location’s mint reads exactly two things, the type’s rule and the parent bucket, which yields create,
:resetName, thelocation_typehalf of a reclassify, and:move. Two of those are worth stating. The reclassify guard is the classification changing, not the field being present, becauseweb/src/pages/Locations.tsxsendslocation_typeon every save: the defect review caught on the system tier one slice ago (ADR-0101) is refused entry here rather than repeated. And:movere-mints only when the parent actually moves. That was written as NARROWER than the system tier’s, where the same re-mint ran on a move that changed no bucket, and the reason given for leaving the system arm wide was that narrowing it would move an existing expected value. The epic’s review pass established that the premise was false: systems gained generated names one slice earlier on the same unmerged branch, so every expected value at risk had been written by that branch. Both:movearms now re-mint only on a bucket change, and no test expectation moved. The two are spelled differently because the kinds do not have the same number of buckets: a location has two and the parent is the whole of the distinction, so comparing the parent IS comparing the bucket, while a system has three with a parent winning over a location, so its arm compares thenameScopebucket the two placements resolve to and a parented system relocated to another room keeps its name. -
Consequence:
locationgains the nullableordinalthe previous slice deliberately withheld from it (“a column no writer can fill is a fact waiting to be read wrongly”), and the recompute-and-compare invariant now covers all three trees. The bare render stays unwired here for a reason of its own:location_typecarries noabbrevat all, so there is no compact form to substitute into, and a positional location’s name already is its ordinal. -
Amended (#710): the console gets the editor this entry’s “declaration” makes small, a tick box, a stem, and the suppression flag, and the rule’s PRESENCE stays the opt-in, so the tick box is the rule’s existence rather than a fourth field. #692’s clear MOVES INTO it (unticking is the clear) rather than sitting beside it as a button, which is what stops the affordance being half a control: it could undo a state it could not create, and after ADR-0103’s reversal no shipped type carries a rule, so opting IN is the only route to a generated location name at all. Because the mask governs the whole write, the clear names every field the blade writes rather than
name_rulealone. What the editor shows about what a rule produces comes from the server:name_rulereads back withexamples, the first two names the rule mints, from the samenameMinta create allocates from, and the blade composes its sentence around those two strings. Nothing inweb/knows that a counted name is<stem>-<n>, which is ADR-0104’s refusal of a second implementation applied to a rule rather than to a row. An UNSAVED rule is shown nothing about its output, deliberately: the only honest source is the mint, and predicting from the browser is the duplication this closes. The character rule for a stem IS restated in TypeScript (ADR-0113’s inline vocabulary, refusing the save), the 90-character ceiling is NOT, because that number is arithmetic over the mint’s output space rather than a name rule. The mint’s own refusal turns out to be unreachable through this body:stemcarriesmaxLength: 90, and 90 plus the widest provable ordinal is exactly the 100-character name cap, so every stem the schema admits mints legally at both ends andErrInvalidNameRule(still live, still unit-tested) is fenced off by the schema. The refusals a surface actually renders are the schema’s, which is whydescribeErrornow appends Huma’serrors[]: a schema 422’sdetailis always the literal “validation failed”, so every console surface had been showing an operator nothing they could act on.
ADR-0103: A positional name is allocation order, and the real-world designation is a label
Section titled “ADR-0103: A positional name is allocation order, and the real-world designation is a label”-
Date: 2026-08-11 | Status: Accepted | Pages: core entities, location types
-
Context: ADR-0102 shipped
flooras the one auto-nameable location type, positional and stem-less, on the argument that “a floor has a name the platform can know”. The dev estate is the first real data to meet that argument, and it falsifies the sentence as written: the West Building’s only seeded floor is the building’s Level 2, and the generator calls it1, because a positional ordinal is the lowest free number in the parent’s bucket and nothing else. -
Decision:
floorstays positional, and the divergence is kept rather than removed, because the two fields are answering two different questions. A name is an address: it has to be unique under its parent, legal, and typeable, and the platform owning it means an operator did not have to think of one. A label is what a human reads, and a building’s own designation for a floor (“Level 2”, “B1”, “Mezzanine”, the skipped 13) is signage, a real-world fact the platform has no access to. So the platform allocates the name and the operator types the label, and the seeded estate ships both cases of the same type side by side: the floor under Innovation Hall is named1and labelled Level 1 (they coincide), and the floor under the West Building is named1and labelled Level 2 (they do not). -
Decision (the sharper rule this generalises to): a stem-less positional name is right where the position is an arbitrary disambiguator and wrong where the number is a real-world fact. It is never a claim about the world, so a type whose ordinal an operator will read as a designation (a rack unit, an output, a channel) wants its number typed, not allocated.
floorsits on that line rather than safely inside it, and it stays shipped-positional on the strength of the workflow it serves: building out a tower floor by floor, where typing forty names is the cost and the designations arrive later as labels. -
Rejected, and why each:
- Make
floornominal and drop its rule (the plain reversal of ADR-0102). It is the more honest reading of “a floor has a name the platform can know”, and it was rejected on cost rather than principle: it leaves the shipped estate with no auto-nameable location type, so the feature is inert everywhere until an operator defines one, and it moves nine storage cases and an e2e that assert against the seededfloorrule. The reversal stays available and this entry is where it starts. This is the option the amendment below takes. - Seed a Level 1 under the West Building so the numbers line up. It makes the estate agree by construction and teaches the wrong lesson: correspondence would look like a guarantee, and it is a coincidence of enumerating from the bottom in order.
- Ship a
floorlabel rule (Floor {{.Name}}). It renders “Floor 1” for a floor that is Level 2, which is the same defect with a nicer typeface.
- Make
-
Consequence: the shipped location label rule stays empty and the dev estate labels all thirteen of its locations by hand, which is not a gap in the demonstration but the demonstration: a location’s label is operator space by design, and the two floors are where that stops being an abstraction. It also means a positional name is not safe to read as a designation anywhere in the product, so a console surface that shows a floor must show its label. (The empty half of this is superseded by ADR-0105, which ships a global location rule; the operator-space half stands.)
-
Amended (#657), and REVERSED for
floor: the architect rejected the divergence on this type, taking the option listed as rejected-on-cost above. Two arguments, and the second is the one this entry did not have.The allocator’s answer misreads as a designation. A building with floors 0, 1 and 2 is named
1,2and3, in whatever order somebody seeds them. This entry called that friction worth keeping; the architect calls it an invitation to misread the estate, and the invitation is extended by the platform rather than by the operator.A floor’s designation is not an integer at all. Real buildings sign B2, LG, G, M, 1, 12A, P3. Modelling that as an ordinal is a category error, not merely an imprecision, which is what makes the reversal obviously right rather than a matter of taste. It also dissolves the objection that looks hardest: a negative floor cannot be spelled under
^[a-z0-9][a-z0-9-]*$, since a leading hyphen is refused, but nobody signs a floor-1, they sign itB1, which is already a legal name. A rule shaped like.Ordinalcould never have reached that name whatever the sign handling.So
floorbecomes nominal: noname_rule, and an operator names it exactly as they name a campus, a building and a room. The dev estate’s two floors are namedlevel-2andlevel-1for the designations they actually carry, ADR-0105’s shipped location rule renders “Level 2” and “Level 1” from those names, and the two pins this entry made load-bearing are released: name and label are one fact now instead of two that disagreed. -
Consequence of the reversal (the cost, stated rather than hidden): of the four seeded location types none carries a name rule, so location name generation ships dormant: correct, tested, and demonstrated by nothing in a shipped estate. Seeding a fifth positional type to keep the demonstration alive is refused for the reason seeding a Level 1 was, that a demonstration constructed to make the feature look used teaches the wrong thing. The generator keeps its coverage through a positional type the TESTS create (a parking deck: its number is an arbitrary disambiguator, which is exactly what a floor’s is not), because a feature losing its tests when its last shipped user goes away is how one quietly stops working.
-
Consequence of the reversal (an existing estate keeps the rule, and cannot drop it on the wire):
SeedLocationTypeis insert-when-absent, the forked-template half of the seed model, so deleting the line un-ships nothing from an estate that already booted with it: itsfloorstill carries{"stem": ""}and still names floors1. That is the mirror image of ADR-0102’s own consequence (the rule reached new estates only), and it is not fixable by a backfill, because alocation_typerow is operator space. It is sharper than the shipping direction was, because ADR-0102’s second recorded limit bites here: a rule cannot be cleared on the wire, only replaced, since an omitted key and an explicitnullboth decode to a nil pointer. Such an estate reaches the new default only by a direct write, and the operator guide says so rather than implying a release does it. Closing that needs the wire-contract change ADR-0102 costed and declined. -
What survives for genuinely positional types: everything except the placement of
floor. A stem-less positional name is still right where the position is an arbitrary disambiguator (a parking deck, a rack row, a berth) and wrong where the number is a real-world fact an operator reads off a wall;floorsat on that line and is now placed firmly on the nominal side of it. The name-versus-label distinction stands untouched: a name is an address, a label is what a human reads, and the two coinciding on the dev estate’s floors is a property of naming them well, not a guarantee. A console surface that shows a location still shows its label. -
Tracked under #689, the ninth slice of epic #657; the amendment folded into #698 after the rollup review.
ADR-0104: A create form shows the name it can know, and never mints one to preview it
Section titled “ADR-0104: A create form shows the name it can know, and never mints one to preview it”- Date: 2026-08-11 | Status: Accepted | Pages: work with an entity, UI
- Context: eight slices of epic #657 built a
generator that names a component, a system and a location from what it is and where it sits, and the
console was the last surface that could not reach it: two of the three create forms derived the name
from the display name and refused to submit without one, so every console-created row arrived
operator-named whether the operator meant that or not. The sub-issue asked the form to “produce both
values without the operator typing either”, and the epic’s own mechanics say that only half of one of
them is knowable. A generated name is
<stem>-<n>: the stem falls out of the classification the operator has just chosen, and the ordinal is the lowest free number among the LIVE siblings in the placement bucket, allocated under an advisory lock inside the create’s own transaction (ADR-0097). The label is worse: its data map carries the row’s ownNameandOrdinal(ADR-0098), so it cannot be rendered before the row it describes exists. - Decision: the form shows the shape and marks the unknown half as unknown. The stem is resolved
in the browser from the type registry the picker already loaded, and the ordinal is written as the
literal token
n, which is not a digit and cannot be misread as the value:display-n,boardroomfor a mint that suppresses its first ordinal,nalone for a positional type. Each shape travels with the sentence that makes it honest, and for a suppressing mint that sentence is load-bearing rather than decorative, since the shownboardroombecomesboardroom-2for the second one in the room. - Decision (the placement is context, not a prefix): the bucket the name has to be unique in is
shown beside the field as a path (
Unique at Headquarters / West Building / Level 2 / Boardroom), read-only. The sub-issue asked for the ancestry as an editable field’s read-only prefix (boi-17c-[editable]), which is stale by two slices: names became scoped to placement, so a name no longer contains its ancestry at all, and rendering the path into the field would put back exactly the redundancy the scoping removed. - Rejected, and why each:
- A draft-preview verb that mints and rolls back. It matches the acceptance’s wording and buys a
number that is provisional: any other create in the same bucket can take it between the preview
and the commit, so the form would show a value that silently turns out different, which is the one
outcome this whole affordance exists to prevent. It is also not free to ask: a rolled-back mint
takes the same
pg_advisory_xact_lockon the bucket that real creates take, so a form previewing as the operator changes a picker would serialise the estate’s creates behind a UI affordance. Refused on both counts, and the second is the one that would not have shown up in review. - Re-rendering the label rule in TypeScript. A second implementation of the rule engine in another language, which is the defect the epic’s third slice swept 42 hand-rolled copies of onto a single primitive to end. The label is therefore not previewed at all: the form says a rule will render one and marks it Generated, and shows nothing it cannot know. (Amended by #699 below: the refusal stands, and the label is now shown, because the ONE engine renders it.)
- Fetching the resolved stem from the server per selection. It removes the one thing that can
drift (the browser’s walk of the type chain versus the gateway’s) at the cost of a round trip per
picker change and a new route. Declined because the walk is not new here: the console already
climbs both type registries for the icon, and #688 makes that walk a single primitive
(
lib/typechain.ts) with the stem as its third consumer, so there is one client-side climb rather than three. What closes the gap instead is a browser-tier e2e that reads the shape the console showed and asserts the row lands with that stem, which is the only tier that can see both answers. Amended (#695): this argument is spent, and it was always an argument about COUNT rather than about kind. The stem consumer went with #702 and the two icon consumers go here, solib/typechain.tsis deleted: there is no client-side climb at all, one primitive or otherwise. The registry LISTING carriesresolved_iconbeside the rawicon, resolved in the gateway over rows the list already holds, so it costs no read the console did not already make. Both fields ship because they answer different questions: an edit blade posts the raw one back (blank means inherit) and a cell draws the resolved one.
- A draft-preview verb that mints and rolls back. It matches the acceptance’s wording and buys a
number that is provisional: any other create in the same bucket can take it between the preview
and the commit, so the form would show a value that silently turns out different, which is the one
outcome this whole affordance exists to prevent. It is also not free to ask: a rolled-back mint
takes the same
- Consequence: a name is optional on all three estate create forms, and required only where nothing
will generate one (an unclassified system, a
location_typewith no name rule, acomponent_typechain with no stem). Each of those states shows the missing fact by name rather than a disabled button with no explanation. The console can now produce a row the platform owns the name of, which every earlier slice of this epic could only be reached from the API or the CLI. - Amendment (#699, 2026-08-11): a render is not a mint, and the distinction is what this decision
turned on without naming. Both refusals above are about ALLOCATING. The provisional-answer
argument is that a minted ordinal can be taken by another create before the commit; the
serialisation argument is that a rolled-back mint takes the bucket’s
pg_advisory_xact_lock. Neither reaches an operation that allocates nothing: resolve the rule through the same tiers, build the same closed data map, execute it with the same one engine, and write the tokennwhere the ordinal would go. No lock, no write transaction, no allocation, and no second implementation of anything. That operation isPOST /<collection>:renderLabel, and the create form now shows both generated values in locked fields rather than showing a shape and a promise. The no-allocation claim is held by a test that reads back every SQL statement the render issued (#650’s counting instrument) and by a create five renders later still taking ordinal 1, rather than by this paragraph.- The TypeScript refusal is unchanged and is what makes this shape the only one available: the label is shown because the Go engine rendered it, not because the browser learned to.
- The NAME’s shape stays client-side, so the two halves of the form now answer from different tiers. That is a deliberate trade rather than an oversight: a stem resolves synchronously from a registry the picker has already loaded, so the locked name appears the instant a picker moves, where a round trip would leave the form’s primary affordance empty until it lands. The cost is the drift ADR-0104 already accepted, and the round-trip objection to resolving the stem server-side is now void, since the form makes a round trip per picker change regardless. Folding the name into the same answer is therefore available and cheap, and is deliberately not taken here.
- The gate is the entity’s own
:create, the permission the create it precedes needs: the answer exists to be acted on, and an operator who cannot create has no use for it. The PLACEMENT refs are a separate question and resolve within the caller’slocation:readandsystem:readscopes, because the rendered string can carry a location’s label and a system type’s label, so without that the route is a disclosure channel. A location draft injects no scope at all, because a location’s data map reads no other estate row (ADR-0098’s exclusion survives on that tier). Amended (#713): the component’ssystemref resolves insystem:readandsystem:update, and the route carries the create’s conditionalsystem:updatepermission, because the create binds that reference as a membership (ADR-0107). The rule is no longer “the placement resolves in a read scope” but “each placement reference resolves exactly where the create resolves it”, which is the only version of it a preview and its create cannot disagree about. - Three states per field, not two, and the third is not a loading state. Generation is unavailable, permanently, for a component_type chain with no stem, a system with no system_type, a system_type chain with no stem, and a location_type with no name rule; the label has its own, where no rule resolves at any tier, and the field then shows the NAME (the read ladder’s third rung) rather than sitting locked and empty. That state was every location in a fresh estate when this was written, because no location label rule shipped at any tier; a global one ships as of ADR-0105, so it is now the state an operator reaches by clearing the rule rather than the state they start in.
- Amendment (#657, 2026-08-11): the lock is an inline action, and a locked field is
readonlyrather thandisabled. The affordance moved INSIDE the field, a square icon button in the field’s daisyUI join, which is where the console already puts an in-field action (a Variables row’s set / revert, a secret’s reveal, a setting’s Restore to default). Both actions read as that Settings row does, on purpose: handing a field back to the platform IS restoring it to its default, and one idea should not carry two visual languages. The button has no text at all, so its tooltip (“Override”, “Restore to default”) is the visible copy and the accessible name says which field.disabledhad to go, and that is the substance of this amendment. A disabled input fires no click, so click-to-override cannot work on one, and it is out of the tab order, so the value the row is about to carry has no keyboard path.readonlyis not editable, is still focusable, and still fires events. The consequence is that the locked LOOK is now drawn rather than inherited: daisyUI 5 ships no.input-disabledclass, only:disabled/[disabled]selectors, so the class the markup carried was a no-op and the whole locked appearance came from the attribute..input-lockedinapp.csscarries it, and hover only warms the border.- Focus does NOT take the pen, although a click does. A locked field is a tab stop by the decision above, so claiming on focus would mean tabbing from the pickers to the Create button claimed both pens and blanked both fields on the way past, which is the state the locking exists to prevent. Clicking is a deliberate act; passing through is not. The click accelerator is one-way for the same reason: the way back discards what the operator typed and belongs on the button. The always-present button is what makes every state keyboard-reachable, and the section’s tab order is asserted rather than inspected.
- Amendment (#702, 2026-08-12): reading the lowest free ordinal is not minting one, so the form
shows the real number and the create carries a precondition. This decision’s two refusals were
both about ALLOCATING, and #699 already applied that distinction to the label render. The same
distinction reaches the ordinal itself, which this decision missed: the number a create will take
is
pickOrdinalover the sibling names in the placement bucket, and running that computation is a read. It takes nopg_advisory_xact_lock, opens no write transaction and allocates nothing, so the serialisation argument, the one that “would not have shown up in review”, does not reach it. The form therefore showsdisplay-3, notdisplay-n, and the token is retired from the codebase (OrdinalToken,nameMint.shapeand the console’sORDINAL_TOKENare all gone rather than left unused).-
The provisional-answer argument stands, and is answered rather than avoided. Another create in the same bucket really can take the number in between, and hiding the number was one way to cope with that; refusing is the better one. The form posts the ordinal it was shown as
expected_ordinalon the create, the create allocates under its own lock exactly as it always did, andconfirmOrdinalcompares the two inside that transaction before anything is written. A create that would land a different name is a 409 naming the number that moved and the name it mints; the form re-reads the draft, shows the new name, and the operator submits again. A refusal is honest where a silent difference is not, and a silent difference is precisely what the locked field exists to prevent. -
It is a number, never a name. A locked field that posted a name would claim the pen and set
name_generatedfalse, inverting the whole affordance, so the precondition is the ordinal and the API refuses it (422) beside a supplied name, where nothing is allocated for it to be about. It is a pointer withminimum: 1so absent and zero cannot both spell “no expectation”, and an operator-named draft answers with no ordinal at all, which is what makes “the field is locked” and “there is a precondition to post” one fact rather than two. -
The form has to tell this refusal from every other one, because its recovery (re-read, re-show, resubmit) is specific to it and a create’s OTHER 409 is a name collision the operator has to resolve themselves. Matching on the message would tie the console to server copy, so both refusals a form acts on carry a
huma.ErrorDetaillocating them on the request field the recovery touches:body.expected_ordinalfor the conflict, carrying the ordinal that moved as its value, andbody.namefor the four “the platform will not name this row” refusals. That is the RFC 9457errorsarray theErrorModelalready publishes, so it costs no new wire shape. -
The name’s shape stops being client-side, which #699 explicitly left available. That amendment called folding the name into the same answer “available and cheap, and deliberately not taken here”, on the grounds that a stem resolves synchronously from a loaded registry. It is taken now: the draft already resolves the stem, the mint and the bucket to render the label, so returning the NAME costs nothing, and the form makes a round trip per picker change regardless. The cost it removes is the drift this decision accepted, which is the naming half of #695. The consequence is that “the platform will not name this row” is now an ANSWER from the server rather than a null the browser computed, so the field’s three states are unchanged but their source is one tier instead of two, and the submit gate waits for that answer rather than flipping synchronously.
-
The draft takes the same scopes its create takes, in the same order, because it now resolves the same references: the parent decides which bucket the ordinal is read from, so it resolves in the caller’s
<kind>:createscope, the set the create resolves it in. A location’s draft therefore takes a scope where it took none, and what that scope guards is the sibling read rather than the render (ADR-0098’s exclusion of placement from a location’s label data map is untouched). The draft does not rehearse the create’s own all-scope gate on a ROOT create: it answers what a row would be called, not whether the create is permitted, and the permission is the route’s gate.Amended (#702 review), and that last sentence is REVERSED. The draft refuses the parentless bucket in the same set the create refuses it, because the answer is not inert: it carries the lowest free ordinal, read from the bucket’s sibling NAMES, so it reports which of that bucket’s names are taken. On the root bucket the probe is chosen rather than fixed, since writing a stem into a forked type’s name rule is ordinary (#703) and needs only
location_type:create. Driven, the root draft answeredsecret-region-2to a principal whose create in that bucket is a 403. The gate is a SCOPE, so it sits beside the route’s permission gate rather than contradicting it, and it is unconditional rather than only when a name is being drafted, for the same reason the create’s gate is. -
Amended (#702 review): the precondition binds the NAME, not the ordinal. The claim a locked field makes is “the row will be called what I am displaying”, and the ordinal is one of three inputs to that rather than the claim itself: a name is the stem, the suppression rule and the number together, so a claim on the number alone passes unchanged while the other two move. Driven: a form drafts
display-1,PATCH /component-types/display {"stem":"monitor"}lands, the create postsexpected_ordinal: 1, and the row is created asmonitor-1with the precondition MET, which is exactly the silent difference the locked field exists to prevent. Nothing races there, and forking a type and rewriting what it mints is an ordinary operator act since #703, so the console was shielded only by an accident (the stem is not in the draft body, so it is not in the query key that would have re-asked) and a CLI or API caller was not shielded at all.expected_namereplacesexpected_ordinal, andconfirmDraftedNamecompares the string. Keeping the number and adding the stem and the bucket beside it was refused: it is three fields where one already carries all three by construction, and it would need extending again the next time a mint grows an input. It is still a precondition and not the name field: the create leavesnameempty, so the row is stillname_generatedand the ordinal column is still the platform’s, and posting it beside a supplied name is the same 422 as before. The 409’sErrorDetailmoves tobody.expected_nameand carries the name the create WOULD have produced as its value, which is what the form shows next. The draftedordinalstays on the render’s answer as an informational fact, since a surface that shows where a value came from teaches the mechanism it operates.
-
- Amendment (#693, 2026-08-12): the lock is the console’s ONE vocabulary for the pen, so it reaches
the edit blade and the list’s chip retires. This decision described a create form, and the pen’s
other half was left where it had grown: a full-text
Generatedchip in the identity cell, read by 18 flat-list pages and every tree. Two things were wrong with that and only one is cosmetic. It charged the Name column the width of the word on every platform-labelled row, on the very column #690 had just had to put a floor under. And it stated an ownership fact where the operator could not act on it, while the surface that CAN act on it, the edit blade, said nothing at all. SoPenTogglemoves out of the create form into its own module and the blade’s display-name field consumes it, and the chip goes from both list renderers.- The name’s own chip stays, and that is the consistency rather than an exception. The component blade marks a platform-picked NAME in its read state, beside the name fact. The rule both now follow is that a pen states itself beside the field it owns, on the surface where the operator can change it; a list is neither.
- The blade has a third state the create form does not, and it is a locked field showing a value
that is about to change: “Restore to default” on a row an operator labelled by hand. Nothing can
know what the rule will render for it, because
:renderLabelpreviews a row that does not exist and reads the lowest FREE ordinal in the bucket, which for an existing row is the NEXT sibling’s number. Asking it would answer confidently and wrongly. The field therefore keeps showing the label that is there and the hint says the platform rewrites it on save. A lock over an EMPTY field was refused here for the same reason it was refused on the create form. - It closes a silent pen theft, which is why this is a fix and not a restyle. Every blade seeded
a plain signal from
display_nameand posteddisplay() || undefined, so opening the pencil on a platform-labelled row and saving ANY other field posted the platform’s own rendering back as an override.labelPenread that as the operator claiming the label, the row stopped following its rule, and nothing on screen had said so. The field now posts the PEN’s value, which is the empty string while it is locked, and the API reads that as “still the platform’s”. One expression covers the no-op and the hand-back, and the hand-back is the console’s first way back at all. labelGeneratedretires with the chip. It asked “is there a rendered label here to mark”, which is a marker’s question and returns false for a row whose rule rendered nothing; a field asks “who holds the pen”, which is true for that same row and must open it locked. One predicate answering two questions differently is how a surface marks one thing and does another.
- Tracked under #688, the eighth slice of epic #657, amended by #699, its tenth, by the affordance pass folded into #698, by #702, and by #693.
ADR-0105: A rule reads a name as words, and the location tier ships the restatement it once refused
Section titled “ADR-0105: A rule reads a name as words, and the location tier ships the restatement it once refused”- Date: 2026-08-11 | Status: Accepted | Pages: core entities, find things in your estate, location types
- Context: epic #657 shipped a label engine
(ADR-0098) and an operator-owned
acronym dictionary (ADR-0099)
that nothing could reach.
titleupper-cases each word and leaves the SEPARATOR standing, so{{title .Name}}rendersnorth-wingas “North-Wing”; the closed FuncMap’s other three run the other way (slug) or ignore word boundaries entirely (upper,lower). There was therefore no rule an operator could write that turned a kebab NAME into the words in it, and “HQ West” fromhq-westwas unreachable by any spelling. It bit hardest on locations, whose global rule shipped deliberately empty, so every location in a shipped estate fell to the read ladder’s last rung and read its raw name. - Decision (the function):
wordsjoins the closed FuncMap: a run of-or_becomes one space, a leading or trailing run is dropped rather than becoming an edge space, and every other character, including whitespace the fact already carried, is untouched. It is deliberately not a normalizer and deliberately notwordRe’s “anything that is not a letter or a digit”: a catalog display name’s parentheses and slashes are punctuation somebody chose, and only the two separators a NAME is built from are spent. Adding a function is a three-place act by design (the FuncMap, the AST allowlist, andFuncNames), and two of the three is silent in both directions, so the published set is now WALKED by a test that parses each name in a real rule rather than described by a comment. Amended (#701): the three-place act is ONE place, and the “by design” was wrong about which design. What the property needs is that a function be in the FuncMap AND the grammar, not that a person type it into both: the FuncMap,FuncNamesand the allowlist now all derive from a single ordered declaration, so two-of-three is unwritable rather than merely tested for, and the fourth copy this entry did not count (the prose that teaches the language) becomes a table rendered from that same declaration. Adding a function stays a deliberate, test-visible act, because the closed-set test still names the members and the generated artifact still has to be regenerated and committed. The walking test stands and is joined by its converse, since a name admitted by the grammar with no implementation behind it was the direction nothing checked. - Decision (the location rule): the global location rule ships as
{{title (words .Name)}}, written todefault_template, the boot-seed-owned column an operator’stemplatesits beside untouched. The seed’s own comment argued against exactly this and is rewritten rather than deleted: it said any rule at that tier is “either a constant or a restatement”, and the constant half stands (labelling every room “Room” is worse than the name it would replace) while the restatement half was true only while a restatement could echo. This one re-cases the name and runs the operator’s dictionary over it, so it produces a string the fallback cannot, which is the test a rule at this tier has to pass. - What this does NOT change: the read ladder’s last rung is still verbatim. A row with no stored
label renders its
nameexactly, never a prettified version, and the difference is that this rule RENDERS AND STORES a label rather than teaching a read path to guess one. That distinction is #613’s, corrected the same day for having the same confusion in it. - Consequence (the upgrade is not automatic, and says so): a shipped rule change restamps nothing by
itself (ADR-0100,
ADR-0102):
the blast radius is the whole estate, so it waits for the verb. A new estate gets a rendered label at
create; an existing one keeps its raw kebab locations until an operator runs
/locations:recomputeLabels, and the operator docs say that plainly rather than implying a release does it for them. - Consequence (the estate stops masking the rule): #689
pinned a
display_nameon all thirteen dev-estate locations, because nothing would have rendered one. Four of those pins now restate what the rule renders, so they are released and the RENDERED values pinned in their place; nine survive with a reason each, ADR-0103’s two floors among them, since a positional name is allocation order and releasing those deletes that worked example. Amended (#657): ADR-0103 was then reversed forfloor, so those two floors are namedlevel-2andlevel-1for their designations, this rule renders “Level 2” and “Level 1” from those names, and their pins are released too. Six rendered values are pinned in place of released ones and seven survive. The media lab’s name becomesmedia-lab: every other location name in the estate is one word, so without it nothing the console shows demonstrates a separator becoming a space. - Known gap, deliberately not closed here:
HQis not in the shipped acronym list, sohq-westrenders “Hq West” out of the box and needs one operator edit to read “HQ West”. Adding it is a change to a settings DEFAULT rather than to this rule, it ripples through three generated artifacts, and ADR-0099’s replace-not-merge semantics mean a shipped entry is not free. Left as a follow-up so this slice stays what it says it is. - Tracked under epic #657, folded into #698 after the rollup review.
ADR-0106: A location type is platform-owned, and a nullable object clears under the mask
Section titled “ADR-0106: A location type is platform-owned, and a nullable object clears under the mask”-
Date: 2026-08-12 | Status: Accepted | Pages: storage, core entities, API, location types
-
Context: the boot seed’s contract is documented as an authoritative upsert, and
SeedLocationTypewason conflict (name) do nothing. A shipped default was therefore a one-way ratchet: adding one reached every estate, removing one reached only new installs. #657 hit it live, shipping aname_ruleonfloorand then reversing that in ADR-0103, at which point the withdrawn rule could not be taken back out of an estate that had booted in between, and noPATCHcould clear one either. The do-nothing seed was not an oversight: shipped location types seededofficial: false, so the rows belonged to the operator and an authoritative re-seed would have stomped their edits. The ratchet was the symptom; the ownership model was the cause. -
Decision (ownership):
location_typeadopts the registry-fork primitive (ADR-0095), the second registry to do so aftercomponent_type, rather than growing a third mechanism beside it. Shipped rows seedofficial: trueand the platform owns them, so the seed writes them withON CONFLICT DO UPDATEand a withdrawal is just another write. An operator still shapes their own place vocabulary: an edit of a shipped type stores their whole version of its mutable columns as a shadow keyed on the row’s own uuid, every read resolves the shadow over the row, andPOST /location-types/{id}:restorediscards it. One uuid and one name per logical row either way, so no foreign key, no URL and no audit row learns about the fork. The rejected alternative was thedefault_template/templatecolumn pair the global label rules use, which is right for a table of three rows with no operator-created siblings and wrong here: it doubles every future column on a registry that already has five mutable ones, and it answers only the columns somebody thought to split. -
Decision (what the image carries): the fork’s whole-row rule is per registry, not per column name.
allowed_parent_typesis IN the image although it names other types, because alocation_typeis flat: it has no parent link, and the placement constraint is a fact the row states about itself, one an operator legitimately reshapes. Holding it official would leave a fork unable to say the thing the registry exists to say. -
Decision (the migration’s discriminator): estates already carry operator edits on the shipped rows themselves, so a one-time backfill moves them into shadows before the flip. Telling an edit from a shipped value is the whole difficulty, and it is NOT done by comparing the row against what this release ships. That comparison cannot see the case this ADR exists for: a row holding a value a previous release shipped and this one withdrew differs from today’s YAML and reads exactly like an edit, so preserving it would defeat the withdrawal, and a row edited to a value a later release happens to ship has the mirror problem. The audit trail decides instead, because it is a record rather than an inference: every operator write of a registry row writes its audit row in the same transaction, so a shipped row with no
createand noupdateof its own has never been touched and whatever it holds is what some release shipped.createcounts as well asupdatefor a case that is reachable today, an operator deleting a shipped type and creating their own under the same name. The known imprecision is biased toward preserving: a contract write audits againstlocation_typetoo, so one addressed by uuid looks like an edit here and leaves the row holding a shadow of the values it already had, which reads identically and costs a:restore. The reverse mistake, reverting an edit, is not recoverable from the console at all.Amended (#703), and the discriminator is REMOVED. Everything above answers “how do you tell an operator’s edit from a shipped value”, and the architect’s ruling of 2026-08-12 is that there is no such edit to tell apart: Omniglass has cut no release and holds no operator data, so no upgraded estate exists and the capture was preserving rows that do not. A review then found the discriminator wrong in two ways that both lose data silently, and its
createleg exercised by nothing, which is what a premise that never occurs does to the code written for it. The backfill is now one statement,set official = trueon the four shipped names, and its test asserts the migration writes no shadow, so the machinery cannot grow back quietly as a fix for a problem that was ruled out of existence. This is correct only while that premise holds. The first real release creates estates carrying operator data, and from then on a change of ownership on a shipped row owes those estates an answer to the question this bullet asks, in a NEW migration reasoning about their rows: this one has already run, and it reasons about nothing. Whoever ships that release should read the paragraphs above as the standing argument, still sound; only the machinery went away. -
Decision (the wire): a nullable OBJECT field clears by being named in
update_maskwith no value, reusing the primitive from ADR-0091 rather than inventing a per-type spelling. The house three-state string sentinel does not generalize:""clears a string because a string has an empty value to overload, and an object has none ({}is a rule with default fields, not the absence of one). An explicitnullcannot carry the intent either, since it decodes to the same nil as an omitted key. This is the convention for every nullable object field that follows, so it is recorded on the API page and not only in the handler. -
Consequence (a capability that had to be defended): flipping the shipped rows to
officialactivates a guard that had been dormant on this registry, since nolocation_typerow was official before.SetLocationTypePropertyand its three siblings would have started refusing the four types an estate actually uses, silently withdrawing the contract editor for them. They no longer consult the official flag: a contract line is a row in its own table rather than a column of the registry row the fork covers, and nothing seeds one, so every line in those tables is an operator’s already. An ownership change may not take a working capability away as a side effect. -
Consequence (an expectation inverts):
internal/seed/seed_test.goasserted “official location_types = 0, a shipped location type is operator-owned”. It now asserts 4 and guards the opposite claim. The same inversion lands on the wire test and on the console, where origin reads three ways (shipped, yours, overridden) exactly as it does on component types, and the blade’s destructive slot offers Restore shipped on a forked row. -
What this does NOT change: the
label_ruletable’sdefault_template/templatepair stays as it is (ADR-0098). It is one row per labelled entity kind with no operator-created rows, where a per-column split is the natural fit and an overlay table would be more machinery than three rows are worth. A shipped row still cannot be DELETED, forked or not, and an operator-created location type is untouched by any of this: it is written in place, it never carries a shadow, and:restorehas nothing to give it back.
ADR-0107: A create that writes a membership costs what the membership route costs
Section titled “ADR-0107: A create that writes a membership costs what the membership route costs”-
Date: 2026-08-12 | Status: Accepted | Pages: identity and access, API, core entities
-
Context:
POST /componentsaccepts asystemreference, and what it does with it is insert the component’s primary membership intosystem_member. That is the same rowPUT /systems/{name}/members/{component}writes, and that route is gated on thesystem:updatepermission and resolves its system in thesystem:updatescope. The create asked for neither: the reference resolved existence-only until #700 made it resolve insystem:read, and the route requiredcomponent:createalone. A principal holdingcomponent:createand no system permission at all could therefore write a membership through the create that the membership route refused it, which makes the gate on the membership route decorative rather than binding. -
Decision: the create’s
systemreference resolves in the caller’ssystem:updatescope, and the route requires thesystem:updatepermission when the reference is present. Two paths that write one row cost one permission. The scope half is not implied by the permission half and is the one a test has to drive separately: a principal holdingsystem:updateon one system and the all-scoped viewer floor beside it may READ every system in the estate, and before this could bind any of them on a create. -
Decision (where the check lives): in the handler, not the middleware, because the condition is a body field and Huma’s operation middleware cannot see the body. To keep the generated spec a faithful map of what a route enforces rather than of what it always enforces, the second permission is published as an
x-omniglass-conditional-permissionextension beside the primaryx-omniglass-permissionstamp, exactly as a platform-tier write publishesx-omniglass-platform-permission, and it joins the route-derived permission universe the roles view and the docs lint both read. Reusing the existing stamp mechanism was preferred to a hand-written note in prose, which is the drift the generate-first rule exists to prevent. -
Consequence (accepted, and the reason it is not a bug):
operatorholdscomponent:create,update,rename,moveand nosystem:*permission at all;deployholdssystem:create,update,rename,movebeside the same component set. So an operator can no longer create a component INTO a system. That is the role line the seed already draws rather than damage from this change: an operator maintains components, a deploy tech builds out systems and their membership. The alternative, grantingoperatorthesystem:updatepermission, was refused as a much larger grant than “may bind a membership while creating a component”: it would also let an operator edit systems generally, which is exactly the line the two roles are drawn along. The recovery needs no grant at all, since creating the component and binding it are separately authorized acts: the operator creates it, and whoever holds the permission adds it after. -
Consequence (the narrowing has to be met before the form is filled in): a live narrowing discovered as a 403 after an operator has filled in a create form is the outcome #699’s rule exists to prevent, so the console does not offer the system picker to a principal that cannot use it, and the slot explains itself and names the permission rather than vanishing. The API’s refusal names
system:updatetoo, which is what the CLI prints, because a refusal an operator cannot act on sends them to an administrator with no ask.Amended (#707 review): that claim was FALSE as shipped, on the layer this decision itself calls out. The console gate read the
system:updatepermission and nothing else, while authorization is two layers and this decision moved BOTH. A principal holding the permission over no system at all was offered the picker, filled the form in, and was refused on submit, which is the exact outcome the paragraph promised to prevent. That principal is not exotic: a location-scopeddeploygrant resolvessystem:updateto the empty set, becauseapplicableKinds("system")is{"system"}alone and the cross-tier expansion is unbuilt (#10), and devseed ships one astech-east. The gate now also requires a system carrying theupdateaction, the server’s own per-row answer computed from the same per-action scope the gateway enforces, offers only those systems, and names whichever of the two layers is missing. The console filters data rather than resolving a scope in the browser, which it must not do.Amended (#707 review): the API’s refusal denied the row’s existence. The bind resolved in the
system:updatescope alone, so a system outside it came back as the non-disclosingErrSystemNotFoundand the route answered “system not found” (422) for a row the same caller couldGET. The bind now takessystem:readas well: update still decides whether it happens, read decides what the refusal may say. Out of the read scope is the same non-disclosing 422 as before, so nothing new is disclosed; inside it, the refusal is a 403 naming the scope, which discloses nothing either, since that caller can read the row. -
What this does NOT change: the LOCATION reference on the same create still resolves in
location:read, because a location is read and rendered into the label rather than written (ADR-0100, #700). The two references are deliberately not symmetric: what decides the action a placement reference resolves for is what the write DOES with it.Amended (#713): the residual this entry left open is closed. As shipped, the
:renderLabeldraft keptsystem:readon the reasoning that it renders a preview and writes nothing, and the entry accepted that a preview could therefore be served for a create the platform would refuse, with the console’s own gate as the thing that kept the two in step. That is the caller’s good behaviour standing in for the platform’s, and it is also wrong about what the preview contains: the rendered string carries the system’s TYPE label, so the lenient resolve hands back a fact about a row the caller may not bind, one tier past the disclosurelocation:readcloses on the other reference. The draft now resolves itssystemthrough the create’s own resolver, insystem:readandsystem:update, and carries the create’s conditional permission, so both halves of the gate rehearse and the refusals are the same sentinels in the same order. The LOCATION reference is unchanged on both routes, and remainslocation:readon both, for the reason above: it is read and rendered, never written.
ADR-0108: Settlement reads one clock, and a zero window is a statement of intent
Section titled “ADR-0108: Settlement reads one clock, and a zero window is a statement of intent”-
Date: 2026-08-12 | Status: Accepted | Pages: commands
-
Context:
Settlecompares a sample’stsagainst anowits caller supplies. Everytson the telemetry tables istimestamp with time zone default now(), so that end of the comparison is written by Postgres, whilenowwastime.Now()in the server process. Two clocks, one comparison. Atsettle_window_seconds: 0the test reduces tonow.Sub(intended.TS) < 0, true exactly when the sample is stamped ahead of the host, so the verdict for the setting that most wants to be decisive was decided by skew.TestCommandIssueAPIfailed intermittently on precisely that branch, and the operator-facing version is worse than a flake: a deployment whose database sits on another host gets the same coin flip on every zero-window command, and a command that genuinely failed can be reportedpendingand never settle. -
Decision (the clock): the comparison takes
nowfrom the database, read withselect now()inside the caller’s transaction (dbNow), at both settle sites.now()istransaction_timestamp, which is the point rather than an incidental detail: a settle-check running in the transaction that just opened the intended value reads exactly the timestamp that row was stamped with, so the two ends of the comparison are one reading of one clock rather than two readings that happen to be close. A check in a later transaction reads a strictly later timestamp from the same server, so elapsed time is elapsed time and never skew.Amended (#718): that last sentence over-claims, and the code comment repeating it has been corrected.
now()istransaction_timestampand both settle paths run at READ COMMITTED, so anIssueCommandthat BEGAN after a settle-check’s transaction did can still commit before one of its statements takes a snapshot; the intended row it then reads carries atslater than that transaction’snow(), and the delta is negative. No verdict is wrong today, and not by luck: a negative delta is less than any positive window, which ispending, the right answer for a command issued a moment ago, and the zero case never consults a timestamp at all. What is corrected is the claim, not the behaviour, because an invariant an isolation level does not provide is one a future change can lean on.settled_atis stamped with the same value, which also puts it in the currency the insert path’s ownnow()already uses. -
Decision (the zero window): a window of zero is terminal by construction, checked before any arithmetic.
settle_window_seconds: 0is the documented way to say “settle immediately”, which is a claim about intent rather than about elapsed time, so no timestamp of any provenance may make itpending. Taking the clock from the database is what makes every other window honest; this is what makes the zero case independent of the clock rather than merely agreeing with it, and it is the half that survives a future caller that suppliesnowfrom somewhere else. -
What was refused: stamping the sample from Go on the write path, which is the other way to get one clock. It inverts the ripple:
tsdefaults tonow()across every telemetry table and other readers rely on database time ordering, so moving the authority to the process would touch far more than settlement to fix a defect that lives in settlement. Also refused: widening the comparison with a tolerance. A tolerance makes the test stop failing without making the behavior stop depending on skew, which is the failure this repo has paid for twice. -
Consequence (cost, stated): one extra round trip on each of the two settle paths,
IssueCommandandCommandSettlement. Both already run several statements inside one transaction, so it is a small fraction of either, and it is the price of the comparison being about time rather than about which host answered.Settlestays pure and still takesnowas an argument; what changed is who supplies it, which is the repo’s own rule about the clock being an edge concern pushed out of the core. -
Consequence (a shape that was already true becomes stated): nothing in the catalog ships a zero window with a target today (
rebootis fire-and-forget), so the change moves no seeded behavior. It moves what an operator’s own zero-windowcommand_typedoes, from “terminal if the clocks happen to cooperate” to “terminal”. -
Extended (#719): the same principle, a different mechanism, and no round trip. The mixed comparison survived on the history reads: a system’s and a location’s health transitions, a component’s property transitions, a component’s events, and a component’s and a node’s logs each built
time.Now().UTC().Add(-window)in the server process and filtered atsthe database stamped. Less urgent than settlement, because a multi-minute window absorbs ordinary skew where a zero window had no margin at all, and still a boundary decided by two clocks with nothing bounding the difference, which is why the same read answers differently against a database on another host.The round trip
dbNowcosts was weighed rather than assumed, and refused: these are READ paths, and the arc that precedes this one (#653, #725, #726) spent itself taking round trips OFF them. It did not have to be paid, because the dilemma the issue poses (pay a round trip, or document the mixed comparison) has a third answer the settle path did not have.Settlecompares in Go, so it needsnowas a VALUE and something has to fetch it. A history read only ever hands its boundary to awhere, so it needs a BOUND, and a bound can be computed where the data is: the window travels as a duration and the query filtersts >= now() - make_interval(secs => $n). One clock, the database’s, and the instant is never named in Go at all. Six read paths, zero extra statements. Neither horn of the issue’s choice was taken and nothing was left implicit.What this costs instead is that the boundary is now per statement rather than per request, so the reachability panel’s per-interface strips are each bounded by their own statement’s
transaction_timestampinstead of by one instant shared across the loop. Sub-millisecond, against a 24-hour window, and the instant they used to share was skewed against the data anyway.
ADR-0109: An alarm carries an acknowledgement, and not a snooze or a resolve
Section titled “ADR-0109: An alarm carries an acknowledgement, and not a snooze or a resolve”- Date: 2026-08-13 | Status: Accepted | Pages: alarms and actions, identity and access, API
- Context:
alarm:ack,snooze,resolvehad appeared for most of a year ininternal/seed/seed_shrink_test.go, ininternal/rbac/rbac_test.go, and in the permission-format fence and the worked example on the identity-access page. Nothing seeded it, no route registered it, and no design document ever specified what snooze or resolve would do. It read as prior art, which is exactly the risk of borrowing a real resource’s vocabulary for a fixture: three verbs looked decided, and only one of them had a meaning. - Decision (what is built): the acknowledgement, alone. Two nullable columns on
alarm,acknowledged_atandacknowledged_by, plusPOST /components/{name}/alarms/{id}:acknowledge, thealarm:acknowledgepermission, the seeded grant onoperator, anunacknowledgedfilter on the list read, and the console affordance. - Decision (orthogonal, not a status): an alarm’s raised state belongs to its condition: the
one-open invariant is per
(component, dedup_key)and the raiser owns the key (ADR-0075). An acknowledgement is a fact about a person. Two independent facts, therefore two nullable columns and not astatusenum, which would force them into one column and make acknowledged and cleared mutually exclusive. All four combinations are real and each one is tested: raised and unacknowledged (the queue), raised and acknowledged (somebody is on it), cleared having never been acknowledged (it came and went), and cleared after being acknowledged. A cleared alarm stays acknowledgeable, because the orthogonality runs in both directions and the record of who read an incident outlives the fix exactly as the row does. - Decision (no health recompute): raising and clearing both recompute the health chain in their own transaction, and acknowledging deliberately does not. Acknowledging is not fixing: a transition recorded here would stamp a change at a moment when nothing about the estate moved, and the health history is meant to be edges and only edges.
- Decision (the permission’s name):
alarm:acknowledge. Every other seeded action spells its verb (create,update,rename,move,reveal,issue,push,purge,revoke-session), and a permission string is operator-facing: it is rendered in the Roles view, in the grant builder’s tooltips, and in/auth/me.alarm:ackwould have been the only abbreviation in the set, and its only claim was the fixture this ADR exists to disown. - Decision (idempotent, not refused): a second acknowledgement of the same alarm keeps the first
person and the first time, returns 200, and writes no second audit row. The recorded fact is
monotonic, and the first sighting is the one that means something operationally (time to
acknowledge). Refusing would turn a double click into an error an operator has to interpret, and
would make two people opening the same queue race into a 409 that teaches nothing. It is the same
shape the raise path already has: a re-raise of an open condition returns the existing incident
and writes no audit row, because the no-op leg changed nothing. The database decides it, through a
conditional
where acknowledged_at is null, rather than a read-then-write that a concurrent acknowledgement could lose. - Decision (the scope, and why
deploydoes not get it): the acknowledgement resolvesvisible_set(P, alarm, acknowledge)from its own permission on the component tier, not fromcomponent:updateand not fromcomponent:read. A responder role may acknowledge without editing components, and a principal commonly holds an estate-wide read beside a narrow write, so binding the scope to either neighbour answers the wrong question in one direction or the other. Given that,deploydoes not get the grant: adeploygrant is made at a location in practice (devseed’stech-eastis exactly that shape), and a location-kind grant fills no component-tier scope at all (#714). The capability would resolve to nothing while the console’s capability-only hint still offered the button: a grant that reads as given and refuses when used. It is revisitable when the cross-tier expansion (#10) lands, andTestALocationGrantOfDeployReachesOneTiernow carriesalarmin its matrix so the question resurfaces on its own. - What was refused: snooze, because its purpose is to suppress notification and the
outbound notification registry is unbuilt
(#618); a snooze with nothing to suppress
is a column that lies about what it does, and it belongs with that issue or after it. Resolve,
because the platform already has
DELETE /components/{name}/alarms/{id}, so a human “resolving” an alarm whose raised state belongs to its condition is either that clear under a second name or a different concept nobody has specified. Un-acknowledge, which is a second verb and a second decision rather than a nullable field somebody flips, and has no evidence behind it yet. Bulk acknowledge, until there is evidence the queue needs it. And a denormalized acknowledger label on the alarm row:audit_logalready denormalizes the actor’s name at write time for exactly the purge case, so the alarm keeps the foreign key (ON DELETE SET NULL) and resolves the current label on read, which reads honestly empty after a purge while the durable record survives where it already lived. - Divergence recorded: the identity-access page’s three-way status split says a target the caller can read but not act on is a 403. Every scoped route in the platform answers 404 there, this one included, because the target is resolved through the gateway with the action’s own scope and a miss is a miss. Making it three-way is one shared refusal primitive across every scoped route rather than a per-route special case, and doing it for this route alone would have made it the only route in the system that answers differently from its own siblings. Tracked in #736; the page now marks the branch as design.
- Tracked under #728, #733, #734 and #735.
ADR-0110: A principal’s identifier is the gateway’s answer, not a stored function’s
Section titled “ADR-0110: A principal’s identifier is the gateway’s answer, not a stored function’s”-
Date: 2026-08-13 | Status: Accepted | Pages: storage, audit, identity and access
-
Context:
principal_label(uuid)was a stored SQL function,coalesce(human.username, service.label), and it was wrong on both counts. It put the platform’s answer to “what names this principal” in the database, which is the one place this repository says logic never lives, and it called the answer a LABEL when both branches return an identifier. It was also unpinned: no test named it, so it could have returned anything and the suite would have agreed. The word matters beyond tidiness, becauselabelis about to become the schema’s name for the friendly string on eighteen tables (#613), and a word cannot be renamed onto a column while it still means two other things somewhere else. -
Decision: the function is dropped and the answer moves to
internal/storage/principal_ident.go, which names the tables and columns ONCE (principalIdentSources) and renders every shape a statement can need from that one list, so a caller picks a shape and never a column. -
Which shape, measured rather than assumed. A read over many rows LEFT JOINs the sources and folds them in Go. Two positions cannot join, and both are bounded reads:
alarmColsis read by anUPDATE ... RETURNING(RETURNINGcannot left-join, and giving theUPDATEaFROMclause would change which rows it updates), and the audit insert runs inside the CALLER’s transaction on every operator write, where a Go fold means a second round trip and the alarm write path pins its statement count as the exact equation12 + 5*slots + 4*locations(alarm_cost_test.go, ADR-0094) that counts that insert. Both render the sources as correlated sub-selects instead. The first draft of this decision used sub-selects everywhere, and a review measured it: on a 500-member group roster, projecting them AND sorting on them costs 3011 shared buffer hits and 2000 index searches where the join costs 18, because Postgres does not common up two identical scalar sub-selects. Moving the policy out of the database was the point; paying a round trip on every operator write, or two thousand index probes on an unbounded roster, was not. -
What holds the shapes together. The columns are named once, so only the ORDER is written twice (as Go control flow and as a coalesce), and
principal_ident_test.godrives a human, a service account, a node and an unknown id through EVERY shape against a real database, asserts they agree and asserts what they agree ON, and fails on the first disagreement. Asserting only the agreement would pass when all shapes are wrong the same way, which is exactly what naming the wrong column in the one source list would do. That is the recompute-and-compare shape the gateway already uses wherever one fact has two readers, applied to a policy rather than to a derived column. -
The audit READ moved with them.
ListAuditLogkept its own hand-writtencoalesce(ah.username, a.actor_username, ''), which resolved a HUMAN actor live and a SERVICE actor only from the row’s snapshot: a fourth statement of the policy, asymmetric between two kinds for no recorded reason. It now resolves both live through the same sources and falls back to the snapshot, which is what makes the trail survive a purge (ADR-0016). -
A node is still not a source.
principal_labelnever readnode.name, so a node principal resolved to null, and this change preserves that rather than quietly widening what an audit row says. Nothing seeds a node grant today, so no path reaches it; widening the resolution is its own decision with its own test.Amended (#738): a node’s identifier is its
name, and it is the third source. The preservation above was right for a refactor about WHERE the answer lives and wrong to leave standing, because what it preserved was a blank actor on an audit row, the kind of defect found much later from a trail that cannot say who. The identifier isnode.namefor the same reasons the other two columns are identifiers: it is the only operator-visible handle the row has, it is unique (node_name_key, besidehuman_username_keyandservice_name_key, ADR-0111), and it is already the estate address and the NATS subject token, so it is the string an operator would recognise.node.display_namecannot be one: it is optional and not unique, and this string is denormalized as bare text intoaudit_logwhere nothing survives beside it. Reachability is unchanged (nothing seeds a node principal with a grant), so this is what a node acting WOULD read as rather than a fix to something an estate sees today. The cost is one moreLEFT JOINper resolution on the read shapes and one more correlated sub-select on the two positions a join cannot reach, the same arithmetic the measurement above covers; the invariant test now drives the node arm to a real answer instead of to the empty string, and a second test drives all three kinds through the audit write, the audit read, the alarm read and the group roster, because a fold reading one column fewer than its query returned is a defect only a surface can show.
ADR-0111: A service account’s identifier is a name, and it is unique
Section titled “ADR-0111: A service account’s identifier is a name, and it is unique”- Date: 2026-08-13 | Status: Accepted | Pages: identity and access, audit
- Context:
servicehas exactly two columns,principal_idandlabel, and the second one is what IDENTIFIES a service principal: it is the username analogue forkind=service, the only operator-visible handle the row has. It was also on the wire assvcBody.Label, three lines from the human body’sdisplay_name, so two different concepts read as one. Under the identity triad an identifier is aname, and this was the only place in the schema wherelabelmeant one, which matters beyond tidiness becauselabelis about to become the schema’s word for the friendly string on eighteen tables (#613). - Decision: the column is
service.name, on the wire asname, in the console as Name. - Uniqueness: yes, answered rather than inherited. The column carried no index and no
constraint. It is now
service_name_key, matchinghuman_username_keyandnode_name_key, so all three principal-kind identifiers behave the same way. The reason is not symmetry, it is that the string is DENORMALIZED as bare text where nothing survives beside it: intoaudit_log.actor_usernameat write time so the trail outlives a purge, and into an alarm’s acknowledgement on every read. Two service accounts sharing a name make both unresolvable after the fact, which is exactly why a username is unique. It is also free today and expensive later: there is no create path for a service principal on the gateway or the API, so nothing can be holding a duplicate an operator typed, and the estate has no releases and no operator data. The migration still copes: a dedupe backfill runs first, keeping the name on the oldest row of each duplicated set (principal.idis uuidv7, so it sorts by creation time) and suffixing every other with its own principal id, which is unique by construction rather than by luck. - The declaration moved with it, and grew a guard.
servicewas declaredShapeIDOnly, whose published sentence is “nobody names it”. That was believable only while the identifier was calledlabel, and renaming the column would have left the declaration saying the opposite with a green suite, because nothing checked it.serviceis nowShapeHumanNotAKey(a username analogue, on its own rule, not an address), and a new guard reads the generated schema facts and fails anyShapeIDOnlytable carrying anamecolumn. - The mixed fallback is what the rename was for. The group roster read
coalesce(h.display_name, s.label, '')into one field: a human’s friendly string falling through to a service account’s identifier. Written out after the rename it readscoalesce(h.display_name, s.name, ''), and the mistake is on the page. The fix is not a better chain but two fields,name(the identifier, resolved by the gateway’sprincipalIdent) anddisplay_name(the friendly string, which only a human has a column for), with the renderer choosing. A service member’s identifier used to arrive in a field calleddisplay_name, which an API test asserted verbatim. - Breaking wire change, on two read shapes:
svcBody.labelissvcBody.name, and the roster’susernameisnamewithdisplay_namenarrowed to humans. No CLI flag moves, because neither field is a request field. - Tracked under #563, under #613.
ADR-0112: A generated flag carries the schema’s type, and a structured field carries JSON
Section titled “ADR-0112: A generated flag carries the schema’s type, and a structured field carries JSON”- Date: 2026-08-13 | Status: Accepted | Pages: the CLI, API first
- Decision:
cmd/cligenderives each body flag’s type from the OpenAPI property it comes from. A scalar the shell can type takes that flag type (integeris anintflag,booleanaboolflag,numberafloat64flag,stringa string flag), so a value the schema refuses is refused at the shell rather than by the server’s 422 after an authenticated round trip. Every other shape keeps one string flag parsed as JSON: an object, an array, an untypedany, and a nullable number or boolean. - Context: every body flag was a string, and every non-string field was coerced at run time by
jsonOrString. The schema said integer, the CLI said string, and the generated CLI reference publishedstringfor all of them, which is the generate-first drift class moved inside the generator: a fact the spec states, restated by hand as something else. The same wave surfaced it twice, onname_rule(an object) and onsettle_window_seconds(an integer with a floor of 0 the flag could not carry). - Why a structured field stays one JSON string, decided rather than left: a nested value has no
shell-native flag type. The alternatives are repeated
key=valuepairs (pflag’sStringToString), which can express neither nesting nor an array member, or a flag per leaf, which would make the flag NAMES depend on how a$refhappens to nest and rename them when it changes. Above both,nullhas to stay sendable: a nullable object is cleared by naming it inupdate_maskand sending null (ADR-0106), and a typed flag has no null, so--name-rule nullis the only spelling of that clear. That is why a nullable number or boolean keeps the JSON spelling too, though the spec carries none today. - The one nullable exception: a nullable string stays a plain string flag. This API clears a
string with the empty string rather than with null, and routing it through JSON would hand it the
quoting hazard the string passthrough exists to avoid (a name that is literally
30). - What moved for an operator: a boolean is now a real bool flag, so
--propagates=falseis the spelling and--propagates falsereadsfalseas a positional argument. One documented line in the CLI guide was exactly that mistake, so the docs flag check now fails on a bool flag handed a space-separatedtrueorfalse, which is a claim a page can no longer make by accident. A required flag’s rendered example names what the flag takes (--position <int>,--fields <json>) instead of repeating the field name, since--position positionread as a runnable line and is now refused by the parser rather than by the server. - Corrected on the way in: the issue names
expected_ordinalas one of the two fields that surfaced this. That field does not exist: the #702 review replaced it withexpected_name, andinternal/docslintrefuses the word.settle_window_secondsis the integer under test instead. - Tracked under #711.
ADR-0113: A validation rule is TypeScript, and a native constraint attribute is not one
Section titled “ADR-0113: A validation rule is TypeScript, and a native constraint attribute is not one”- Date: 2026-08-13 | Status: Accepted | Pages: design system
- Decision: a console control carries no
required,min,max,minlength,maxlength,patternorstep. A rule is a pure function over the typed value (lib/validate.ts,readSettleWindowinlib/command_types.ts), the surface renders its message inline beside the field, and the form binding’sdisabled/validrefuses the submit.aria-requiredstays, because it announces a fact to a screen reader without claiming the browser will refuse the value.validation-guard.test.tsscans every.tsxfor a native constraint on aninput,selectortextarea. - Context: the browser enforces a constraint attribute on a real form submission, and this
console performs none on the paths an operator uses. A Drawer’s action rail is drawn by the shell
and portaled outside the
<form>(ADR-0054), a blade has no<form>at all, and the inline editors (RoleEditor, ContractEditor) save from anonClick. The audit is what decided it: 21 attributes on 24 rendered controls, of which 17 sites are on a surface with no form submission at all, and the remaining four (three on Login, one on the forced password change) sit in forms whose submit button isdisabledin exactly the states native validation would have refused. Zero of the twenty-four could ever fire. A reader could not tell a live attribute from a decorative one because there were none of the first kind. - Why not make them live instead (
form.requestSubmit()from the rail): it would have covered the Drawers and left every blade needing this decision anyway, since a blade is a field set with a contributor registry rather than a form, so native validation could not be the one vocabulary. It would also mean threading a form ref through the binding into a portaled shell to reproduce an effectdisabled/validalready produce, and then undoing the disabled gate so the browser could refuse instead, which is a worse operator experience: a native bubble is unstyled browser chrome that says less than the inline message and vanishes on blur. - What it costs and what it does not: every removed attribute already had a TypeScript rule
behind it except one, the token drawer’s
min="1" max="365", which is nowtokenTtlErrorand refuses a 400-day token at the field rather than at the server’s 422.type="email"andtype="number"stay: an input TYPE carries keyboard, autofill and spinner behaviour, and is not a claim that a value will be refused. - Found by #718, whose brief asked for a
min="0"on an input that had carried one since #411 and had never fired once. Tracked under #724.
ADR-0114: An inherited registry fact clears with the empty string, and the pattern is what admits it
Section titled “ADR-0114: An inherited registry fact clears with the empty string, and the pattern is what admits it”- Date: 2026-08-14 | Status: Accepted | Pages: the API, core entities
- Decision:
stem,abbrevandicononcomponent_typeandsystem_typejoin the three-state string sentinel theirlabel_ruleneighbour has always honoured: an omitted field is unchanged, an explicit""clears the column to NULL so the inheritance walk resumes at the nearest ancestor, and a value sets. All four columns are the samecase ... nullif(...)line in both handlers, and the fork leg decodes the sentinel too, since a shadow image is read back as the row. The mask is not the instrument (ADR-0106 scopes mask-with-no-value to nullable OBJECT fields, on the ground that an object has no empty value to overload; ADR-0091 keeps the sentinel for strings). - The clearing spelling is a pattern alternation, not a dropped rule.
stemcarriedminLength: 1and^[a-z0-9][a-z0-9-]*$on the PATCH body, so""was a 422 in the validator before the handler ran and the capability was unreachable from every client. The patch’sminLengthgoes and the character rule is wrapped in an optional group,^([a-z0-9][a-z0-9-]*)?$, which is the spellinglocation_type.name_rule.stemalready ships. That admits exactly one new string.Bad Stem,-leading-hyphen,UPPERand a trailing space are all still refused, on both registries, and there is an e2e that says so. CREATE keepsminLength: 1: there is nothing to clear on a row that does not exist yet, so""there is a typo rather than an intent. - A root keeps its stem. A root has no ancestor to inherit from, so clearing its stem is
ErrRootComponentTypeNeedsStem(and its system twin), the same refusal create gives, moved to the second path that can reach the same broken row. The rule is one pure function over the patch and the parent id, so the two handlers cannot disagree about it, and it is deliberately narrower than “a root clears nothing”: a root with no icon of its own is answered by the console’s fallback, where a root with no stem is a name the platform cannot mint. - Why this and not the mask, restated for the columns that prompted it: the mask would work, and
it would mean two spellings of “clear a string” in one PATCH body, since
label_rulebeside these three already clears on"". One vocabulary per kind of field is the property worth having. - What it cost. Both console blades now send
""for an empty box where #677 and #656 taught them to send nothing, so the three guard tests those slices left (“no''ever rides the body”) invert rather than being deleted: they now assert the sentinel. That is the correct reading of both slices. They were data-destruction fixes against a coalescing patch, and the patch stopped coalescing here. - Found by #716, filed out of #677’s review rather than folded into it, so the destructive bug shipped the day it was found and the missing capability stayed honest until it could land on two registries, the wire, validation and both consoles at once.
ADR-0115: An inherited fact is served with the value it inherits, and the ancestor it came from
Section titled “ADR-0115: An inherited fact is served with the value it inherits, and the ancestor it came from”- Date: 2026-08-14 | Status: Accepted | Pages: core entities, the UI
- Decision: The
component_typeandsystem_typeLISTINGS serveinherited_stem,inherited_iconandinherited_abbrevbeside the raw fields, each paired with aninherited_*_sourcenaming the ancestor the value comes from. The console’s edit blade shows the value as the field’s placeholder and the ancestor in a provenance dot beside the label, and its read state shows the value in place of the em dash it used to render, marked with the same dot in the same slot (see the two amendments below: the first replaced the attribution sentence the read state shipped with, the second removed the hint sentence that then duplicated the dot). - This is a different question from
resolved_icon, and that is the whole reason it exists. ADR-0104’s #695 amendment serves what a row SHOWS, which is the row’s own value on every row that states one. A placeholder asks what the row would show if it stated nothing. Those are the same string only on rows that already inherit, so a blade that usedresolved_iconas its placeholder would print the value an operator had just deleted back at them as the thing they were about to inherit. The new fields start their climb at the PARENT;resolved_* = own ?? inherited_*falls out of that, and one walk answers both. - A mark of its own, and not the lock. A placeholder natively means “leave this blank and you get this”, so the value goes there; the RELATION gets one new mark of its own (below), and the lock was available and is refused: ADR-0104 gives it one meaning, the platform owns this value, and an inherited fact is one an operator MAY set, which is the opposite. Borrowing the icon would cost the lock the only meaning it has.
- Amended, in this same wave (#716): the mark is a teal dot beside the LABEL, and the read state’s
attribution sentence comes out. This entry first said “no new glyph”, with the read state
rendering the inherited value and
inherited from <ancestor>under it. That shipped in the morning and two spikes against the real console falsified it by lunchtime. The sentence was the third telling of one fact on the one line that had no room for it (a dimmed value, an attribution beneath it, and a hint beneath the box), and it could not appear in the EDIT state at all, which is the state that needs it most: there the inherited value is a grey placeholder in a box that looks exactly like an empty one. What ships instead is one teal dot, present or absent, present meaning this value comes from somewhere that is not this row.- Beside the label, because that is the only placement that works in both states. Measured on
the real console at 1320px: beside the VALUE, trailing is right in read (every value starts at
the same x and the dot follows the last character) and collapses in edit (a 407px input exiles it
380px from the placeholder it is about, into the slot the console’s own in-field actions occupy),
while leading is right in edit and reads as a list bullet in read, stepping every marked value
11px out of a column the unmarked ones hold. A hanging gutter lands at two different x in the two
states. The LABEL is in the same position in both, and
FieldRow’s label row andKVStacked’s eyebrow are the same slot, so one treatment covers read and edit and the mark does not move when the pencil is clicked. - Present or absent, with no encoding of distance. A segment-per-rung mark was built first and
refused on its own frames: its width was driven by how deep that corner of the estate happens to
nest (8px at two rungs, 28px at six, the same object in the design’s mind and a different one on
screen), and three marks encoding one chain never lined up, because each trailed a label of a
different length. Distance lives in the hover. The full CHAIN stays a follow-on that needs the
server to serve it, since
inherited_*_sourcenames the winning ancestor and nothing between, and filling the gap in the browser is the climb #695 deleted. - A hover-only fact is unreachable, so the dot is not one. The trigger is a button, so it is a
tab stop that opens on focus, and the whole fact is in its accessible name (
Stem is inherited from mic) rather than only in the tooltip, because a hover has no keyboard and no touch equivalent. - The mark and the hint cannot disagree, including mid-keystroke. The spike caught the dot vanishing live as the operator typed, before any save, while the hint under the box still promised the inheritance in the present tense. Both answers now come from one predicate over one string, the text the field is currently showing, which the field primitive supplies (the persisted value while reading, the draft while editing), so the keystroke that empties a box moves both at once.
- The mark is threaded as DATA, never as an element. A
JSX.Elementin a prop compiles to a getter, so it is rebuilt whenever anything that getter reads notifies; a mark derived from a query is then replaced under the pointer on every refetch, and acreateMemoover the derived value is not a fix, since the array it tracks is rebuilt equal-but-new. The primitives take the ancestor’s NAME and build the mark themselves, which is the same shapeFieldRowalready had for the (i).
- Beside the label, because that is the only placement that works in both states. Measured on
the real console at 1320px: beside the VALUE, trailing is right in read (every value starts at
the same x and the dot follows the last character) and collapses in edit (a 407px input exiles it
380px from the placeholder it is about, into the slot the console’s own in-field actions occupy),
while leading is right in edit and reads as a list bullet in read, stepping every marked value
11px out of a column the unmarked ones hold. A hanging gutter lands at two different x in the two
states. The LABEL is in the same position in both, and
- Amended again, same wave (#742): the hint stops restating the mark, and keeps the one sentence
the mark cannot say. The amendment above left the field saying the relation twice while the box
was empty: the dot beside the label, and
Inherited from <ancestor>.appended to the hint, one fact in one field at one instant. The appended clause comes out. What stays is its conditional twin,Empty inherits from <ancestor>., which the field shows only while the box HOLDS a value, and that asymmetry is the point rather than an oversight. In that state there is no dot (the row states this value) and no visible placeholder (the box is not empty), so the sentence is the only thing that tells an operator emptying the box returns the field to inheriting, and the only thing that names what it would inherit. The first sentence described a state the mark already shows; the second describes an action nothing else offers, and deleting both would leave the console with no route back to inheriting. The two therefore take turns, off the same single predicate: exactly one of the dot and the sentence is on screen in any state, and the keystroke that empties the box swaps them. The CREATE forms keep their ownLeave blank to inherithints, since neither the dot norInheritedFieldreaches a row that does not exist yet. - Amended a third time (#743): the LIST shows the value, and the mark is a blade and detail
affordance only. Everything above shipped on the blade alone, so the console’s two surfaces
contradicted each other about one fact two clicks apart: the Stem and Abbrev cells rendered an em
dash on exactly the rows whose blade showed the value they inherit. Those cells now render the
value the row takes, through one
InheritedCellprimitive shared by both registries. Nothing is fetched for it, sinceinherited_*already rides the listing that is also the blade’s read.- The value, undifferentiated: no mark belongs in a table row. A table is for SCANNING values, and the blade is where a value’s ORIGIN is explained. Those are two jobs, and the surface that does the first is not improved by being made to do the second: a reader running down the Stem column is answering “what is this type’s stem”, and every row that also answered “and whose is it” would be charging that reader for a question they did not ask. So a cell states the value and stops. The placement rule above (beside the label, in a field) is therefore not extended to tables at all, rather than being re-derived for them: it has no list case, because there is no list mark to place.
- A stated value and an inherited one render IDENTICALLY. That is the treatment, not a
consequence of dropping the mark, and it is asserted rather than left to be noticed. It also
settles the dimming question in the same breath: every value in these columns is muted already
(
text-base-content/60), and nothing distinguishes them further. - The Icon cell is the precedent the other two now MATCH. It has rendered
resolved_iconsince #695 with no distinction at all, and that was read as the odd column out while this amendment was being drafted the other way round. It is the house treatment for a table. Stem and Abbrev join it, and the Icon column changes only in that its fallback chain now lives in the shared cell. - The em dash keeps its one meaning. A row that states nothing with nothing above it still reads as an em dash, because deleting it outright would be this defect pointed the other way, a cell asserting a value that does not exist.
- What this closes. The tab-stop and duplicate-accessible-name questions a list mark would have raised (up to three focusable marks per inheriting row, in a table body with no other tab stop, and one accessible name repeated down a column) do not arise: there is no mark in a row to raise them.
- The ancestor is read, never described. Both affordances that name it, the mark and the conditional hint, name the row the value actually came from rather than saying “its parent”, because a grandchild whose parent states no stem takes its grandparent’s, and an operator told to change “its parent” would edit the wrong row. It is per FACT rather than per row for the same reason: one type can take its stem from a grandparent and its abbrev from its parent, which the e2e fixture is built to exhibit.
- Nothing is derived in the browser. #695
deleted
lib/typechain.tsand #702 and #710 each refused to reintroduce a client-side walk; the two facts with no served answer were the reason a blade could not show this at all, so the read model grew rather than the console. The web fixtures seed inherited values no client-side climb could produce, so a console that derived them would fail. - What it cost, measured. #695’s report established that the listing is a single unfiltered
whole-registry select; that its blade READ is the same query is a separate fact, and it holds
because
useComponentTypeRowfinds its row in the listing rather than fetching one.TestListComponentTypesCostIsFlatInRegistryDepthgrows the registry twenty levels deeper, the dimension a per-level query would charge for, and pins the read at one statement, the same number the seeded registry costs. location_typetakes none of it. It is flat (no parent link) and carries nostemorabbrevcolumn at all, so it has no inherited fact to show. It gains only the vocabulary change below.- “Restore shipped” becomes “Restore default”. The blade’s destructive slot on a forked shipped
row named the thing being restored FROM rather than the thing being restored TO, and diverged from
the console’s own restore vocabulary (Settings and the pen both say default, per ADR-0104’s #657
amendment). Both registries that offer it move, with the operator guide; the term is added to the
internal/docslintdenylist, and the two historical records the denylist already allowlists (decisions.md,build-log.md) keep the wording that was true on the day they were written. - Found by #716’s own review: the clear it shipped is what made the empty box reachable on purpose, and an empty box that says nothing is a capability that teaches the operator nothing.
ADR-0116: A refusal picks its status from the difference between two scopes
Section titled “ADR-0116: A refusal picks its status from the difference between two scopes”- Date: 2026-08-14 | Status: Accepted | Pages: identity and access
- Decision: A targeted mutation resolves its target twice: within the caller’s
<resource>:readscope, then within the action’s own scope. Outside the read scope the answer is the non-disclosing 404 it always was. Inside the read scope and outside the action scope the answer is 403. The status is the difference between the two sets, not a property of either one. - Why acknowledging existence here discloses nothing. The caller can
GETthe row. A 403 that says “you may not do that here” hands them a fact they already hold, while the 404 it replaces makes a claim about existence that the same caller can immediately disprove with a read. That is worse than uninformative: an operator looking straight at a component in the console, told it does not exist, reads it as a broken platform and files a bug against the wrong thing, which is exactly what #728 did. - The condition, which is not a convention. The read set each route passes must be the caller’s
own
<resource>:read. The 403 names a row, so it may only ever be reached by a caller who could have read that row anyway; a route that checked a wider set (the action’s own, a neighbouring tier’s, a convenient all-scope) would disclose the existence of a row to somebody holding no grant to see it, which is the precise leak the non-disclosing 404 exists to prevent. This is the one thing to check when adopting the split on a new route. - Adoption of an existing primitive, not a new one.
resolveScopedhas made this split for the three tree entities’ own CRUD since it was written. What answered 404 for the readable case was the seventeen routes that hang off a tree entity and resolve it as their target: property set and clear on component, system and location; system membership add, remove and setPrimary; system role declare, withdraw, assign, unassign and swapPositions; component alarm raise, clear and acknowledge. Each now routes through the same primitive, via an owner-generic seam (resolveTargetID, exported asResolveActionTarget) for the ones whose gateway write takes no scope of its own. A second refusal helper would have been the failure mode, and is not present. - Two carve-outs, both deliberate. A CROSS-TIER placement reference keeps the non-disclosing
not-found through
resolvePlacementRef: a 403 separating “no such location” from “a location you may not see” is the disclosure this whole model closes, one tier up (ADR-0107). And the estate-wide resources (principal,principal_group, thetagregistry,node) are all-or-nothing: they require an all scope and already answer 403, so there is no readable-but-not-actionable state for them to describe. - Proved per route, not per entity. The authz conformance matrix’s registry gains a per-entity list of the routes that hang off it, and drives all three branches through each. Per entity would not have done: the routes are not uniform across the registry (properties on all three tiers, membership and roles only on system, alarms only on component), so an entity-level assertion would have left twelve of the seventeen unproved.
- Corrected on the way through. The identity-access page explained the gap with “every scoped route resolves its target through the gateway with the action’s own scope”, which was already false when it was written: the tree CRUD split, and the matrix asserted it. The page now says the narrower true thing.
- Not in scope, and filed:
POST /components/{name}/commands:issuefences its WRITE withcomponent:read(#749), and the node placement bind’s coverage gap stays open (#750). Neither is a status question, and folding either into an adoption sweep would have buried a security change inside it.
ADR-0117: An actuation is fenced by the permission that authorizes it
Section titled “ADR-0117: An actuation is fenced by the permission that authorizes it”- Date: 2026-08-14 | Status: Accepted | Pages: identity and access, commands
- Decision:
POST /components/{name}/commands:issueresolves its target withvisible_set(P, command, issue)as the action half and the caller’s owncomponent:readas the read half, through the sameResolveActionTargetsplit as the acknowledgement (ADR-0116). It used to resolve all three of its gateway calls withcomponent:read, so the set that fenced the write was the caller’s read set and the issue grant narrowed nothing. - Why this one is not a status question. Every route ADR-0116 touched was answering the wrong
REFUSAL. This one was answering the wrong REQUEST: a principal holding
viewer @ allbeside a room-scoped operator grant could issue commands to every component in the estate, and a command records an intent a driver acts on. The blast radius is a device doing something, not a row being named. That is why it shipped on its own rather than inside the sweep. commandbecomes a component-tier resource, and that is the load-bearing half. The resource was not in the scope resolver’sapplicableKindsat all, so it fell to the default: only an all-scoped grant resolved to anything, and every scoped grant resolved to the empty set. Fencing on that set without registering the resource would have denied every scoped issuer rather than fencing them, which is a worse outcome than the bug. It joins the component tier besidealarm, for the same reason and with the same shape: a command is issued TO a component, and it resolves from grants carryingcommand:issueso that a wide component read cannot widen what may be commanded, and a component-update grant is not what decides who may command.- The component tier alone, not the three-tier arc. The
commandtable’s owner columns allow any exclusive-arc owner, but the only route that issues one addresses a component. Admittinglocationorsystemhere would put roots in the set that a component’s own ancestor chain can never match (the tier mismatchresolvePlacementRefrecords) while tellingscope.Coversthat a command scope may be checked against those tables. A route that issues to another tier adds that tier, with a test. - Consequence, stated rather than discovered later. A principal whose only
command:issuegrant sits at the location or system tier can no longer issue anything. That is the same tier rulealarm:acknowledgealready lives under (ADR-0109, #714): such a grant reaches no component, so it could not update, read, or acknowledge one either, and being able to command one was the anomaly. It resolves when a grant’s scope can span tiers, not before. - Resolved once, bound by id.
IssueCommandandCommandSettlementtake the resolved id and no scope. The fence on an actuation is a read-versus-issue split rather than a single set, so it cannot be applied from one parameter inside a gateway write; a set re-checked there on an already-resolved row could only re-answer the settled question or disagree with it. It also removes two of the route’s three name resolves, and a path with one resolve cannot have a second that lands on a different row (ruling 2, #627). The scope check moves out of the write’s transaction, which is the one thing given up: at READ COMMITTED an in-transaction guard never closed that window either, it only narrowed it. - Proved in the conformance matrix, not beside it, so the
mixedprincipal (readable, outside the action scope) drives the route automatically. The RED was not a wrong status: it was a 200 with a recorded invocation on a component outside the caller’s issue scope.
ADR-0118: The friendly string an operator reads is a label, and unset is SQL NULL
Section titled “ADR-0118: The friendly string an operator reads is a label, and unset is SQL NULL”- Date: 2026-08-14 | Status: Accepted | Pages: core entities, storage, the API, identity and access, glossary, design system
- Decision:
display_namebecomeslabelon all twenty-three tables that carry it, and the pen beside it becomeslabel_generatedon the three that carry one. The column is nullable, and unset is SQL NULL and nothing else, enforced in Go at the gateway write path rather than by a constraint. Breaking wire change: the JSON field, the CLI flag (--display-namebecomes--label), and the console’s field label move with it. The wire itself keeps a plain string, because every read projection coalesces. - The rename finishes a word rather than introducing one. When it was first proposed, “label”
was a candidate. It is now the system’s vocabulary everywhere except the column it named:
internal/label/is the rule engine (ADR-0098),label_ruleis a column on five tables plus a table of its own, the API says:renderLabel,:previewLabelsand:recomputeLabels, and forty console files sayentityLabel,labelPenorlabelGenerated. Until this decision a route called:recomputeLabelswrote a column calleddisplay_name, and the console’slabelGeneratedreaddisplay_name_generated. - The pen renames in the same sweep or not at all.
display_name_generated(#657) is what answers “did an operator type this, or did the platform render it from a rule”. Slice 2 of that epic deferred its naming here on exactly these grounds. It staysNOT NULL DEFAULT false: it answers a different question, and there is no third state for it to hold. - Nullable, and the argument is the useful part. This slice first shipped the opposite,
NOT NULL DEFAULT '', and reversed it before merge on the strength of its own diff. The evidence was in the code the floor forced: every one of the seven registry orderings had to be spelledorder by nullif(label, '') nulls last, name. Anullifin a read is the read path converting the stored representation back into the one it wanted, and seven of them in a single slice is not a coincidence, it is the schema telling you which value it should have stored. Postgres already has a value for “there is nothing here”,nulls lastis already the default for an ASC sort, and the orderings collapse toorder by label nulls last, namethe moment the column can hold it. The same test that made the ordering hazard visible stayed red through the reversal and green after it, against the new spelling, rather than being assumed equivalent. - The argument FOR the floor had a hole, and it is worth recording because it is the kind that
recurs. It ran: collapsing “unset” on a nullable column needs a
CHECK, and #570 is already filed against that pattern, therefore the empty string. But this repository puts that class of rule in Go at the gateway write path, which is exactly where ADR-0110 had just movedprincipal_labelout of the database. Once the normalization is in Go it can map empty to NULL as cheaply as to'', so the choice was never “a CHECK or the empty string”: the premise smuggled in the assumption that the rule had to live in the schema. - What makes NULL the ONLY spelling is the Go half, not the migration. The migration drops the
defaults and converts the rows that exist; on its own that trades one two-state mess for another
the first time a write binds
"".labelOrNullandlabelPatch(internal/storage/label_unset.go) are the one place the decision is applied: they trim, and an empty result is NULL. Every write path binds through them, and a behavioural sweep drives each of those paths with a whitespace-only label and reads the RAW column back, because every projection coalesces and would report NULL and''as identical. It is complete by construction: a labelled table with neither a prover nor a declared reason fails the suite. That completeness is not decoration. It caughtCreateStandardstill binding its label raw after every other path had been converted, and it caught it as an ordering failure two registries away from the defect. - A PATCH needs two parameters, and that is where the empty string still means something.
Clearing a label and leaving it alone are different instructions that both arrive as SQL NULL, so
coalesce($n, label)cannot express them. Each statement now carries a set flag beside the value, appended to the parameter list so no existing placeholder is renumbered. The empty string keeps its wire meaning (“clear it”, the house convention ADR-0114 settled for every other nullable string); what changed is that it is translated in Go rather than stored. - The read side keeps a plain Go string. Every read struct’s
Labelis astringand every patch struct’s is a*string, checked by enumerating the declarations rather than assumed, so every read projection iscoalesce(label, '')and nothing above the gateway learns that the column is nullable. That is why this reversal moves no generated artifact except the schema facts: the OpenAPI, the typed client and the CLI are unchanged. One trap in doing it: acoalesceinside a CTE drops the output column’s NAME, so the two that feed a named reference downstream are spelledcoalesce(r.label, '') as label. - The load-bearing consequence is still the ORDERING, and it was already broken before either
spelling. All seven registry orderings read this column, and all seven of those tables were
storing
''for unset, so all seven already floated unlabelled rows to the TOP of the picker. They areorder by label nulls last, namenow.nulls lastis redundant on an ASC sort and written anyway, because it is the half that is a decision rather than a default. The two shadow-resolved registries resolve on the presence of the jsonb key rather than with a coalesce, so a fork that CLEARED its label sorts at the end with the other unlabelled rows instead of under the official row’s label. The console re-sorts every registry list client-side, so the rule is written twice and both halves are pinned:byLabelinweb/src/lib/entities.ts. stemandabbrevare deliberately NOT swept. TheirNULLmeans inherit from the nearest ancestor that sets one (ADR-0114), a third state with real content, and it is a different question from whether a row has a label.- One place the old word survived as DATA rather than as an identifier.
registry_shadow.imageis an operator’s forked copy of a shipped registry row, stored as jsonb keyed by column name, and a column rename cannot reach inside a jsonb value. An operator who had relabelled a forkedcomponent_typeorlocation_typewould have had that relabelling silently ignored the moment the gateway started asking forimage->>'label'. A cleared label survives there as a PRESENT key holding JSON null, never as a dropped key: dropping it would re-inherit the official row’s label, which is the opposite of what an operator who cleared it asked for.audit_log.old/audit_log.neware deliberately left alone: they record what a row looked like under the field names it had at the time, and rewriting them would falsify the record. - The word now collides with the console’s own
label, and two source guards were re-anchored rather than suppressed.labelwas already the name of the prop a Button, an InfoTip, an InlineActions row, a nav item and a facet chip take, so the one-renderer guard’s patterns matched eleven pieces of ordinary UI chrome the day the column moved. The fallback pattern now excludes the exact readprops.labeland nothing wider (a component handed an entity readsprops.entity.label, which still matches), and the interpolation pattern is anchored to an identifier read on the same line, which is the shape of the search-haystack bug it exists for. “Display name” joins “Technical name” and “Segment” on the console’s retired-word list, anddisplay_name,--display-nameand the two-word noun join the docs denylist.
ADR-0119: A table an operator names carries a label, or declares why it does not
Section titled “ADR-0119: A table an operator names carries a label, or declares why it does not”- Date: 2026-08-14 | Status: Accepted | Pages: core entities, tags, config, secrets, and variables, data collection, storage
- Decision:
TableIdentitygainsNoLabel, the declared reason a table an operator names carries no label. Empty means it carries one. A guard checks that against the generated schema facts in both directions, and a table nobody names (ShapeIDOnly,ShapeFixedKey) must carry no label at all.tag,variable,secretandinterfacegain the column through the full slice.
The declaration is the fix; the four columns are the consequence. Four key-bearing tables had no label, nothing failed, and the omission survived long enough to become an epic. It survived because the declaration was complete about one half of the identity triad and silent about the other: every table has a declared SHAPE, and the guard fails on a table missing from the map, so a new table cannot arrive unclassified. Nothing asked whether the friendly string was there. Writing the new guard against the schema as it stood named exactly the four, which is the evidence that the declaration is load-bearing rather than decorative.
The four were not interchangeable, and the differences are where the work was.
tagis the only NAME-addressed one (/tags/{name}), so its proof is that the label and the address do not disturb each other. Its name is fixed at creation and there is no:renamecustom method, so the rename half is proved the way this table permits: the update that exists lands on the name it started with, and a governance edit that says nothing about the label leaves it.variableis uuid-addressed, and its collision is not with the address but with the other thing its PATCH carries. The route’svaluebecomes optional so a label-only edit is expressible; the cost is that ajson-typed variable can no longer be set to JSON null through it, an explicit null and an omitted field being the same thing on the wire.secretcarries the most projections, so the work was the sweep: the directory (both scope arms), the create and update returns, the audit image, and the per-component cascade. The reveal and the copy carry no identity at all, only the decrypted field map, and that is pinned rather than assumed. A relabel is not a rename, which matters here more than anywhere: the sealed fields are bound to(owner, name, field)as AAD, so the test reveals the secret again afterwards.interfaceis the strongest case rather than the weakest. Its name is derived from its type, so the label is the ONE identity string an operator types, and it is settable at create because an interface labelled by a following call is unlabelled at the moment it is made. The epic argued this from “threesshinterfaces on one component”, which the schema does not permit: the unique index is(component, name)and the name is the protocol, so the second is a 409. That conflict is now pinned by a test, and the narrower true statement carries the decision anyway, since every SSH interface in the estate readssshand nothing says which device role it plays.
Ordering follows the label, and it is a decision that had already gone wrong once. The lists say
order by label nulls last, name. nulls last is redundant on an ASC sort and written anyway,
because #756 found seven registries already
shipping their unlabelled rows at the TOP of the picker, from an order by display_name, name over a
column storing the empty string. A new list is guilty until a test says otherwise, and the console’s
one comparator (byLabel) is the same rule on the other side of the wire. The two CASCADE
projections deliberately keep ordering by NAME: there the name is the cascade key that groups a
winner with the candidates it shadows, not a display order.
The exemptions are declared, not remembered. interface_type retires with the interface.type
FK, so a column added there would be dropped with the table. file.name is already the label, and a
second string would be a second label rather than the identifier a label sits beside. service is a
username analogue whose name IS what every surface shows, since principalIdent cannot resolve to
something optional and non-unique. blob has no operator surface at all. cmd/identitygen renders
these into the docs, so the published list is generated rather than hand-kept, and
LabelledTables() makes the declaration the only copy of the list that the schema guard and the
unset-label sweep read.
What is deliberately not here. No backfill: existing rows come out unset and every surface renders their name verbatim, never re-cased or prettified, which is the contract this epic was corrected on twice. No uniqueness, no pattern, no reserved words, and a 200-character ceiling, which is what makes it a label rather than a second identifier. Nothing derives a name from a label on the interface create form, since that name is the platform’s to mint.
ADR-0120: The edit face is a URL fact
Section titled “ADR-0120: The edit face is a URL fact”- Date: 2026-08-14 | Status: Accepted | Pages: ui
- Decision:
?edit=1beside a detail address (or beside a blade’s id param,?u=<id>&edit=1) is how the console expresses edit mode: a deep link or a refresh lands the edit face directly (behind the same<resource>:updatethe footer Edit is behind; without it the link lands read-only), leaving edit strips the param via history replace, and the create-as-route and row-pencil handoffs are that URL rather than a signal. The one-shot in-memory handoffs (web/src/lib/pendingedit.ts,openPrincipalInEdit) are retired; one hook (web/src/lib/editurl.ts) owns both directions. The name-to-uuid redirect keeps its query string, so a name-shaped edit link survives resolution. - Context: The mode was reachable only through an in-memory one-shot set just before a
navigate, so no URL could express “this entity, editing”: a refresh mid-edit fell out of edit,
Back behaved arbitrarily, and the docs screenshot pipeline (which declares shots as URLs)
could not capture an edit face without click choreography (#758). The param is a consume-once
intent rather than a state the mode derives from, because deriving would re-enter edit in the
gap between Cancel’s mode flip and the strip; a blade honors the param only when the URL also
names it (
?u=<id>matching), so a stale param cannot flip a blade merely clicked open. The groups blade keeps its own one-shot (openGroupInEdit) for now and adopts the param when it gains an id deep link of its own (#758 leaves it out of scope).
ADR-0121: The console ships its own typefaces
Section titled “ADR-0121: The console ships its own typefaces”- Date: 2026-08-15 | Status: Accepted | Pages: design system, UI
- Decision: The console serves both of its typefaces, IBM Plex Sans (UI) and JetBrains
Mono (data), from its own origin. The woff2 files are vendored under
web/public/fonts/, which Vite copies verbatim into the buildinternal/webui/spa_embed.goembeds, so the faces travel inside the single binary and are served by it;web/src/fonts.cssdeclares them and is generated byweb/scripts/vendor-fonts.mjsfrom the same request the console used to make.index.htmllinks no stylesheet and preconnects to no host off this origin, and a guard test (web/src/font-hosting-guard.test.ts) fails the suite if one comes back. Three sub-decisions ride with it. Every script the CDN served is vendored, not a latin subset: latin, latin-ext, cyrillic, cyrillic-ext, greek and vietnamese, 54 files and 1,005,028 bytes, so no operator-supplied string (a location name, a label, a principal name) renders differently than it did before, andunicode-rangekeeps the per-script split so a latin page still fetches only the latin file. A codepoint no block covers falls through to the next family in--og-font-ui/--og-font-dataand renders in the platform face, which is what already happened for every script these families do not carry.font-display: blockreplacesswap: same-origin and embedded, the block period costs nothing measurable, and block is the only value that cannot paint fallback metrics (optionalcan keep them for the life of the page). The capture refuses to photograph a fallback render:web/e2e/fontguard.mjsasserts per shot that every face is declared and that both families rendered from their own files, aborting otherwise. - Context: The console linked
fonts.googleapis.comwithdisplay=swap, so it painted in fallback metrics until a third-party fetch completed. Two costs, one cause. An estate on a closed campus or AV network, the deployment this product targets, never completes that fetch and renders the console in the wrong face permanently, having also disclosed each viewer to a third party. And the docs capture opens a new browser context per shot, so every shot re-raced the CDN: on3b16b325two of ten shots lost, were written anyway, and failed the zero-tolerance freshness gate as UI drift on a PR that changed no UI, turning main red. The measurement that named it: the two failing shots differed in the same 3,806 and 456 pixels of the shared sidebar, and the differing pixels were 20,955 greyscale glyph edges against 1,405 colourful, which is text metrics rather than content (#775). Waiting ondocument.fonts.readybefore the shutter was the cheaper fix and was rejected: it greens the gate while leaving the console unable to render correctly where it is deployed, which is the more expensive half of the same defect.DOCS_SHOTS_PROXY, the egress-proxy escape hatch that existed for capture hosts that could not reach the CDN, retires with the dependency it worked around (it had been inert since the capture moved into a container that never received it). The screenshots recaptured byte-identical, which is the check on the vendoring: the files served are the same faces at the same version the browser had been fetching, so this changed no pixel of the console.
ADR-0122: A commissioning gap is not a failure, so the verdict domain gains incomplete
Section titled “ADR-0122: A commissioning gap is not a failure, so the verdict domain gains incomplete”- Date: 2026-08-09 | Status: Accepted | Pages: health, glossary
- Decision: the verdict domain becomes
healthy<incomplete<degraded<outage. A role short of quorum because its assigned hardware is failing contributes its declaredimpact, unchanged; a role short because the hardware was never installed contributesincomplete. A role that is both reads itsimpact, the worse of the two by rank. A role declaringimpact: nonereadshealthywhen empty rather thanincomplete, because a slot whose failure does not matter has an absence that does not matter either. A role inside a choice whose alternate did not win contributes nothing at all,incompleteincluded. No migration: the enum lives ininternal/healthand the value persists as a string, so the only storage-side change is thehealthproperty type’s seededvalidation.enum, which is boot-seed reference data. Amends ADR-0050’s three-value domain; its transition-only recording, pure-package judgement, and worst-wins rollup stand unrevised. - Context: the estate canvas (#630)
paints one dot per component across the whole estate, and it was the surface that made the
three-value domain untenable. Most of a real estate is mid-commissioning for months, and under
the old rule every unstaffed role contributed its declared
impact, so a half-built site rendered as a wall of red indistinguishable from a site on fire. A colour an operator learns to ignore is worse than no colour. Ranking the new value betweenhealthyanddegradedis what makes it useful in both directions: a gap is visible above a finished room and invisible beneath anything actually broken, so neither reading is drowned by the other. Theimpact: nonecarve-out came out of the existing suite rather than the design:TestImpactMappingandTestSystemVerdictWorstWinsboth asserted that an unstaffed harmless role stays harmless, and they were right, since reporting a gap there would put a permanentincompleteon every confidence monitor nobody ever intends to staff, reintroducing the saturation this decision exists to remove. Rejected: a separate non-health field for commissioning state (two axes to roll up, two badges to reconcile, and the estate view would have to render both to answer one question); and reusingunknown, which already means “no health has been read” and would conflate never-measured with never-installed. - Tracked under epic #630, slice #631.
ADR-0123: The word for everything an install manages is fleet
Section titled “ADR-0123: The word for everything an install manages is fleet”- Date: 2026-08-14 | Status: Accepted | Pages: glossary, ui, health, identity and access
- Decision: the totality an install manages is a fleet: the systems are the fleet’s
units, the components staff them, and the location tree is the geography they are stationed
across. The rename is all-in, in one sweep: Go identifiers (
FleetProjection,FleetView), the view route (/views/fleet), the console surface and its nav entry, docs prose, the glossary, and the scope grammar (fleet-wide). The old noun joins the docs denylist; the decision log and build log keep it as the historical record they are. - Context: the product’s differentiation is the SYSTEM model: every competitor manages rooms or devices, and none can say “a fleet of systems” because none has one. A fleet is uniform units maintained at scale against a type, which is exactly a standard-conforming system: standard as airframe type, system as tail number, slots as parts, verdict as maintenance state, location as base. The old word was accurate and industry-anchored (UK/enterprise IT usage) but reads as property and governance rather than operation, and the surface being named is operated daily. Two costs are accepted with eyes open: in observability vocabulary a fleet often means the COLLECTOR fleet (Elastic Fleet, Datadog Fleet Automation), so the word will occasionally be misread as node management; and the noun names the units better than the buildings, which the location tree keeps first-class regardless. Surface-only renaming (a fresh label over the old domain word) was rejected as a permanent translation tax between nav, glossary, and API vocabulary.
- Tracked under epic #630, ahead of slice #633.
ADR-0124: On the fleet canvas, health colour is exceptional and identity colour is the ground
Section titled “ADR-0124: On the fleet canvas, health colour is exceptional and identity colour is the ground”- Date: 2026-08-15 | Status: Superseded (2026-08-16, by the amendment below) | Pages: ui, health
- Amendment (2026-08-16): reversed for operator clarity, on the architect’s review of the
shipped canvas against the design it was built from. A dot’s colour is its component’s
verdict and nothing else (one channel, one meaning; a healthy dot is green), and the
system’s verdict rides the cluster outline, neutral when healthy and tinted when not,
which is what the design’s per-system card border was. Identity hue was this decision’s
own invention, not the design’s, and it made a colour mean two things. A side effect: the
canvas raster is now deterministic per seed, so the screenshot gate no longer masks it.
hueForstays for the surfaces that use it as identity (list rows, health panels). - Decision: a healthy component dot wears its system’s identity hue, through the exact
recipe the DOM’s
.og-system-dotalready uses (oklch(var(--tag-l) var(--tag-c) <hueFor>)), so a canvas dot and a DOM dot can never drift. Incomplete, degraded and outage dots wear the semantic verdict colours (--og-incomplete,--color-warning,--color-error); a verdict the console cannot narrow wears--og-unknownand never a substituted word.hueFor’s reserved OKLCH bands already guarantee an identity hue cannot land inside a status band, which is the invariant that makes the mixture legible. - Context: the slice inherited a contradiction: “coloured by state” beside “takes its hue
from
system_color”, and the prototype resolved it a third way (no per-system hue at all). Ruled during definition true-up: a healthy fleet should read as identity-coloured wallpaper with the clusters telling themselves apart, and anything not healthy should be the only status-coloured pixels on screen, which is what makes a failure pop at fleet scale. The paint plan (paintGroups) pins the rule at the unit tier per group, not per pixel. - Tracked under epic #630, slice #633.
ADR-0125: A band shows the location’s recorded verdict, not the console’s fold
Section titled “ADR-0125: A band shows the location’s recorded verdict, not the console’s fold”- Date: 2026-08-15 | Status: Accepted | Pages: ui, health
- Decision: a fleet band’s verdict chip renders
FleetLocationBody.verdict, the location’s own server-recorded row, resolved through the grouping’s optionalrecordedVerdictseam. The console’s fold over in-scope clusters (Band.verdict, worst-wins) stays available, named for what it is, and no chip renders it. - Context: the band navigates to
/locations/{id}, whose badge reads the same recorded row; a band that says healthy beside a detail page that says outage, one click apart, is a visible contradiction. The fold also covers only the clusters the caller may read, so under a narrow scope it is not the location’s verdict at all. The console-computes-no-verdict rule (web/src/lib/health.ts) already pointed here; this records the band as a consumer of it. - Tracked under epic #630, slice #633.
ADR-0126: The zoom face is a URL fact on the identity routes
Section titled “ADR-0126: The zoom face is a URL fact on the identity routes”- Date: 2026-08-15 | Status: Accepted | Pages: ui
- Decision: the location, system and component zooms render at the identity routes the epic
already ruled (
/locations/{id},/systems/{id},/components/{id}) behind a query param (?zoom=1), with the inventory detail remaining each route’s default face. A fleet-canvas drill carries the param; deep links, refresh and the back button behave as ADR-0120 established for the edit face, which is this decision’s precedent verbatim. - Context: the epic ruled both “the zoom is a function of which entity the URL names” and
“the four tables stay live and untouched while the medium is explored”, and the two collided
at one address: the inventory detail owns
/locations/{id}today. The architect’s ruling (2026-08-15): the zoom was originally intended to replace the old views, and the param is the right first step; it may become the default later, with the table face retiring entirely once the medium is judged. Rejected: a location param under/fleet(a second address for the same row, which the epic forbids) and replacing the detail now (falsifies the tables-stay ruling mid-exploration). - Tracked under epic #630, ahead of slice #635.
ADR-0127: Operator surfaces carry tooltips, not prose, and the system body goes components-first
Section titled “ADR-0127: Operator surfaces carry tooltips, not prose, and the system body goes components-first”- Date: 2026-08-20 | Status: Accepted | Pages: ui, design system
- Decision: Two console rules, ruled together on the system zoom review. First, an operator
surface carries no inline explanatory prose: every explainer rides its label’s
(i)tooltip (InfoTip;Eyebrowis the section form), and the standard-editor’s vocabulary (choices, alternates, accepted types) never leads an operator view. Second, the system body’s unit is the COMPONENT, its role a badge on the card; role-level chrome renders only where it says something a badge cannot (quorum beyond one, a shortfall, an unstaffed role), because the overwhelmingly common room is one role to one occupant, and a box around a single card is chrome without information. - Context: The zoom pages shipped with teaching paragraphs inline (the history strip’s pedagogy, per-role arithmetic on healthy 1:1 roles, “built as all-in-one”) and one card per role. Reviewing the built page, the architect ruled the prose buries the operator’s read (“this is an operator’s view”) and the role-shaped body misrepresents the fleet’s common case. The pedagogy doctrine is satisfied by hover; #784 tracks sweeping the remaining surfaces onto the same rule.
ADR-0128: The standard declares the room map, normalized and display-only
Section titled “ADR-0128: The standard declares the room map, normalized and display-only”- Date: 2026-08-20 | Status: Accepted | Pages: storage, ui
- Decision: A standard may declare a room map: one jsonb value (
maponstandard) holding an aspect ratio and one normalized position per role position ({role, position, x, y}, coordinates in [0, 1], positions 1-based to matchposition_labels). Data, not code: every system conforming to the standard renders the same room for free, the join of “where this position sits” and “who holds it” made client-side from the health read. Validated in Go on write (positive aspect, bounds, no duplicate pair); a JSON null clears it; nothing queries into the blob, which is why it is a value and not a table. Render-only in v1: authored through the standard PATCH (or seed YAML), the visual editor deliberately out. - Context: The system workspace epic (#788) ruled a top-down room view onto the system zoom (“Mic 1 · Shure MXA910” at its wall position, live status on each marker). The map is a property of the DESIGN, not of one room, so it lives on the standard, the same data-not-code guardrail the driver arc carries; a relational table per position would buy queryability nothing displays and cost a migration per shape change.
ADR-0129: The zoom face becomes the identity routes’ default; one way to look
Section titled “ADR-0129: The zoom face becomes the identity routes’ default; one way to look”- Date: 2026-08-21 | Status: Accepted | Pages: ui
- Decision: The fleet faces stop being a query-param variant:
/locations/{id},/systems/{id}, and/components/{id}render the zoom, workspace, and leaf by default, and?zoom=1is tolerated but no longer written. The classic detail face survives at?view=detail(and under a legacy?edit=1) solely because editing still lives there; it retires when edit-in-blade lands. The reconciliation’s target, ruled with this: one altitude rule (locations drill, systems open full screen, components open in a blade that can expand to its route), every entity edits in its blade, flat tables survive as a list-density toggle for bulk work, and the parallel inventory faces go away. - Context: ADR-0126 parked the zoom behind a param precisely so the table faces could stay alive until the medium was judged. Judged: the console had become two products (an inventory world that edits and a fleet world that observes), with browse, detail, and edit each existing twice. The architect ruled the merge on the #795 review; this entry flips the default (stage 1), and the remaining stages (entity blades, edit-in-blade, operate absorption, classic retirement) follow as their own body of work.
ADR-0130: Lists that promise write order order by a DB-assigned sequence
Section titled “ADR-0130: Lists that promise write order order by a DB-assigned sequence”- Date: 2026-08-21 | Status: Accepted | Pages: storage
- Decision: A read whose contract is “in the order it was written” orders by a
bigint generated always as identitycolumn the database assigns at insert, never bycreated_at(or anynow()stamp) and never by a uuidv7 id.audit_logandprincipalgain the column (db/migrations/20260821220000_write_sequence.sql); the audit trail orders byseq descand the principal directory bykind, seq. Remaining clock-ordered reads (the health ledger’s id-ordered history, ts-ordered event, log, and sample reads) are tracked by #801 and convert the same way when touched. - Context: The #780 screenshot-gate flaps (the audit page interleave, the users
directory row swap) resisted three ordering fixes because every candidate key was
secretly the same key:
now()and uuidv7 both read the wall clock, and on a loaded WSL2 host the clock is not monotonic; NTP correction steps it several seconds mid-run, so stamps taken by different transactions invert relative to true write order. A six-run probe caught a fixture user stamped 5.6 seconds after a row its own process wrote later. An identity column is allocated by the database in insert order and no clock touches it; regression tests simulate the step by rewriting committed rows’ clock keys and asserting the lists hold.
ADR-0131: The device taxonomy is OAVC, and the demo catalog is fictional
Section titled “ADR-0131: The device taxonomy is OAVC, and the demo catalog is fictional”- Date: 2026-08-24 | Status: Accepted | Pages: core-entities, storage
- Decision: The
component_typeregistry mirrors the OpenAVCloud AV Device Taxonomy v1.1: its high-level categories are the tree’s roots, its subcategories the types, and the concrete classes a standard’s slot names the leaves. Microphone subtypes are arranged by FORM FACTOR (ceiling, wireless, gooseneck, boundary), never by inner technology (beamforming is a capability, not a device kind); dante-card sits under dsp. The seed catalog becomes fictional: vendors and SKUs come from the omniglass-lab brand universe (Boreal, Kestrel, Lyra, Meridian, Newtron, Polaris, Shinra Cloud, Tessellate), with the AV-iQ product database as the shape-realism source, because the demo fleet is not sponsored. Standards are written the way an integrator writes room standards: a room type’s full signal chain, size-serialized where the display drives the variant (mr55..mr98 and ds55/ds75 pin the display SKU per variant throughparent_standard_idpluspinned_products). A video bar is the ONE all-in-one conferencing appliance at the front of a room, never a microphone and never shared; the divisible pair’s shared component is its DSP and amplifier rack, which replaces the physically impossible shared-bar teaching case. - Context: The architect’s review of the #796 seed caught video bars filling microphone roles across the fleet and a “shared bar” serving two rooms, both domain-impossible; the ruling (in chat, 2026-08-24) adopted the OAVC taxonomy for types, fictional lab brands for SKUs, and the integrator-style standards catalog, recorded here and built as #802.
ADR-0132: Configure is the one deep editor
Section titled “ADR-0132: Configure is the one deep editor”- Date: 2026-08-25 | Status: Accepted | Pages: ui
- Decision: Every fleet workspace (system zoom, location zoom, component leaf) carries a
Configure tab, and it is the ONE deep editor: identity, classification, placement, the
kind’s manage panels, and tags in one savable face. The blade stays a quick face: its label
pen and tag adder edit in place, and its other rows are jump anchors into Configure
(
?tab=configure&edit=1#section).?edit=1retargets from the retired edit face to Configure (the ADR-0120 URL-fact rule, new destination); the classic detail face and?view=detailretire entirely, with an explicit miss face replacing the old not-found and disambiguation pages. - Context: ADR-0129 kept the classic face alive “until edit-in-blade lands” and named edit-in-blade as the target. Building it showed two editors (a growing blade and a workspace) would duplicate every manage panel; the #800 ruling (the One Way to Edit float) chose one deep editor in the workspace plus a thin quick face, deliberately diverging from 0129’s recorded target. 0129’s zoom-by-default half stands untouched.
ADR-0133: A select over a loaded collection binds through a ref, not a value prop
Section titled “ADR-0133: A select over a loaded collection binds through a ref, not a value prop”- Date: 2026-08-27 | Status: Accepted | Pages: design system, UI
- Decision: A
<select>whose options come from a collection the server answers for carries novalue=prop. It takes its value frombindSelectValue(value, ...options)(web/src/lib/selectvalue.ts), used as the element’sref: the returned callback creates an effect that reads every option source and then assignsel.value, so the value is re-applied on whichever of the two inputs arrives second. The effect is created inside the ref callback, so it belongs to the owner that rendered the control and is disposed with the edit face rather than outliving it, and it runs after the<For>that fills the control because Solid flushes user effects after the render effects that insert the options. The signature requires at least one option source, since a binding that tracks none is the defect itself. Thirteen controls convert, eight of them pickers that carry the fix to every consumer at once:TreeSelect(the parent and owner picker on Locations, Systems, Components, Variables and Secrets),ComponentTypeSelect,SystemTypeSelect, the interface blade’s node picker, the product form’s vendor and driver pickers, the parent-standard picker, and the command-type target picker, which passes both of its catalogs. The other five are page-local: the node placement, and the workspace Configure face’s four (system type, standard, location type, and the location’s parent mover, whose option pool is a memo over two queries and is passed as the tracked source itself). Two shapes are exempt, and the exemption is the decision. A select over a hard-coded or generated option list (a kind enum, a settings field’senumconstraint) has no async gap to lose a value in. A select whose value starts empty and only ever moves because the operator moved it (a create form, an add-or-assign picker) has nothing stored to lose, and the placeholder its fallback would land on is the value it already holds. Thirty-six of the console’s forty-nine select sites are one of those two, so a blanket conversion would have been as wrong as a per-page patch. - Context: A native
<select>holds no memory of a value it has no<option>for: assign one and the control keeps nothing, and when the options arrive the browser’s selectedness algorithm picks the first one instead, silently. Every face that deep-links into edit has the ingredients, because the stored value is known as soon as the entity resolves while the options come from a separate query. Either order is possible, so the defect presents as a flake rather than a bug, and an operator who saved in that window saved the fallback: on the Configure face’s location type, whose select carries no placeholder option, the fallback is not even empty, it is the first type in the catalog, so the save silently retypes the location. Avalue=binding cannot answer it. Solid re-runs that binding when the value changes, and in the losing order the value never changes, the options do. Holding the control back behind a<Show>until its collection settles was the alternative and was rejected: it makes every picker on every edit face flicker between a fact and a control on a slow answer, and it would have to be repeated at each of the thirteen sites, which is the per-page patch this decision exists to avoid. It survived until a screenshot gate with no tolerance made it reproducible: theentity-edit-faceshot flipped between two location types across captures (ADR-0121 landed the gate that caught it) (#398, #772, #782).