Implement NK-WP-0013 playbook capability contract

This commit is contained in:
2026-05-22 14:49:25 +02:00
parent c3f721397a
commit 8e720dd78a
12 changed files with 1322 additions and 19 deletions

View File

@@ -153,8 +153,11 @@ creds-emergency-reprint: ## Re-deliver emergency bundle (if lost/stolen — repr
iam-profile-conformance-test: ## Run IAM Profile v0.2 conformance fixture tests
python3 -m pytest tools/iam-profile-conformance/tests
playbook-contract-test: ## Run Playbook Capability Contract fixture tests
python3 -m pytest tools/playbook-capability-contract/tests
.PHONY: help hooks hooks-test sops-setup sops-edit sops-encrypt sops-decrypt sops-rotate \
check-secrets creds-init creds-generate creds-bundle creds-apply creds-verify \
creds-status creds-rotate \
creds-agent-init creds-agent-status creds-emergency-reprint \
iam-profile-conformance-test
iam-profile-conformance-test playbook-contract-test

View File

@@ -0,0 +1,56 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://netkingdom.local/schemas/playbook-capability-declaration_v0.1.schema.json",
"title": "NetKingdom Playbook Capability Declaration v0.1",
"type": "object",
"additionalProperties": false,
"required": ["apiVersion", "kind", "metadata", "spec"],
"properties": {
"apiVersion": {
"type": "string",
"const": "netkingdom.io/playbook-capability/v0.1"
},
"kind": {
"type": "string",
"const": "PlaybookCapabilityDeclaration"
},
"metadata": {
"type": "object",
"additionalProperties": false,
"required": ["id", "name", "owner", "repo", "domain", "contract_version"],
"properties": {
"id": {"type": "string", "minLength": 1},
"name": {"type": "string", "minLength": 1},
"owner": {"type": "string", "minLength": 1},
"repo": {"type": "string", "minLength": 1},
"domain": {"type": "string", "minLength": 1},
"contract_version": {"type": "string", "const": "0.1"},
"source_links": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["label", "path"],
"properties": {
"label": {"type": "string", "minLength": 1},
"path": {"type": "string", "minLength": 1}
}
}
}
}
},
"spec": {
"type": "object",
"additionalProperties": false,
"required": ["playbook", "capabilities", "parameters", "responsibilities", "trust", "catalog"],
"properties": {
"playbook": {"type": "object"},
"capabilities": {"type": "array", "minItems": 1},
"parameters": {"type": "array"},
"responsibilities": {"type": "array", "minItems": 1},
"trust": {"type": "object"},
"catalog": {"type": "object"}
}
}
}
}

View File

@@ -0,0 +1,299 @@
---
id: netkingdom-playbook-capability-contract
type: standard
title: "NetKingdom Playbook Capability Contract v0.1"
domain: netkingdom
status: accepted
version: "0.1"
created: "2026-05-22"
updated: "2026-05-22"
scope: meta-orchestration
adr:
- docs/adr/ADR-0012-playbook-capability-contract-ownership.md
schema:
- canon/schemas/playbook-capability-declaration_v0.1.schema.json
validator:
- tools/playbook-capability-contract/playbook_contract_validator.py
---
# NetKingdom Playbook Capability Contract v0.1
## Purpose
The Playbook Capability Contract is the declared interface between
NetKingdom meta-orchestration and Railiance execution playbooks.
It lets a playbook state:
- which capability it provisions;
- which parameters it exposes, including defaults, constraints, and
security sensitivity;
- which resources and responsibilities it claims;
- which trust states it requires or satisfies;
- how it is published into a catalog.
NetKingdom consumes declarations to select playbooks, choose safe
parameter overrides, sequence trust states, and build a responsibility
map. Railiance owns the playbooks and execution mechanics.
## Ownership
NetKingdom owns this contract. Railiance publishes conformant
declarations. Execution stays in Railiance. See ADR-0012.
## File Convention
Declaration files SHOULD live in the publishing repo at:
```text
capabilities/playbooks/<declaration-id>.yaml
```
Each file describes one playbook or one stable playbook entry point. A
playbook with materially different modes may publish multiple
declarations if those modes provide different capabilities or expose
different security-sensitive parameters.
## Top-Level Shape
```yaml
apiVersion: netkingdom.io/playbook-capability/v0.1
kind: PlaybookCapabilityDeclaration
metadata:
id: railiance-infra.bootstrap-host
name: Railiance S1 host bootstrap
owner: railiance-infra
repo: railiance-infra
domain: railiance
contract_version: "0.1"
spec:
playbook: {}
capabilities: []
parameters: []
responsibilities: []
trust: {}
catalog: {}
```
## Capability Vocabulary
`spec.capabilities[].id` MUST be one of the controlled vocabulary values
below. Capability ids are stable comparison keys.
| Capability id | Tier | Meaning |
| --- | --- | --- |
| `s1.os-baseline` | S1 | Host provisioning, OS convergence, hardening, and substrate access baseline |
| `s1.secret-bootstrap` | S1 | Bootstrap secret material, SOPS/age handling, emergency material placement |
| `s2.cluster-runtime` | S2 | Kubernetes runtime, ingress, networking, admission, and cluster access |
| `s3.platform-services` | S3 | Databases, caches, object storage, brokers, and shared platform services |
| `c0.bootstrap-identity` | C0 | Local/bootstrap identity before runtime IAM exists |
| `c1.lightweight-sso` | C1 | key-cape lightweight SSO profile implementation |
| `c2a.light-2fa` | C2a | Lightweight built-in second factor such as TOTP/WebAuthn |
| `c2b.token-authority` | C2b | privacyIDEA or equivalent token authority |
| `c3.runtime-secrets` | C3 | OpenBao or equivalent runtime secret authority |
| `c4.fine-grained-authorization` | C4 | flex-auth and delegated PDP readiness |
| `c5.enterprise-federation` | C5 | expanded-mode Keycloak, enterprise federation, or SAML brokering |
| `c6.self-optimizing-audit` | C6 | audit feedback loops, drift surfacing, and continuous adaptation |
The `S*` entries align to Railiance stack layers. The `C*` entries align
to the NetKingdom capability progression. A declaration may list more
than one capability only when the same playbook entry point truly
provides each one.
## Resource Kinds
Every capability and responsibility claim references one or more
resource kinds:
| Resource kind | Meaning |
| --- | --- |
| `identities` | humans, service accounts, agents, groups, tenants, and assurance evidence |
| `roles_scopes_policies` | roles, scopes, policy packages, protected-system registrations, decision records |
| `secrets_credentials` | bootstrap material, runtime secrets, dynamic credentials, leases, credential rotations |
| `infrastructure_resources` | hosts, runtime, networking, platform services, storage, and deployment substrate |
## Parameter Declarations
Each parameter entry has this shape:
```yaml
- name: swapfile_size_mb
type: integer
required: false
default: 4096
constraints:
minimum: 0
maximum: 65536
sensitivity: operational
tuning_authority: netkingdom_tunable
description: Swap size applied by the bootstrap playbook.
```
Allowed `type` values:
- `string`
- `integer`
- `number`
- `boolean`
- `array`
- `object`
Allowed `sensitivity` values:
- `public` - safe to display and tune freely.
- `operational` - affects behavior or sizing, not secret material.
- `security_sensitive` - affects security posture and requires platform
review.
- `secret_reference` - points to secret material but must not contain the
secret value itself.
Allowed `tuning_authority` values:
- `playbook_default` - NetKingdom should rely on the playbook default.
- `netkingdom_tunable` - NetKingdom may override for a scenario.
- `platform_only` - only platform-control-plane authority may override.
- `tenant_tunable` - tenant-scoped scenario owners may override within
constraints.
- `forbidden` - declaration exposes the value for audit only; callers may
not override it.
Security-sensitive and secret-reference parameters MUST NOT be
`tenant_tunable`. Secret-reference defaults must be references or paths,
not plaintext secret values.
Supported constraints:
| Constraint | Applies to | Meaning |
| --- | --- | --- |
| `enum` | all scalar types | value must be one of the listed values |
| `minimum` / `maximum` | integer, number | numeric bounds |
| `min_items` / `max_items` | array | array length bounds |
| `pattern` | string | regular expression the value must match |
## Responsibility Claims
Responsibility entries feed the responsibility map. They do not transfer
execution ownership to NetKingdom.
```yaml
- resource_kind: infrastructure_resources
owner: railiance-infra
resources:
- server:target_hosts
- os-baseline
repo_owns: Provisioning, convergence, and verification mechanics.
netkingdom_orchestrates: Whether this substrate capability is selected, and which security posture is required.
```
`owner` names the repo or provider that holds execution ownership.
`repo_owns` explains the implementation responsibility.
`netkingdom_orchestrates` explains the meta-orchestration responsibility.
## Trust States And Readiness
Declarations use the trust-state vocabulary from the platform
architecture:
- `bare_host_trust`
- `cluster_trust`
- `bootstrap_secret_trust`
- `bootstrap_identity_trust`
- `runtime_secret_trust`
- `runtime_identity_trust`
- `runtime_authorization_trust`
- `tenant_onboarding_trust`
Each declaration lists states it requires and states it satisfies:
```yaml
trust:
requires:
- state: bare_host_trust
readiness_checks: []
satisfies:
- state: bootstrap_secret_trust
readiness_checks:
- id: sops-age-material-present
description: SOPS/age material is present for bootstrap secrets.
evidence: ansible role sops_agent converged successfully
```
Readiness checks are evidence obligations. The declaration names the
check; the Railiance playbook or verification tooling performs it.
## Catalog And Consumption Model
A catalog is an index of declarations. For v0.1, the catalog mechanism is
file-based:
1. Railiance repos publish declarations under
`capabilities/playbooks/*.yaml`.
2. NetKingdom or a future catalog job discovers those files from known
orchestrated repos.
3. The validator checks each declaration against this contract.
4. A scenario states required capability ids and parameter overrides.
5. NetKingdom selects declarations that provide the required
capabilities.
6. NetKingdom applies only allowed parameter overrides, rejecting
out-of-range, tenant-forbidden, or security-unsafe overrides.
7. NetKingdom composes the responsibility and trust-state claims into a
scenario responsibility map and readiness sequence.
The declaration is not an execution plan. It is the interface that lets a
separate playbook runner execute safely.
## Scenario Shape
The validator supports a small scenario file for conformance demos:
```yaml
id: scenario:s1-host-bootstrap-reference
authority: platform
requires:
capabilities:
- s1.os-baseline
parameter_overrides:
railiance-infra.bootstrap-host:
target_hosts:
- railiance01
swapfile_size_mb: 8192
```
Allowed scenario authorities are `platform`, `netkingdom`, and `tenant`.
Tenant authority cannot override `platform_only`,
`security_sensitive`, or `secret_reference` parameters.
## Conformance
A declaration conforms when it passes:
```text
tools/playbook-capability-contract/playbook_contract_validator.py
```
The validator checks:
- top-level API version and kind;
- required metadata;
- controlled capability ids and tiers;
- resource-kind vocabulary;
- parameter type/default/constraint/sensitivity/tuning rules;
- responsibility claims;
- trust-state and readiness-check shape;
- catalog publication metadata;
- optional scenario selection and parameter override compatibility.
## Reference Adoption
The reference declaration for v0.1 is in:
```text
../railiance-infra/capabilities/playbooks/railiance-infra.bootstrap-host.yaml
```
It describes the existing Railiance S1 Ansible bootstrap playbook and can
be selected by the sample scenario in:
```text
examples/playbook-capability-contract/scenario-s1-host-bootstrap.yaml
```

View File

@@ -0,0 +1,112 @@
# ADR-0012 - Playbook Capability Contract Ownership
**Status:** Accepted
**Date:** 2026-05-22
**Deciders:** Bernd Worsch, Codex
## Context
ADR-0007 refined NetKingdom's orchestration role into a
meta-orchestration layer. NetKingdom selects the services and playbooks a
scenario needs, decides which parameters may be tuned, and holds the
responsibility map. Railiance remains the execution-orchestration layer:
Railiance playbooks provision and converge the actual infrastructure,
cluster, platform services, and application layers.
That split requires a stable interface. If a Railiance playbook only
describes behavior implicitly, NetKingdom cannot safely compose it into a
scenario, compare it with another playbook, or know which parameter
changes are safe. The IAM Profile provides the precedent: the consumer
that needs a stable contract defines the contract, and providers conform
to it.
## Decision
NetKingdom owns the Playbook Capability Contract schema and vocabulary.
Railiance owns playbook implementation and publishes one conformant
declaration per playbook.
The first canonical contract is
`canon/standards/playbook-capability-contract_v0.1.md`, backed by the
machine-readable schema in
`canon/schemas/playbook-capability-declaration_v0.1.schema.json` and the
validator in `tools/playbook-capability-contract/`.
The contract is NetKingdom-owned with Railiance co-design:
- NetKingdom defines the schema, controlled vocabulary, trust-state
language, parameter-sensitivity rules, and conformance criteria.
- Railiance authors and maintains declarations beside the playbooks they
describe.
- Railiance execution stays unchanged. The declaration never becomes the
playbook runner.
- NetKingdom meta-orchestration consumes declarations to select,
parametrize, sequence, and build responsibility maps for scenarios.
ADR-0007 remains unchanged: execution stays in Railiance.
## Versioning
The contract uses explicit document versions:
- Patch/editorial changes clarify wording or examples without changing
declaration semantics.
- Minor versions add optional fields, vocabulary entries, or validator
warnings that existing declarations can ignore.
- Breaking versions change required fields, field meanings, allowed
vocabulary, parameter-sensitivity semantics, trust-state semantics, or
catalog consumption rules.
Declarations carry `metadata.contract_version`. A catalog may accept more
than one contract version during a migration window, but must report the
version used for each selected playbook.
## Breaking-Change Governance
A breaking change requires:
1. an ADR or ADR refinement explaining the change and migration path;
2. a new versioned standard and schema;
3. an updated validator;
4. a coexistence window for the previous supported version where
practical;
5. notice to known declaration publishers, especially Railiance repos.
Breaking changes include:
- removing or renaming required fields;
- changing capability ids or resource-kind vocabulary;
- changing trust-state meanings;
- changing which parameter sensitivities are tenant-tunable;
- changing catalog selection or override semantics;
- moving execution responsibility out of Railiance into NetKingdom.
## Consequences
- Playbook declaration files live beside Railiance playbooks, normally at
`capabilities/playbooks/*.yaml`.
- NetKingdom can validate declarations before consuming them.
- A playbook interface change becomes visible and versioned instead of an
implicit break.
- The responsibility map can be assembled from declarations, while
Railiance keeps execution ownership.
## Alternatives Considered
### Put The Contract In Railiance
Railiance owns execution, so this is tempting. But NetKingdom is the
consumer that needs stable scenario composition and responsibility-map
inputs. Keeping the contract in NetKingdom mirrors the IAM Profile
pattern and keeps scenario semantics close to the responsibility map.
### Make Declarations Free-Form Documentation
Free-form docs are readable but not safely composable. NetKingdom needs a
validator and controlled vocabulary so a playbook change cannot silently
break a scenario.
### Build A Dedicated Execution-Orchestration Repo Now
ADR-0007 explicitly defers that. The contract is useful now and does not
require a new runner or repo boundary.

View File

@@ -326,6 +326,13 @@ ADR-0007 records the current decision: keep orchestration in Railiance
playbooks for now, with NetKingdom defining the trust-state model,
readiness checks, OpenBao boundaries, and security semantics.
The playbook interface for that split is the NetKingdom Playbook
Capability Contract (`canon/standards/playbook-capability-contract_v0.1.md`).
Railiance playbooks publish declarations beside the playbooks; NetKingdom
validates and consumes those declarations to select capabilities,
parametrize allowed inputs, and assemble responsibility/trust-state
views without taking over execution.
## flex-auth And Topaz Implications
flex-auth work must preserve the recursive boundary between platform

View File

@@ -40,6 +40,10 @@ NetKingdom's role over orchestrated repos is **meta-orchestration**
services/playbooks a scenario needs, (2) **parametrizes** them where
tuning is warranted, and (3) holds **responsibility** for the resources
and the security boundaries — leaving execution mechanics to the repo.
The Playbook Capability Contract
(`canon/standards/playbook-capability-contract_v0.1.md`) is the declared
interface NetKingdom uses for playbook selection and safe
parametrization.
---

View File

@@ -0,0 +1,12 @@
# Playbook Capability Contract Examples
`scenario-s1-host-bootstrap.yaml` demonstrates NetKingdom selecting and
parametrizing a Railiance playbook from its declaration alone.
Run it against the reference Railiance declaration:
```bash
python3 tools/playbook-capability-contract/playbook_contract_validator.py \
../railiance-infra/capabilities/playbooks/railiance-infra.bootstrap-host.yaml \
--scenario examples/playbook-capability-contract/scenario-s1-host-bootstrap.yaml
```

View File

@@ -0,0 +1,10 @@
id: scenario:s1-host-bootstrap-reference
authority: platform
requires:
capabilities:
- s1.os-baseline
parameter_overrides:
railiance-infra.bootstrap-host:
target_hosts:
- railiance01
swapfile_size_mb: 8192

View File

@@ -0,0 +1,28 @@
# Playbook Capability Contract Validator
Executable checks for
`canon/standards/playbook-capability-contract_v0.1.md`.
Runtime dependency: Python 3.11+ with `PyYAML`. Fixture tests also
require `pytest`.
Validate a declaration:
```bash
python3 tools/playbook-capability-contract/playbook_contract_validator.py \
../railiance-infra/capabilities/playbooks/railiance-infra.bootstrap-host.yaml
```
Validate and compose a sample scenario:
```bash
python3 tools/playbook-capability-contract/playbook_contract_validator.py \
../railiance-infra/capabilities/playbooks/railiance-infra.bootstrap-host.yaml \
--scenario examples/playbook-capability-contract/scenario-s1-host-bootstrap.yaml
```
Run tests:
```bash
python3 -m pytest tools/playbook-capability-contract/tests
```

View File

@@ -0,0 +1,557 @@
#!/usr/bin/env python3
"""
Validate NetKingdom Playbook Capability Contract v0.1 declarations.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
API_VERSION = "netkingdom.io/playbook-capability/v0.1"
KIND = "PlaybookCapabilityDeclaration"
CONTRACT_VERSION = "0.1"
CAPABILITIES = {
"s1.os-baseline": "S1",
"s1.secret-bootstrap": "S1",
"s2.cluster-runtime": "S2",
"s3.platform-services": "S3",
"c0.bootstrap-identity": "C0",
"c1.lightweight-sso": "C1",
"c2a.light-2fa": "C2a",
"c2b.token-authority": "C2b",
"c3.runtime-secrets": "C3",
"c4.fine-grained-authorization": "C4",
"c5.enterprise-federation": "C5",
"c6.self-optimizing-audit": "C6",
}
RESOURCE_KINDS = {
"identities",
"roles_scopes_policies",
"secrets_credentials",
"infrastructure_resources",
}
PARAM_TYPES = {"string", "integer", "number", "boolean", "array", "object"}
SENSITIVITIES = {"public", "operational", "security_sensitive", "secret_reference"}
TUNING_AUTHORITIES = {
"playbook_default",
"netkingdom_tunable",
"platform_only",
"tenant_tunable",
"forbidden",
}
TRUST_STATES = {
"bare_host_trust",
"cluster_trust",
"bootstrap_secret_trust",
"bootstrap_identity_trust",
"runtime_secret_trust",
"runtime_identity_trust",
"runtime_authorization_trust",
"tenant_onboarding_trust",
}
SCENARIO_AUTHORITIES = {"platform", "netkingdom", "tenant"}
@dataclass
class Issue:
level: str
path: str
message: str
@dataclass
class Declaration:
path: Path
data: dict[str, Any]
@property
def id(self) -> str:
return str(self.data.get("metadata", {}).get("id", ""))
@property
def capabilities(self) -> set[str]:
values = self.data.get("spec", {}).get("capabilities", [])
if not isinstance(values, list):
return set()
return {str(item.get("id")) for item in values if isinstance(item, dict)}
@property
def parameters(self) -> dict[str, dict[str, Any]]:
values = self.data.get("spec", {}).get("parameters", [])
if not isinstance(values, list):
return {}
return {
str(item.get("name")): item
for item in values
if isinstance(item, dict) and item.get("name")
}
def issue(level: str, path: str, message: str) -> Issue:
return Issue(level, path, message)
def load_yaml(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as handle:
data = yaml.safe_load(handle)
if not isinstance(data, dict):
raise ValueError("document must be a YAML object")
return data
def is_type(value: Any, declared_type: str) -> bool:
if value is None:
return True
if declared_type == "string":
return isinstance(value, str)
if declared_type == "integer":
return isinstance(value, int) and not isinstance(value, bool)
if declared_type == "number":
return (isinstance(value, int) or isinstance(value, float)) and not isinstance(value, bool)
if declared_type == "boolean":
return isinstance(value, bool)
if declared_type == "array":
return isinstance(value, list)
if declared_type == "object":
return isinstance(value, dict)
return False
def validate_constraints(value: Any, param: dict[str, Any], path: str) -> list[Issue]:
issues: list[Issue] = []
constraints = param.get("constraints", {})
if constraints is None:
return issues
if not isinstance(constraints, dict):
return [issue("ERROR", f"{path}.constraints", "constraints must be an object")]
if "enum" in constraints:
enum = constraints["enum"]
if not isinstance(enum, list) or not enum:
issues.append(issue("ERROR", f"{path}.constraints.enum", "enum must be a non-empty list"))
elif value is not None and value not in enum:
issues.append(issue("ERROR", path, f"value {value!r} is outside enum {enum!r}"))
if value is None:
return issues
for key, comparator in (("minimum", lambda got, want: got < want), ("maximum", lambda got, want: got > want)):
if key in constraints:
if not isinstance(constraints[key], (int, float)):
issues.append(issue("ERROR", f"{path}.constraints.{key}", f"{key} must be numeric"))
elif isinstance(value, (int, float)) and not isinstance(value, bool) and comparator(value, constraints[key]):
issues.append(issue("ERROR", path, f"value {value!r} violates {key}={constraints[key]!r}"))
for key, comparator in (("min_items", lambda got, want: len(got) < want), ("max_items", lambda got, want: len(got) > want)):
if key in constraints:
if not isinstance(constraints[key], int):
issues.append(issue("ERROR", f"{path}.constraints.{key}", f"{key} must be an integer"))
elif isinstance(value, list) and comparator(value, constraints[key]):
issues.append(issue("ERROR", path, f"value length violates {key}={constraints[key]!r}"))
if "pattern" in constraints:
pattern = constraints["pattern"]
if not isinstance(pattern, str):
issues.append(issue("ERROR", f"{path}.constraints.pattern", "pattern must be a string"))
elif isinstance(value, str) and re.search(pattern, value) is None:
issues.append(issue("ERROR", path, f"value {value!r} does not match pattern {pattern!r}"))
return issues
def validate_required_object(parent: dict[str, Any], path: str, required: list[str]) -> list[Issue]:
issues: list[Issue] = []
for key in required:
if key not in parent:
issues.append(issue("ERROR", f"{path}.{key}", "required field missing"))
return issues
def validate_metadata(data: dict[str, Any]) -> list[Issue]:
metadata = data.get("metadata")
if not isinstance(metadata, dict):
return [issue("ERROR", "metadata", "metadata must be an object")]
issues = validate_required_object(
metadata,
"metadata",
["id", "name", "owner", "repo", "domain", "contract_version"],
)
for key in ("id", "name", "owner", "repo", "domain"):
if key in metadata and not isinstance(metadata[key], str):
issues.append(issue("ERROR", f"metadata.{key}", "must be a string"))
if metadata.get("contract_version") != CONTRACT_VERSION:
issues.append(issue("ERROR", "metadata.contract_version", f"must be {CONTRACT_VERSION!r}"))
return issues
def validate_playbook(spec: dict[str, Any]) -> list[Issue]:
playbook = spec.get("playbook")
if not isinstance(playbook, dict):
return [issue("ERROR", "spec.playbook", "playbook must be an object")]
issues = validate_required_object(playbook, "spec.playbook", ["path", "type", "invocation", "description"])
for key in ("path", "type", "invocation", "description"):
if key in playbook and not isinstance(playbook[key], str):
issues.append(issue("ERROR", f"spec.playbook.{key}", "must be a string"))
return issues
def validate_capabilities(spec: dict[str, Any]) -> list[Issue]:
values = spec.get("capabilities")
if not isinstance(values, list) or not values:
return [issue("ERROR", "spec.capabilities", "must be a non-empty list")]
issues: list[Issue] = []
seen: set[str] = set()
for idx, item in enumerate(values):
path = f"spec.capabilities[{idx}]"
if not isinstance(item, dict):
issues.append(issue("ERROR", path, "capability must be an object"))
continue
issues.extend(validate_required_object(item, path, ["id", "tier", "resource_kinds", "description"]))
cap_id = item.get("id")
tier = item.get("tier")
if cap_id in seen:
issues.append(issue("ERROR", f"{path}.id", f"duplicate capability id {cap_id!r}"))
seen.add(str(cap_id))
expected_tier = CAPABILITIES.get(cap_id)
if expected_tier is None:
issues.append(issue("ERROR", f"{path}.id", f"unknown capability id {cap_id!r}"))
elif tier != expected_tier:
issues.append(issue("ERROR", f"{path}.tier", f"tier must be {expected_tier!r} for {cap_id!r}"))
resource_kinds = item.get("resource_kinds")
if not isinstance(resource_kinds, list) or not resource_kinds:
issues.append(issue("ERROR", f"{path}.resource_kinds", "must be a non-empty list"))
else:
unknown = sorted(set(str(kind) for kind in resource_kinds) - RESOURCE_KINDS)
if unknown:
issues.append(issue("ERROR", f"{path}.resource_kinds", f"unknown resource kinds: {unknown}"))
return issues
def validate_parameters(spec: dict[str, Any]) -> list[Issue]:
values = spec.get("parameters")
if not isinstance(values, list):
return [issue("ERROR", "spec.parameters", "must be a list")]
issues: list[Issue] = []
seen: set[str] = set()
for idx, item in enumerate(values):
path = f"spec.parameters[{idx}]"
if not isinstance(item, dict):
issues.append(issue("ERROR", path, "parameter must be an object"))
continue
issues.extend(
validate_required_object(
item,
path,
["name", "type", "required", "sensitivity", "tuning_authority", "description"],
)
)
name = item.get("name")
declared_type = item.get("type")
if name in seen:
issues.append(issue("ERROR", f"{path}.name", f"duplicate parameter {name!r}"))
seen.add(str(name))
if declared_type not in PARAM_TYPES:
issues.append(issue("ERROR", f"{path}.type", f"unknown parameter type {declared_type!r}"))
if "required" in item and not isinstance(item["required"], bool):
issues.append(issue("ERROR", f"{path}.required", "required must be boolean"))
if item.get("sensitivity") not in SENSITIVITIES:
issues.append(issue("ERROR", f"{path}.sensitivity", f"unknown sensitivity {item.get('sensitivity')!r}"))
if item.get("tuning_authority") not in TUNING_AUTHORITIES:
issues.append(issue("ERROR", f"{path}.tuning_authority", f"unknown tuning authority {item.get('tuning_authority')!r}"))
if item.get("sensitivity") in {"security_sensitive", "secret_reference"} and item.get("tuning_authority") == "tenant_tunable":
issues.append(issue("ERROR", path, "security-sensitive parameters cannot be tenant_tunable"))
if "default" in item and declared_type in PARAM_TYPES and not is_type(item["default"], declared_type):
issues.append(issue("ERROR", f"{path}.default", f"default does not match type {declared_type!r}"))
if declared_type in PARAM_TYPES:
issues.extend(validate_constraints(item.get("default"), item, path))
return issues
def validate_responsibilities(spec: dict[str, Any]) -> list[Issue]:
values = spec.get("responsibilities")
if not isinstance(values, list) or not values:
return [issue("ERROR", "spec.responsibilities", "must be a non-empty list")]
issues: list[Issue] = []
for idx, item in enumerate(values):
path = f"spec.responsibilities[{idx}]"
if not isinstance(item, dict):
issues.append(issue("ERROR", path, "responsibility must be an object"))
continue
issues.extend(
validate_required_object(
item,
path,
["resource_kind", "owner", "resources", "repo_owns", "netkingdom_orchestrates"],
)
)
if item.get("resource_kind") not in RESOURCE_KINDS:
issues.append(issue("ERROR", f"{path}.resource_kind", f"unknown resource kind {item.get('resource_kind')!r}"))
resources = item.get("resources")
if not isinstance(resources, list) or not resources:
issues.append(issue("ERROR", f"{path}.resources", "resources must be a non-empty list"))
for key in ("owner", "repo_owns", "netkingdom_orchestrates"):
if key in item and not isinstance(item[key], str):
issues.append(issue("ERROR", f"{path}.{key}", "must be a string"))
return issues
def validate_trust_states(spec: dict[str, Any]) -> list[Issue]:
trust = spec.get("trust")
if not isinstance(trust, dict):
return [issue("ERROR", "spec.trust", "trust must be an object")]
issues: list[Issue] = []
for section in ("requires", "satisfies"):
values = trust.get(section, [])
path = f"spec.trust.{section}"
if not isinstance(values, list):
issues.append(issue("ERROR", path, "must be a list"))
continue
for idx, item in enumerate(values):
item_path = f"{path}[{idx}]"
if not isinstance(item, dict):
issues.append(issue("ERROR", item_path, "trust state entry must be an object"))
continue
if item.get("state") not in TRUST_STATES:
issues.append(issue("ERROR", f"{item_path}.state", f"unknown trust state {item.get('state')!r}"))
checks = item.get("readiness_checks", [])
if not isinstance(checks, list):
issues.append(issue("ERROR", f"{item_path}.readiness_checks", "must be a list"))
continue
if section == "satisfies" and not checks:
issues.append(issue("ERROR", f"{item_path}.readiness_checks", "satisfied trust states require readiness checks"))
for cidx, check in enumerate(checks):
check_path = f"{item_path}.readiness_checks[{cidx}]"
if not isinstance(check, dict):
issues.append(issue("ERROR", check_path, "readiness check must be an object"))
continue
issues.extend(validate_required_object(check, check_path, ["id", "description", "evidence"]))
return issues
def validate_catalog(spec: dict[str, Any]) -> list[Issue]:
catalog = spec.get("catalog")
if not isinstance(catalog, dict):
return [issue("ERROR", "spec.catalog", "catalog must be an object")]
issues = validate_required_object(catalog, "spec.catalog", ["publish", "maturity", "consumers"])
if "consumers" in catalog and not isinstance(catalog["consumers"], list):
issues.append(issue("ERROR", "spec.catalog.consumers", "consumers must be a list"))
return issues
def validate_declaration(declaration: Declaration) -> list[Issue]:
data = declaration.data
issues: list[Issue] = []
issues.extend(validate_required_object(data, "$", ["apiVersion", "kind", "metadata", "spec"]))
if data.get("apiVersion") != API_VERSION:
issues.append(issue("ERROR", "apiVersion", f"must be {API_VERSION!r}"))
if data.get("kind") != KIND:
issues.append(issue("ERROR", "kind", f"must be {KIND!r}"))
issues.extend(validate_metadata(data))
spec = data.get("spec")
if not isinstance(spec, dict):
return issues + [issue("ERROR", "spec", "spec must be an object")]
issues.extend(validate_required_object(spec, "spec", ["playbook", "capabilities", "parameters", "responsibilities", "trust", "catalog"]))
issues.extend(validate_playbook(spec))
issues.extend(validate_capabilities(spec))
issues.extend(validate_parameters(spec))
issues.extend(validate_responsibilities(spec))
issues.extend(validate_trust_states(spec))
issues.extend(validate_catalog(spec))
return issues
def effective_parameter_value(param: dict[str, Any], overrides: dict[str, Any], declaration_id: str) -> tuple[bool, Any]:
name = str(param.get("name"))
if name in overrides:
return True, overrides[name]
if "default" in param:
return False, param["default"]
return False, None
def validate_override_allowed(param: dict[str, Any], value: Any, scenario_authority: str, path: str) -> list[Issue]:
issues: list[Issue] = []
authority = param.get("tuning_authority")
sensitivity = param.get("sensitivity")
name = param.get("name")
declared_type = str(param.get("type"))
if authority in {"forbidden", "playbook_default"}:
issues.append(issue("ERROR", path, f"parameter {name!r} cannot be overridden"))
if scenario_authority == "tenant" and authority in {"platform_only", "forbidden", "playbook_default"}:
issues.append(issue("ERROR", path, f"tenant authority cannot override {authority} parameter {name!r}"))
if scenario_authority == "tenant" and sensitivity in {"security_sensitive", "secret_reference"}:
issues.append(issue("ERROR", path, f"tenant authority cannot override {sensitivity} parameter {name!r}"))
if not is_type(value, declared_type):
issues.append(issue("ERROR", path, f"override for {name!r} does not match type {declared_type!r}"))
issues.extend(validate_constraints(value, param, path))
return issues
def compose_scenario(declarations: list[Declaration], scenario: dict[str, Any]) -> tuple[list[Issue], dict[str, Any]]:
issues: list[Issue] = []
authority = scenario.get("authority", "platform")
if authority not in SCENARIO_AUTHORITIES:
issues.append(issue("ERROR", "scenario.authority", f"unknown authority {authority!r}"))
authority = "platform"
requires = scenario.get("requires", {})
required_caps = requires.get("capabilities", []) if isinstance(requires, dict) else []
if not isinstance(required_caps, list) or not required_caps:
issues.append(issue("ERROR", "scenario.requires.capabilities", "scenario must require at least one capability"))
required_caps = []
overrides = scenario.get("parameter_overrides", {})
if overrides is None:
overrides = {}
if not isinstance(overrides, dict):
issues.append(issue("ERROR", "scenario.parameter_overrides", "must be an object"))
overrides = {}
selected: list[Declaration] = []
for cap_id in required_caps:
matches = [declaration for declaration in declarations if cap_id in declaration.capabilities]
if not matches:
issues.append(issue("ERROR", "scenario.requires.capabilities", f"no declaration provides {cap_id!r}"))
continue
selected.append(matches[0])
# Preserve order while deduplicating declarations selected for several capabilities.
selected_by_id: dict[str, Declaration] = {}
for declaration in selected:
selected_by_id.setdefault(declaration.id, declaration)
composed = {
"scenario": scenario.get("id", "scenario:unnamed"),
"authority": authority,
"selected_declarations": [],
}
for declaration_id, declaration in selected_by_id.items():
declaration_overrides = overrides.get(declaration_id, {})
if not isinstance(declaration_overrides, dict):
issues.append(issue("ERROR", f"scenario.parameter_overrides.{declaration_id}", "must be an object"))
declaration_overrides = {}
params_out: dict[str, Any] = {}
for name in declaration_overrides:
if name not in declaration.parameters:
issues.append(issue("ERROR", f"scenario.parameter_overrides.{declaration_id}.{name}", "unknown parameter override"))
for param in declaration.parameters.values():
name = str(param.get("name"))
overridden, value = effective_parameter_value(param, declaration_overrides, declaration_id)
if param.get("required") is True and value is None:
issues.append(issue("ERROR", f"scenario.parameter_overrides.{declaration_id}.{name}", "required parameter has no default or override"))
if overridden:
issues.extend(validate_override_allowed(param, value, str(authority), f"scenario.parameter_overrides.{declaration_id}.{name}"))
params_out[name] = {
"value": value,
"source": "override" if overridden else "default",
"sensitivity": param.get("sensitivity"),
"tuning_authority": param.get("tuning_authority"),
}
composed["selected_declarations"].append(
{
"id": declaration_id,
"path": str(declaration.path),
"capabilities": sorted(declaration.capabilities),
"parameters": params_out,
}
)
return issues, composed
def print_report(issues: list[Issue], composed: dict[str, Any] | None, json_output: bool) -> None:
if json_output:
print(
json.dumps(
{
"issues": [item.__dict__ for item in issues],
"composition": composed,
},
indent=2,
sort_keys=True,
)
)
return
if not issues:
print("PASS playbook capability contract conformance")
for item in issues:
print(f"{item.level:5} {item.path}: {item.message}")
if composed is not None:
print("")
print("Composition")
print(json.dumps(composed, indent=2, sort_keys=True))
def load_declarations(paths: list[str]) -> tuple[list[Declaration], list[Issue]]:
declarations: list[Declaration] = []
issues: list[Issue] = []
for raw_path in paths:
path = Path(raw_path)
try:
data = load_yaml(path)
except Exception as exc:
issues.append(issue("ERROR", str(path), f"failed to load declaration: {exc}"))
continue
declaration = Declaration(path=path, data=data)
issues.extend(validate_declaration(declaration))
declarations.append(declaration)
return declarations, issues
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Validate NetKingdom Playbook Capability Contract declarations.")
parser.add_argument("declarations", nargs="+", help="Declaration YAML files")
parser.add_argument("--scenario", help="Optional scenario YAML to compose from declarations")
parser.add_argument("--json", action="store_true", help="Print JSON report")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
declarations, issues = load_declarations(args.declarations)
composed = None
if args.scenario:
try:
scenario = load_yaml(Path(args.scenario))
except Exception as exc:
issues.append(issue("ERROR", args.scenario, f"failed to load scenario: {exc}"))
scenario = {}
scenario_issues, composed = compose_scenario(declarations, scenario)
issues.extend(scenario_issues)
print_report(issues, composed, args.json)
return 1 if any(item.level == "ERROR" for item in issues) else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,198 @@
import importlib.util
import sys
from pathlib import Path
import yaml
TOOL_PATH = Path(__file__).resolve().parents[1] / "playbook_contract_validator.py"
SPEC = importlib.util.spec_from_file_location("playbook_contract_validator", TOOL_PATH)
validator = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = validator
SPEC.loader.exec_module(validator)
def valid_declaration() -> dict:
return {
"apiVersion": validator.API_VERSION,
"kind": validator.KIND,
"metadata": {
"id": "railiance-infra.bootstrap-host",
"name": "Railiance S1 host bootstrap",
"owner": "railiance-infra",
"repo": "railiance-infra",
"domain": "railiance",
"contract_version": "0.1",
},
"spec": {
"playbook": {
"path": "ansible/playbooks/bootstrap.yaml",
"type": "ansible",
"invocation": "make converge",
"description": "Converges the S1 host baseline.",
},
"capabilities": [
{
"id": "s1.os-baseline",
"tier": "S1",
"resource_kinds": ["infrastructure_resources", "secrets_credentials"],
"description": "OS baseline and bootstrap secret handling.",
}
],
"parameters": [
{
"name": "target_hosts",
"type": "array",
"required": True,
"constraints": {"min_items": 1},
"sensitivity": "operational",
"tuning_authority": "netkingdom_tunable",
"description": "Inventory hosts to converge.",
},
{
"name": "swapfile_size_mb",
"type": "integer",
"required": False,
"default": 4096,
"constraints": {"minimum": 0, "maximum": 65536},
"sensitivity": "operational",
"tuning_authority": "netkingdom_tunable",
"description": "Swap file size.",
},
{
"name": "wireguard_enabled",
"type": "boolean",
"required": False,
"default": False,
"sensitivity": "security_sensitive",
"tuning_authority": "platform_only",
"description": "Enable WireGuard role.",
},
],
"responsibilities": [
{
"resource_kind": "infrastructure_resources",
"owner": "railiance-infra",
"resources": ["server:target_hosts", "os-baseline"],
"repo_owns": "Ansible convergence mechanics.",
"netkingdom_orchestrates": "Whether S1 is selected for the scenario.",
}
],
"trust": {
"requires": [],
"satisfies": [
{
"state": "bare_host_trust",
"readiness_checks": [
{
"id": "os-baseline-converged",
"description": "Ansible baseline converged.",
"evidence": "bootstrap playbook completed",
}
],
}
],
},
"catalog": {
"publish": "capabilities/playbooks/railiance-infra.bootstrap-host.yaml",
"maturity": "reference",
"consumers": ["netkingdom-meta-orchestration"],
},
},
}
def declaration_from(data: dict, tmp_path: Path) -> validator.Declaration:
path = tmp_path / "declaration.yaml"
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
return validator.Declaration(path=path, data=data)
def error_messages(issues):
return [item.message for item in issues if item.level == "ERROR"]
def test_valid_declaration_passes(tmp_path):
declaration = declaration_from(valid_declaration(), tmp_path)
issues = validator.validate_declaration(declaration)
assert error_messages(issues) == []
def test_unknown_capability_fails(tmp_path):
data = valid_declaration()
data["spec"]["capabilities"][0]["id"] = "s9.magic"
declaration = declaration_from(data, tmp_path)
issues = validator.validate_declaration(declaration)
assert any("unknown capability id" in msg for msg in error_messages(issues))
def test_tenant_tunable_secret_reference_fails(tmp_path):
data = valid_declaration()
data["spec"]["parameters"][2]["sensitivity"] = "secret_reference"
data["spec"]["parameters"][2]["tuning_authority"] = "tenant_tunable"
declaration = declaration_from(data, tmp_path)
issues = validator.validate_declaration(declaration)
assert any("security-sensitive parameters cannot be tenant_tunable" in msg for msg in error_messages(issues))
def test_scenario_composition_selects_and_overrides(tmp_path):
declaration = declaration_from(valid_declaration(), tmp_path)
scenario = {
"id": "scenario:s1-host-bootstrap-reference",
"authority": "platform",
"requires": {"capabilities": ["s1.os-baseline"]},
"parameter_overrides": {
"railiance-infra.bootstrap-host": {
"target_hosts": ["railiance01"],
"swapfile_size_mb": 8192,
}
},
}
issues, composed = validator.compose_scenario([declaration], scenario)
assert error_messages(issues) == []
selected = composed["selected_declarations"][0]
assert selected["id"] == "railiance-infra.bootstrap-host"
assert selected["parameters"]["target_hosts"]["source"] == "override"
assert selected["parameters"]["swapfile_size_mb"]["value"] == 8192
def test_tenant_authority_cannot_override_platform_only(tmp_path):
declaration = declaration_from(valid_declaration(), tmp_path)
scenario = {
"id": "scenario:bad-tenant-override",
"authority": "tenant",
"requires": {"capabilities": ["s1.os-baseline"]},
"parameter_overrides": {
"railiance-infra.bootstrap-host": {
"target_hosts": ["tenant-host"],
"wireguard_enabled": True,
}
},
}
issues, _ = validator.compose_scenario([declaration], scenario)
assert any("tenant authority cannot override platform_only" in msg for msg in error_messages(issues))
def test_required_parameter_without_override_fails(tmp_path):
declaration = declaration_from(valid_declaration(), tmp_path)
scenario = {
"id": "scenario:missing-required-parameter",
"authority": "platform",
"requires": {"capabilities": ["s1.os-baseline"]},
"parameter_overrides": {},
}
issues, _ = validator.compose_scenario([declaration], scenario)
assert any("required parameter has no default or override" in msg for msg in error_messages(issues))

View File

@@ -4,13 +4,13 @@ type: workplan
title: "Playbook Capability Contract"
domain: netkingdom
repo: net-kingdom
status: proposed
status: finished
owner: worsch
topic_slug: netkingdom
planning_priority: high
planning_order: 13
created: "2026-05-21"
updated: "2026-05-21"
updated: "2026-05-22"
depends_on:
- NK-WP-0006
state_hub_workstream_id: 32a54d8e-8633-42a6-8ec1-104842c581c1
@@ -82,7 +82,7 @@ Out of scope:
```task
id: NK-WP-0013-T1
state_hub_task_id: d40f8b29-e983-4d52-bc1f-5f1c51709e7d
status: todo
status: done
priority: high
```
@@ -96,7 +96,7 @@ co-design. Define contract **versioning and breaking-change governance**.
```task
id: NK-WP-0013-T2
state_hub_task_id: ece4b5b1-e1c2-449d-b0f4-83b7010bc838
status: todo
status: done
priority: high
```
@@ -110,7 +110,7 @@ same capability are comparable.
```task
id: NK-WP-0013-T3
state_hub_task_id: c956f4a8-b9fa-44ab-8174-31999b98e3b1
status: todo
status: done
priority: high
```
@@ -124,7 +124,7 @@ adequate" safe rather than guesswork.
```task
id: NK-WP-0013-T4
state_hub_task_id: e7de05a6-528a-4213-b6db-2c2e90353996
status: todo
status: done
priority: high
```
@@ -137,7 +137,7 @@ responsibility map and sequence for a scenario from the declarations.
```task
id: NK-WP-0013-T5
state_hub_task_id: 05a2ff7d-86c4-4de9-9ea8-39a9ad5352a8
status: todo
status: done
priority: high
```
@@ -151,7 +151,7 @@ the IAM Profile conformance check, NK-WP-0012-T5).
```task
id: NK-WP-0013-T6
state_hub_task_id: 769ed490-c091-41c1-b2e2-e8e378470b6b
status: todo
status: done
priority: medium
```
@@ -172,6 +172,23 @@ the rest. Cross-repo coordination item for the Railiance domain.
NetKingdom composes and parametrizes it from that declaration alone.
- Contract versioning and breaking-change governance is documented.
## Completion Notes
- ADR: `docs/adr/ADR-0012-playbook-capability-contract-ownership.md`
- Canonical contract:
`canon/standards/playbook-capability-contract_v0.1.md`
- Machine-readable schema:
`canon/schemas/playbook-capability-declaration_v0.1.schema.json`
- Validator and composition demo:
`tools/playbook-capability-contract/playbook_contract_validator.py`
- Reference Railiance declaration:
`../railiance-infra/capabilities/playbooks/railiance-infra.bootstrap-host.yaml`
- Sample scenario:
`examples/playbook-capability-contract/scenario-s1-host-bootstrap.yaml`
- Fixture tests cover valid declarations, controlled vocabulary failures,
forbidden tenant overrides, missing required parameters, and successful
selection/parameter composition.
## Dependencies & Sequencing
- **Realizes** the playbook-contract dependency from ADR-0007's
@@ -184,14 +201,14 @@ the rest. Cross-repo coordination item for the Railiance domain.
- Parallels NK-WP-0012: same consumer-defines-contract pattern, same
conformance-check shape, applied to orchestration instead of identity.
## Open Questions
## Resolved Questions
- Contract format and home: a net-kingdom canon standard plus a
machine-readable schema (e.g. JSON/YAML schema) the catalog validates
against?
- Catalog mechanism: a file convention in each playbook repo that NetKingdom
aggregates, or a published registry?
- How parameter sensitivity interacts with tenant boundaries (which
parameters a tenant-scoped scenario may set vs. platform-only).
- Whether the conformance validator is a standalone net-kingdom tool or a
shared library, mirroring the same open question in NK-WP-0012.
- Contract format and home: NetKingdom canon standard plus a
machine-readable JSON schema and standalone validator.
- Catalog mechanism: v0.1 uses file convention
`capabilities/playbooks/*.yaml`; a registry can be layered on later.
- Parameter sensitivity: tenant scenarios cannot override
`platform_only`, `forbidden`, `playbook_default`,
`security_sensitive`, or `secret_reference` parameters.
- Validator form: standalone NetKingdom tool for v0.1, mirroring
NK-WP-0012's executable-contract pattern.