Tags
PartialA tag is an operator key: value label attached to an entity to organize, filter, and scope by dimensions
Omniglass does not model natively (category: audio-dsp, environment: prod, cost_center: 4021). A tag is not a
signal and carries no lifecycle; it rides the same cascade as
config, secrets, and variables, but with a union-on-key, override-on-value combinator
rather than a single most-specific value.
Two layers: the key vocabulary and the value binding
Section titled “Two layers: the key vocabulary and the value binding”tagis the governed key vocabulary: one row per key (category,environment), shared across the whole tenant (one registry per database, which is the tenant boundary). It owns no value.tag_bindingis the value cell: it sets a value for a key at one owner on the exclusive arc (platform | location | system | component | node), exactly the arc a variable or a secret is owned at.
Splitting them is what keeps the vocabulary normalized: the key environment is minted once and reused, so no one
invents env beside environment beside Environment. The key name is validated as a lowercase identifier in a pure
internal/tag package, so the canonical spelling is enforced on the way in, not by convention.
The governance split is the point
Section titled “The governance split is the point”Minting a key and setting a value are two different permissions, and that split is deliberate:
- Minting a key is a tenant-wide governance action, gated by an all-scope
tag:creategrant (an admin or curator, identity and access). Editing a key’s governance fields istag:updateand deleting it istag:delete, both all-scope. - Setting a value is the ordinary entity write. Binding
environment: prodonto a component is acomponent:update, onto a system asystem:update, onto a location alocation:update, the same write an operator already holds on the entity. Binding needs no new permission: an operator who may edit a component may tag it, using the keys the vocabulary already governs. A platform binding (the install-wide value for a key) has no owning entity to defer to, so it is gated bytag:updateplus the install-wideplatform:update.
So the vocabulary stays curated while tagging stays routine. Reading the vocabulary and an entity’s tags rides the
viewer floor (tag:read, component:read).
A key governs where it applies and whether it cascades
Section titled “A key governs where it applies and whether it cascades”Two fields on the key shape how its bindings behave:
applies_tonarrows a key to a subset of entity kinds (component,system,location,node). An empty set is universal (the key applies everywhere); a non-empty set rejects a binding on any other kind at write time (arack_positionkey thatapplies_to: [location]cannot be bound onto a component). The vocabulary carries its own scoping rules, so a key means the same thing wherever it is legal.propagatessays whether a bound value cascades. A propagating key (the default) resolves down the tree: tag a location once and every system and component beneath it inherits the value. A non-propagating key binds as a flat per-entity set: it resolves only from a binding on the entity itself, never from an ancestor. That flat case is the shape a file needs (a file is not on the structural arc, so it has no parent to cascade from); the same key model serves both, toggled by this one field.
Values resolve union-on-key, override-on-value
Section titled “Values resolve union-on-key, override-on-value”The effective tags for a component resolve down the structural cascade (platform -> location tree -> system tree -> component tree), the same walk the variable and secret resolvers use, but with a different combinator:
- Keys union. An entity surfaces every key bound at or above it. Two different keys set at two different scopes both appear; they do not compete.
- Values override. For a single key set at several scopes, the most-specific binding wins (highest band, then nearest depth), exactly like a variable. The shadowed candidates come back too, so the surface can teach the override.
A non-propagating key is admitted into the resolve only from the target entity itself, so its ancestor bindings never
leak downward. The GET /components/{name}/effective-tags route returns the resolved set (winner plus shadowed
candidates); GET /components/{name}:listTags (a custom method) returns only the bindings set directly on the
component, and the writes are the :setTag / :removeTag custom methods. The system band resolves through
system_member membership (the primary membership by default, ?system= to ask as a specific system the component
belongs to), so a shared device answers differently per system.
Systems and locations resolve too. A component walks the full arc, but every entity has an effective set. A
location resolves platform plus its own location tree. A system resolves platform, its own system tree, and
the location it is placed at (its location_id tree): a system in a PCI building surfaces compliance: pci, the
same way a component picks up its own location’s tags. A node is estate-wide, not a scope tree, so it resolves platform plus its own direct bindings only (no inheritance). This is the read behind the directory Tags column: the list
routes (GET /components, /systems, /locations, /nodes) each carry an effective_tags map (key to winning value,
winners only) per row, resolved for the whole page in one batched query (a Gateway.EffectiveTags per kind, no
per-row fetch). Provenance (which scope a value came from) stays in the per-entity effective-tags detail view, not the
column.
A key may constrain its values to an enum. A key carries an allowed_values set: empty (the default)
leaves it free text, and a non-empty set is the enum a bound value must belong to, so environment can be
declared as one of prod, staging, dev. The binding write enforces membership (a value outside a key’s
allowed set is a 422), and the add control renders a strict dropdown for an enum key. A free key instead
autocompletes the distinct values already in use for it (a GET /tags/{name}:values read), so an operator
reaches for prod instead of retyping it, without the key having to declare the set up front
(ADR-0024).
Storage
Section titled “Storage”The key vocabulary and the value cell; the physical layout (the owner arc, the cascade key) lives on
storage. Both tables are built. The column mechanics below render from the
generated schema facts (make gen), so they cannot drift; the notes stay hand-written.
The tag row is the tenant-wide governed key vocabulary; minting a key needs tag:create.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK, default uuidv7() | |
name | text | not null | The governed key operators bind by |
applies_to | ARRAY | not null, default '{}'::text[] | Narrows the key to entity kinds; empty is universal |
propagates | boolean | not null, default true | Toggles cascade versus flat per-entity binding |
created_at | timestamp with time zone | not null, default now() | |
updated_at | timestamp with time zone | not null, default now() | |
allowed_values | ARRAY | not null, default '{}'::text[] | The value enum; empty is free text, enforced on the binding write |
CHECK constraints and unique indexes on tag
-
tag_name_key:CREATE UNIQUE INDEX tag_name_key ON public.tag USING btree (name)
The tag_binding row is the key: value cell at one owner on the exclusive arc
(platform / location / system / component / node); it resolves union on key, override on value down the
cascade. Setting a value is the owner’s own update write, except at platform,
which takes tag:update plus platform:update.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK, default uuidv7() | |
tag_id | uuid | FK → tag.id, not null | The key being bound |
owner_kind | text | not null | Which arm of the exclusive owner arc holds the cell |
value | text | not null | The bound value the cascade resolves |
created_at | timestamp with time zone | not null, default now() | |
updated_at | timestamp with time zone | not null, default now() | |
node_id | uuid | FK → node.principal_id | |
component_id | uuid | FK → component.id | |
system_id | uuid | FK → system.id | |
location_id | uuid | FK → location.id |
CHECK constraints and unique indexes on tag_binding
-
tag_binding_owner_arc:CHECK ((((owner_kind = 'platform'::text) AND (component_id IS NULL) AND (system_id IS NULL) AND (location_id IS NULL) AND (node_id IS NULL)) OR ((owner_kind = 'component'::text) AND (component_id IS NOT NULL) AND (system_id IS NULL) AND (location_id IS NULL) AND (node_id IS NULL)) OR ((owner_kind = 'system'::text) AND (system_id IS NOT NULL) AND (component_id IS NULL) AND (location_id IS NULL) AND (node_id IS NULL)) OR ((owner_kind = 'location'::text) AND (location_id IS NOT NULL) AND (component_id IS NULL) AND (system_id IS NULL) AND (node_id IS NULL)) OR ((owner_kind = 'node'::text) AND (node_id IS NOT NULL) AND (component_id IS NULL) AND (system_id IS NULL) AND (location_id IS NULL)))) -
tag_binding_owner_kind_check:CHECK ((owner_kind = ANY (ARRAY['platform'::text, 'component'::text, 'system'::text, 'location'::text, 'node'::text]))) -
tag_binding_component_key_uuid:CREATE UNIQUE INDEX tag_binding_component_key_uuid ON public.tag_binding USING btree (tag_id, component_id) WHERE (owner_kind = 'component'::text) -
tag_binding_location_key_uuid:CREATE UNIQUE INDEX tag_binding_location_key_uuid ON public.tag_binding USING btree (tag_id, location_id) WHERE (owner_kind = 'location'::text) -
tag_binding_node_key:CREATE UNIQUE INDEX tag_binding_node_key ON public.tag_binding USING btree (tag_id, node_id) WHERE (owner_kind = 'node'::text) -
tag_binding_platform_key:CREATE UNIQUE INDEX tag_binding_platform_key ON public.tag_binding USING btree (tag_id) WHERE (owner_kind = 'platform'::text) -
tag_binding_system_key_uuid:CREATE UNIQUE INDEX tag_binding_system_key_uuid ON public.tag_binding USING btree (tag_id, system_id) WHERE (owner_kind = 'system'::text)