generated from coulomb/repo-seed
Implement NK-WP-0013 playbook capability contract
This commit is contained in:
5
Makefile
5
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
|
iam-profile-conformance-test: ## Run IAM Profile v0.2 conformance fixture tests
|
||||||
python3 -m pytest tools/iam-profile-conformance/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 \
|
.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 \
|
check-secrets creds-init creds-generate creds-bundle creds-apply creds-verify \
|
||||||
creds-status creds-rotate \
|
creds-status creds-rotate \
|
||||||
creds-agent-init creds-agent-status creds-emergency-reprint \
|
creds-agent-init creds-agent-status creds-emergency-reprint \
|
||||||
iam-profile-conformance-test
|
iam-profile-conformance-test playbook-contract-test
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
299
canon/standards/playbook-capability-contract_v0.1.md
Normal file
299
canon/standards/playbook-capability-contract_v0.1.md
Normal 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
|
||||||
|
```
|
||||||
112
docs/adr/ADR-0012-playbook-capability-contract-ownership.md
Normal file
112
docs/adr/ADR-0012-playbook-capability-contract-ownership.md
Normal 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.
|
||||||
@@ -326,6 +326,13 @@ ADR-0007 records the current decision: keep orchestration in Railiance
|
|||||||
playbooks for now, with NetKingdom defining the trust-state model,
|
playbooks for now, with NetKingdom defining the trust-state model,
|
||||||
readiness checks, OpenBao boundaries, and security semantics.
|
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 And Topaz Implications
|
||||||
|
|
||||||
flex-auth work must preserve the recursive boundary between platform
|
flex-auth work must preserve the recursive boundary between platform
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ NetKingdom's role over orchestrated repos is **meta-orchestration**
|
|||||||
services/playbooks a scenario needs, (2) **parametrizes** them where
|
services/playbooks a scenario needs, (2) **parametrizes** them where
|
||||||
tuning is warranted, and (3) holds **responsibility** for the resources
|
tuning is warranted, and (3) holds **responsibility** for the resources
|
||||||
and the security boundaries — leaving execution mechanics to the repo.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
12
examples/playbook-capability-contract/README.md
Normal file
12
examples/playbook-capability-contract/README.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
28
tools/playbook-capability-contract/README.md
Normal file
28
tools/playbook-capability-contract/README.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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())
|
||||||
@@ -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))
|
||||||
@@ -4,13 +4,13 @@ type: workplan
|
|||||||
title: "Playbook Capability Contract"
|
title: "Playbook Capability Contract"
|
||||||
domain: netkingdom
|
domain: netkingdom
|
||||||
repo: net-kingdom
|
repo: net-kingdom
|
||||||
status: proposed
|
status: finished
|
||||||
owner: worsch
|
owner: worsch
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
planning_priority: high
|
planning_priority: high
|
||||||
planning_order: 13
|
planning_order: 13
|
||||||
created: "2026-05-21"
|
created: "2026-05-21"
|
||||||
updated: "2026-05-21"
|
updated: "2026-05-22"
|
||||||
depends_on:
|
depends_on:
|
||||||
- NK-WP-0006
|
- NK-WP-0006
|
||||||
state_hub_workstream_id: 32a54d8e-8633-42a6-8ec1-104842c581c1
|
state_hub_workstream_id: 32a54d8e-8633-42a6-8ec1-104842c581c1
|
||||||
@@ -82,7 +82,7 @@ Out of scope:
|
|||||||
```task
|
```task
|
||||||
id: NK-WP-0013-T1
|
id: NK-WP-0013-T1
|
||||||
state_hub_task_id: d40f8b29-e983-4d52-bc1f-5f1c51709e7d
|
state_hub_task_id: d40f8b29-e983-4d52-bc1f-5f1c51709e7d
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ co-design. Define contract **versioning and breaking-change governance**.
|
|||||||
```task
|
```task
|
||||||
id: NK-WP-0013-T2
|
id: NK-WP-0013-T2
|
||||||
state_hub_task_id: ece4b5b1-e1c2-449d-b0f4-83b7010bc838
|
state_hub_task_id: ece4b5b1-e1c2-449d-b0f4-83b7010bc838
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ same capability are comparable.
|
|||||||
```task
|
```task
|
||||||
id: NK-WP-0013-T3
|
id: NK-WP-0013-T3
|
||||||
state_hub_task_id: c956f4a8-b9fa-44ab-8174-31999b98e3b1
|
state_hub_task_id: c956f4a8-b9fa-44ab-8174-31999b98e3b1
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ adequate" safe rather than guesswork.
|
|||||||
```task
|
```task
|
||||||
id: NK-WP-0013-T4
|
id: NK-WP-0013-T4
|
||||||
state_hub_task_id: e7de05a6-528a-4213-b6db-2c2e90353996
|
state_hub_task_id: e7de05a6-528a-4213-b6db-2c2e90353996
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ responsibility map and sequence for a scenario from the declarations.
|
|||||||
```task
|
```task
|
||||||
id: NK-WP-0013-T5
|
id: NK-WP-0013-T5
|
||||||
state_hub_task_id: 05a2ff7d-86c4-4de9-9ea8-39a9ad5352a8
|
state_hub_task_id: 05a2ff7d-86c4-4de9-9ea8-39a9ad5352a8
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ the IAM Profile conformance check, NK-WP-0012-T5).
|
|||||||
```task
|
```task
|
||||||
id: NK-WP-0013-T6
|
id: NK-WP-0013-T6
|
||||||
state_hub_task_id: 769ed490-c091-41c1-b2e2-e8e378470b6b
|
state_hub_task_id: 769ed490-c091-41c1-b2e2-e8e378470b6b
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
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.
|
NetKingdom composes and parametrizes it from that declaration alone.
|
||||||
- Contract versioning and breaking-change governance is documented.
|
- 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
|
## Dependencies & Sequencing
|
||||||
|
|
||||||
- **Realizes** the playbook-contract dependency from ADR-0007's
|
- **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
|
- Parallels NK-WP-0012: same consumer-defines-contract pattern, same
|
||||||
conformance-check shape, applied to orchestration instead of identity.
|
conformance-check shape, applied to orchestration instead of identity.
|
||||||
|
|
||||||
## Open Questions
|
## Resolved Questions
|
||||||
|
|
||||||
- Contract format and home: a net-kingdom canon standard plus a
|
- Contract format and home: NetKingdom canon standard plus a
|
||||||
machine-readable schema (e.g. JSON/YAML schema) the catalog validates
|
machine-readable JSON schema and standalone validator.
|
||||||
against?
|
- Catalog mechanism: v0.1 uses file convention
|
||||||
- Catalog mechanism: a file convention in each playbook repo that NetKingdom
|
`capabilities/playbooks/*.yaml`; a registry can be layered on later.
|
||||||
aggregates, or a published registry?
|
- Parameter sensitivity: tenant scenarios cannot override
|
||||||
- How parameter sensitivity interacts with tenant boundaries (which
|
`platform_only`, `forbidden`, `playbook_default`,
|
||||||
parameters a tenant-scoped scenario may set vs. platform-only).
|
`security_sensitive`, or `secret_reference` parameters.
|
||||||
- Whether the conformance validator is a standalone net-kingdom tool or a
|
- Validator form: standalone NetKingdom tool for v0.1, mirroring
|
||||||
shared library, mirroring the same open question in NK-WP-0012.
|
NK-WP-0012's executable-contract pattern.
|
||||||
|
|||||||
Reference in New Issue
Block a user