From c3ce41a5dbfd519582de7c7f6b77764525f31932 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:43:26 -0600 Subject: [PATCH 1/2] chore: back-merge v0.23.0 into dev (#817) * chore(main): release 0.19.0 * chore(main): release 0.19.1 * chore(main): release 0.20.0 * chore(main): release 0.21.0 * chore(main): release 0.22.0 * chore(main): release 0.23.0 --------- Co-authored-by: Brad Edwards Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 15 +++++++++++++++ implementations/python/pyproject.toml | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 788b0fa76..97bce112d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.22.0" + ".": "0.23.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index feb8f427d..424ec12f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 PRs do **not** edit this file directly. release-please maintains it from the Conventional Commit history on `main` (#684). +## [0.23.0](https://github.com/Brad-Edwards/aces/compare/v0.22.0...v0.23.0) (2026-07-17) + + +### Features + +* add DSL evaluation evidence gate ([#795](https://github.com/Brad-Edwards/aces/issues/795)) ([03c3101](https://github.com/Brad-Edwards/aces/commit/03c3101d48c359d96604d8227059af5c12c513e2)) +* add stateful realization resources ([#782](https://github.com/Brad-Edwards/aces/issues/782)) ([5c47235](https://github.com/Brad-Edwards/aces/commit/5c472351c2d5ce15ca07fb03522f4a5846b6a63d)) +* **sdl:** add participant interactive access ([#807](https://github.com/Brad-Edwards/aces/issues/807)) ([7e17b97](https://github.com/Brad-Edwards/aces/commit/7e17b97ce0e47ac11bfee278ccb872f6a57d2bf8)) + + +### Documentation + +* design deterministic scenario trial realization ([#804](https://github.com/Brad-Edwards/aces/issues/804)) ([60e9ad6](https://github.com/Brad-Edwards/aces/commit/60e9ad6b6d836b88283301f472f7a830e4edc51a)) +* design participant information-flow control adoption ([#806](https://github.com/Brad-Edwards/aces/issues/806)) ([0c87adf](https://github.com/Brad-Edwards/aces/commit/0c87adf02105fe4269d9b77aacce8e86bdf968dc)) + ## [0.22.0](https://github.com/Brad-Edwards/aces/compare/v0.21.0...v0.22.0) (2026-07-15) diff --git a/implementations/python/pyproject.toml b/implementations/python/pyproject.toml index e1f62c97a..7fad9a82d 100644 --- a/implementations/python/pyproject.toml +++ b/implementations/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "aces-sdl" -version = "0.22.0" +version = "0.23.0" description = "Backend-agnostic cyber range scenario description language and runtime." dynamic = ["readme"] requires-python = ">=3.11" From 8bf12eeb0d43e6e23a86abb012d7b31b076bd69a Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Thu, 16 Jul 2026 20:15:50 -0700 Subject: [PATCH 2/2] fix: enforce stateful resource admission (#816) * Enforce stateful resource admission * Fix SonarCloud findings (cycle 1) --- contracts/schema-publication-manifest.json | 18 +- .../instantiated-scenario-snapshot-v1.json | 70 ++++- .../schemas/sdl/instantiated-scenario-v1.json | 70 ++++- .../schemas/sdl/sdl-authoring-input-v1.json | 68 ++++- ...tateful-realization-resources-preflight.md | 200 ++++++++++++++ .../packages/aces_contracts/apparatus.py | 3 + .../packages/aces_contracts/contracts.py | 35 +++ .../aces_processor/semantics/realization.py | 8 +- .../packages/aces_runtime/control_plane.py | 81 +++++- .../python/packages/aces_sdl/_declarations.py | 2 + .../aces_sdl/_stateful_resource_references.py | 120 +++++++-- .../python/packages/aces_sdl/composition.py | 40 ++- .../python/packages/aces_sdl/scenario.py | 10 - .../packages/aces_sdl/stateful_resources.py | 54 +++- .../packages/aces_sdl/validator/_core.py | 1 + .../packages/aces_sdl/validator/_sections.py | 10 + .../tests/test_runtime_control_plane.py | 125 +++++++++ .../python/tests/test_runtime_planner.py | 18 ++ .../test_stateful_realization_resources.py | 249 +++++++++++++++++- specs/sdl/stateful-resources.md | 15 +- 20 files changed, 1097 insertions(+), 100 deletions(-) create mode 100644 docs/decisions/issue-780-dsl-435-stateful-realization-resources-preflight.md diff --git a/contracts/schema-publication-manifest.json b/contracts/schema-publication-manifest.json index 692186f8f..4e2fa7efb 100644 --- a/contracts/schema-publication-manifest.json +++ b/contracts/schema-publication-manifest.json @@ -190,20 +190,20 @@ "contract_id": "instantiated-scenario-snapshot-v1", "schema_path": "contracts/schemas/sdl/instantiated-scenario-snapshot-v1.json", "stability": "draft", - "content_hash": "46365e6661556673f124fc7b04383c451cf25396d551c564171bb009fa515dc9", + "content_hash": "93b4355e8633fb76ed35851a6cd2573ff09f041b2ae089fe69f55611a6589886", "last_change": { - "summary": "Combined typed stateful realization resources (issue #780) with participant interactive-access declarations (issue #805).", - "content_hash": "46365e6661556673f124fc7b04383c451cf25396d551c564171bb009fa515dc9" + "summary": "Published unique-item constraints for stateful resource outputs, consumers, and dependency lists (issue #780).", + "content_hash": "93b4355e8633fb76ed35851a6cd2573ff09f041b2ae089fe69f55611a6589886" } }, { "contract_id": "instantiated-scenario-v1", "schema_path": "contracts/schemas/sdl/instantiated-scenario-v1.json", "stability": "draft", - "content_hash": "96ece657528da43f97750f56d8e72d1c9f2e504f09d8fa673aa9154db83a59be", + "content_hash": "bfb1b79c11861a57accc2614eba9760cbcc19a9b6ce34dabf64404048eee0e8b", "last_change": { - "summary": "Combined typed stateful realization resources (issue #780) with participant interactive-access declarations (issue #805).", - "content_hash": "96ece657528da43f97750f56d8e72d1c9f2e504f09d8fa673aa9154db83a59be" + "summary": "Published unique-item constraints for stateful resource outputs, consumers, and dependency lists (issue #780).", + "content_hash": "bfb1b79c11861a57accc2614eba9760cbcc19a9b6ce34dabf64404048eee0e8b" } }, { @@ -456,10 +456,10 @@ "contract_id": "sdl-authoring-input-v1", "schema_path": "contracts/schemas/sdl/sdl-authoring-input-v1.json", "stability": "draft", - "content_hash": "b192b76721340ea032d72bba7060c4ddf956e3e88edeca33cf17c74149b77891", + "content_hash": "4869ea7e7e26e63172b5799f0f6ee46c79cf802e426f66846a7f0471cac145a7", "last_change": { - "summary": "Combined typed stateful realization resources (issue #780) with participant interactive-access declarations (issue #805).", - "content_hash": "b192b76721340ea032d72bba7060c4ddf956e3e88edeca33cf17c74149b77891" + "summary": "Published unique-item constraints for stateful resource outputs, consumers, and dependency lists (issue #780).", + "content_hash": "4869ea7e7e26e63172b5799f0f6ee46c79cf802e426f66846a7f0471cac145a7" } }, { diff --git a/contracts/schemas/sdl/instantiated-scenario-snapshot-v1.json b/contracts/schemas/sdl/instantiated-scenario-snapshot-v1.json index 3ff54012c..73ac876f4 100644 --- a/contracts/schemas/sdl/instantiated-scenario-snapshot-v1.json +++ b/contracts/schemas/sdl/instantiated-scenario-snapshot-v1.json @@ -3488,7 +3488,8 @@ }, "minItems": 1, "title": "Consumers", - "type": "array" + "type": "array", + "uniqueItems": true }, "generator": { "$ref": "#/$defs/GeneratedArtifactKind" @@ -3504,7 +3505,8 @@ "type": "string" }, "title": "Ordering Dependencies", - "type": "array" + "type": "array", + "uniqueItems": true }, "outputs": { "items": { @@ -3512,7 +3514,8 @@ }, "minItems": 1, "title": "Outputs", - "type": "array" + "type": "array", + "uniqueItems": true }, "provenance": { "minLength": 1, @@ -3530,7 +3533,8 @@ "type": "string" }, "title": "Refresh Dependencies", - "type": "array" + "type": "array", + "uniqueItems": true } }, "required": [ @@ -6984,7 +6988,8 @@ }, "minItems": 1, "title": "Consumers", - "type": "array" + "type": "array", + "uniqueItems": true }, "lifecycle": { "$ref": "#/$defs/VolumeLifecycle" @@ -6997,7 +7002,8 @@ "type": "string" }, "title": "Ordering Dependencies", - "type": "array" + "type": "array", + "uniqueItems": true }, "refresh_dependencies": { "items": { @@ -7007,7 +7013,8 @@ "type": "string" }, "title": "Refresh Dependencies", - "type": "array" + "type": "array", + "uniqueItems": true } }, "required": [ @@ -23351,5 +23358,52 @@ ], "title": "SDL Instantiated Scenario Snapshot v1", "type": "object", - "x-aces-document-phase": "canonical-instantiated-snapshot" + "x-aces-document-phase": "canonical-instantiated-snapshot", + "x-aces-invariants": [ + { + "description": "Generated artifact output names and paths, consumers, and dependency entries must be unique, and generated artifact consumers must be read-only.", + "id": "stateful-generated-artifact-semantics", + "inputs": [ + { + "contract_id": "instantiated-scenario-snapshot-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "aces_sdl.stateful_resources.GeneratedArtifact._unique_outputs_and_consumers" + }, + { + "description": "Persistent volume consumers and dependency entries must be unique and access cardinality must match the declared portable access mode.", + "id": "stateful-persistent-volume-semantics", + "inputs": [ + { + "contract_id": "instantiated-scenario-snapshot-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "aces_sdl.stateful_resources.PersistentVolume._unique_consumers" + }, + { + "description": "Stateful resource consumers and dependencies must resolve unambiguously, use the POSIX v1 path dialect, and must not collide on a consumer node mount destination.", + "id": "stateful-cross-resource-semantics", + "inputs": [ + { + "contract_id": "instantiated-scenario-snapshot-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "aces_sdl._stateful_resource_references.stateful_resource_reference_errors" + } + ], + "x-aces-semantic-profile": { + "contract_id": "instantiated-scenario-snapshot-v1", + "entry_schema_contract_id": "aces-semantic-invariants-v1", + "entry_schema_pointer": "#/$defs/AcesSemanticInvariantEntryModel", + "id": "aces-semantic-invariants-v1", + "keyword": "x-aces-invariants", + "required": true, + "uri": "https://aces.dev/schemas/semantic-invariants/v1" + } } diff --git a/contracts/schemas/sdl/instantiated-scenario-v1.json b/contracts/schemas/sdl/instantiated-scenario-v1.json index 41e8c955c..3c7238102 100644 --- a/contracts/schemas/sdl/instantiated-scenario-v1.json +++ b/contracts/schemas/sdl/instantiated-scenario-v1.json @@ -3488,7 +3488,8 @@ }, "minItems": 1, "title": "Consumers", - "type": "array" + "type": "array", + "uniqueItems": true }, "generator": { "$ref": "#/$defs/GeneratedArtifactKind" @@ -3504,7 +3505,8 @@ "type": "string" }, "title": "Ordering Dependencies", - "type": "array" + "type": "array", + "uniqueItems": true }, "outputs": { "items": { @@ -3512,7 +3514,8 @@ }, "minItems": 1, "title": "Outputs", - "type": "array" + "type": "array", + "uniqueItems": true }, "provenance": { "minLength": 1, @@ -3530,7 +3533,8 @@ "type": "string" }, "title": "Refresh Dependencies", - "type": "array" + "type": "array", + "uniqueItems": true } }, "required": [ @@ -6485,7 +6489,8 @@ }, "minItems": 1, "title": "Consumers", - "type": "array" + "type": "array", + "uniqueItems": true }, "lifecycle": { "$ref": "#/$defs/VolumeLifecycle" @@ -6498,7 +6503,8 @@ "type": "string" }, "title": "Ordering Dependencies", - "type": "array" + "type": "array", + "uniqueItems": true }, "refresh_dependencies": { "items": { @@ -6508,7 +6514,8 @@ "type": "string" }, "title": "Refresh Dependencies", - "type": "array" + "type": "array", + "uniqueItems": true } }, "required": [ @@ -23330,5 +23337,52 @@ "title": "SDL Instantiated Scenario v1", "type": "object", "x-aces-authored-identity-profile": "aces-sdl-semantic/v1", - "x-aces-document-phase": "instantiated-scenario" + "x-aces-document-phase": "instantiated-scenario", + "x-aces-invariants": [ + { + "description": "Generated artifact output names and paths, consumers, and dependency entries must be unique, and generated artifact consumers must be read-only.", + "id": "stateful-generated-artifact-semantics", + "inputs": [ + { + "contract_id": "instantiated-scenario-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "aces_sdl.stateful_resources.GeneratedArtifact._unique_outputs_and_consumers" + }, + { + "description": "Persistent volume consumers and dependency entries must be unique and access cardinality must match the declared portable access mode.", + "id": "stateful-persistent-volume-semantics", + "inputs": [ + { + "contract_id": "instantiated-scenario-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "aces_sdl.stateful_resources.PersistentVolume._unique_consumers" + }, + { + "description": "Stateful resource consumers and dependencies must resolve unambiguously, use the POSIX v1 path dialect, and must not collide on a consumer node mount destination.", + "id": "stateful-cross-resource-semantics", + "inputs": [ + { + "contract_id": "instantiated-scenario-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "aces_sdl._stateful_resource_references.stateful_resource_reference_errors" + } + ], + "x-aces-semantic-profile": { + "contract_id": "instantiated-scenario-v1", + "entry_schema_contract_id": "aces-semantic-invariants-v1", + "entry_schema_pointer": "#/$defs/AcesSemanticInvariantEntryModel", + "id": "aces-semantic-invariants-v1", + "keyword": "x-aces-invariants", + "required": true, + "uri": "https://aces.dev/schemas/semantic-invariants/v1" + } } diff --git a/contracts/schemas/sdl/sdl-authoring-input-v1.json b/contracts/schemas/sdl/sdl-authoring-input-v1.json index 9ccedce3c..e838f1e69 100644 --- a/contracts/schemas/sdl/sdl-authoring-input-v1.json +++ b/contracts/schemas/sdl/sdl-authoring-input-v1.json @@ -2701,7 +2701,8 @@ }, "minItems": 1, "title": "Consumers", - "type": "array" + "type": "array", + "uniqueItems": true }, "generator": { "$ref": "#/$defs/GeneratedArtifactKind" @@ -2714,7 +2715,8 @@ "type": "string" }, "title": "Ordering Dependencies", - "type": "array" + "type": "array", + "uniqueItems": true }, "outputs": { "items": { @@ -2722,7 +2724,8 @@ }, "minItems": 1, "title": "Outputs", - "type": "array" + "type": "array", + "uniqueItems": true }, "provenance": { "minLength": 1, @@ -2734,7 +2737,8 @@ "type": "string" }, "title": "Refresh Dependencies", - "type": "array" + "type": "array", + "uniqueItems": true } }, "required": [ @@ -5151,7 +5155,8 @@ }, "minItems": 1, "title": "Consumers", - "type": "array" + "type": "array", + "uniqueItems": true }, "lifecycle": { "$ref": "#/$defs/VolumeLifecycle" @@ -5161,14 +5166,16 @@ "type": "string" }, "title": "Ordering Dependencies", - "type": "array" + "type": "array", + "uniqueItems": true }, "refresh_dependencies": { "items": { "type": "string" }, "title": "Refresh Dependencies", - "type": "array" + "type": "array", + "uniqueItems": true } }, "required": [ @@ -18673,6 +18680,53 @@ "title": "SDL Normalized Authoring Object v1", "type": "object", "x-aces-document-phase": "normalized-authoring-object", + "x-aces-invariants": [ + { + "description": "Generated artifact output names and paths, consumers, and dependency entries must be unique, and generated artifact consumers must be read-only.", + "id": "stateful-generated-artifact-semantics", + "inputs": [ + { + "contract_id": "sdl-authoring-input-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "aces_sdl.stateful_resources.GeneratedArtifact._unique_outputs_and_consumers" + }, + { + "description": "Persistent volume consumers and dependency entries must be unique and access cardinality must match the declared portable access mode.", + "id": "stateful-persistent-volume-semantics", + "inputs": [ + { + "contract_id": "sdl-authoring-input-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "aces_sdl.stateful_resources.PersistentVolume._unique_consumers" + }, + { + "description": "Stateful resource consumers and dependencies must resolve unambiguously, use the POSIX v1 path dialect, and must not collide on a consumer node mount destination.", + "id": "stateful-cross-resource-semantics", + "inputs": [ + { + "contract_id": "sdl-authoring-input-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "aces_sdl._stateful_resource_references.stateful_resource_reference_errors" + } + ], + "x-aces-semantic-profile": { + "contract_id": "sdl-authoring-input-v1", + "entry_schema_contract_id": "aces-semantic-invariants-v1", + "entry_schema_pointer": "#/$defs/AcesSemanticInvariantEntryModel", + "id": "aces-semantic-invariants-v1", + "keyword": "x-aces-invariants", + "required": true, + "uri": "https://aces.dev/schemas/semantic-invariants/v1" + }, "x-aces-source-profile": "sdl-yaml/v1", "x-aces-validates-raw-source": false } diff --git a/docs/decisions/issue-780-dsl-435-stateful-realization-resources-preflight.md b/docs/decisions/issue-780-dsl-435-stateful-realization-resources-preflight.md new file mode 100644 index 000000000..7df5f3289 --- /dev/null +++ b/docs/decisions/issue-780-dsl-435-stateful-realization-resources-preflight.md @@ -0,0 +1,200 @@ +# Issue #780 / DSL-435 — Stateful Realization Resources Preflight + +Date: 2026-07-16 + +This note records architecture guardrails for DSL-435. It does not implement +the language, processor, provisioner, or a backend. + +No new ADR is required. ADR-004 owns compile/plan/execute and dependency +semantics; ADR-009 and ADR-061 own the normative schema boundary; ADR-036 owns +package direction; ADR-056/057 own secret and redaction distinctions; ADR-070 +owns realization honesty; ADR-072 owns validation-strength language; ADR-075 +owns release/version governance; and ADR-076 owns authored identifiers and +compiled addresses. + +## Decisions and boundaries + +`generated_artifacts` and `persistent_volumes` are authored desired state. +They are not `Content` placements, observed `RuntimeMount` records, filesystem +inventory, generic metadata, associated-artifact manifests, or backend-native +fragments. Those incumbents may share narrow lexical helpers, but none carries +the lifecycle, access, dependency, sensitivity, and realization meaning of +these declarations. + +Each declaration has one portable id and one compiler-owned address: +`provision.generated-artifact.` or +`provision.persistent-volume.`. Mutable fields never enter identity. The +same typed resource flows through `ScenarioContent`, `InstantiatedScenario`, a +`ResolvedResource` specialization in `RuntimeModel`, `PlannedResource`, +`ProvisionOp`, and `SnapshotEntry`; no parallel DTO, metadata side channel, or +backend-specific schema is permitted. + +Three provenance concepts remain distinct: + +- generator provenance is a non-secret, inert reference to the declared recipe + or source and is not proof that bytes were generated correctly; +- SEM-218 explicitness provenance is carried by the existing + `CompiledRealizationRequirement` and `RealizationProvenanceEntry`; and +- operational evidence that a backend generated, attached, or retained state + belongs to existing realization observation/conformance surfaces, not the SDL + declaration or snapshot payload alone. + +Output bytes, private keys, credentials, rendered configuration, and backend +handles are unrepresentable in this contract. `secret` is a sensitivity label, +not a field that authorizes a raw secret value. A mixed-sensitivity artifact is +consumed as one resource; implementations must not infer per-output filtering. +Authors needing different audiences split the outputs into separate resources. + +`ordering_dependencies` and `refresh_dependencies` retain the meanings fixed by +ADR-004. The processor must use the existing typed dependency graph, +topological ordering, reverse delete ordering, and refresh propagation. A +consumer reference is not silently an ordering edge, and lexical address order +is never a lifecycle guarantee. If consumer attachment imposes an order, that +order must be stated once in the normative semantics and lowered explicitly. + +Capability admission has two independent obligations. The provisioner must +declare support for the portable resource kind, and the existing SEM-218 gate +must confirm exact, non-approximating realization support for the complete +resource payload. Neither a generic exactness claim nor a kind-support flag can +substitute for the other. + +## Canonical concerns to reuse + +- **Source admission:** `load_sdl_yaml()` and its source/alias limits, + `SDLModel(extra="forbid")`, `PortableIdentifier`/`QualifiedName`, the + `Scenario` -> `ExpandedScenario` -> `InstantiatedScenario` phase boundary, + and unresolved-variable admission. +- **References and composition:** the SDL section/reference catalogs, + `_mapping_scopes.HASHMAP_SECTIONS`, `_module_symbols.symbol_index()`, module + export/collision checks, `DeclarationIndex`, and `SemanticValidator`. + Section-qualified references remain authoritative when a bare name is + ambiguous; composition rewrites through section-specific symbol maps. +- **Compilation and identity:** the compiler address builders, + `ResolvedResource`, `RuntimeModel.__post_init__`, `resource_payload()`, and + `aces_contracts.addressing.require_compiled_address()`. +- **Planning:** `PLAN_RESOURCE_TYPES_BY_DOMAIN`, + `require_plan_operation_identity()`, and the functions in + `aces_processor.semantics.planner` for graph validation, stable ordering, + reconciliation, refresh, and deletion. +- **Realization honesty:** `CompiledRealizationRequirement`, + `realization_support_diagnostics()`, `realization_envelope_diagnostics()`, + `realization_disclosure()`, `BackendManifest`, and + `ProvisionerCapabilities`. +- **Runtime admission and errors:** `RuntimeManager` plan provenance checks, + `RuntimeControlPlane._submitted_plan_diagnostics()`, + `_call_backend_diagnostics()`, `_call_backend_apply()`, `Diagnostic`, + `ApplyResult`, `OperationReceipt`, and `OperationStatus`. No new exception or + logging hierarchy is warranted. +- **Persistence and observation:** `RuntimeSnapshot`, `SnapshotEntry`, and the + existing `ControlPlaneStore` atomic snapshot path. A snapshot preserves the + admitted desired payload and SEM-218 ledger; it is not proof of native volume + durability or artifact contents. +- **Contracts and workflow:** the hand-governed schemas under + `contracts/schemas/`, `schema_bundle()`, the schema publication manifest, + SDL catalog parity and lineage ledgers, canonical repo-policy checks, and + release-please. A consumer-visible feature uses the repository's `feat:` + release signal; package versions and `CHANGELOG.md` are not hand-edited. + +## Security and whole-path gates + +1. **YAML and model shape.** Safe bounded YAML loading, duplicate-key checks, + closed Pydantic models, identifier validation, collection cardinality, path + validation, and enum validation run before semantic resolution. Validators + emit bounded, source-anchored messages and must not echo generated material. +2. **Semantic and instantiation admission.** Consumer and dependency references + resolve against canonical declaration identities, not set membership or + delimiter guessing. Bare cross-section collisions fail as ambiguous. + Instantiation leaves no `${...}` token in a compiled path, provenance + reference, consumer, output, or dependency. +3. **Path and host exposure.** Output paths are canonical contained relative + paths. Mount destinations are canonical paths in the consumer guest/runtime, + never host paths. The contract must either declare a POSIX-only v1 and reject + incompatible consumers or carry an explicit closed path dialect; it must not + accept a POSIX-looking path for a Windows consumer and let the backend guess. + A materializing backend anchors writes below an owned root, rejects symlink + and traversal escapes after native normalization, uses fixed non-shell + invocation, and never places sensitive bytes in argv, environment, command + text, stdout/stderr, diagnostics, or audit events. +4. **Compiler and plan shape.** Canonical-address, unique-address, closed + resource-type, dependency-resolution, and cycle gates complete before any + backend call. The full typed spec, including lifecycle and sensitivity + metadata, survives projection without reinterpretation. +5. **Manifest and dispatch admission.** Manifest-kind support and SEM-218 exact + support are checked at every dispatch entry point, including direct + `RuntimeControlPlane`/HTTP submission. Backend `validate()` is an additional + stricter gate, not the sole authority. An error diagnostic prevents + `execute_operation()` and therefore prevents `Provisioner.apply()`. +6. **HTTP/auth surface.** The existing mutating-role authorization, target + binding, request-size guard, idempotency fingerprint, and redacted 500 + envelope remain in force. No bearer token, credential resolver, environment + binding, CLI secret option, or ambient configuration surface is introduced. +7. **Backend result and persistence.** `_call_backend_apply()` retains the + baseline snapshot on malformed results or SEM-218 approximation, reports + only structured diagnostics, and accepts realization provenance only after + exact readback. The authorized snapshot API and local store may expose the + declared metadata, so generator provenance and payload metadata must be + non-secret by construction. + +## Admission blockers and gotchas + +- Bare dependency names shared by `generated_artifacts` and + `persistent_volumes` must fail during semantic admission with a qualified-ref + diagnostic. Accepting them and raising a compiler `ValueError` later is both + too late and the wrong error surface. +- Consumer mount destinations must be unique across the combined stateful + resource set for a node unless an explicit overlay/stacking semantic is added. + Per-resource duplicate checks do not catch cross-resource collisions. +- `read_only_many` admits no writer. `read_write_once` admits at most one writer + node. Generated-artifact write access needs explicit copy/writeback and + provenance semantics; absent those semantics it must fail rather than imply + mutable exact state. +- Paths must reject non-canonical equivalents, control/NUL characters, + traversal, root destinations, and path-dialect mismatches. Do not preserve + multiple spellings such as repeated separators as different exact payloads. +- Output names and paths, consumers, and dependency entries require stable + uniqueness rules. Semantic uniqueness not expressible in ordinary JSON + Schema must use the repository's existing semantic-invariant disclosure and + model/corpus validation; schema success must not be described as semantic + success under ADR-072. +- `retain` and `ephemeral` govern delete behavior. A delete operation keeps the + prior payload so the backend can honor it. Retained native state must never be + silently adopted by name on a later create, and a lifecycle transition must + not become an implicit destructive migration. +- A capability default is false. Stubs may claim full support to exercise the + contract; production manifests claim only behavior their provisioner and + conformance evidence actually support. Do not obfuscate normative vocabulary + literals to appease secret scanners; fix or scope the scanner rule instead. +- The SDL mapping catalog, module-symbol catalog, normative section/reference + tables, generated bundle, published schemas, lineage ledger, manifest + renderer, fixtures, and plan resource-type registry are synchronized + incumbents. Do not add a third section list, reference resolver, schema, or + capability registry. + +## Extensibility seam + +The extension seam is the portable resource-kind discriminator plus governed +capability dimensions. Common identity/lifecycle/consumer/dependency fields +remain stable; a future generator kind receives a kind-scoped typed payload, +not a generic `options` map. When backends need partial support, capability +sets are keyed by the existing portable generator kind, lifecycle, access mode, +and path-dialect vocabularies rather than adding one boolean per variation or +placing provider terms in `constraints`. The planner consumes those sets through +one table-driven capability gate. + +## Non-goals and anti-patterns + +- No provider selection, storage class, host path, cloud volume id, Docker + Compose fragment, Terraform fragment, shell command, or backend handle in SDL. +- No raw generated bytes, credential value, secret store, certificate authority, + renderer, artifact registry, backup system, or volume implementation. +- No conversion of observed mounts/filesystem inventory or `Content` placement + into desired state, and no claim that desired snapshot metadata is operational + evidence. +- No storage sizing, performance tier, snapshot/backup, encryption-key, or + cross-region policy until a separate portable requirement defines it. +- No second graph engine, generic resource base beyond the existing + `ResolvedResource`, duplicate realization-support mechanism, new persistence + repository, new exception hierarchy, or feature-specific logger. +- No silent backend fallback, best-effort dropping of unsupported fields, + provider-private extension blob, ambiguity resolution by declaration order, + or reliance on incidental dictionary/lexical ordering. diff --git a/implementations/python/packages/aces_contracts/apparatus.py b/implementations/python/packages/aces_contracts/apparatus.py index 2abc71c73..295b92846 100644 --- a/implementations/python/packages/aces_contracts/apparatus.py +++ b/implementations/python/packages/aces_contracts/apparatus.py @@ -6,6 +6,9 @@ from .vocabulary import RealizationSupportMode +DECLARED_CAPABILITY_MATCH_REQUIREMENT_KIND = "declared-capability-match" +RUNTIME_REALIZATION_DOMAIN = "runtime-realization" + def _require_non_empty_strings(values: frozenset[str], *, field_name: str) -> None: if any(not value.strip() for value in values): diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index 47f03448c..0b4df0a10 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -471,6 +471,40 @@ def _attach_experiment_datetime_invariants(contract_id: str, json_schema: dict[s ) +def _attach_stateful_resource_invariants(contract_id: str, json_schema: dict[str, Any]) -> None: + if contract_id not in { + "sdl-authoring-input-v1", + "instantiated-scenario-v1", + "instantiated-scenario-snapshot-v1", + }: + return + input_contract = [{"contract_id": contract_id, "instance_path": "#"}] + _add_aces_invariant( + json_schema, + "stateful-generated-artifact-semantics", + "Generated artifact output names and paths, consumers, and dependency entries must be unique, and " + "generated artifact consumers must be read-only.", + validator="aces_sdl.stateful_resources.GeneratedArtifact._unique_outputs_and_consumers", + inputs=input_contract, + ) + _add_aces_invariant( + json_schema, + "stateful-persistent-volume-semantics", + "Persistent volume consumers and dependency entries must be unique and access cardinality must match " + "the declared portable access mode.", + validator="aces_sdl.stateful_resources.PersistentVolume._unique_consumers", + inputs=input_contract, + ) + _add_aces_invariant( + json_schema, + "stateful-cross-resource-semantics", + "Stateful resource consumers and dependencies must resolve unambiguously, use the POSIX v1 path dialect, " + "and must not collide on a consumer node mount destination.", + validator="aces_sdl._stateful_resource_references.stateful_resource_reference_errors", + inputs=input_contract, + ) + + def _validate_reported_value_status( value_status: str, value: object | None, @@ -8158,6 +8192,7 @@ def schema_bundle() -> dict[str, dict[str, Any]]: _attach_sdl_identifier_constraints(contract_id, json_schema) _attach_instantiation_invariants(contract_id, json_schema) _attach_experiment_datetime_invariants(contract_id, json_schema) + _attach_stateful_resource_invariants(contract_id, json_schema) _attach_json_schema_metadata(contract_id, json_schema) _attach_compiled_address_map_constraints(contract_id, json_schema) _attach_plan_identity_constraints(contract_id, json_schema) diff --git a/implementations/python/packages/aces_processor/semantics/realization.py b/implementations/python/packages/aces_processor/semantics/realization.py index e9f8ae14c..5d07aebfc 100644 --- a/implementations/python/packages/aces_processor/semantics/realization.py +++ b/implementations/python/packages/aces_processor/semantics/realization.py @@ -11,6 +11,10 @@ from aces_backend_protocols.capabilities import BackendManifest from aces_contracts.addressing import require_compiled_address +from aces_contracts.apparatus import ( + DECLARED_CAPABILITY_MATCH_REQUIREMENT_KIND, + RUNTIME_REALIZATION_DOMAIN, +) from aces_contracts.diagnostics import Diagnostic, Severity from aces_contracts.planning import ChangeAction, ProvisioningPlan, ProvisionOp from aces_contracts.realization_envelope import ( @@ -45,12 +49,12 @@ # The single coarse realization domain string already published by backend # manifests (see ``aces_backend_stubs.stubs``). Kept opaque per the SEM-218 # extensibility seam. -REALIZATION_DOMAIN = "runtime-realization" +REALIZATION_DOMAIN = RUNTIME_REALIZATION_DOMAIN # The exact-requirement kind every concrete (exact) author declaration maps to. # A backend that honors exact declarations lists this in # ``supported_exact_requirement_kinds``; one that cannot must reject (I2). -EXACT_REQUIREMENT_KIND = "declared-capability-match" +EXACT_REQUIREMENT_KIND = DECLARED_CAPABILITY_MATCH_REQUIREMENT_KIND # Authored realization concerns mapped onto the published constraint-kind # vocabulary, keyed by (head section, leaf field) of the classifier path. The diff --git a/implementations/python/packages/aces_runtime/control_plane.py b/implementations/python/packages/aces_runtime/control_plane.py index 6ed7292f3..f0b6ba535 100644 --- a/implementations/python/packages/aces_runtime/control_plane.py +++ b/implementations/python/packages/aces_runtime/control_plane.py @@ -11,7 +11,12 @@ from datetime import UTC, datetime from uuid import uuid4 +from aces_backend_protocols.backend_manifest import BackendManifest from aces_backend_protocols.domain_topology import domain_topology_plan_diagnostics +from aces_contracts.apparatus import ( + DECLARED_CAPABILITY_MATCH_REQUIREMENT_KIND, + RUNTIME_REALIZATION_DOMAIN, +) from aces_contracts.diagnostics import Diagnostic from aces_contracts.planning import ( EvaluationPlan, @@ -62,6 +67,18 @@ WorkflowStatus.CANCELLED, WorkflowStatus.TIMED_OUT, } +_STATEFUL_ADMISSION_BY_RESOURCE_TYPE = { + "generated-artifact": ( + "supports_generated_artifacts", + "provisioner.generated-artifacts-unsupported", + "generated artifacts", + ), + "persistent-volume": ( + "supports_persistent_volumes", + "provisioner.persistent-volumes-unsupported", + "persistent volumes", + ), +} def _utc_now() -> str: @@ -72,24 +89,64 @@ def _submitted_plan_diagnostics( plan: ProvisioningPlan | OrchestrationPlan | EvaluationPlan, domain: RuntimeDomain, snapshot: RuntimeSnapshot, - supported_domain_profiles: frozenset[str] | None = None, + manifest: BackendManifest | None = None, ) -> list[Diagnostic]: admitted = set(snapshot.entries) | {operation.address for operation in plan.operations} - diagnostic: Diagnostic | None = None + diagnostics: list[Diagnostic] = [] for operation in plan.operations: diagnostic = _submitted_operation_diagnostic(operation, domain, snapshot, admitted) if diagnostic is not None: + diagnostics.append(diagnostic) break - if diagnostic is not None: - return [diagnostic] - if domain is RuntimeDomain.PROVISIONING and isinstance(plan, ProvisioningPlan): - topology_diagnostics = domain_topology_plan_diagnostics( - plan, - snapshot=snapshot, - supported_domain_profiles=supported_domain_profiles, + if not diagnostics and domain is RuntimeDomain.PROVISIONING and isinstance(plan, ProvisioningPlan): + if manifest is None: + raise ValueError("provisioning submission admission requires a backend manifest") + stateful_diagnostic = _stateful_submission_diagnostic(plan, manifest) + if stateful_diagnostic is not None: + diagnostics.append(stateful_diagnostic) + else: + diagnostics.extend( + domain_topology_plan_diagnostics( + plan, + snapshot=snapshot, + supported_domain_profiles=manifest.provisioner.supported_domain_profiles, + )[:1] + ) + return diagnostics + + +def _stateful_submission_diagnostic( + plan: ProvisioningPlan, + manifest: BackendManifest, +) -> Diagnostic | None: + for operation in plan.operations: + admission = _STATEFUL_ADMISSION_BY_RESOURCE_TYPE.get(operation.resource_type) + if admission is None: + continue + capability_attribute, unsupported_code, resource_label = admission + if not getattr(manifest.provisioner, capability_attribute): + return Diagnostic( + code=unsupported_code, + domain="provisioning", + address=operation.address, + message=f"Provisioner does not support {resource_label}.", + ) + exact_supported = any( + declaration.domain == RUNTIME_REALIZATION_DOMAIN + and DECLARED_CAPABILITY_MATCH_REQUIREMENT_KIND in declaration.supported_exact_requirement_kinds + for declaration in manifest.realization_support ) - return topology_diagnostics[:1] - return [] + if not exact_supported: + return Diagnostic( + code="realization.unsupported-exact-requirement", + domain="runtime-realization", + address=operation.address, + message=( + "Backend declares no exact realization support for the submitted " + f"{operation.resource_type} resource." + ), + ) + return None def _submitted_operation_diagnostic( @@ -184,7 +241,7 @@ def submit_provisioning( plan, RuntimeDomain.PROVISIONING, self._snapshot, - self._target.manifest.provisioner.supported_domain_profiles, + self._target.manifest, ) if diagnostics: return self._reject_diagnostics( diff --git a/implementations/python/packages/aces_sdl/_declarations.py b/implementations/python/packages/aces_sdl/_declarations.py index 9eec05c3f..ec6282d1e 100644 --- a/implementations/python/packages/aces_sdl/_declarations.py +++ b/implementations/python/packages/aces_sdl/_declarations.py @@ -274,6 +274,8 @@ def _add_node_declarations(index: DeclarationIndex, scenario: ScenarioContent) - "events", "scripts", "stories", + "generated_artifacts", + "persistent_volumes", "accounts", "identity_domains", "relationships", diff --git a/implementations/python/packages/aces_sdl/_stateful_resource_references.py b/implementations/python/packages/aces_sdl/_stateful_resource_references.py index a53a90d2d..2104be31c 100644 --- a/implementations/python/packages/aces_sdl/_stateful_resource_references.py +++ b/implementations/python/packages/aces_sdl/_stateful_resource_references.py @@ -1,50 +1,120 @@ -"""Cross-section reference validation for stateful realization resources.""" +"""Semantic validation for stateful realization resource references.""" from collections.abc import Mapping -from .nodes import Node +from .nodes import Node, OSFamily from .stateful_resources import GeneratedArtifact, PersistentVolume -def _validate_consumer_references( - *, owner: str, resource: GeneratedArtifact | PersistentVolume, node_refs: set[str] -) -> None: +def _node_name(reference: str, nodes: Mapping[str, Node]) -> str | None: + name = reference.removeprefix("nodes.") if reference.startswith("nodes.") else reference + return name if name in nodes else None + + +def _dependency_candidates( + reference: str, + *, + generated_artifacts: Mapping[str, GeneratedArtifact], + persistent_volumes: Mapping[str, PersistentVolume], +) -> list[str]: + if reference.startswith("generated_artifacts."): + name = reference.removeprefix("generated_artifacts.") + return [reference] if name in generated_artifacts else [] + if reference.startswith("persistent_volumes."): + name = reference.removeprefix("persistent_volumes.") + return [reference] if name in persistent_volumes else [] + + candidates: list[str] = [] + if reference in generated_artifacts: + candidates.append(f"generated_artifacts.{reference}") + if reference in persistent_volumes: + candidates.append(f"persistent_volumes.{reference}") + return candidates + + +def _consumer_reference_errors( + *, + owner: str, + resource: GeneratedArtifact | PersistentVolume, + nodes: Mapping[str, Node], + occupied_destinations: dict[tuple[str, str], str], +) -> list[str]: + errors: list[str] = [] for consumer in resource.consumers: - if consumer.node not in node_refs: - raise ValueError(f"{owner} consumer node reference {consumer.node!r} is missing") + node_name = _node_name(consumer.node, nodes) + if node_name is None: + errors.append(f"{owner} consumer node reference {consumer.node!r} is missing") + continue + node = nodes[node_name] + if node.os is OSFamily.WINDOWS or node.os == OSFamily.WINDOWS.value: + errors.append(f"{owner} uses a POSIX mount_destination for Windows consumer node {node_name!r}") + destination = (node_name, consumer.mount_destination) + previous = occupied_destinations.get(destination) + if previous is None: + occupied_destinations[destination] = owner + else: + errors.append( + f"{owner} mount_destination {consumer.mount_destination!r} on node {node_name!r} " + f"is already consumed by {previous}" + ) + return errors -def _validate_dependency_references( +def _dependency_reference_errors( *, owner: str, resource: GeneratedArtifact | PersistentVolume, - stateful_refs: set[str], -) -> None: + generated_artifacts: Mapping[str, GeneratedArtifact], + persistent_volumes: Mapping[str, PersistentVolume], +) -> list[str]: + errors: list[str] = [] for dependency in (*resource.ordering_dependencies, *resource.refresh_dependencies): - if dependency not in stateful_refs: - raise ValueError(f"{owner} dependency reference {dependency!r} is missing") + candidates = _dependency_candidates( + dependency, + generated_artifacts=generated_artifacts, + persistent_volumes=persistent_volumes, + ) + if not candidates: + errors.append(f"{owner} dependency reference {dependency!r} is missing") + elif len(candidates) > 1: + choices = ", ".join(candidates) + errors.append(f"{owner} dependency reference {dependency!r} is ambiguous; use one of: {choices}") + return errors -def validate_stateful_resource_references( +def stateful_resource_reference_errors( *, nodes: Mapping[str, Node], generated_artifacts: Mapping[str, GeneratedArtifact], persistent_volumes: Mapping[str, PersistentVolume], -) -> None: - """Reject incomplete stateful graphs before compilation or dispatch.""" - - node_refs = set(nodes) | {f"nodes.{name}" for name in nodes} - stateful_refs = ( - set(generated_artifacts) - | {f"generated_artifacts.{name}" for name in generated_artifacts} - | set(persistent_volumes) - | {f"persistent_volumes.{name}" for name in persistent_volumes} - ) +) -> list[str]: + """Return bounded semantic errors before compilation or dispatch.""" + + errors: list[str] = [] + occupied_destinations: dict[tuple[str, str], str] = {} for section, resources in ( ("generated_artifacts", generated_artifacts), ("persistent_volumes", persistent_volumes), ): for name, resource in resources.items(): owner = f"{section}.{name}" - _validate_consumer_references(owner=owner, resource=resource, node_refs=node_refs) - _validate_dependency_references(owner=owner, resource=resource, stateful_refs=stateful_refs) + errors.extend( + _consumer_reference_errors( + owner=owner, + resource=resource, + nodes=nodes, + occupied_destinations=occupied_destinations, + ) + ) + errors.extend( + _dependency_reference_errors( + owner=owner, + resource=resource, + generated_artifacts=generated_artifacts, + persistent_volumes=persistent_volumes, + ) + ) + return errors + + +__all__ = ["stateful_resource_reference_errors"] diff --git a/implementations/python/packages/aces_sdl/composition.py b/implementations/python/packages/aces_sdl/composition.py index f2e083ef7..68b36b1c4 100644 --- a/implementations/python/packages/aces_sdl/composition.py +++ b/implementations/python/packages/aces_sdl/composition.py @@ -25,7 +25,7 @@ from ._composition_provenance import ( resolved_import_record as _resolved_import_record, ) -from ._errors import SDLInstantiationError, SDLParseDiagnostic, SDLParseError +from ._errors import SDLInstantiationError, SDLParseDiagnostic, SDLParseError, SDLValidationError from ._identifiers import QualifiedName from ._module_symbols import FORWARDING_AGENTS_SECTION from ._module_symbols import HASHMAP_SECTIONS as _HASHMAP_SECTIONS @@ -86,6 +86,35 @@ def _rewrite_section_ref(name: str, section: str, name_map: Mapping[str, str]) - return name_map.get(name, name) +def _rewrite_stateful_dependency_ref( + reference: str, + symbols: dict[str, dict[str, str] | set[str]], + *, + owner: str, +) -> str: + """Rewrite through the resource section that owns the dependency.""" + + matching_sections: list[str] = [] + for section in ("generated_artifacts", "persistent_volumes"): + section_map = symbols[section] + if not isinstance(section_map, Mapping): + continue + if reference.startswith(f"{section}."): + return _rewrite_section_ref(reference, section, section_map) + if reference in section_map: + matching_sections.append(section) + + if len(matching_sections) > 1: + choices = ", ".join(f"{section}.{reference}" for section in matching_sections) + raise SDLValidationError([f"{owner} dependency reference {reference!r} is ambiguous; use one of: {choices}"]) + if matching_sections: + section = matching_sections[0] + section_map = symbols[section] + assert isinstance(section_map, Mapping) + return _rewrite_section_ref(reference, section, section_map) + return reference + + def _validate_descriptor_exports( scenario: ScenarioContent, descriptor: ModuleDescriptor, @@ -246,7 +275,7 @@ def _namespace_payload( if isinstance(content, dict) and content.get("target"): content["target"] = _maybe_rename(str(content["target"]), symbols["nodes"]) for section_name in ("generated_artifacts", "persistent_volumes"): - for resource in namespaced.get(section_name, {}).values(): + for resource_name, resource in namespaced.get(section_name, {}).items(): if not isinstance(resource, dict): continue for consumer in resource.get("consumers", []): @@ -257,7 +286,12 @@ def _namespace_payload( "refresh_dependencies", ): resource[dependency_field] = [ - _maybe_rename(reference, symbols["named"]) for reference in resource.get(dependency_field, []) + _rewrite_stateful_dependency_ref( + reference, + symbols, + owner=f"{section_name}.{resource_name}", + ) + for reference in resource.get(dependency_field, []) ] for account in namespaced.get("accounts", {}).values(): if isinstance(account, dict): diff --git a/implementations/python/packages/aces_sdl/scenario.py b/implementations/python/packages/aces_sdl/scenario.py index b5e5ec754..1f8623285 100644 --- a/implementations/python/packages/aces_sdl/scenario.py +++ b/implementations/python/packages/aces_sdl/scenario.py @@ -26,7 +26,6 @@ ) from ._mapping_scopes import HASHMAP_SECTIONS from ._scenario_instantiation import collect_variable_tokens, resolve_json_pointer -from ._stateful_resource_references import validate_stateful_resource_references from .accounts import Account from .agents import Agent from .conditions import Condition @@ -289,15 +288,6 @@ def _validate_declaration_keys(cls, value: object) -> object: _validate_runtime_forwarding_agent_identifiers(value) return value - @model_validator(mode="after") - def _validate_stateful_resource_references(self) -> "ScenarioContent": - validate_stateful_resource_references( - nodes=self.nodes, - generated_artifacts=self.generated_artifacts, - persistent_volumes=self.persistent_volumes, - ) - return self - @property def advisories(self) -> list[str]: """Non-fatal SDL advisories gathered during semantic validation.""" diff --git a/implementations/python/packages/aces_sdl/stateful_resources.py b/implementations/python/packages/aces_sdl/stateful_resources.py index 77691096c..ad272dd0a 100644 --- a/implementations/python/packages/aces_sdl/stateful_resources.py +++ b/implementations/python/packages/aces_sdl/stateful_resources.py @@ -47,15 +47,32 @@ class VolumeAccessMode(str, Enum): def _validate_relative_path(value: str) -> str: path = PurePosixPath(value) - if not value or path.is_absolute() or ".." in path.parts or value.endswith("/"): - raise ValueError("generated output path must be a contained relative file path") + if ( + not value + or not path.parts + or path.is_absolute() + or ".." in path.parts + or str(path) != value + or "\\" in value + or any(ord(character) < 32 or ord(character) == 127 for character in value) + ): + raise ValueError("generated output path must be a canonical contained POSIX relative file path") return value def _validate_mount_destination(value: str) -> str: path = PurePosixPath(value) - if not value or not path.is_absolute() or ".." in path.parts or str(path) == "/": - raise ValueError("mount_destination must be a contained absolute path below root") + if ( + not value + or not path.is_absolute() + or value.startswith("//") + or ".." in path.parts + or str(path) == "/" + or str(path) != value + or "\\" in value + or any(ord(character) < 32 or ord(character) == 127 for character in value) + ): + raise ValueError("mount_destination must be a canonical contained POSIX absolute path below root") return value @@ -85,10 +102,10 @@ class GeneratedArtifact(SDLModel): generator: GeneratedArtifactKind lifecycle: GeneratedArtifactLifecycle provenance: str = Field(min_length=1) - outputs: list[GeneratedArtifactOutput] = Field(min_length=1) - consumers: list[StatefulResourceConsumer] = Field(min_length=1) - ordering_dependencies: list[str] = Field(default_factory=list) - refresh_dependencies: list[str] = Field(default_factory=list) + outputs: list[GeneratedArtifactOutput] = Field(min_length=1, json_schema_extra={"uniqueItems": True}) + consumers: list[StatefulResourceConsumer] = Field(min_length=1, json_schema_extra={"uniqueItems": True}) + ordering_dependencies: list[str] = Field(default_factory=list, json_schema_extra={"uniqueItems": True}) + refresh_dependencies: list[str] = Field(default_factory=list, json_schema_extra={"uniqueItems": True}) @model_validator(mode="after") def _unique_outputs_and_consumers(self) -> GeneratedArtifact: @@ -101,6 +118,12 @@ def _unique_outputs_and_consumers(self) -> GeneratedArtifact: raise ValueError("generated artifact output paths must be unique") if len(consumers) != len(set(consumers)): raise ValueError("generated artifact consumers must be unique") + if any(consumer.access_mode is ConsumerAccessMode.READ_WRITE for consumer in self.consumers): + raise ValueError("generated artifact consumers must be read_only") + if len(self.ordering_dependencies) != len(set(self.ordering_dependencies)): + raise ValueError("generated artifact ordering_dependencies must be unique") + if len(self.refresh_dependencies) != len(set(self.refresh_dependencies)): + raise ValueError("generated artifact refresh_dependencies must be unique") return self @@ -109,9 +132,9 @@ class PersistentVolume(SDLModel): lifecycle: VolumeLifecycle access_mode: VolumeAccessMode - consumers: list[StatefulResourceConsumer] = Field(min_length=1) - ordering_dependencies: list[str] = Field(default_factory=list) - refresh_dependencies: list[str] = Field(default_factory=list) + consumers: list[StatefulResourceConsumer] = Field(min_length=1, json_schema_extra={"uniqueItems": True}) + ordering_dependencies: list[str] = Field(default_factory=list, json_schema_extra={"uniqueItems": True}) + refresh_dependencies: list[str] = Field(default_factory=list, json_schema_extra={"uniqueItems": True}) @model_validator(mode="after") def _unique_consumers(self) -> PersistentVolume: @@ -122,6 +145,15 @@ def _unique_consumers(self) -> PersistentVolume: consumer.access_mode is ConsumerAccessMode.READ_WRITE for consumer in self.consumers ): raise ValueError("read_only_many volume consumers must be read_only") + writer_nodes = { + consumer.node for consumer in self.consumers if consumer.access_mode is ConsumerAccessMode.READ_WRITE + } + if self.access_mode is VolumeAccessMode.READ_WRITE_ONCE and len(writer_nodes) > 1: + raise ValueError("read_write_once volumes admit at most one writer node") + if len(self.ordering_dependencies) != len(set(self.ordering_dependencies)): + raise ValueError("persistent volume ordering_dependencies must be unique") + if len(self.refresh_dependencies) != len(set(self.refresh_dependencies)): + raise ValueError("persistent volume refresh_dependencies must be unique") return self diff --git a/implementations/python/packages/aces_sdl/validator/_core.py b/implementations/python/packages/aces_sdl/validator/_core.py index d31d81228..51b075b6e 100644 --- a/implementations/python/packages/aces_sdl/validator/_core.py +++ b/implementations/python/packages/aces_sdl/validator/_core.py @@ -222,6 +222,7 @@ def validate(self) -> None: self._verify_scripts() self._verify_stories() self._verify_roles() + self._verify_stateful_resources() # New section passes self._verify_content() diff --git a/implementations/python/packages/aces_sdl/validator/_sections.py b/implementations/python/packages/aces_sdl/validator/_sections.py index 415a759af..57c9296bd 100644 --- a/implementations/python/packages/aces_sdl/validator/_sections.py +++ b/implementations/python/packages/aces_sdl/validator/_sections.py @@ -6,6 +6,7 @@ from pydantic import BaseModel from .._base import VARIABLE_TOKEN_RE +from .._stateful_resource_references import stateful_resource_reference_errors from ..entities import flatten_entities from ..explicitness import classify_scenario_explicitness from ..realization_designation import designation_records, resolve_json_pointer_surface @@ -14,6 +15,15 @@ class _SectionsMixin: + def _verify_stateful_resources(self) -> None: + self._errors.extend( + stateful_resource_reference_errors( + nodes=self._s.nodes, + generated_artifacts=self._s.generated_artifacts, + persistent_volumes=self._s.persistent_volumes, + ) + ) + def _verify_variables(self) -> None: defined = set(getattr(self._s, "variables", {})) self._check_variable_refs(self._s, "", defined) diff --git a/implementations/python/tests/test_runtime_control_plane.py b/implementations/python/tests/test_runtime_control_plane.py index 7596e6555..cde2fed8f 100644 --- a/implementations/python/tests/test_runtime_control_plane.py +++ b/implementations/python/tests/test_runtime_control_plane.py @@ -3,7 +3,11 @@ from __future__ import annotations import textwrap +from dataclasses import replace +from typing import Any +import pytest +from aces_backend_protocols.capabilities import BackendManifest from aces_backend_stubs.stubs import create_stub_components, create_stub_manifest from aces_contracts.contracts import ( ParticipantActionResultModel, @@ -332,6 +336,127 @@ def test_control_plane_rejects_snapshot_resource_identity_disagreement() -> None assert [diagnostic.code for diagnostic in receipt.diagnostics] == ["runtime.plan-resource-incoherent"] +class _CountingProvisioner: + def __init__(self, delegate: Any) -> None: + self._delegate = delegate + self.validate_calls = 0 + self.apply_calls = 0 + + def validate(self, provisioning_plan: ProvisioningPlan): + self.validate_calls += 1 + return self._delegate.validate(provisioning_plan) + + def apply(self, provisioning_plan: ProvisioningPlan, snapshot: RuntimeSnapshot): + self.apply_calls += 1 + return self._delegate.apply(provisioning_plan, snapshot) + + +def _stateful_plan(resource_type: str = "generated-artifact") -> ProvisioningPlan: + return ProvisioningPlan( + operations=[ + ProvisionOp( + action=ChangeAction.CREATE, + address=f"provision.{resource_type}.config", + resource_type=resource_type, + payload={"spec": {"provenance": "config.yml"}}, + ) + ] + ) + + +def _target_with_manifest(manifest: BackendManifest) -> tuple[RuntimeTarget, _CountingProvisioner]: + base = create_stub_target() + provisioner = _CountingProvisioner(base.provisioner) + return ( + RuntimeTarget( + name=base.name, + manifest=manifest, + provisioner=provisioner, + orchestrator=base.orchestrator, + evaluator=base.evaluator, + participant_runtime=base.participant_runtime, + ), + provisioner, + ) + + +@pytest.mark.parametrize( + ("resource_type", "capability_attribute", "expected_code"), + [ + ( + "generated-artifact", + "supports_generated_artifacts", + "provisioner.generated-artifacts-unsupported", + ), + ( + "persistent-volume", + "supports_persistent_volumes", + "provisioner.persistent-volumes-unsupported", + ), + ], +) +def test_control_plane_rejects_stateful_kind_before_backend_calls( + resource_type: str, + capability_attribute: str, + expected_code: str, +) -> None: + manifest = create_stub_manifest() + unsupported = replace( + manifest, + capabilities=replace( + manifest.capabilities, + provisioner=replace(manifest.provisioner, **{capability_attribute: False}), + ), + ) + target, provisioner = _target_with_manifest(unsupported) + + receipt = RuntimeControlPlane(target).submit_provisioning(_stateful_plan(resource_type)) + + assert receipt.accepted is False + assert [diagnostic.code for diagnostic in receipt.diagnostics] == [expected_code] + assert provisioner.validate_calls == 0 + assert provisioner.apply_calls == 0 + + +def test_control_plane_rejects_stateful_plan_without_exact_realization_support() -> None: + manifest = create_stub_manifest() + support = replace( + manifest.realization_support[0], + supported_exact_requirement_kinds=frozenset(), + ) + target, provisioner = _target_with_manifest(replace(manifest, realization_support=(support,))) + + receipt = RuntimeControlPlane(target).submit_provisioning(_stateful_plan()) + + assert receipt.accepted is False + assert [diagnostic.code for diagnostic in receipt.diagnostics] == ["realization.unsupported-exact-requirement"] + assert provisioner.validate_calls == 0 + assert provisioner.apply_calls == 0 + + +def test_control_plane_rejects_exact_support_from_another_domain() -> None: + manifest = create_stub_manifest() + support = replace(manifest.realization_support[0], domain="orchestration") + target, provisioner = _target_with_manifest(replace(manifest, realization_support=(support,))) + + receipt = RuntimeControlPlane(target).submit_provisioning(_stateful_plan()) + + assert receipt.accepted is False + assert [diagnostic.code for diagnostic in receipt.diagnostics] == ["realization.unsupported-exact-requirement"] + assert provisioner.validate_calls == 0 + assert provisioner.apply_calls == 0 + + +def test_control_plane_dispatches_stateful_plan_after_both_admission_gates() -> None: + target, provisioner = _target_with_manifest(create_stub_manifest()) + + receipt = RuntimeControlPlane(target).submit_provisioning(_stateful_plan()) + + assert receipt.accepted is True + assert provisioner.validate_calls == 1 + assert provisioner.apply_calls == 1 + + def test_control_plane_submits_orchestration_with_portable_workflow_state(): scenario = _scenario(""" name: workflow diff --git a/implementations/python/tests/test_runtime_planner.py b/implementations/python/tests/test_runtime_planner.py index 1286ac325..ede40d475 100644 --- a/implementations/python/tests/test_runtime_planner.py +++ b/implementations/python/tests/test_runtime_planner.py @@ -1019,12 +1019,28 @@ def test_semantic_capability_validation_catches_real_requirements(self): name: limited nodes: corp: {type: switch} + stateful: {type: vm, os: linux} dc: type: vm os: windows resources: {ram: 1 gib, cpu: 1} conditions: {health: ops} roles: {ops: operator} +generated_artifacts: + dc-config: + generator: rendered_config + lifecycle: regenerate_on_change + provenance: config/dc.yml + outputs: + - {name: dc-config, path: dc.yml, sensitivity: restricted} + consumers: + - {node: stateful, mount_destination: /etc/aces/dc.yml, access_mode: read_only} +persistent_volumes: + dc-data: + lifecycle: retain + access_mode: read_write_once + consumers: + - {node: stateful, mount_destination: /var/lib/aces, access_mode: read_write} infrastructure: corp: count: 1 @@ -1084,6 +1100,8 @@ def test_semantic_capability_validation_catches_real_requirements(self): assert "provisioner.max-total-nodes-exceeded" in codes assert "provisioner.acls-unsupported" in codes assert "provisioner.unsupported-account-feature" in codes + assert "provisioner.generated-artifacts-unsupported" in codes + assert "provisioner.persistent-volumes-unsupported" in codes assert "orchestrator.unsupported-section" in codes assert "orchestrator.workflows-unsupported" in codes assert "evaluator.unsupported-section" in codes diff --git a/implementations/python/tests/test_stateful_realization_resources.py b/implementations/python/tests/test_stateful_realization_resources.py index 00ff8e975..4080f489f 100644 --- a/implementations/python/tests/test_stateful_realization_resources.py +++ b/implementations/python/tests/test_stateful_realization_resources.py @@ -3,13 +3,16 @@ from __future__ import annotations import textwrap +from pathlib import Path import pytest +from aces_contracts.contracts import schema_bundle +from jsonschema import Draft202012Validator from aces.backends.stubs import create_stub_manifest from aces.core.runtime.compiler import compile_runtime_model from aces.core.runtime.planner import plan -from aces.core.sdl import SDLParseError, parse_sdl +from aces.core.sdl import SDLParseError, SDLValidationError, parse_sdl, parse_sdl_file def _scenario(extra: str = ""): @@ -125,7 +128,7 @@ def test_stateful_resources_reject_unknown_references(mutation: str, message: st " lifecycle: retain\n access_mode: read_write_once\n consumers:", ) invalid_sdl = textwrap.dedent(f"name: invalid\nnodes:\n indexer: {{type: vm, os: linux}}\n{mutation}\n") - with pytest.raises(SDLParseError, match=message): + with pytest.raises(SDLValidationError, match=message): parse_sdl(invalid_sdl) @@ -156,3 +159,245 @@ def test_stateful_resource_dependency_cycle_fails_before_backend_dispatch(): execution = plan(compile_runtime_model(scenario), create_stub_manifest()) assert not execution.is_valid assert any(diagnostic.code == "provisioning.ordering-cycle" for diagnostic in execution.diagnostics) + + +def test_stateful_resources_reject_ambiguous_bare_dependency_during_semantic_admission(): + invalid_sdl = textwrap.dedent( + """ + name: ambiguous-stateful-dependency + nodes: + vm: {type: vm, os: linux} + generated_artifacts: + shared: + generator: rendered_config + lifecycle: regenerate_on_change + provenance: config.yml + outputs: [{name: config, path: config.yml, sensitivity: restricted}] + consumers: [{node: vm, mount_destination: /etc/config.yml, access_mode: read_only}] + consumer: + generator: rendered_config + lifecycle: regenerate_on_change + provenance: consumer.yml + outputs: [{name: consumer, path: consumer.yml, sensitivity: restricted}] + consumers: [{node: vm, mount_destination: /etc/consumer.yml, access_mode: read_only}] + ordering_dependencies: [shared] + persistent_volumes: + shared: + lifecycle: retain + access_mode: read_write_once + consumers: [{node: vm, mount_destination: /var/lib/shared, access_mode: read_write}] + """ + ) + + with pytest.raises(SDLValidationError, match="ambiguous.*generated_artifacts.shared.*persistent_volumes.shared"): + parse_sdl(invalid_sdl) + + +def test_composed_stateful_resources_reject_ambiguous_bare_dependency(tmp_path: Path): + module = tmp_path / "stateful-module.yaml" + module.write_text( + textwrap.dedent( + """ + name: stateful-module + version: 1.0.0 + module: + id: acme/stateful-module + version: 1.0.0 + exports: + nodes: [vm] + generated_artifacts: [shared, consumer] + persistent_volumes: [shared] + nodes: + vm: {type: vm, os: linux} + generated_artifacts: + shared: + generator: rendered_config + lifecycle: regenerate_on_change + provenance: shared.yml + outputs: [{name: shared, path: shared.yml, sensitivity: restricted}] + consumers: [{node: vm, mount_destination: /etc/shared.yml, access_mode: read_only}] + consumer: + generator: rendered_config + lifecycle: regenerate_on_change + provenance: consumer.yml + outputs: [{name: consumer, path: consumer.yml, sensitivity: restricted}] + consumers: [{node: vm, mount_destination: /etc/consumer.yml, access_mode: read_only}] + ordering_dependencies: [shared] + persistent_volumes: + shared: + lifecycle: retain + access_mode: read_write_once + consumers: [{node: vm, mount_destination: /var/lib/shared, access_mode: read_write}] + """ + ), + encoding="utf-8", + ) + root = tmp_path / "root.yaml" + root.write_text( + textwrap.dedent( + """ + name: root + imports: + - source: local:stateful-module.yaml + namespace: imported + version: 1.0.0 + """ + ), + encoding="utf-8", + ) + + with pytest.raises(SDLValidationError, match="ambiguous.*generated_artifacts.shared.*persistent_volumes.shared"): + parse_sdl_file(root) + + +@pytest.mark.parametrize( + ("resources", "message", "error_type"), + [ + ( + """ + generated_artifacts: + config: + generator: rendered_config + lifecycle: regenerate_on_change + provenance: config.yml + outputs: [{name: config, path: config.yml, sensitivity: restricted}] + consumers: [{node: first, mount_destination: /srv/shared, access_mode: read_only}] + persistent_volumes: + data: + lifecycle: retain + access_mode: read_write_once + consumers: [{node: first, mount_destination: /srv/shared, access_mode: read_write}] + """, + "mount_destination.*already consumed", + SDLValidationError, + ), + ( + """ + generated_artifacts: + config: + generator: rendered_config + lifecycle: regenerate_on_change + provenance: config.yml + outputs: [{name: config, path: config.yml, sensitivity: restricted}] + consumers: [{node: first, mount_destination: /etc/config.yml, access_mode: read_write}] + """, + "generated artifact consumers must be read_only", + SDLParseError, + ), + ( + """ + persistent_volumes: + data: + lifecycle: retain + access_mode: read_write_once + consumers: + - {node: first, mount_destination: /srv/first, access_mode: read_write} + - {node: second, mount_destination: /srv/second, access_mode: read_write} + """, + "read_write_once.*at most one writer node", + SDLParseError, + ), + ( + """ + persistent_volumes: + data: + lifecycle: retain + access_mode: read_write_once + consumers: [{node: windows, mount_destination: /srv/data, access_mode: read_write}] + """, + "POSIX.*Windows", + SDLValidationError, + ), + ( + """ + persistent_volumes: + data: + lifecycle: retain + access_mode: read_write_once + consumers: [{node: first, mount_destination: //srv/data, access_mode: read_write}] + """, + "canonical contained POSIX", + SDLParseError, + ), + ], +) +def test_stateful_resources_reject_cross_resource_access_conflicts( + resources: str, + message: str, + error_type: type[Exception], +): + invalid_sdl = textwrap.dedent( + """ + name: invalid-stateful-access + nodes: + first: {type: vm, os: linux} + second: {type: vm, os: linux} + windows: {type: vm, os: windows} + """ + ) + textwrap.dedent(resources) + + with pytest.raises(error_type, match=message): + parse_sdl(invalid_sdl) + + +@pytest.mark.parametrize( + "invalid_path", + [ + ".", + "config//app.yml", + "config/./app.yml", + "config\\app.yml", + ], +) +def test_generated_artifact_output_paths_must_be_canonical_posix(invalid_path: str): + invalid_sdl = textwrap.dedent( + f""" + name: invalid-output-path + nodes: + vm: {{type: vm, os: linux}} + generated_artifacts: + config: + generator: rendered_config + lifecycle: regenerate_on_change + provenance: config.yml + outputs: [{{name: config, path: {invalid_path!r}, sensitivity: restricted}}] + consumers: [{{node: vm, mount_destination: /etc/config.yml, access_mode: read_only}}] + """ + ) + + with pytest.raises(SDLParseError, match="canonical contained POSIX"): + parse_sdl(invalid_sdl) + + +def test_stateful_collection_schema_rejects_exact_duplicates(): + payload = { + "name": "duplicate-stateful-members", + "nodes": {"vm": {"type": "vm", "os": "linux"}}, + "generated_artifacts": { + "config": { + "generator": "rendered_config", + "lifecycle": "regenerate_on_change", + "provenance": "config.yml", + "outputs": [ + {"name": "config", "path": "config.yml", "sensitivity": "restricted"}, + {"name": "config", "path": "config.yml", "sensitivity": "restricted"}, + ], + "consumers": [{"node": "vm", "mount_destination": "/etc/config.yml", "access_mode": "read_only"}], + } + }, + } + + schema = schema_bundle()["sdl-authoring-input-v1"] + assert not Draft202012Validator(schema).is_valid(payload) + + +def test_stateful_schema_discloses_model_only_semantic_invariants(): + schema = schema_bundle()["sdl-authoring-input-v1"] + invariant_ids = {entry["id"] for entry in schema["x-aces-invariants"]} + + assert { + "stateful-generated-artifact-semantics", + "stateful-persistent-volume-semantics", + "stateful-cross-resource-semantics", + } <= invariant_ids + assert schema["x-aces-semantic-profile"]["required"] is True diff --git a/specs/sdl/stateful-resources.md b/specs/sdl/stateful-resources.md index fd8c79557..0e8fa261f 100644 --- a/specs/sdl/stateful-resources.md +++ b/specs/sdl/stateful-resources.md @@ -20,14 +20,18 @@ generator, its regeneration lifecycle, non-secret provenance, the complete output set, and every consumer. Each output carries a contained relative path and a sensitivity class (`public`, `restricted`, or `secret`). The contract contains desired metadata only; secret values and rendered bytes never enter -SDL, plans, diagnostics, or provenance. +SDL, plans, diagnostics, or provenance. Output paths use one canonical POSIX +relative-path spelling. Generated artifacts are immutable inputs to consumers; +every consumer therefore declares `read_only` access. ## Persistent volumes A persistent volume declares `retain` or `ephemeral` lifecycle, portable single/multi-writer access semantics, and every consumer. Consumers name a -declared node, an absolute mount destination below `/`, and read-only or -read-write access. +declared non-Windows node, a canonical POSIX absolute mount destination below +`/`, and read-only or read-write access. `read_write_once` admits at most one +writer node. A node and mount-destination pair may be owned by only one +generated artifact or persistent volume. ## Graph and realization rules @@ -41,3 +45,8 @@ requirement and emits its typed payload into the provisioning plan. Backends must either honor the complete declared resource or reject the plan; silently substituting an observed mount, generic content placement, or provider-private configuration is not conformant. + +Published JSON Schemas reject exact duplicate collection members. Relational +uniqueness, cross-resource reference resolution, mount ownership, and access +cardinality are published as `x-aces-invariants` and enforced by semantic SDL +admission; JSON Schema success alone is not semantic admission.