Node metadata — classification and the register
Included from Basic tier
The classification framework: adoptable,
schema'd metadata profiles whose typed values annotate estate nodes.
Metadata is what governance selects on — approval-policy and
notification predicates, ${{ metadata.* }} conditions, the register —
and is structurally absent from the execution plane (invariant I1):
a metadata value never reaches action inputs, rendered templates or the
runner's step context, so classifying a node cannot change what a step
receives. The API surface is MetadataService (see
opschain docs api-v2); the CLI noun is opschain metadata
(opschain docs cli/metadata); the hands-on walkthrough is
opschain learn governance-classification. Base platform — no pack
entitlement; PostgreSQL-backed (compositions without the metadata plane
answer not_found).
Profiles and the core vocabulary
A profile is a versioned field schema: each field has a key, a type
(enum | bool | date | string forms), optional enum values, applies_to
(node kinds), inherit (none | subtree) and an optional
protective flag. The canonical core profile ships base-owned,
seeded at boot — the generic all-enterprise base vocabulary:
core.criticality (how important the
node is to the business), core.data_sensitivity (data
classification), core.rto_class / core.rpo_class (recovery
time/point objective classes), core.cost_centre (string — the FinOps
anchor), core.lifecycle (pilot|active|deprecated|retired),
core.external_exposure (internal|partner|public_internet) and
core.managed_by (internal|vendor|managed_service) paired with
core.managed_by_name (string — the named team, vendor or MSP), and
core.end_of_support (date, YYYY-MM-DD — when the node's platform or
software leaves vendor or organisational support, the end-of-life anchor
an unsupported-platform posture selects on) — all
subtree-inherited; its schema refuses edits. OpsChain holds no regulatory opinion in core: regulatory
bindings (CPS 230/234, SOCI, DORA) ship their own profiles and map
core fields into their determinations.
Customer profiles live under the customer-owned x-<org>.* namespace:
the profile key and every field family must sit under it — non-x-
families are reserved for the base core.* themes and pack-shipped
bindings (the packs:bindings-<framework> family).
Revisions: any field-set change bumps the profile version.
Additive revisions are free; narrowing ones (a removed field, a shrunk
enum) stale-flag existing values — never delete, never
auto-migrate. A revision may declare per-field old→new value_mappings;
ApplyMetadataProfileMigration applies them to stale values as
per-node audited writes — migration is an act, not a side effect — and
unmapped stale values stay counted and reportable. Field types never
change in place.
Values, inheritance and provenance
SetNodeMetadata writes and clears field values per node.
GetNodeMetadata answers the node's own values with staleness plus the
effective view: per-field inherit: none|subtree resolved up the
ancestor walk with provenance naming the governing ancestor —
operators classify at the environment and the assets follow as resolved
values, not copies.
BulkAnnotate is the adoption path: a path-glob
scope AND-composed with node kinds and metadata predicates —
eq/in/bool, the explicit unclassified selector, and the date
comparisons before/after (one YYYY-MM-DD value) and elapsed
(the date is in the past at evaluation time; a non-date value matches
none of the three) — applied as
per-node audited writes. Covered nodes outside the caller's read authz
are not covered; nodes no written field applies to are skipped and
counted.
The register
ListMetadataRegister is the estate-wide node × field view: rows with
staleness, filterable by profile/field/value/stale, read-authz filtered
per row, paged, plus the unclassified-coverage counts.
ExportMetadataRegister renders the same rows as CSV — the
asset-register evidence lane.
Each row also carries owner (the OWNER column): the node's effective
owner resolved from the /governance/owners fabric by the same
nearest-declaration walk the impacted-asset-owner approval uses, empty
when no declaration covers the node. Ownership lives on the governance
fabric; the register joins it, never stores it — it is not a metadata
field.
Authorization and the step-up floor
Writes require the delegable metadata-writer determination —
updatable on the reserved /metadata<node path> rule-path family
(profile writes and migrations decide at /metadata), grantable per
subtree through ordinary path-RBAC rules and never implied by node
write authz; denial is permission_denied. Reads ride node
read authz, 404-on-deny; the profile catalogue is vocabulary and reads
authenticated-only.
Writes touching a protective field (core.criticality,
core.data_sensitivity, core.external_exposure) additionally sit on the step-up
floor (metadata.protective_write) — lowering a
classification is the governance-evasion shape, so it demands an
elevated session (see opschain docs identity).
The full per-RPC determination × pack × step-up table is on
opschain docs governance-matrix.
Audit and the match snapshot
Every write is audited with before/after per field (43):
api:metadata_profiles:create|update, api:node_metadata:set,
api:node_metadata:bulk_annotate (per node, with the selector scope),
api:node_metadata:migrate — all riding the node's path, so
estate-scoped subscriptions match them ("notify on any
reclassification" is one subscription; see opschain docs events).
Writes invalidate the compiled effective-metadata snapshot the
match paths read (62 G8): approval-policy and notification metadata
predicates and ${{ metadata.* }} conditions see a write at the next
sweep/entry, never mid-walk. Predicate-carrying selectors are
fail-closed: a policy with metadata predicates matches nothing on an
unclassified estate.
What changes classification behaviour
MetadataServicewrites (metadata-writer determination + conditional step-up) — seeopschain docs api-v2.- Profile revisions and migrations change staleness, never values, until a migration is explicitly applied.
- The write-invalidated snapshot bounds when a reclassification reaches the match paths: next sweep/entry, never mid-walk.