Skip to content

Label Registry — definitions, assignments, selectors, SelectorPort

This document is the authoritative bounded-context reference for the Label Registry that ships under internal/labels. It covers the ubiquitous language, the three-scope namespacing rule, the value-schema catalogue, the reserved keys and seeded Platform Definitions, the selector grammar (EBNF) with worked examples, the ReBAC ownership matrix, the audit contract, and the SelectorPort seam that every non-transport consumer must route through.

Labels in plexsphere are descriptive, not authoritative — they never grant or deny permissions, only narrow the set of objects a selector-aware operation targets. For the list of things labels are not, jump to ../../../README.md#what-labels-are-not.

For the bounded-context siblings and upstream platform references see:

Ubiquitous language

Six terms travel together across the codebase, the OpenAPI contract, the audit log, and operator-facing tooling. Internal code never paraphrases them; documentation and error messages adopt the exact spelling below.

TermDefinitionCode anchor
DefinitionMetadata for a label: the local key, the value schema, the applicable object kinds, the on_delete policy, the immutability flag, the cloud-tag-propagation flag, and ReBAC ownership. Lives at one of three scopes (platform, domain, project).internal/labels/definition.go
AssignmentA triple (object, qualified_key, value) that attaches one Definition to one concrete first-class object. Every Assignment carries a foreign key to its Definition plus a denormalised qualified key so the selector engine can index assignments without joining back to label_definition.internal/labels/assignment.go
Effective Label SetThe union of all Platform-, Domain-, and Project-scoped Assignments on a given object, returned with fully qualified keys so callers can tell which scope each label came from. See the ASCII diagram in Effective Label Set derivation.EffectiveLabelSet on AssignmentRepo in internal/labels/ports.go
SelectorA bounded expression grammar — six operators — that decides whether a given Effective Label Set matches a query. Used by policy targeting, bulk actions, observability scopes, and dashboard filters.internal/labels/selector/grammar.go, ast.go, eval.go
ScopeThe authority that owns a Definition: platform (system admins only), domain (a single Domain admin), or project (a single Project admin). Scope controls visibility and ReBAC ownership.Scope / ScopeID in internal/labels/types.go
Qualified KeyThe scope-prefixed key form used on the wire and in the audit log: platform/<key>, <domain-slug>/<key>, or <domain-slug>:<project-slug>/<key>. Derived once from the Definition's scope and local key, then denormalised onto every Assignment.Key.Qualified(resolver) in internal/labels/types.go

Scopes and namespacing

A Label Definition lives in exactly one scope. The scope determines who may create and edit the Definition (ReBAC) and which objects may carry the Assignment (visibility).

ScopeOwnerVisible toQualified-key formWorked example
platformSystem admins (seeded + curated)Every object in the installationplatform/<local-key>platform/env=prod
domainDomain admins and subjects with labels:manage on the DomainEvery object inside that one Domain (Projects, Resources, Nodes, Identities federated to the Domain, …)<domain-slug>/<local-key>acme/cost-center=payments
projectProject admins and subjects with labels:manage on the ProjectEvery object inside that one Project (Resources, Nodes, Executions, Secrets, Bootstrap Tokens, Access Sessions)<domain-slug>:<project-slug>/<local-key>acme:prod/release-train=r24.05

The scope prefix is derived from the Definition automatically — an operator picks only the local key (env, cost-center, release-train) and the registry computes the qualified form at create time, writes it to label_definition.qualified_key, and mirrors it onto every new label_assignment.qualified_key. This means:

  • No collisions across scopes. Platform env and Domain acme/env coexist. A response always carries both keys fully qualified, so the caller knows exactly which scope authored each.
  • No silent renaming. A slug change on the owning Domain or Project would invalidate every derived qualified key at once; the registry blocks slug mutations that would collide with existing Assignments in the same transaction.
  • Case-sensitive, non-normalised. Env and env are distinct local keys. The registry performs no Unicode normalisation, no lowercasing, and no whitespace trimming — the key stored is the key queried.

Value schemas

A Definition declares exactly one of five value-schema kinds. The schema is a JSONB discriminated union stored in label_definition.value_schema with a CHECK on the discriminator; the domain code is in internal/labels/value_schema.go. Validation errors name only the failing field and the schema kind — they never echo the rejected value — so the audit trail and error logs cannot leak tag payloads that may themselves be confidential .

KindAcceptsDefaults / boundsExample
stringAny JSON string up to max_len bytes (UTF-8 counted by byte length, matching the column width).max_len defaults to 256 bytes.description="PCI scope boundary"
enumA JSON string that appears verbatim in the values list.Empty values list rejected at Definition-create time.values=["prod","staging","dev"]
numericA JSON number in the closed interval [min, max].Both bounds required; NaN and Inf rejected.min=0, max=65535 for a port.
booleanJSON true or false only.No configuration; null and string "true" are rejected.(no parameters)
regexA JSON string matching the compiled Go regex in pattern.pattern is compiled eagerly; invalid patterns fail Definition create, not Assignment time.pattern="^v[0-9]+\\.[0-9]+\\.[0-9]+$" for a version tag.

Reserved keys and seeded Platform Definitions

Three invariants keep the Platform namespace safe:

  1. The platform/ prefix is reserved: only System admins may create or edit Platform Definitions. Domain admins attempting to create platform/… from below get ErrReservedKey (HTTP 422).
  2. Three Platform Definitions are seeded and immutable, created by migration 0005_labels.sql with immutable=TRUE, created_by='system'. They reconcile on every boot via labels.ReconcileSeeds (internal/labels/services/seeds.go) and their absence fails /readyz the same way a missing SpiceDB schema does.
  3. Value and per-object counts are bounded — string values are capped at 256 bytes and per-object assignment counts at 64. The selector engine assumes both bounds when planning Postgres queries.

Seeded Platform Definitions

Qualified keyValue schemaApplicable object kindsPurpose
platform/originenum in ["provisioned","adopted"]ResourceRecords whether the substrate was created by plexsphere (Crossplane) or adopted from pre-existing infrastructure. Drives cloud-tag propagation gating.
platform/mesh-ipregex with pattern ^[0-9a-fA-F:.]+$ (IPv4 + IPv6 mesh addresses)NodeThe mesh-fabric address assigned by the Key & Peer Manager at enrolment. Read-only from the operator's perspective.
platform/domainstring (max 256 bytes)Every Domain-scoped kindThe slug of the owning Domain. Lets cross-Domain audits attribute every object without a join.

Any edit to a seeded Definition — UpdateDefinition, DeleteDefinition, or a direct Assignment that would violate immutability — returns ErrImmutableViolation with HTTP 422.

Note — platform/origin casing. The platform/origin label enum uses lowercase values (provisioned / adopted) while the tenancy.Origin value object on the Resource aggregate uses capitalized literals (Provisioned / Adopted). This is an intentional divergence between two independent surfaces: the label enum is operator-facing taxonomy and follows the lowercase label-value convention shared by every seeded Definition, whereas tenancy.Origin is a closed Go enum whose string form matches the CHECK constraint on plexsphere.resources.origin. The two are not wired together — neither surface reads the other's value — so the casing difference is harmless. Do not "fix" one to match the other.

Selector grammar (EBNF)

Every selector string is parsed by a hand-written recursive-descent parser in internal/labels/selector/grammar.go. The grammar below is the authoritative source; keep this EBNF in lockstep with the parser on every change.

ebnf
Selector   ::= [ Clause { "," Clause } ]          (* AND composition *)
Clause     ::= Eq | Neq | In | Exists | Absent
Eq         ::= Key "=" Value
Neq        ::= Key "!=" Value
In         ::= Key "in" "(" Value { "," Value } ")"
Exists     ::= Key
Absent     ::= "!" Key
Key        ::= [ Prefix "/" ] LocalKey             (* prefix optional *)
Prefix     ::= PlatformPrefix | DomainPrefix | ProjectPrefix
PlatformPrefix ::= "platform"
DomainPrefix   ::= Slug
ProjectPrefix  ::= Slug ":" Slug
LocalKey       ::= DNSLabel           (* lowercase alnum + . _ -, 1-64 *)
Slug           ::= DNSLabel
Value          ::= JSONString | JSONNumber | JSONBool  (* string values are printable ASCII only *)

An unqualified LocalKey (no <prefix>/) is valid and matches assignments whose qualified key equals that token exactly — useful in Domain-scoped selectors where the prefix is implied by context. String values are restricted to printable ASCII by the lexer (internal/labels/selector/grammar.go); multi-byte UTF-8 values stored on Assignments must be matched through the in (...) branch with explicit JSON encoding rather than a bare literal.

The six operators form a closed set — no regex matching on values, no numeric ranges in selectors, no OR at the top level. OR is expressed exclusively via in (...); AND via the comma separator. The empty selector ("") is the canonical "match everything" and evaluates to true against any label set (see the DECISION block on And in internal/labels/selector/ast.go).

Examples

SelectorMatches an object whose Effective Set contains …
platform/env=prodplatform/env=prod
acme/cost-center!=r&dany acme/cost-center except r&d
platform/env in (prod, staging)platform/env=prod OR platform/env=staging
acme:prod/release-trainany value for acme:prod/release-train (existence)
!platform/criticalityobjects that have no platform/criticality label
platform/env=prod, acme/cost-center=paymentsboth clauses (AND)
platform/env in (prod, staging), !platform/criticalitytier env and no criticality marker

Rejected tokens carry the byte position of the offending character so editor tooling and the Dashboard can highlight the exact column .

Effective Label Set derivation

The effective label set of an object is the union of its Platform-, Domain-, and Project-level Assignments. Merge order is fixed at Platform → Domain → Project; every key appears fully qualified so the three layers cannot silently overwrite each other — a platform/env and an acme/env are different keys with different values and both round-trip through the Effective Set.

text
      Platform scope                  Domain scope                Project scope
      (seeded + curated)              (acme/…)                    (acme:prod/…)
  +------------------------+    +------------------------+    +------------------------+
  |                        |    |                        |    |                        |
  | label_definition       |    | label_definition       |    | label_definition       |
  |   scope_kind=platform  |    |   scope_kind=domain    |    |   scope_kind=project   |
  |                        |    |                        |    |                        |
  +-----------+------------+    +-----------+------------+    +-----------+------------+
              |                             |                             |
              | Assignments                 | Assignments                 | Assignments
              | on object X                 | on object X (in Domain)     | on object X (in Project)
              v                             v                             v
  +---------------------------------------------------------------------------------+
  |                                                                                 |
  |                         UNION with fully qualified keys                         |
  |                     (no overwrite — platform/env != acme/env)                   |
  |                                                                                 |
  +---------------------------------+-----------------------------------------------+
                                    |
                                    v
                         +----------+-----------+
                         |                      |
                         | EffectiveLabelSet(X) |   ← returned to selector engine,
                         |                      |     cloud-tag propagation, and
                         |                      |     audit reads
                         +----------------------+

The derivation is implemented by AssignmentRepo.EffectiveLabelSet(ctx, obj) LabelSet in internal/labels/ports.go; the Postgres adapter groups the three layers with a single indexed query on (object_kind, object_id) and pins the scope order in the SELECT list so the merge is deterministic across pages.

AssignmentRepo exposes a propagation-filtered sibling read, EffectiveCloudTagLabelSet(ctx, obj). It returns the same merged qualified-key → value map, narrowed to Assignments whose parent Definition has cloud_tag_propagation = true, ordered by qualified_key ASC; orphaned Assignments are excluded exactly as in EffectiveLabelSet, and an object with no propagating Assignments yields a non-nil empty map. The Provisioning Broker consumes this read at Composite Resource render time to decide which labels become cloud tags — see ../provisioning/label-propagation.md. The query is EffectiveCloudTagLabelSet in internal/platform/db/queries/40_labels.sql.

ReBAC ownership matrix

Every mutating label operation is gated by a dual ReBAC check — the Definition-side assign permission and the object-side maintainer (or higher) permission — before the write lands. Owning the Definition does not confer the right to tag every object; owning the object does not confer the right to invent or mis-use a Definition. Both together keep each bounded context responsible for its own invariants. The structural lock that prevents a labeldefinition from ever becoming a permission subject is pinned by TestSchemaLabelsNeverGrantPermissions in internal/authz/schema_invariants_test.go (the historical name for "no label-derived relations" — see ../identity/rebac.md#label-assignment-and-rebac-px-0012).

OperationRequired permission on DefinitionRequired permission on target object
CreateDefinition(scope=platform)System admin (platform has no ReBAC object)
CreateDefinition(scope=domain)domain:<slug>#manage (Domain admin)
CreateDefinition(scope=project)project:<id>#manage (Project admin)
UpdateDefinitionlabeldefinition:<id>#owner
DeleteDefinitionlabeldefinition:<id>#owner
PutAssignmentlabeldefinition:<id>#assign<object>#maintainer (or higher)
DeleteAssignmentlabeldefinition:<id>#assign<object>#maintainer (or higher)
ListAssignmentsForObject<object>#read
MatchObjectsBySelectorEach candidate object must satisfy #read — the SelectorPort filters the result in-process.

The two Check calls emit exactly one audit entry per mutation: either a PermissionDenied{reason=insufficient_relation} denial (if either check fails) or a single success entry (if both succeed). The implementation lives in internal/labels/services/assignment_service.go.

Audit contract

Every Definition mutation, Assignment mutation, and deletion emits exactly one entry to the Platform Audit Log via the shared audit.Sink. The shape is fixed and aligned with the shared audit.Entry surface:

FieldValue
actionlabels.definition.create / labels.definition.update / labels.definition.delete / labels.assignment.put / labels.assignment.delete
actorThe authenticated principal (user or service account) resolved by the transport middleware
objectFor Definition actions: labeldefinition:<id> — for Assignment actions: <object_kind>:<object_id>
qualified_keyThe fully qualified label key — always present for Assignment actions, and for Definition actions that change a key
before / afterJSON-encoded value, redacted via the ValueSchema rules (regex and string payloads are recorded; enum labels that match configured redaction globs are replaced with <redacted>)
reasoninsufficient_relation on a ReBAC denial, value_schema_violation on a validation failure, immutable_violation on a write to a seeded Definition, reserved_key on a cross-scope attempt at a reserved prefix, or empty on success

The audit contract is covered by the transport integration tests in internal/transport/http/v1/labels_integration_test.go. One mutation → one audit entry is an invariant, not a best-effort target.

SelectorPort contract

labels.SelectorPort is the sole public consumption surface for non-transport packages. The interface is tiny on purpose:

go
type SelectorPort interface {
    Matches(ctx context.Context, object ObjectRef, sel selector.Node) (bool, error)
    List(ctx context.Context, scope ScopeID, sel selector.Node, page ListCursor, limit int32) (ObjectRefPage, error)
}
  • Matches loads the object's Effective Label Set and evaluates sel in-memory via selector.Evaluate. This keeps the hot path free of a database round-trip when the caller already has the label set in scope.
  • List returns a keyset-paginated page of ObjectRefs via the Postgres-backed MatchObjectsBySelector query, translated once per operator. A 200-selector differential test in internal/labels/migrations/repository_pg_test.go asserts the in-memory evaluator and the SQL evaluator return identical match sets on the same inputs, so a consumer can freely switch between the two without silent drift.

Consumers (depguard-enforced allow-list)

The stories below are the only non-transport packages permitted to depend on internal/labels. Every other bounded context that needs label-aware behaviour must reach the registry exclusively through this port; direct imports are denied by the no-cross-context-imports depguard rule and mirrored by the positive-invariant test tests/workspace/labels_depguard_alignment_test.go .

SubsystemUse of SelectorPort
Policy EngineEvaluates policy target selectors against Node and Identity objects while compiling per-node rule sets. The Network Policy Engine bounded context re-declares this port minimally in ../../../internal/policy/ports.go so the policy module stays free of internal/labels imports; the composition root binds the in-policy seam to this port through a one-line type-conversion adapter. See ../policy/model.md#selectorport-consumer-carve-out for the consumer-side view.
Provisioning BrokerResolves which Resource assignments should propagate into Crossplane Composition rendering; reads the propagation-filtered effective label set via AssignmentRepo.EffectiveCloudTagLabelSet, the cloud_tag_propagation = true sibling of EffectiveLabelSet.
Action OrchestratorExpands "dispatch to every Node matching <selector>" into the concrete Node set at action_request time.
ObservabilityScopes dashboards, alert rules, and saved searches to the objects that satisfy the saved selector.

The allow-list lives in three places and moves together: .golangci.yml (depguard rules), tests/workspace/labels_depguard_alignment_test.go (positive-invariant gate), and the internal/labels row in the bounded-context map (operator-facing documentation). A new consumer requires an edit to all three in the same commit.

What Labels Are Not

See ../../../README.md#what-labels-are-not for the canonical list. In short: labels are not a permission system (ReBAC is), they are not a replacement for relations, and they are not a free-form annotation store — every Assignment corresponds to a visible Definition in its scope.

Cross-references