provisioning/adrs/adr-020-extension-capability-declarations.ncl

104 lines
8.8 KiB
Text
Raw Normal View History

let d = import "adr-defaults.ncl" in
d.make_adr {
id = "adr-020",
title = "Extension Capability Declarations: provides/requires/conflicts_with Taxonomy",
status = 'Accepted,
date = "2026-04-03",
context = "ADR-016 introduced typed Formula DAGs for intra-server taskserv execution order. To enable formula dependency resolution at the workspace composition layer (inter-formula DAGs, ADR-021), the Orchestrator needs a machine-readable declaration of what each taskserv produces and what it depends on. Without this, a workspace composition DAG cannot validate that a Formula consuming `kubernetes-api-server` has at least one upstream Formula that provides it — the constraint is implicit and unenforced. Ten built-in taskservs existed with only `name/version/description/supported_providers` metadata — no capability declarations.",
decision = "Every taskserv `metadata.ncl` file must declare three fields: `provides: Array CapabilityEntry` (capabilities this taskserv makes available after successful execution), `requires: Array CapabilityRequirement` (capabilities this taskserv needs from another provider before it can run), and `conflicts_with: Array String` (taskserv names that are mutually exclusive — installing both would produce an irreconcilable conflict). A `CapabilityEntry` carries `id: String` (dot-namespaced, e.g. `kubernetes.api-server`), `kind: CapabilityKind` (`'Service | 'StorageClass | 'NetworkPolicy | 'Runtime | 'CertManager | 'Monitoring | 'Registry | 'DNS | 'Auth`), and `description: String`. A `CapabilityRequirement` carries `capability: String` (the capability `id`), `kind: RequirementKind` (`'Required | 'Optional`), and `description: String`. These fields are validated by `schemas/lib/extension-metadata.ncl` at Nickel typecheck time and audited at runtime by the `provisioning-dag-integrity` reflection mode.",
rationale = [
{
claim = "Machine-readable capability declarations enable schema-time resolution validation",
detail = "The `provisioning-dag-integrity` reflection mode cross-checks every `Required` capability in `requires[]` against the set of `provides[].id` values across all taskservs. An unresolved Required capability is a hard error surfaced before any deployment attempt. Without typed declarations, this check requires reading code comments or documentation.",
},
{
claim = "ConflictsWith enforces mutual exclusion at the registry level",
detail = "A taskserv pair in `conflicts_with` that both appear in a Formula is caught by `provisioning-validate-formula`. The registry-level declaration makes conflicts auditable and tool-enforceable — no runtime failure needed to discover an incompatible combination.",
},
{
claim = "CapabilityKind enum scopes the semantic surface of each capability",
detail = "Using a typed enum (`'Service | 'StorageClass | ...`) rather than free-form strings prevents capability ID sprawl. The DAG resolution query `extensions capabilities --type Service` is only possible with a bounded kind set.",
},
{
claim = "Optional vs Required separation allows partial-graph deployments",
detail = "`'Optional` requirements express soft preferences (e.g. coredns optionally uses an upstream DNS). `'Required` requirements are hard blockers. The distinction enables the orchestrator to warn on unresolved Optional capabilities while failing on unresolved Required ones.",
},
{
claim = "Dot-namespaced capability IDs provide scoping without a global registry",
detail = "IDs like `kubernetes.api-server`, `storage.ceph-block`, `network.cni` are self-documenting and conflict-resistant without requiring a central registry. The namespace prefix is the domain (kubernetes, storage, network, container, tls, dns, monitoring, identity).",
},
],
consequences = {
positive = [
"All 10 built-in taskservs now have typed capability declarations — `provisioning-dag-integrity` runs clean",
"CLI `provisioning catalog capabilities` and `provisioning extensions graph` are powered by these declarations",
"WorkspaceComposition dependency resolution can validate inter-formula capability chains at dag.ncl export time",
"Capability declarations are a first-class artifact — diffable, auditable, versionable in git",
],
negative = [
"New taskservs must populate provides/requires/conflicts_with or fail schema validation — increases authoring burden",
"Capability IDs are not validated against a central registry — a typo in `requires[].capability` fails silently if no provider declares the misspelled ID",
"CapabilityKind enum is closed — adding a new kind requires updating `schemas/lib/dag/contracts.ncl` and re-exporting all metadata files that use it",
],
},
alternatives_considered = [
{
option = "Free-form capability tags (Array String) instead of typed CapabilityEntry",
why_rejected = "Free strings cannot be validated for kind, cannot be queried by type, and cannot carry descriptions. The typed record is required for `provisioning catalog capabilities --type Service` to function and for the `provisioning-dag-integrity` mode to distinguish Required from Optional resolution failures.",
},
{
option = "Single `capabilities` array with a `direction` discriminant (provides/requires encoded as field)",
why_rejected = "A flat array conflates semantically different operations — providing a capability and requiring one have different validation rules and different consumers. Separate `provides` and `requires` arrays make intent explicit and allow independent schema validation.",
},
{
option = "Encode conflicts_with as capability-level conflicts (A.provides X conflicts with B.provides X)",
why_rejected = "Capability-level conflicts are more granular but harder to author — taskserv authors must reason about every capability pair. Taskserv-level mutual exclusion (`conflicts_with: [\"containerd\"]`) is the correct granularity for installation-time enforcement and maps directly to the package manager mental model.",
},
{
option = "Central capability registry file (a single capabilities.ncl across all extensions)",
why_rejected = "A central registry creates a write-contention hotspot when multiple extensions are developed in parallel. Distributed declarations in each metadata.ncl, aggregated by the reflection mode and CLI, achieve the same discoverability with independent authoring.",
},
],
constraints = [
{
id = "capability-ids-dot-namespaced",
claim = "All capability IDs in provides[].id and requires[].capability must use dot-namespaced format: `<domain>.<name>` (e.g. `kubernetes.api-server`, `storage.ceph-block`)",
scope = "catalog/taskservs/*/metadata.ncl",
severity = 'Hard,
check = { tag = 'Grep, pattern = "id = \"[^.]+\"", paths = ["catalog/taskservs/"], must_be_empty = true },
rationale = "Flat IDs (no dot) are ambiguous and collision-prone. The dot namespace convention is the only disambiguation mechanism without a central registry.",
},
{
id = "all-taskservs-must-declare-capability-fields",
claim = "Every taskserv metadata.ncl must declare provides, requires, and conflicts_with — even if as empty arrays",
scope = "catalog/taskservs/*/metadata.ncl, schemas/lib/extension-metadata.ncl",
severity = 'Hard,
check = { tag = 'NuCmd, cmd = "provisioning catalog capabilities 2>/dev/null | length | test { $in > 0 }", expect_exit = 0 },
rationale = "Missing fields are caught by the schema contract but also by `provisioning-dag-integrity`. A taskserv without declarations is invisible to capability resolution — it will never be identified as a provider or dependency.",
},
{
id = "conflicts-with-holds-taskserv-names-not-capability-ids",
claim = "conflicts_with[] must contain taskserv directory names (e.g. `\"containerd\"`), not capability IDs",
scope = "catalog/taskservs/*/metadata.ncl",
severity = 'Hard,
check = { tag = 'NuCmd, cmd = "nu -c 'ls catalog/taskservs/ | get name | each { |d| $d | path basename }'", expect_exit = 0 },
rationale = "The conflict resolution algorithm in `provisioning-validate-formula` looks up taskserv names in the extensions registry. Capability IDs in conflicts_with would never match and silently fail to enforce the constraint.",
},
],
ontology_check = {
decision_string = "Extension capability declarations via provides/requires/conflicts_with typed fields in metadata.ncl, validated by extension-metadata schema and provisioning-dag-integrity reflection mode",
invariants_at_risk = ["type-safety-nickel", "config-driven-always"],
verdict = 'Safe,
},
related_adrs = ["adr-016-workspace-formula-dag"],
}