Appearance
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:
../identity/rebac.md— ReBAC schema, zedtoken flow, and theLabel Assignment and ReBAC (PX-0012)section that pins the dual-check invariant referenced below.../../architecture/storage-topology.md— the shared Postgres node that backslabel_definitionandlabel_assignment, and the Down-destroys-history rule.../../how-to/labels/manage-label-definitions.md— operator runbook for creating, editing, and deleting Definitions at each scope viaplexctland the Dashboard.../../contributing/layout.md— the bounded-context map row that enumerates the SelectorPort consumer list this document pins.../../../README.md#labels— the top-level narrative in the README; this document is the implementation-side reference for that specification.
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.
| Term | Definition | Code anchor |
|---|---|---|
| Definition | Metadata 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 |
| Assignment | A 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 Set | The 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 |
| Selector | A 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 |
| Scope | The 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 Key | The 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).
| Scope | Owner | Visible to | Qualified-key form | Worked example |
|---|---|---|---|---|
platform | System admins (seeded + curated) | Every object in the installation | platform/<local-key> | platform/env=prod |
domain | Domain admins and subjects with labels:manage on the Domain | Every object inside that one Domain (Projects, Resources, Nodes, Identities federated to the Domain, …) | <domain-slug>/<local-key> | acme/cost-center=payments |
project | Project admins and subjects with labels:manage on the Project | Every 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
envand Domainacme/envcoexist. 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.
Envandenvare 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 .
| Kind | Accepts | Defaults / bounds | Example |
|---|---|---|---|
string | Any 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" |
enum | A JSON string that appears verbatim in the values list. | Empty values list rejected at Definition-create time. | values=["prod","staging","dev"] |
numeric | A JSON number in the closed interval [min, max]. | Both bounds required; NaN and Inf rejected. | min=0, max=65535 for a port. |
boolean | JSON true or false only. | No configuration; null and string "true" are rejected. | (no parameters) |
regex | A 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:
- The
platform/prefix is reserved: only System admins may create or edit Platform Definitions. Domain admins attempting to createplatform/…from below getErrReservedKey(HTTP 422). - Three Platform Definitions are seeded and immutable, created by migration
0005_labels.sqlwithimmutable=TRUE, created_by='system'. They reconcile on every boot vialabels.ReconcileSeeds(internal/labels/services/seeds.go) and their absence fails/readyzthe same way a missing SpiceDB schema does. - 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 key | Value schema | Applicable object kinds | Purpose |
|---|---|---|---|
platform/origin | enum in ["provisioned","adopted"] | Resource | Records whether the substrate was created by plexsphere (Crossplane) or adopted from pre-existing infrastructure. Drives cloud-tag propagation gating. |
platform/mesh-ip | regex with pattern ^[0-9a-fA-F:.]+$ (IPv4 + IPv6 mesh addresses) | Node | The mesh-fabric address assigned by the Key & Peer Manager at enrolment. Read-only from the operator's perspective. |
platform/domain | string (max 256 bytes) | Every Domain-scoped kind | The 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/origincasing. Theplatform/originlabel enum uses lowercase values (provisioned/adopted) while thetenancy.Originvalue 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, whereastenancy.Originis a closed Go enum whose string form matches theCHECKconstraint onplexsphere.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
| Selector | Matches an object whose Effective Set contains … |
|---|---|
platform/env=prod | platform/env=prod |
acme/cost-center!=r&d | any acme/cost-center except r&d |
platform/env in (prod, staging) | platform/env=prod OR platform/env=staging |
acme:prod/release-train | any value for acme:prod/release-train (existence) |
!platform/criticality | objects that have no platform/criticality label |
platform/env=prod, acme/cost-center=payments | both clauses (AND) |
platform/env in (prod, staging), !platform/criticality | tier 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).
| Operation | Required permission on Definition | Required 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) | — |
UpdateDefinition | labeldefinition:<id>#owner | — |
DeleteDefinition | labeldefinition:<id>#owner | — |
PutAssignment | labeldefinition:<id>#assign | <object>#maintainer (or higher) |
DeleteAssignment | labeldefinition:<id>#assign | <object>#maintainer (or higher) |
ListAssignmentsForObject | — | <object>#read |
MatchObjectsBySelector | — | Each 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:
| Field | Value |
|---|---|
action | labels.definition.create / labels.definition.update / labels.definition.delete / labels.assignment.put / labels.assignment.delete |
actor | The authenticated principal (user or service account) resolved by the transport middleware |
object | For Definition actions: labeldefinition:<id> — for Assignment actions: <object_kind>:<object_id> |
qualified_key | The fully qualified label key — always present for Assignment actions, and for Definition actions that change a key |
before / after | JSON-encoded value, redacted via the ValueSchema rules (regex and string payloads are recorded; enum labels that match configured redaction globs are replaced with <redacted>) |
reason | insufficient_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)
}Matchesloads the object's Effective Label Set and evaluatesselin-memory viaselector.Evaluate. This keeps the hot path free of a database round-trip when the caller already has the label set in scope.Listreturns a keyset-paginated page of ObjectRefs via the Postgres-backedMatchObjectsBySelectorquery, translated once per operator. A 200-selector differential test ininternal/labels/migrations/repository_pg_test.goasserts 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 .
| Subsystem | Use of SelectorPort |
|---|---|
| Policy Engine | Evaluates 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 Broker | Resolves 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 Orchestrator | Expands "dispatch to every Node matching <selector>" into the concrete Node set at action_request time. |
| Observability | Scopes 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
../../../README.md#labels— canonical specification.../identity/rebac.md#label-assignment-and-rebac-px-0012— dual-check invariant and the structural schema lock.../../architecture/storage-topology.md#label-registry-tables-px-0012— the two Postgres tables, the seeded rows, and the Down destroys-history contract.../../contributing/layout.md— the bounded-context map row enumerating the SelectorPort consumers.../../how-to/labels/manage-label-definitions.md— operator runbook for Definition lifecycle.../../../internal/labels/doc.go— package godoc; every type named in this document has a matching symbol underinternal/labels.