From 8e720dd78ae1d45c61dab2f9c747d7995735cbb7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 22 May 2026 14:49:25 +0200 Subject: [PATCH] Implement NK-WP-0013 playbook capability contract --- Makefile | 5 +- ...ok-capability-declaration_v0.1.schema.json | 56 ++ .../playbook-capability-contract_v0.1.md | 299 ++++++++++ ...-playbook-capability-contract-ownership.md | 112 ++++ ...platform-identity-security-architecture.md | 7 + docs/responsibility-map.md | 4 + .../playbook-capability-contract/README.md | 12 + .../scenario-s1-host-bootstrap.yaml | 10 + tools/playbook-capability-contract/README.md | 28 + .../playbook_contract_validator.py | 557 ++++++++++++++++++ .../tests/test_playbook_contract_validator.py | 198 +++++++ ...NK-WP-0013-playbook-capability-contract.md | 53 +- 12 files changed, 1322 insertions(+), 19 deletions(-) create mode 100644 canon/schemas/playbook-capability-declaration_v0.1.schema.json create mode 100644 canon/standards/playbook-capability-contract_v0.1.md create mode 100644 docs/adr/ADR-0012-playbook-capability-contract-ownership.md create mode 100644 examples/playbook-capability-contract/README.md create mode 100644 examples/playbook-capability-contract/scenario-s1-host-bootstrap.yaml create mode 100644 tools/playbook-capability-contract/README.md create mode 100644 tools/playbook-capability-contract/playbook_contract_validator.py create mode 100644 tools/playbook-capability-contract/tests/test_playbook_contract_validator.py diff --git a/Makefile b/Makefile index 066b101..aa512d6 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/canon/schemas/playbook-capability-declaration_v0.1.schema.json b/canon/schemas/playbook-capability-declaration_v0.1.schema.json new file mode 100644 index 0000000..7099ea0 --- /dev/null +++ b/canon/schemas/playbook-capability-declaration_v0.1.schema.json @@ -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"} + } + } + } +} diff --git a/canon/standards/playbook-capability-contract_v0.1.md b/canon/standards/playbook-capability-contract_v0.1.md new file mode 100644 index 0000000..0993d71 --- /dev/null +++ b/canon/standards/playbook-capability-contract_v0.1.md @@ -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/.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 +``` diff --git a/docs/adr/ADR-0012-playbook-capability-contract-ownership.md b/docs/adr/ADR-0012-playbook-capability-contract-ownership.md new file mode 100644 index 0000000..f1cc3e9 --- /dev/null +++ b/docs/adr/ADR-0012-playbook-capability-contract-ownership.md @@ -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. diff --git a/docs/platform-identity-security-architecture.md b/docs/platform-identity-security-architecture.md index 4a6f9e3..5bee838 100644 --- a/docs/platform-identity-security-architecture.md +++ b/docs/platform-identity-security-architecture.md @@ -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 diff --git a/docs/responsibility-map.md b/docs/responsibility-map.md index 6ee2400..9194f54 100644 --- a/docs/responsibility-map.md +++ b/docs/responsibility-map.md @@ -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. --- diff --git a/examples/playbook-capability-contract/README.md b/examples/playbook-capability-contract/README.md new file mode 100644 index 0000000..5fe75d9 --- /dev/null +++ b/examples/playbook-capability-contract/README.md @@ -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 +``` diff --git a/examples/playbook-capability-contract/scenario-s1-host-bootstrap.yaml b/examples/playbook-capability-contract/scenario-s1-host-bootstrap.yaml new file mode 100644 index 0000000..3d287a4 --- /dev/null +++ b/examples/playbook-capability-contract/scenario-s1-host-bootstrap.yaml @@ -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 diff --git a/tools/playbook-capability-contract/README.md b/tools/playbook-capability-contract/README.md new file mode 100644 index 0000000..5c80668 --- /dev/null +++ b/tools/playbook-capability-contract/README.md @@ -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 +``` diff --git a/tools/playbook-capability-contract/playbook_contract_validator.py b/tools/playbook-capability-contract/playbook_contract_validator.py new file mode 100644 index 0000000..2c63031 --- /dev/null +++ b/tools/playbook-capability-contract/playbook_contract_validator.py @@ -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()) diff --git a/tools/playbook-capability-contract/tests/test_playbook_contract_validator.py b/tools/playbook-capability-contract/tests/test_playbook_contract_validator.py new file mode 100644 index 0000000..31dde94 --- /dev/null +++ b/tools/playbook-capability-contract/tests/test_playbook_contract_validator.py @@ -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)) diff --git a/workplans/NK-WP-0013-playbook-capability-contract.md b/workplans/NK-WP-0013-playbook-capability-contract.md index bc6d7df..29a7cb9 100644 --- a/workplans/NK-WP-0013-playbook-capability-contract.md +++ b/workplans/NK-WP-0013-playbook-capability-contract.md @@ -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.