diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 71e95baf4..d3535156f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.20.0" + ".": "0.21.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index bbdb496f4..e53b8e09d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ 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.21.0](https://github.com/Brad-Edwards/aces/compare/v0.20.0...v0.21.0) (2026-07-14) + + +### Features + +* add scoped realization posture cascade ([#766](https://github.com/Brad-Edwards/aces/issues/766)) ([7776866](https://github.com/Brad-Edwards/aces/commit/77768667242a23728a96aeed9294f62fca30831a)) +* define behavioral relation taxonomy and claim discipline ([#770](https://github.com/Brad-Edwards/aces/issues/770)) ([feed07d](https://github.com/Brad-Edwards/aces/commit/feed07d303dbcbbb9bf4230a72ea5ca9c49f279f)) + + +### Bug Fixes + +* define node service reachability semantics ([#764](https://github.com/Brad-Edwards/aces/issues/764)) ([2c3fa06](https://github.com/Brad-Edwards/aces/commit/2c3fa06a40f69095191b029fcb1062ebfe3b5f60)) +* preserve explicitness provenance at runtime ([#762](https://github.com/Brad-Edwards/aces/issues/762)) ([1b63b2a](https://github.com/Brad-Edwards/aces/commit/1b63b2a0b10dcd80e29b8bad14558ad9c6b706d0)) +* **sdl:** separate runtime contract from observed evidence ([#761](https://github.com/Brad-Edwards/aces/issues/761)) ([69acb69](https://github.com/Brad-Edwards/aces/commit/69acb691435c72f4a831358d7ee2f83ab3fcf663)) + + +### Documentation + +* rebuild related-work comparison from reproducible evidence ([#765](https://github.com/Brad-Edwards/aces/issues/765)) ([ed9577a](https://github.com/Brad-Edwards/aces/commit/ed9577aecbe65de19953a45744d3bdc219d9af0b)) + ## [0.20.0](https://github.com/Brad-Edwards/aces/compare/v0.19.1...v0.20.0) (2026-07-13) diff --git a/contracts/concept-authority/controlled-vocabularies-v1.json b/contracts/concept-authority/controlled-vocabularies-v1.json index 90855ca33..588c3e4f8 100644 --- a/contracts/concept-authority/controlled-vocabularies-v1.json +++ b/contracts/concept-authority/controlled-vocabularies-v1.json @@ -678,6 +678,22 @@ } } }, + "provisioner-domain-profiles": { + "title": "Provisioner Domain Profiles", + "description": "Provisioner-supported authored identity-domain realization profiles.", + "kind": "vocabulary", + "governed_scopes": [ + "capabilities.provisioner.supported_domain_profiles" + ], + "extension_policy": "governed-extension", + "extension_pattern": "^x-[a-z0-9]+(?:-[a-z0-9]+)*:[a-z0-9]+(?:-[a-z0-9]+)*$", + "terms": { + "active_directory": { + "title": "Active Directory", + "description": "Authored Active Directory controller and domain-join topology realization." + } + } + }, "orchestrator-supported-sections": { "title": "Orchestrator Supported Sections", "description": "Orchestrator-supported SDL section vocabulary.", diff --git a/contracts/concept-authority/reference-models-v1.json b/contracts/concept-authority/reference-models-v1.json index b280328a2..2edce454d 100644 --- a/contracts/concept-authority/reference-models-v1.json +++ b/contracts/concept-authority/reference-models-v1.json @@ -46,9 +46,33 @@ "node", "groups", "auth_method", + "domain_ref", "disabled" ] }, + "scenario-identity-domain": { + "title": "Scenario Identity Domain", + "description": "Shared reference model for authored identity-domain realization topology.", + "concept_family": "identities", + "authoritative_schema": { + "contract_id": "sdl-authoring-input-v1", + "schema_pointer": "#/$defs/IdentityDomain", + "instance_path": "identity_domains.*" + }, + "reused_schemas": [ + { + "contract_id": "instantiated-scenario-v1", + "schema_pointer": "#/$defs/IdentityDomain", + "instance_path": "identity_domains.*" + } + ], + "key_fields": [ + "profile", + "dns_name", + "netbios_name", + "authority_account_ref" + ] + }, "scenario-relationship": { "title": "Scenario Relationship", "description": "Shared reference model for typed directed relationships between scenario elements.", @@ -69,7 +93,9 @@ "type", "source", "target", - "properties" + "properties", + "domain_controller", + "domain_join" ] }, "scenario-condition": { diff --git a/contracts/fixtures/backend-manifest/backend-manifest-v2/valid/stub.json b/contracts/fixtures/backend-manifest/backend-manifest-v2/valid/stub.json index f8d4bdcc1..80a4ccfe9 100644 --- a/contracts/fixtures/backend-manifest/backend-manifest-v2/valid/stub.json +++ b/contracts/fixtures/backend-manifest/backend-manifest-v2/valid/stub.json @@ -145,6 +145,9 @@ "shell", "spn" ], + "supported_domain_profiles": [ + "active_directory" + ], "supported_content_types": [ "dataset", "directory", @@ -187,6 +190,10 @@ "family": "identities", "scope": "capabilities.provisioner.supported_account_features" }, + { + "family": "identities", + "scope": "capabilities.provisioner.supported_domain_profiles" + }, { "family": "actions-and-events", "scope": "capabilities.orchestrator.supported_sections" diff --git a/contracts/fixtures/concept-authority/controlled-vocabularies-v1/valid/reference.json b/contracts/fixtures/concept-authority/controlled-vocabularies-v1/valid/reference.json index 90855ca33..588c3e4f8 100644 --- a/contracts/fixtures/concept-authority/controlled-vocabularies-v1/valid/reference.json +++ b/contracts/fixtures/concept-authority/controlled-vocabularies-v1/valid/reference.json @@ -678,6 +678,22 @@ } } }, + "provisioner-domain-profiles": { + "title": "Provisioner Domain Profiles", + "description": "Provisioner-supported authored identity-domain realization profiles.", + "kind": "vocabulary", + "governed_scopes": [ + "capabilities.provisioner.supported_domain_profiles" + ], + "extension_policy": "governed-extension", + "extension_pattern": "^x-[a-z0-9]+(?:-[a-z0-9]+)*:[a-z0-9]+(?:-[a-z0-9]+)*$", + "terms": { + "active_directory": { + "title": "Active Directory", + "description": "Authored Active Directory controller and domain-join topology realization." + } + } + }, "orchestrator-supported-sections": { "title": "Orchestrator Supported Sections", "description": "Orchestrator-supported SDL section vocabulary.", diff --git a/contracts/fixtures/concept-authority/reference-models-v1/valid/reference.json b/contracts/fixtures/concept-authority/reference-models-v1/valid/reference.json index b280328a2..2edce454d 100644 --- a/contracts/fixtures/concept-authority/reference-models-v1/valid/reference.json +++ b/contracts/fixtures/concept-authority/reference-models-v1/valid/reference.json @@ -46,9 +46,33 @@ "node", "groups", "auth_method", + "domain_ref", "disabled" ] }, + "scenario-identity-domain": { + "title": "Scenario Identity Domain", + "description": "Shared reference model for authored identity-domain realization topology.", + "concept_family": "identities", + "authoritative_schema": { + "contract_id": "sdl-authoring-input-v1", + "schema_pointer": "#/$defs/IdentityDomain", + "instance_path": "identity_domains.*" + }, + "reused_schemas": [ + { + "contract_id": "instantiated-scenario-v1", + "schema_pointer": "#/$defs/IdentityDomain", + "instance_path": "identity_domains.*" + } + ], + "key_fields": [ + "profile", + "dns_name", + "netbios_name", + "authority_account_ref" + ] + }, "scenario-relationship": { "title": "Scenario Relationship", "description": "Shared reference model for typed directed relationships between scenario elements.", @@ -69,7 +93,9 @@ "type", "source", "target", - "properties" + "properties", + "domain_controller", + "domain_join" ] }, "scenario-condition": { diff --git a/contracts/fixtures/realization-envelope/realization-envelope-v1/valid/generic.json b/contracts/fixtures/realization-envelope/realization-envelope-v1/valid/generic.json index dcea4855e..553c0b97b 100644 --- a/contracts/fixtures/realization-envelope/realization-envelope-v1/valid/generic.json +++ b/contracts/fixtures/realization-envelope/realization-envelope-v1/valid/generic.json @@ -12,7 +12,7 @@ }, "configuration": { "mode": "generic", - "configuration_digest": "sha256:2af0fbd4a95b95a3a51d1488f641985c93e4c470baeb188f3e981f8b36327450", + "configuration_digest": "sha256:227d7807516ed39ad04acd4eb9fac26d0f0d3d9c81eb42a3cbf25c9215888a8c", "architecture": "x86_64", "image_policy": "local-qcow2", "network_policy": "libvirt-managed", @@ -20,6 +20,7 @@ "supported_os_families": ["linux"], "supported_content_types": ["file"], "supported_account_features": ["auth_method", "disabled", "groups", "home", "shell"], + "supported_domain_profiles": [], "supports_acls": true, "memory_mib": {"minimum": 128, "maximum": null}, "vcpus": {"minimum": 1, "maximum": null} @@ -36,5 +37,5 @@ {"concern": "service", "disposition": "unsupported", "observation_strength": "none", "mechanism": null, "transformations": []}, {"concern": "acl", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "libvirt-nwfilter", "transformations": []} ], - "digest": "sha256:eb3b54b199249da599cdd59e050612314ed16fe6039630307894ee8d8e2a466f" + "digest": "sha256:85136f73d2752c5fdaf8c4219aefc42de756486555c17fe2f4b71a7c042221f0" } diff --git a/contracts/provenance/sdl-lineage-ledger-v1.json b/contracts/provenance/sdl-lineage-ledger-v1.json index cbfca2b5f..09e3717d6 100644 --- a/contracts/provenance/sdl-lineage-ledger-v1.json +++ b/contracts/provenance/sdl-lineage-ledger-v1.json @@ -184,6 +184,38 @@ } ] }, + { + "subject_id": "sdl-field:identity_domains", + "subject_kind": "top_level_field", + "disposition": "current", + "authority": { + "artifact": "contracts/schemas/sdl/sdl-authoring-input-v1.json", + "pointer": "#/properties/identity_domains", + "contract_id": "sdl-authoring-input-v1" + }, + "claims": [ + { + "plane": "semantics", + "classification": "aces_native", + "source_refs": [], + "aces_boundaries": [ + { + "artifact": "contracts/schemas/sdl/sdl-authoring-input-v1.json", + "symbol_or_pointer": "#/properties/identity_domains" + } + ], + "source_boundaries": [], + "divergence": "Defined by the current ACES normative contract; external systems may be intellectual precedents but do not define this subject.", + "compatibility": "not_applicable", + "citation_refs": [], + "internal_authority_refs": [ + "specs/sdl/authored-domain-topology.md", + "docs/decisions/adrs/adr-082-authored-identity-domain-topology.md" + ], + "compatibility_direction": "not_applicable" + } + ] + }, { "subject_id": "sdl-field:action_contracts", "subject_kind": "top_level_field", @@ -2599,6 +2631,36 @@ } ] }, + { + "subject_id": "reference-model:scenario-identity-domain", + "subject_kind": "reference_model", + "disposition": "current", + "authority": { + "artifact": "contracts/concept-authority/reference-models-v1.json", + "pointer": "#/models/scenario-identity-domain" + }, + "claims": [ + { + "plane": "semantics", + "classification": "aces_native", + "source_refs": [], + "aces_boundaries": [ + { + "artifact": "contracts/concept-authority/reference-models-v1.json", + "symbol_or_pointer": "#/models/scenario-identity-domain" + } + ], + "source_boundaries": [], + "divergence": "Defined by the current ACES normative contract; external systems may be intellectual precedents but do not define this subject.", + "compatibility": "not_applicable", + "citation_refs": [], + "internal_authority_refs": [ + "specs/sdl/authored-domain-topology.md" + ], + "compatibility_direction": "not_applicable" + } + ] + }, { "subject_id": "reference-model:scenario-assertion", "subject_kind": "reference_model", diff --git a/contracts/realization-envelopes/libvirt-qemu/generic-v1.json b/contracts/realization-envelopes/libvirt-qemu/generic-v1.json index dcea4855e..553c0b97b 100644 --- a/contracts/realization-envelopes/libvirt-qemu/generic-v1.json +++ b/contracts/realization-envelopes/libvirt-qemu/generic-v1.json @@ -12,7 +12,7 @@ }, "configuration": { "mode": "generic", - "configuration_digest": "sha256:2af0fbd4a95b95a3a51d1488f641985c93e4c470baeb188f3e981f8b36327450", + "configuration_digest": "sha256:227d7807516ed39ad04acd4eb9fac26d0f0d3d9c81eb42a3cbf25c9215888a8c", "architecture": "x86_64", "image_policy": "local-qcow2", "network_policy": "libvirt-managed", @@ -20,6 +20,7 @@ "supported_os_families": ["linux"], "supported_content_types": ["file"], "supported_account_features": ["auth_method", "disabled", "groups", "home", "shell"], + "supported_domain_profiles": [], "supports_acls": true, "memory_mib": {"minimum": 128, "maximum": null}, "vcpus": {"minimum": 1, "maximum": null} @@ -36,5 +37,5 @@ {"concern": "service", "disposition": "unsupported", "observation_strength": "none", "mechanism": null, "transformations": []}, {"concern": "acl", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "libvirt-nwfilter", "transformations": []} ], - "digest": "sha256:eb3b54b199249da599cdd59e050612314ed16fe6039630307894ee8d8e2a466f" + "digest": "sha256:85136f73d2752c5fdaf8c4219aefc42de756486555c17fe2f4b71a7c042221f0" } diff --git a/contracts/realization-envelopes/libvirt-qemu/guest-certified-appliance-v1.json b/contracts/realization-envelopes/libvirt-qemu/guest-certified-appliance-v1.json index 0018d09cf..acb838edc 100644 --- a/contracts/realization-envelopes/libvirt-qemu/guest-certified-appliance-v1.json +++ b/contracts/realization-envelopes/libvirt-qemu/guest-certified-appliance-v1.json @@ -31,6 +31,7 @@ "home", "shell" ], + "supported_domain_profiles": [], "supports_acls": false, "memory_mib": { "minimum": 64, @@ -40,7 +41,7 @@ "minimum": 1, "maximum": 2 }, - "configuration_digest": "sha256:b33ad469eaf1a47963e49da8adc7376fc22880ac3e6126b427239daa00dc6d99" + "configuration_digest": "sha256:fbb560799256b26deb85fead1de2a681f5f93d16a03bb7c3e7d9792fcf88ea6c" }, "concerns": [ { @@ -114,5 +115,5 @@ "transformations": [] } ], - "digest": "sha256:8416a600a6f1864e1b80e49fa60eef5f423f9249f2ea7dc56e14d8a669e33e2e" + "digest": "sha256:9f5e7b452821febb9e4f160b843dfc76f7d2ace1fb3dd2adb675fc9ce020aaa7" } diff --git a/contracts/realization-envelopes/libvirt-qemu/techvault-appliance-v1.json b/contracts/realization-envelopes/libvirt-qemu/techvault-appliance-v1.json index a08f79dcd..ab6070405 100644 --- a/contracts/realization-envelopes/libvirt-qemu/techvault-appliance-v1.json +++ b/contracts/realization-envelopes/libvirt-qemu/techvault-appliance-v1.json @@ -12,7 +12,7 @@ }, "configuration": { "mode": "techvault-appliance", - "configuration_digest": "sha256:227dc29681c61c341291ef4ef30b8c777e166103a5e76a74d87cc2164a7bf5ca", + "configuration_digest": "sha256:48ce2b3930c9d1a08df96440efbef6499f96b28bcabd3319fe83372856b0fff5", "architecture": "x86_64", "image_policy": "generated-initramfs-appliance", "network_policy": "generated-appliance-network", @@ -20,6 +20,7 @@ "supported_os_families": ["linux"], "supported_content_types": [], "supported_account_features": [], + "supported_domain_profiles": [], "supports_acls": false, "memory_mib": {"minimum": 64, "maximum": 128}, "vcpus": {"minimum": 1, "maximum": 2} @@ -36,5 +37,5 @@ {"concern": "service", "disposition": "unsupported", "observation_strength": "none", "mechanism": null, "transformations": []}, {"concern": "acl", "disposition": "unsupported", "observation_strength": "none", "mechanism": null, "transformations": []} ], - "digest": "sha256:b0cdf9c60cf42a50782f0d417cd931462c4d630e35e7546734710219477622ed" + "digest": "sha256:8af7fad829cecd3a0b91213d4e54097e63b0d519ab3efaf9008b8e53365599f4" } diff --git a/contracts/schema-publication-manifest.json b/contracts/schema-publication-manifest.json index 9a5b68027..65df66e2f 100644 --- a/contracts/schema-publication-manifest.json +++ b/contracts/schema-publication-manifest.json @@ -42,10 +42,10 @@ "contract_id": "backend-manifest-v2", "schema_path": "contracts/schemas/backend-manifest/backend-manifest-v2.json", "stability": "draft", - "content_hash": "e2beb9d9f20f7564c8b485b653c706b8b3d83ebdc535bff62e80fbcd82d8200f", + "content_hash": "014178ea05ae875548c1121e11a59cb66215614a63bde35544dd972f0f53420c", "last_change": { - "summary": "Published evaluator proposition, assertion, evidence, truth-outcome, temporal, and binding-provenance capability declarations and assertion-aware orchestration support.", - "content_hash": "e2beb9d9f20f7564c8b485b653c706b8b3d83ebdc535bff62e80fbcd82d8200f" + "summary": "Added the governed provisioner domain-profile capability dimension for authored identity-domain realization (issue #763).", + "content_hash": "014178ea05ae875548c1121e11a59cb66215614a63bde35544dd972f0f53420c" } }, { @@ -190,20 +190,20 @@ "contract_id": "instantiated-scenario-snapshot-v1", "schema_path": "contracts/schemas/sdl/instantiated-scenario-snapshot-v1.json", "stability": "draft", - "content_hash": "8e8048de4b5161c1a02d0900811f388b0d57a92b6d8434b0242f0a5254e9b377", + "content_hash": "10544c816e9407607d4bbc8002eb23645f2fc5bc6c7e86ca7fbd4ae3f0c6d25e", "last_change": { - "summary": "Added SEM-218 realization designation provenance and clarified Node.services reachability semantics (issue #748).", - "content_hash": "8e8048de4b5161c1a02d0900811f388b0d57a92b6d8434b0242f0a5254e9b377" + "summary": "Added authored identity-domain declarations, typed controller and join relationships, and explicit account domain bindings (issue #763).", + "content_hash": "10544c816e9407607d4bbc8002eb23645f2fc5bc6c7e86ca7fbd4ae3f0c6d25e" } }, { "contract_id": "instantiated-scenario-v1", "schema_path": "contracts/schemas/sdl/instantiated-scenario-v1.json", "stability": "draft", - "content_hash": "515cacc41d24c643e7655de70a40dc1b6305a14b9d840083d5d9ff694d58df29", + "content_hash": "03aee5d25be44733ef09697b403235189bc91fc65da7bd6ff7ecd8ff6685d72a", "last_change": { - "summary": "Added SEM-218 scoped realization records and clarified Node.services reachability semantics (issue #748).", - "content_hash": "515cacc41d24c643e7655de70a40dc1b6305a14b9d840083d5d9ff694d58df29" + "summary": "Added authored identity-domain declarations, typed controller and join relationships, and explicit account domain bindings (issue #763).", + "content_hash": "03aee5d25be44733ef09697b403235189bc91fc65da7bd6ff7ecd8ff6685d72a" } }, { @@ -390,10 +390,10 @@ "contract_id": "realization-envelope-v1", "schema_path": "contracts/schemas/realization-envelope/realization-envelope-v1.json", "stability": "draft", - "content_hash": "a5992e81fbc935a7ee6eab3b173a89d6e664274f0f143475e303a54d01a27374", + "content_hash": "0eb46ba5c89a8eee9e2ba74fb9079f690bca392eacf774da0900379b4fd79580", "last_change": { - "summary": "Added explicit service-concern disclosure so backend envelopes cannot hide declared guest services inside another concern.", - "content_hash": "a5992e81fbc935a7ee6eab3b173a89d6e664274f0f143475e303a54d01a27374" + "summary": "Added domain-profile capability disclosure to material backend realization configurations (issue #763).", + "content_hash": "0eb46ba5c89a8eee9e2ba74fb9079f690bca392eacf774da0900379b4fd79580" } }, { @@ -456,10 +456,10 @@ "contract_id": "sdl-authoring-input-v1", "schema_path": "contracts/schemas/sdl/sdl-authoring-input-v1.json", "stability": "draft", - "content_hash": "57e984be7783a5eb54ee210da434d2883aa468617b0ee8c2a42de1a769ff30b5", + "content_hash": "27f9ad3203fa48afec0247b7eb8afd376c1acc8dbdac7d88d9e0c96c9b9bf8c3", "last_change": { - "summary": "Published SEM-218 scoped realization defaults and clarified Node.services reachability semantics (issue #748).", - "content_hash": "57e984be7783a5eb54ee210da434d2883aa468617b0ee8c2a42de1a769ff30b5" + "summary": "Added authored identity-domain declarations, typed controller and join relationships, and explicit account domain bindings (issue #763).", + "content_hash": "27f9ad3203fa48afec0247b7eb8afd376c1acc8dbdac7d88d9e0c96c9b9bf8c3" } }, { diff --git a/contracts/schemas/backend-manifest/backend-manifest-v2.json b/contracts/schemas/backend-manifest/backend-manifest-v2.json index 5aec46db7..5d0b5bd19 100644 --- a/contracts/schemas/backend-manifest/backend-manifest-v2.json +++ b/contracts/schemas/backend-manifest/backend-manifest-v2.json @@ -674,6 +674,14 @@ "title": "Supported Content Types", "type": "array" }, + "supported_domain_profiles": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Supported Domain Profiles", + "type": "array" + }, "supported_node_types": { "items": { "minLength": 1, diff --git a/contracts/schemas/realization-envelope/realization-envelope-v1.json b/contracts/schemas/realization-envelope/realization-envelope-v1.json index 85db5f1c6..cc81ebce9 100644 --- a/contracts/schemas/realization-envelope/realization-envelope-v1.json +++ b/contracts/schemas/realization-envelope/realization-envelope-v1.json @@ -619,6 +619,15 @@ "type": "array", "uniqueItems": true }, + "supported_domain_profiles": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Supported Domain Profiles", + "type": "array", + "uniqueItems": true + }, "supported_node_types": { "items": { "minLength": 1, diff --git a/contracts/schemas/sdl/instantiated-scenario-snapshot-v1.json b/contracts/schemas/sdl/instantiated-scenario-snapshot-v1.json index 33b323844..78b8a029b 100644 --- a/contracts/schemas/sdl/instantiated-scenario-snapshot-v1.json +++ b/contracts/schemas/sdl/instantiated-scenario-snapshot-v1.json @@ -149,6 +149,14 @@ "default": false, "title": "Disabled" }, + "domain_ref": { + "default": "", + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + }, + "title": "Domain Ref", + "type": "string" + }, "groups": { "items": { "not": { @@ -3445,6 +3453,56 @@ "title": "FeatureType", "type": "string" }, + "IdentityDomain": { + "additionalProperties": false, + "description": "Scenario-scoped authored identity domain.", + "properties": { + "authority_account_ref": { + "minLength": 1, + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + }, + "title": "Authority Account Ref", + "type": "string" + }, + "dns_name": { + "minLength": 1, + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + }, + "title": "Dns Name", + "type": "string" + }, + "netbios_name": { + "minLength": 1, + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + }, + "title": "Netbios Name", + "type": "string" + }, + "profile": { + "$ref": "#/$defs/IdentityDomainProfile", + "title": "Profile" + } + }, + "required": [ + "profile", + "dns_name", + "netbios_name", + "authority_account_ref" + ], + "title": "IdentityDomain", + "type": "object" + }, + "IdentityDomainProfile": { + "description": "Closed profiles whose controller/join semantics are realizable.", + "enum": [ + "active_directory" + ], + "title": "IdentityDomainProfile", + "type": "string" + }, "ImageAttestation": { "additionalProperties": false, "description": "Observed build-attestation availability and verification result.\n\nAttestation *availability* (``status``) and *verification result*\n(``verification``) are deliberately separate facts: a mutable local image\ntag with no registry-visible OCI/in-toto/SLSA attestation is not the same\nstate as a failed verification (ADR-023 \u00a75).", @@ -4307,6 +4365,22 @@ "title": "Forwarding Agents", "type": "array" }, + "identity_domains": { + "additionalProperties": { + "$ref": "#/$defs/IdentityDomain" + }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, + "title": "Identity Domains", + "type": "object" + }, "infrastructure": { "additionalProperties": { "$ref": "#/$defs/InfraNode" @@ -6881,6 +6955,28 @@ "title": "Description", "type": "string" }, + "domain_controller": { + "anyOf": [ + { + "$ref": "#/$defs/RelationshipDomainController" + }, + { + "type": "null" + } + ], + "default": null + }, + "domain_join": { + "anyOf": [ + { + "$ref": "#/$defs/RelationshipDomainJoin" + }, + { + "type": "null" + } + ], + "default": null + }, "forwarding_edge": { "anyOf": [ { @@ -7000,6 +7096,35 @@ "title": "RelationshipDatabaseAccess", "type": "object" }, + "RelationshipDomainController": { + "additionalProperties": false, + "description": "Typed marker for a node-to-domain controller-role edge.", + "properties": {}, + "title": "RelationshipDomainController", + "type": "object" + }, + "RelationshipDomainJoin": { + "additionalProperties": false, + "description": "Typed member join with explicit ordered controller candidates.", + "properties": { + "controller_refs": { + "items": { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + }, + "type": "string" + }, + "minItems": 1, + "title": "Controller Refs", + "type": "array" + } + }, + "required": [ + "controller_refs" + ], + "title": "RelationshipDomainJoin", + "type": "object" + }, "RelationshipForwardingEdge": { "additionalProperties": false, "description": "Typed forwarding-trust detail carried by a top-level relationship edge.\n\nThe inter-node trust edge between a forwarding / intel-sync agent\n(``forwarder_ref``, a ``forwarding_agent_id``) and the downstream consumer\nit ships to. The consumer's transport listener is named by\n``target_listener_role`` \u2014 REUSING the manager-side\n``RuntimeSecurityMonitoringListenerRole`` lattice rather than forking a\nparallel enum (SCN-010 \u00a75.7).\n\nThe agent enrollment identity itself is never recorded: only the closed\n``RuntimeForwardingEnrollmentClassification`` lattice (``none`` /\n``redacted`` / ``operator_secret``). A present enrollment-identity ref must\nclassify ``redacted`` / ``operator_secret`` \u2014 a raw identity is never the\nportable model.", @@ -7387,7 +7512,9 @@ "connects_to", "depends_on", "manages", - "replicates_to" + "replicates_to", + "domain_controller_for", + "joins_domain" ], "title": "RelationshipType", "type": "string" diff --git a/contracts/schemas/sdl/instantiated-scenario-v1.json b/contracts/schemas/sdl/instantiated-scenario-v1.json index 8b0c917aa..2d9071dc0 100644 --- a/contracts/schemas/sdl/instantiated-scenario-v1.json +++ b/contracts/schemas/sdl/instantiated-scenario-v1.json @@ -149,6 +149,14 @@ "default": false, "title": "Disabled" }, + "domain_ref": { + "default": "", + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + }, + "title": "Domain Ref", + "type": "string" + }, "groups": { "items": { "not": { @@ -3445,6 +3453,56 @@ "title": "FeatureType", "type": "string" }, + "IdentityDomain": { + "additionalProperties": false, + "description": "Scenario-scoped authored identity domain.", + "properties": { + "authority_account_ref": { + "minLength": 1, + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + }, + "title": "Authority Account Ref", + "type": "string" + }, + "dns_name": { + "minLength": 1, + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + }, + "title": "Dns Name", + "type": "string" + }, + "netbios_name": { + "minLength": 1, + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + }, + "title": "Netbios Name", + "type": "string" + }, + "profile": { + "$ref": "#/$defs/IdentityDomainProfile", + "title": "Profile" + } + }, + "required": [ + "profile", + "dns_name", + "netbios_name", + "authority_account_ref" + ], + "title": "IdentityDomain", + "type": "object" + }, + "IdentityDomainProfile": { + "description": "Closed profiles whose controller/join semantics are realizable.", + "enum": [ + "active_directory" + ], + "title": "IdentityDomainProfile", + "type": "string" + }, "ImageAttestation": { "additionalProperties": false, "description": "Observed build-attestation availability and verification result.\n\nAttestation *availability* (``status``) and *verification result*\n(``verification``) are deliberately separate facts: a mutable local image\ntag with no registry-visible OCI/in-toto/SLSA attestation is not the same\nstate as a failed verification (ADR-023 \u00a75).", @@ -6430,6 +6488,28 @@ "title": "Description", "type": "string" }, + "domain_controller": { + "anyOf": [ + { + "$ref": "#/$defs/RelationshipDomainController" + }, + { + "type": "null" + } + ], + "default": null + }, + "domain_join": { + "anyOf": [ + { + "$ref": "#/$defs/RelationshipDomainJoin" + }, + { + "type": "null" + } + ], + "default": null + }, "forwarding_edge": { "anyOf": [ { @@ -6549,6 +6629,35 @@ "title": "RelationshipDatabaseAccess", "type": "object" }, + "RelationshipDomainController": { + "additionalProperties": false, + "description": "Typed marker for a node-to-domain controller-role edge.", + "properties": {}, + "title": "RelationshipDomainController", + "type": "object" + }, + "RelationshipDomainJoin": { + "additionalProperties": false, + "description": "Typed member join with explicit ordered controller candidates.", + "properties": { + "controller_refs": { + "items": { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + }, + "type": "string" + }, + "minItems": 1, + "title": "Controller Refs", + "type": "array" + } + }, + "required": [ + "controller_refs" + ], + "title": "RelationshipDomainJoin", + "type": "object" + }, "RelationshipForwardingEdge": { "additionalProperties": false, "description": "Typed forwarding-trust detail carried by a top-level relationship edge.\n\nThe inter-node trust edge between a forwarding / intel-sync agent\n(``forwarder_ref``, a ``forwarding_agent_id``) and the downstream consumer\nit ships to. The consumer's transport listener is named by\n``target_listener_role`` \u2014 REUSING the manager-side\n``RuntimeSecurityMonitoringListenerRole`` lattice rather than forking a\nparallel enum (SCN-010 \u00a75.7).\n\nThe agent enrollment identity itself is never recorded: only the closed\n``RuntimeForwardingEnrollmentClassification`` lattice (``none`` /\n``redacted`` / ``operator_secret``). A present enrollment-identity ref must\nclassify ``redacted`` / ``operator_secret`` \u2014 a raw identity is never the\nportable model.", @@ -6936,7 +7045,9 @@ "connects_to", "depends_on", "manages", - "replicates_to" + "replicates_to", + "domain_controller_for", + "joins_domain" ], "title": "RelationshipType", "type": "string" @@ -22644,6 +22755,22 @@ "title": "Forwarding Agents", "type": "array" }, + "identity_domains": { + "additionalProperties": { + "$ref": "#/$defs/IdentityDomain" + }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, + "title": "Identity Domains", + "type": "object" + }, "infrastructure": { "additionalProperties": { "$ref": "#/$defs/InfraNode" diff --git a/contracts/schemas/sdl/sdl-authoring-input-v1.json b/contracts/schemas/sdl/sdl-authoring-input-v1.json index 426b7fc97..cf3e371a3 100644 --- a/contracts/schemas/sdl/sdl-authoring-input-v1.json +++ b/contracts/schemas/sdl/sdl-authoring-input-v1.json @@ -112,6 +112,11 @@ "default": false, "title": "Disabled" }, + "domain_ref": { + "default": "", + "title": "Domain Ref", + "type": "string" + }, "groups": { "items": { "type": "string" @@ -2661,6 +2666,59 @@ "title": "FeatureType", "type": "string" }, + "IdentityDomain": { + "additionalProperties": false, + "description": "Scenario-scoped authored identity domain.", + "properties": { + "authority_account_ref": { + "minLength": 1, + "title": "Authority Account Ref", + "type": "string" + }, + "dns_name": { + "minLength": 1, + "title": "Dns Name", + "type": "string" + }, + "netbios_name": { + "minLength": 1, + "title": "Netbios Name", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "$ref": "#/$defs/IdentityDomainProfile" + }, + { + "not": { + "pattern": "[\\r\\n]" + }, + "pattern": "^\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}$", + "type": "string", + "x-aces-variable-reference": true + } + ], + "title": "Profile" + } + }, + "required": [ + "profile", + "dns_name", + "netbios_name", + "authority_account_ref" + ], + "title": "IdentityDomain", + "type": "object" + }, + "IdentityDomainProfile": { + "description": "Closed profiles whose controller/join semantics are realizable.", + "enum": [ + "active_directory" + ], + "title": "IdentityDomainProfile", + "type": "string" + }, "ImageAttestation": { "additionalProperties": false, "description": "Observed build-attestation availability and verification result.\n\nAttestation *availability* (``status``) and *verification result*\n(``verification``) are deliberately separate facts: a mutable local image\ntag with no registry-visible OCI/in-toto/SLSA attestation is not the same\nstate as a failed verification (ADR-023 \u00a75).", @@ -5102,6 +5160,28 @@ "title": "Description", "type": "string" }, + "domain_controller": { + "anyOf": [ + { + "$ref": "#/$defs/RelationshipDomainController" + }, + { + "type": "null" + } + ], + "default": null + }, + "domain_join": { + "anyOf": [ + { + "$ref": "#/$defs/RelationshipDomainJoin" + }, + { + "type": "null" + } + ], + "default": null + }, "forwarding_edge": { "anyOf": [ { @@ -5203,6 +5283,32 @@ "title": "RelationshipDatabaseAccess", "type": "object" }, + "RelationshipDomainController": { + "additionalProperties": false, + "description": "Typed marker for a node-to-domain controller-role edge.", + "properties": {}, + "title": "RelationshipDomainController", + "type": "object" + }, + "RelationshipDomainJoin": { + "additionalProperties": false, + "description": "Typed member join with explicit ordered controller candidates.", + "properties": { + "controller_refs": { + "items": { + "type": "string" + }, + "minItems": 1, + "title": "Controller Refs", + "type": "array" + } + }, + "required": [ + "controller_refs" + ], + "title": "RelationshipDomainJoin", + "type": "object" + }, "RelationshipForwardingEdge": { "additionalProperties": false, "description": "Typed forwarding-trust detail carried by a top-level relationship edge.\n\nThe inter-node trust edge between a forwarding / intel-sync agent\n(``forwarder_ref``, a ``forwarding_agent_id``) and the downstream consumer\nit ships to. The consumer's transport listener is named by\n``target_listener_role`` \u2014 REUSING the manager-side\n``RuntimeSecurityMonitoringListenerRole`` lattice rather than forking a\nparallel enum (SCN-010 \u00a75.7).\n\nThe agent enrollment identity itself is never recorded: only the closed\n``RuntimeForwardingEnrollmentClassification`` lattice (``none`` /\n``redacted`` / ``operator_secret``). A present enrollment-identity ref must\nclassify ``redacted`` / ``operator_secret`` \u2014 a raw identity is never the\nportable model.", @@ -5503,7 +5609,9 @@ "connects_to", "depends_on", "manages", - "replicates_to" + "replicates_to", + "domain_controller_for", + "joins_domain" ], "title": "RelationshipType", "type": "string" @@ -17981,6 +18089,22 @@ "title": "Forwarding Agents", "type": "array" }, + "identity_domains": { + "additionalProperties": { + "$ref": "#/$defs/IdentityDomain" + }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, + "title": "Identity Domains", + "type": "object" + }, "imports": { "items": { "$ref": "#/$defs/ImportDecl" diff --git a/docs/conformance/libvirt-qemu.provisioning-only.report.json b/docs/conformance/libvirt-qemu.provisioning-only.report.json index 770d31133..1439b12a5 100644 --- a/docs/conformance/libvirt-qemu.provisioning-only.report.json +++ b/docs/conformance/libvirt-qemu.provisioning-only.report.json @@ -1,6 +1,6 @@ { "profile": "provisioning-only", - "passed": true, + "passed": false, "cases": [ { "name": "feature-support-bounded", @@ -241,18 +241,14 @@ "diagnostic_codes": [] }, { - "name": "target-provisioning", - "contract_name": "operation-status-v1", + "name": "realization-envelope-constructive", + "contract_name": "realization-envelope-v1", "valid": true, - "passed": true, - "diagnostic_codes": [] - }, - { - "name": "target-snapshot", - "contract_name": "runtime-snapshot-v1", - "valid": true, - "passed": true, - "diagnostic_codes": [] + "passed": false, + "diagnostic_codes": [ + "realization-envelope.negative-probe.no-witness", + "realization-envelope.positive-probe.no-witness" + ] } ], "unsupported_contract_gaps": [], diff --git a/docs/decisions/adrs/README.md b/docs/decisions/adrs/README.md index 9696703c6..7489bd214 100644 --- a/docs/decisions/adrs/README.md +++ b/docs/decisions/adrs/README.md @@ -125,6 +125,9 @@ adr-077-associated-artifact-manifest-boundary adr-078-closed-sdl-phase-contracts-and-portable-derivation-evidence adr-079-backend-neutral-proposition-and-truth-semantics adr-080-revision-pinned-sdl-lineage-and-provenance-ledger +adr-081-behavioral-relation-taxonomy-and-claim-discipline +adr-082-authored-identity-domain-topology +adr-083-participant-tool-decision-surface-and-exposure-semantics ``` | ADR | Title | Status | Date | @@ -211,3 +214,5 @@ adr-080-revision-pinned-sdl-lineage-and-provenance-ledger | [079](adr-079-backend-neutral-proposition-and-truth-semantics.md) | Backend-Neutral Proposition and Truth Semantics | accepted | 2026-07-12 | | [080](adr-080-revision-pinned-sdl-lineage-and-provenance-ledger.md) | Revision-Pinned SDL Lineage And Provenance Ledger | accepted | 2026-07-12 | | [081](adr-081-behavioral-relation-taxonomy-and-claim-discipline.md) | Behavioral-Relation Taxonomy And Claim Discipline | accepted | 2026-07-13 | +| [082](adr-082-authored-identity-domain-topology.md) | Authored Identity-Domain Topology | accepted | 2026-07-13 | +| [083](adr-083-participant-tool-decision-surface-and-exposure-semantics.md) | Participant Tool, Decision-Surface, and Exposure Semantics | proposed | 2026-07-14 | diff --git a/docs/decisions/adrs/adr-082-authored-identity-domain-topology.md b/docs/decisions/adrs/adr-082-authored-identity-domain-topology.md new file mode 100644 index 000000000..6a8da5cbe --- /dev/null +++ b/docs/decisions/adrs/adr-082-authored-identity-domain-topology.md @@ -0,0 +1,129 @@ +# ADR-082: Authored Identity-Domain Topology + +## Status + +accepted + +## Date + +2026-07-13 + +## Classification + +Classification: FM2 + +Required artifacts: an explicit invariant list, a typed normalized compiler +projection, property-oriented semantic tests, cross-stage differential tests, +and admission/readback failure tests. + +Waivers: no model checker is required. The topology is a finite, scenario-local +graph whose invariants are exhaustively checked by a pure analyzer and replayed +over the compiled provisioning plan. + +## Context + +ADR-032 defines observed directory and domain identity inventory under a +node's runtime state. That evidence can report what exists, but it cannot tell a +provisioner what domain to create, which nodes are controllers, or which +controller candidates a member may join. Inferring those facts from operating +systems, account names, SPNs, or observed inventory would make realization +backend-specific and non-reproducible. + +Domain-backed scenario realization therefore needs a separate authored +topology with enough information to validate, compile, order, admit, and verify +the requested state without handling credentials or embedding provider +configuration. + +## Decision + +1. SDL gains a scenario-scoped `identity_domains` map. Its initial closed + profile is `active_directory`, with a DNS name, NetBIOS name, and an explicit + authority account reference. This is realization intent, not runtime + observation. +2. Controller roles and membership are typed relationship edges: + `domain_controller_for` carries `domain_controller`, while `joins_domain` + carries `domain_join.controller_refs`. Controller candidates are explicit, + ordered, non-empty, and unique. Generic relationship properties and node + flags are not topology authority. +3. A domain-scoped account carries `domain_ref`. An SPN requires that explicit + binding. The domain authority account is declared separately by the domain; + no account name, username, or SPN is interpreted as an implicit domain. +4. One pure name-level analyzer owns topology consistency. The semantic + validator renders its issues, and the compiler consumes its normalized node + and account bindings. The initial Active Directory profile enforces these + invariants: + + - every declared domain has at least one VM controller; + - relationship type and typed detail agree; + - duplicate controller and join facts are rejected; + - a node cannot join a domain it controls or belong to multiple Active + Directory domains; + - each member's controller candidates control that same domain; + - the authority account is placed on one of the domain's controllers; + - every domain-bound account is placed on a node in that domain; and + - every SPN-bearing account has an explicit domain binding. +5. Compilation emits a `DomainTopologyBinding` on each participating node and + domain-bound account. It contains the normalized domain identity, profile, + authority account address, node role, and canonical controller addresses. + Member nodes order after every selected controller. Account placements order + after their target node. +6. Provisioner capability truth uses the governed + `supported_domain_profiles` dimension. Account creation and SPN support do + not imply controller or join realization support. +7. A shared plan analyzer checks resources, non-delete operations, and admitted + snapshot state. It runs in normal planning and at direct control-plane + admission before backend validation. Backends may add stricter checks but + cannot weaken this graph contract. +8. Each compiled topology carrier is an exact SEM-218 `domain-topology` + realization requirement. Runtime snapshot readback must preserve the whole + normalized binding; omission or approximation is a backend-contract error. +9. This decision does not define directory installation, DNS service setup, + credential distribution, trust forests, group policy, or a provider-specific + domain API. Those require separate profiles and capability declarations. + +## Alternatives Considered + +### Infer topology from accounts, SPNs, or Windows nodes + +Rejected. The same inventory can represent a standalone host, a member, or a +controller, and inference would make validation and replay ambiguous. + +### Store controller and membership flags on nodes + +Rejected. Flags cannot express a typed target domain, ordered controller +candidates, or relationship-level provenance without parallel ad hoc fields. + +### Reuse observed runtime directory identity + +Rejected. It would collapse author intent and observed evidence, contradicting +ADR-032 and weakening SEM-218 non-approximation checks. + +### Treat domain realization as an account feature + +Rejected. Creating an account or preserving an SPN is materially weaker than +creating a domain controller or joining a machine to a domain. + +## Consequences + +### Positive + +- Domain-backed realization is explicit, portable, and deterministic. +- Semantic validation, compilation, planning, direct admission, and readback + share one normalized graph contract. +- Backends fail closed when they do not claim the requested domain profile. +- Observed runtime identity remains an independent evidence surface. + +### Negative + +- Scenario authors must declare controller and join edges explicitly. +- Backend manifests and realization envelopes gain another governed capability + dimension. +- Additional domain profiles will require explicit invariants, compiler + projection rules, and backend conformance evidence. + +### Limits + +The topology proves declared intent and carrier consistency; it does not prove +that a guest successfully promoted a controller or completed a domain join. +That operational claim requires backend or guest evidence under a separately +defined realization mechanism. diff --git a/docs/decisions/adrs/adr-083-participant-tool-decision-surface-and-exposure-semantics.md b/docs/decisions/adrs/adr-083-participant-tool-decision-surface-and-exposure-semantics.md new file mode 100644 index 000000000..a4d37800b --- /dev/null +++ b/docs/decisions/adrs/adr-083-participant-tool-decision-surface-and-exposure-semantics.md @@ -0,0 +1,311 @@ +# ADR-083: Participant Tool, Decision-Surface, and Exposure Semantics + +## Status + +proposed + +## Date + +2026-07-14 + +## Classification + +Classification: FM2 +Required artifacts: ADR, formal specification, clause-to-contract-to-test matrix +Waivers: Executable SDL fields, compiler records, runtime projections, portable +contracts, schemas, fixtures, and conformance tests are owned by implementation +issues #294, #295, and #296. + +## Context + +Issue #119 is the joint design surface for three requirements that describe one +participant-local boundary: + +- `SEM-219` requires explicit tool and affordance availability, visibility, + invocation, and constraint semantics; +- `SEM-220` requires explicit semantics for open-ended, constrained-form, and + candidate-set decision surfaces; and +- `SEM-226` requires explicit visible-versus-hidden, exposure, withholding, + augmentation, and role-scoping semantics across those surfaces. + +The repository already owns the adjacent authorities. ADR-020 owns authored +participant identity, authority, and operating scope. ADR-022 and the participant +formal specification own action contracts, the time-indexed participant view +relation `V_p,t`, observation boundaries, visibility transitions, and fail-closed +action applicability. ADR-041 owns participant-implementation capability and +run-selection provenance. ADR-054 owns runtime observation and behavior history. +ADR-060 owns neutral backend-facing carriers. ADR-067 composes these surfaces +into the participant behavior model. + +Those authorities do not yet define a participant's current decision surface. +Existing fields can state that an implementation supports a decision-control +mode, expects a category of tool affordance, or selected an exposure policy. +They do not by themselves say which actions or affordances were available to +one participant at one order point, why they were visible, whether they were +eligible or invocable, what constraints applied, or what was actually exposed. + +Without a joint decision, implementations could collapse tool labels into +action meaning, infer participant visibility from global state, treat backend +support as authority, use decision-control modes as surface contents, or infer +selection and outcome merely because an action appeared on a surface. + +## Decision + +Define participant tool, decision-surface, and exposure semantics as one +time-indexed projection over the existing participant semantic model. The +projection is semantic authority; a UI, prompt, API payload, backend object, or +participant implementation is only a realization of it. + +### 1. Keep seven authority layers distinct + +The design separates seven independently reviewable facts: + +1. **Action meaning** is carried by governed participant action contracts. +2. **Authored availability** binds a participant or behavior specification to + action contracts, observation boundaries, authority, and operating scope. +3. **Apparatus support** declares what a participant implementation and backend + claim they can realize. +4. **Run selection** records the implementation, control mode, configuration, + and exposure policy selected for a run. +5. **Current decision surface** projects visible context, action-contract + candidates, affordances, eligibility state, constraints, and limitations for + one participant and episode at one observation/order point. +6. **Realized exposure** records what the apparatus actually made available, + with evidence, provenance, redaction, weakening, and loss disclosures. +7. **Decision and outcome** record the selected attempt and its result. + +No field may implicitly carry more than one of these authorities. An apparatus +expectation is not an authored grant, a grant is not proof of delivery, +visibility is not eligibility, eligibility is not admission, and presentation +is not selection or successful execution. + +### 2. Define a participant-local decision-surface projection + +For participant `p`, episode `e`, and observation/order point `o`, define: + +```text +D(p, e, o) = Project( + behavior and action-contract refs, + V(p, o) and the observation boundary, + participant context and audience scope, + implementation selection and control mode, + exposure policy, + SEM-211 eligibility state, + realized affordance and support disclosures, + evidence, provenance, marking, redaction, and limitations +) +``` + +`D(p,e,o)` contains stable references and disclosed relations, not duplicated +world truth, raw policies, hidden prompts, evaluator state, or backend-native +objects. It is scoped to one participant and episode and anchored to a specific +observation/order point. A cumulative or final surface cannot substitute for +the sequence of surfaces when exposure changed during the episode. + +The portable projection has these conceptual parts: + +- surface form and selection meaning; +- behavior, action-contract, observation-boundary, context-view, + implementation-selection, and exposure-policy references; +- visible context references with source layer, transformation, marking, + redaction, disclosure basis, evidence, provenance, and limitations; +- action entries with presentation or generation basis, visibility state, + eligibility state, constraint references, and realization disclosures; +- affordance entries bound to action contracts and observation effects; and +- the order/event/evidence anchor used to derive the surface. + +Implementation issue #295 must first test whether +`ParticipantContextViewModel` can carry the portable envelope and a stable +typed payload reference without weakening its SEM-214/216 invariants. A new +published decision-surface contract is permitted only if that reuse-first test +fails. It must still compose, rather than duplicate, the referenced contracts. + +### 3. Preserve three distinct tool concepts + +A **tool or artifact identity** names a thing. A **tool affordance** is a +participant-meaningful opportunity to perform an operation and is bound to an +action contract, participant authority/scope, constraint semantics, observation +effects, and evidence expectations. A **tool-affordance expectation** is an +apparatus capability declaration from +`participant-tool-affordance-expectations`. + +A label such as `shell`, `browser`, `http-api`, an executable name, an ATT&CK +technique, or a UI control does not define an action contract. A flat `tools` +list cannot express availability, visibility, invocation authority, support, +constraints, side effects, or realized exposure and is therefore not a valid +portable semantic surface. + +For each affordance, implementations must keep at least these predicates +separate: + +- declared or authored for the participant; +- visible at the current order point; +- supported by the selected apparatus; +- eligible under the action contract and current state; +- invocable/admitted for a particular attempt; and +- realized and evidenced by the runtime. + +An implementation may report `false`, `unknown`, `unsupported`, or a typed +failure at the owning predicate. It must not infer the remaining predicates. + +### 4. Define the three decision-surface forms without changing action meaning + +The semantic surface distinguishes these forms: + +- **Open-ended generation** permits a participant implementation to propose an + action and arguments. The proposal must resolve to a governed action contract, + validate its argument shape, and pass SEM-211 eligibility/admission before it + becomes an attempt. Generation authority is not execution authority. +- **Constrained form** maps a bounded form, grammar, or parameter editor to an + action contract. Defaults, normalization, omitted values, validation, and any + lossy mapping are part of selection meaning and must be disclosed. +- **Candidate-action set** presents a participant-local set of action-contract + entries. Selection identifies one member and its arguments; non-members are + invalid unless an explicit open-extension rule subsequently binds them to a + governed contract and applies the same admission gates. + +These categories describe surface content and selection meaning. They do not +replace the `participant-decision-surface-modes` vocabulary, which describes how +an implementation makes or relays decisions. Participant implementation kind +may change the realization but not the semantics of a form. + +Candidate membership and action eligibility are different. A surface may +honestly show an ineligible action when it also carries the ineligibility state +and reason basis. A surface must not mark an action eligible from global or +future-visible state, or allow open-ended generation to bypass applicability. + +### 5. Refine the existing visibility boundary + +`SEM-226` composes the existing `V_p,t`, view-rule, view-transition, +observation-boundary, context-view, and audience-view semantics. It introduces +no second visible/hidden taxonomy. + +An item may enter `D(p,e,o)` only when all applicable boundaries agree: + +- the item is in `V_p,o` under the compiled observation boundary; +- the source layer and transformation are valid for the participant-facing + view; +- audience/role scope includes `p` without relying on another participant's + authority; +- markings, redaction, withholding, and loss disclosures are satisfied; +- the exposure policy authorizes the class of disclosure; and +- a visibility transition at or before `o` supplies the required event and + evidence anchor when exposure changed over time. + +Observations, control-context artifacts, truth assets, adjudication assets, +private references, scaffold guidance, and augmentation metadata remain +distinct source classes. Augmentation must name its source, transformation, +audience, visibility basis, evidence/provenance, and limitations. It cannot be +smuggled into a generic context or metadata map. + +Future disclosure never justifies earlier exposure. Backend reachability, +operating scope, participant authority, control-plane authorization, and +information visibility are separate boundaries. + +### 6. Preserve cross-stage meaning and evidence + +Authoring declares semantic bindings; validation resolves references and fails +closed; compilation emits canonical addresses and projection inputs; planning +checks apparatus support and declared weakening; execution applies SEM-211 +admission; observation records surface and visibility transitions; conformance +checks that the realized surface agrees with the authored, compiled, selected, +and evidenced facts. + +Runtime evidence must preserve the participant, episode, order point, surface +derivation basis, selected implementation and exposure policy, action/affordance +references, decision, result, and any weakening. Raw logs are not semantic +evidence, and final outcome records do not reconstruct an earlier surface. + +### 7. Bind the implementation issues to one matrix + +The normative clause-to-contract-to-test matrix lives in +`specs/formal/participant-semantics/README.md`. Issues #294, #295, and #296 must +implement their rows through existing package ownership and must update the +matrix when a carrier or enforcement point changes. They may strengthen a row +but may not redefine the joint relations independently. + +## Alternatives Considered + +### Add a flat participant `tools` list + +Rejected. A list cannot distinguish semantic affordances from apparatus +expectations or express visibility, applicability, constraints, side effects, +realization, or evidence. + +### Put decision-surface contents in the decision-control mode + +Rejected. Modes describe how decisions are made or relayed. Overloading them +with actions, observations, prompts, or policy bodies would duplicate semantics +and break comparability. + +### Treat backend support or participant implementation kind as semantics + +Rejected. Both are apparatus facts. They may support or weaken a semantic +contract but cannot grant participant authority or change action meaning. + +### Publish a new standalone visibility or persistence stack + +Rejected. `V_p,t`, observation boundaries, context/audience views, runtime +snapshots, control-plane storage, behavior history, evidence, and provenance +already own those concerns. + +### Require all presented actions to be eligible + +Rejected. Some participant surfaces intentionally disclose unavailable choices. +The portable requirement is explicit eligibility state and fail-closed +admission, not suppression of every ineligible item. + +## Consequences + +### Positive + +- Tool/affordance, decision-surface, and exposure implementations share one + participant-local, time-indexed theory. +- Candidate presentation, eligibility, admission, selection, and outcome can be + audited independently. +- Hidden truth and evaluator-only material remain behind existing view and + audience boundaries. +- Different participant implementations and backends can realize the same + semantic surface while disclosing support and fidelity differences. + +### Negative / costs + +- Implementations must carry stable references, order/event anchors, and + evidence/provenance instead of convenient flat lists or metadata maps. +- Reviewers must distinguish several closely related predicates rather than + accepting a single availability boolean. +- Cross-stage fixtures must cover both semantic and apparatus disagreement. + +### Risks + +- A future DTO may duplicate referenced action, observation, exposure, or + implementation contracts instead of composing them. +- An implementation may compute candidate actions from hidden, global, stale, + or future-visible state. +- A backend may treat support as permission, or a participant implementation may + bypass SEM-211 during open-ended generation. +- A final surface snapshot may erase exposure changes and their evidence + anchors. + +## Non-Goals + +- Defining a participant UI, prompt format, external-agent API, tool runner, + shell/RPC protocol, policy engine, credential broker, or OS sandbox. +- Adding SDL fields, compiler/runtime records, contracts, schemas, fixtures, + storage, or conformance code in this design issue. +- Replacing action contracts, SEM-210 visibility, SEM-211 applicability, + SEM-214 context views, SEM-216 audience boundaries, or participant + implementation provenance. +- Standardizing trajectories, demonstrations, budgets, quotas, reward, or full + clock semantics. + +## References + +- `specs/formal/participant-semantics/README.md` +- [ADR-020](adr-020-declarative-participant-framing-boundaries.md) +- [ADR-022](adr-022-participant-behavior-and-interaction-semantics.md) +- [ADR-041](adr-041-participant-implementation-manifest-and-provenance.md) +- [ADR-054](adr-054-participant-runtime-observable-lifecycle.md) +- [ADR-060](adr-060-participant-backend-facing-contract-surface.md) +- [ADR-067](adr-067-participant-behavior-model.md) +- GitHub issues #119, #294, #295, and #296 diff --git a/docs/decisions/adrs/adr-index.yaml b/docs/decisions/adrs/adr-index.yaml index 866cce0e3..abdf6e25c 100644 --- a/docs/decisions/adrs/adr-index.yaml +++ b/docs/decisions/adrs/adr-index.yaml @@ -361,3 +361,6 @@ adrs: - id: ADR-081 path: docs/decisions/adrs/adr-081-behavioral-relation-taxonomy-and-claim-discipline.md pin: c8e096b85edb7ca97217a8bb0ac74b8817f966b9cf42b66f114dc3a45ed83a90 + - id: ADR-082 + path: docs/decisions/adrs/adr-082-authored-identity-domain-topology.md + pin: 0d5d4997bb2e3abf2ddf2bf9d6d345429a99deef5f343c6ca1aba4b8ebc3a95c diff --git a/docs/decisions/issue-119-sem-219-220-226-participant-decision-surface-preflight.md b/docs/decisions/issue-119-sem-219-220-226-participant-decision-surface-preflight.md new file mode 100644 index 000000000..6750c74fc --- /dev/null +++ b/docs/decisions/issue-119-sem-219-220-226-participant-decision-surface-preflight.md @@ -0,0 +1,347 @@ +# Issue 119 Participant Tool, Decision-Surface, And Exposure Preflight + +Date: 2026-07-14 + +Issue: #119. + +Requirements: none. The GitHub issue title, body, and acceptance criteria are +the contract. `SEM-219` is the branch-CI anchor for the joint `SEM-219`, +`SEM-220`, and `SEM-226` design. + +This note records architecture guardrails for the joint ADR and formal-spec +work. It is non-normative preflight guidance only: it does not publish that ADR +or specification, add SDL syntax or contracts, implement spawned issues #294, +#295, or #296, or claim semantic coverage. + +## Binding Sources + +- ADR-020 owns authored participant identity, role, starting conditions, + authority anchors, and operating scope on `agents.*`. Scenario authority and + scope are not control-plane authorization, OS identity, or apparatus + capability. +- ADR-022 and `specs/formal/participant-semantics/README.md` own participant + action contracts, the time-indexed view relation `V_p,t`, observation + boundaries, visibility transitions, hidden-truth separation, fail-closed + applicability, and evidence-labeled runtime meaning. The joint design extends + that semantic model; it must not create a second action or visibility model. +- ADR-041 owns participant-implementation manifests and run-level selection + provenance. `supported_decision_surface_modes` and + `tool_affordance_expectations` are apparatus capability declarations; + `selected_decision_surface_mode` and `ParticipantExposurePolicyModel` record + run selection. None is authored participant meaning or proof of realized + exposure by itself. +- ADR-054 and `specs/formal/participant-runtime/` own participant runtime + lifecycle, observation envelopes, behavior history, shared state, concurrency, + markings, redaction, loss, and information guarantees. +- ADR-060 and + `specs/formal/runtime-contracts/participant-backend-contracts.md` own neutral + backend-facing carriers, feature-support disclosures, retrieval projections, + and outcome reports. +- ADR-067 and `specs/formal/participant-behavior-model/README.md` own behavior + specifications as aggregates over the existing participant surfaces. The + current `behavior_mode` uses `participant-decision-surface-modes`; it is a + control/selection mode, not the content of a decision surface. +- SEM-214 and SEM-216 already define participant context views and the boundary + among live state, archival evidence, derived results, analysis, and + audience-specific views. A decision surface is not a new source-of-truth + stratum. +- ADR-066 and the SEM-224 observability-plane design keep backend logs, + telemetry, diagnostics, evidence, derived analysis, and participant-visible + observations in separate carriers. +- ADR-009, ADR-012, ADR-036, ADR-061, and the authority-boundary manifest govern + normative artifact authority, shared concepts, package ownership, and schema + evolution. + +## Architecture Decisions And Guardrails + +### Keep the semantic axes separate + +The joint design must distinguish these independently reviewable facts: + +1. **Action meaning** is owned by governed participant action contracts. +2. **Authored availability** binds a participant or behavior specification to + action contracts, observation boundaries, authority, and operating scope. +3. **Apparatus support** declares which control modes, participant contracts, + and tool-affordance categories an implementation supports or expects. +4. **Run selection** records the selected implementation, control mode, + configuration reference/digest, and exposure policy. +5. **Current decision surface** is a participant- and episode-local projection + at an explicit observation/order point over visible context, candidate + action-contract references with explicit eligibility/admission state, + exposed affordance references, and declared limitations. +6. **Realized exposure** records what the runtime actually projected or made + available, with visibility, policy, evidence, provenance, and weakening + disclosures. +7. **Decision and outcome** are the selected action attempt and its result; + neither may be inferred merely because an item appeared on a surface. + +No field or schema should carry more than one of those authorities implicitly. +In particular, an implementation expectation is not an exposure grant, an +exposure grant is not proof of delivery, and visibility is not action +applicability. + +### Extend the existing participant semantic model + +- The issue should publish a new ADR for the joint decision and extend + `specs/formal/participant-semantics/README.md` with the `SEM-219`, `SEM-220`, + and `SEM-226` sections. Do not fork a parallel participant-semantics formal + tree or revise the accepted ADR-022 decision in place. +- The formal design should define the surface as a projection relation over the + existing sets and histories, not as a UI/widget tree or backend DTO. At + minimum, the relation must be parameterized by participant, episode, + observation/order point, behavior/action-contract set, observation boundary + or view relation, implementation selection, exposure policy, and realization + disclosures. +- The design must distinguish declared actions, currently eligible actions, + admitted attempts, selected actions, and completed outcomes. Eligibility is + fail-closed over authority, target, knowledge, resource, temporal, + interaction, and realization preconditions from SEM-211; the design must not + reimplement those predicates. +- `SEM-226` must compose or refine the existing `V_p,t`, view-rule, + view-transition, observation-boundary, and audience-view rules. If it adds a + cross-participant or surface-specific boundary, that boundary must be stated + as an additional projection/constraint over those incumbents, not a second + `visible/hidden` taxonomy. +- The current `### I1` through `### I18` headings are executable-test inputs to + `test_participant_semantics_invariant_oracle.py`. Design-only changes may + state new obligations and map them to existing invariants, but must not add a + new `### I*` heading without intentionally extending that executable oracle in + a spawned implementation issue. + +### Preserve the three distinct tool concepts + +- A **tool or artifact identity** names the thing and belongs to the existing + concept-authority/reference surfaces. +- A **tool affordance** states a participant-meaningful operation or interaction + opportunity. It must bind to action contracts, authority/scope, and + observation effects; a label such as `shell`, `browser`, or `http-api` is not + an executable action contract. +- A **tool-affordance expectation** is an apparatus manifest claim from + `participant-tool-affordance-expectations`. It states what an implementation + expects, not what the scenario author grants or what the runtime exposed. + +`ParticipantExposurePolicyModel.tool_affordance_refs` may record selected +run-level references, but its free-form `constraints` map must not become the +normative affordance language. Realized availability needs explicit evidence or +provenance rather than inference from the manifest or policy alone. + +### Preserve the two distinct decision-surface concepts + +- `participant-decision-surface-modes` governs how an implementation makes or + relays decisions (`autonomous`, `human-supervised`, `scripted`, and related + terms). Reuse it for mode support and selection. +- The decision surface's **content and exposure** are a participant-visible + projection. They require stable refs to existing contracts and view/policy + bases; they are not another mode enum. + +Do not rename or overload `behavior_mode`, +`supported_decision_surface_modes`, or `selected_decision_surface_mode` to carry +action lists, tool instances, prompts, instructions, observations, or policy +bodies. + +### Reuse a view carrier before publishing another DTO + +If later implementation needs a portable retrieval/exchange carrier, first test +whether `ParticipantContextViewModel` can express it through participant and +episode scope, observation point, governed source layers, transformation, +`payload_ref`, visibility projection, markings, redaction policy, evidence, +provenance, limitations, and comparability. Stable action-contract and +affordance refs may remain referenced content rather than duplicated payloads. + +A new published decision-surface contract is justified only if an independently +portable payload cannot be represented without weakening those context-view +invariants. It must not duplicate action-contract, observation-boundary, +exposure-policy, status/history-view, or participant implementation fields. + +## Required Incumbents + +- SDL ingress and authored semantics: `parse_sdl()`, `parse_sdl_file()`, safe + YAML normalization, `SDLModel(extra="forbid")`, `Scenario`, + `InstantiatedScenario`, variable-key rejection, `agents.*`, + `action_contracts`, `observation_boundaries`, `behavior_specifications`, and + existing authority/scope fields. +- SDL semantic validation: `SemanticValidator`, + `analyze_participant_behavior()`, participant outcome analysis, named and + targetable reference validation, controlled-vocabulary validation, and full + post-instantiation revalidation. +- Compiled semantic addresses: `ParticipantBehaviorRuntime`, + `ParticipantBehaviorSpecificationRuntime`, + `ParticipantActionContractRuntime`, + `ParticipantObservationBoundaryRuntime`, and the existing + `participant.*` address families produced by `aces_processor.compiler`. +- Visibility and audience projections: `ParticipantViewRule`, + `ParticipantViewTransition`, `view_relation_timeline`, + `ParticipantObservationEnvelopeModel`, `ParticipantContextViewModel`, + `ParticipantStatusViewModel`, `ParticipantHistoryViewModel`, and the SEM-216 + audience-boundary validators. +- Apparatus and selection: `ParticipantImplementationManifestModel`, + `ParticipantImplementationCapabilitiesModel`, + `ParticipantImplementationSelectionModel`, `ParticipantExposurePolicyModel`, + `ParticipantImplementationProvenanceModel`, + `validate_experiment_apparatus_context_against_manifests()`, and + `participant_action_admission_request_violations()`. +- Runtime admission and evidence: `ParticipantActionAdmissionRequest`, + `RuntimeControlPlane`, `ParticipantRuntime.admit_action()`, + `ParticipantBehaviorHistoryEventModel`, `ParticipantActionResultModel`, + `RuntimeSnapshot`, participant episode/behavior/history validators, + shared-state and concurrency validators, and runtime conformance diagnostics. +- Contract and concept authority: `ContractModel`, `schema_bundle()`, + `contracts/schemas/`, `contracts/fixtures/`, + `contracts/schema-publication-manifest.json`, + `controlled-vocabularies-v1`, concept bindings, + `validate_controlled_vocabulary_scope_values()`, and governed + `x-:` extensions. +- Security, persistence, errors, and audit: + `ControlPlaneSecurityConfig.strict_defaults()`, `ControlPlaneIdentity`, + `ControlPlaneRole`, request-size guards, request fingerprints, idempotency + keys, `AuditEvent`, `ControlPlaneStore`, `InMemoryControlPlaneStore`, + `LocalControlPlaneStore`, `Diagnostic`, `Severity`, `OperationReceipt`, + `OperationStatus`, and the redacted FastAPI internal-error handler. +- Verification patterns: the existing participant semantic invariant oracle, + SEM-208/210 and SEM-216 negative leakage tests, participant-manifest fixtures, + participant-backend contract fixtures, control-plane authorization tests, + runtime snapshot/conformance tests, and controlled-vocabulary tests. +- Repository policy: `.ground-control.yaml`, `.gc/plan-rules.md`, + `noxfile.py`, `tools/check_repo_policy.py`, + `tools/check_requirement_governance.py`, + `tools/check_concept_authority_governance.py`, + `tools/check_generated_schemas.py`, `tools/check_schema_publication.py`, + `tools/check_json_artifacts.py`, `tools/check_semantic_coverage.py`, and + `tools/verify_all.py`. + +## Cross-Cutting Layers + +- **SDL and configuration shape:** any future authored surface must pass safe + parsing, normalized keys, closed SDL models, stable symbol keys, variable + rules, semantic reference resolution, and instantiated-scenario revalidation. + Do not compile a decision surface from raw YAML dictionaries or backend + config. +- **Concept and vocabulary authority:** reuse the existing decision-mode, + tool-affordance-expectation, and exposure-policy vocabularies only for their + current meanings. Add a governed term or concept binding only when portable + comparison requires it; do not create artifact-local enums or accept arbitrary + synonyms. +- **Contract/schema shape:** portable payloads must be closed `ContractModel` + descendants and pass model plus JSON Schema validation. Published schema + changes require normative schema review, reference `schema_bundle()` parity, + valid/invalid fixtures, a publication-manifest change-ledger entry, and + compatibility classification under ADR-061. +- **Participant visibility:** every participant-visible item must resolve at the + relevant observation/order point through the compiled observation boundary, + `V_p,t` timeline, exposure policy, source-layer transformation, audience + scope, marking, redaction policy, evidence/provenance basis, and any disclosed + weakening. Future visibility cannot justify earlier exposure. +- **Action admission:** a visible action or affordance is not necessarily + eligible. Any admission path must reuse compiled action-contract addresses and + SEM-211 authority, capability, target, knowledge, resource, temporal, + interaction, and realization checks, then emit existing behavior-history and + action-result records. +- **Apparatus and provenance:** manifest support, selected mode, configuration + ref/digest, and exposure policy must validate through the existing participant + implementation and experiment apparatus/run models. Capability claims do not + prove selection or realization; actual exposure requires runtime evidence and + provenance. +- **Authentication and authorization:** any future HTTP read or mutation must + enter through `create_control_plane_app()`, fail-closed security defaults, + bearer/proxy identity validation, read-versus-mutating role dependencies, + target binding, request-size limits, idempotency/request fingerprints for + mutations, and audit recording. Participant authority, surface visibility, + and control-plane caller authorization remain three different checks. +- **Secret handling and error envelopes:** credentials, bearer tokens, private + keys, hidden prompts, answer keys, canaries, raw policy/configuration bodies, + raw evidence payloads, environment dumps, backend-native object reprs, and + full tracebacks must not enter SDL, portable contracts, context views, + snapshots, diagnostics, audit details, fixtures, or public HTTP details. Use + references, digests, markings, redaction policies, disclosure bases, + limitations, and evidence/provenance records. Expected failures use collected + SDL validation issues, structured `Diagnostic` values and operation + envelopes, or bounded existing HTTP 4xx details; unexpected failures use the + redacted 500 envelope. Do not add a participant-surface exception hierarchy. +- **Environment and OS exposure:** the semantic design introduces no new env + binding or command-line shape. A later adapter must not put credentials, + prompts, policy bodies, hidden material, or arbitrary user values in process + argv, environment dumps, stdout/stderr, or shell command strings; it must use + injected providers, controlled working directories, bounded timeouts, fixed + invocation shapes, and no `shell=True`. +- **Persistence and observability:** live state remains in first-class + `RuntimeSnapshot` fields and `ControlPlaneStore`; run/apparatus selection and + archival claims remain in participant implementation and experiment-run + provenance contracts. Reuse behavior history, observation envelopes, + diagnostics, audit events, and evidence records for observability. Do not add + a decision-surface store, cache, audit channel, log schema, or metadata/details + side channel, and do not treat raw logs as participant-visible evidence. +- **Package and policy boundary:** authored semantics stay in `aces_sdl`, + compiled projections in `aces_processor`, neutral DTOs in `aces_contracts`, + live control/persistence/security in `aces_runtime`, protocols in + `aces_backend_protocols`, and conformance in `aces_conformance`. The legacy + `implementations/python/src/aces/` tree is compatibility-only. + +## Extensibility Seam + +The reusable seam is a time-indexed decision-surface projection/selector, not a +backend-specific runner or UI model. Its semantic inputs are: + +- participant address and episode id; +- observation/order point and time/clock basis where relevant; +- behavior-specification and action-contract refs; +- observation-boundary/view-relation ref; +- participant implementation selection and decision-control mode; +- exposure-policy ref/version/digest and realized affordance refs; +- evidence, provenance, marking, redaction, limitation, and comparability refs. + +The next reasonable variants should fit by changing those parameters: another +participant implementation, a per-phase surface, a new governed affordance +category, a weaker backend realization, or another audience projection. A +per-phase or per-action surface should add an explicit selection-context +parameter, not overload the mode vocabulary or fork the visibility model. + +## Gotchas And Anti-Patterns + +Avoid: + +- treating a tool name, package, binary, browser, shell, HTTP endpoint, ATT&CK + label, CVE, command, prompt, or UI control as an ACES action contract; +- treating tool-affordance expectation, authored grant, run selection, realized + exposure, invocation, output observation, and evidence as one `tools` list; +- treating decision-control mode as decision-surface content, participant role, + interaction topology, authority, or control-plane permission; +- treating a visible action as currently applicable, authorized, supported, or + successfully admitted; +- treating network reachability, `operating_scope`, backend sandboxing, control + plane authorization, and information visibility as equivalent boundaries; +- collapsing `hidden`, `withheld`, `evidence_only`, `concealed`, `unsupported`, + `unknown`, and `not_applicable`; +- using future disclosure to justify earlier visibility or recording only the + final aggregate surface without its observation/order point; +- exposing world truth, raw archival evidence, evaluator state, private answer + material, hidden prompts, credentials, or backend-private ids through a + participant decision surface; +- turning `ParticipantExposurePolicyModel.constraints`, snapshot `metadata`, + history `details`, audit details, or logs into an untyped policy/surface bag; +- duplicating action, affordance, visibility, exposure, mode, failure, or + support vocabularies in Python, schemas, docs, CLI, API, or backend code; +- adding a second DTO layer, schema registry, validator stack, exception + hierarchy, persistence store, audit/log path, fixture loader, conformance + runner, or workflow/admission path; +- hand-editing implementation code as semantic authority, hand-editing a schema + without publication/generator parity, or weakening an accepted ADR in place; +- marking SEM-219, SEM-220, or SEM-226 active/complete from ADR/spec prose alone. + +## Non-Goals And Implementation Boundaries + +- Implementing the ADR/spec, spawned issues #294-#296, SDL fields, parsers, + validators, compiler records, runtime projections, contracts, schemas, + fixtures, APIs, adapters, storage, conformance, or tests in this preflight. +- Designing a participant UI, agent framework, shell/RPC protocol, generic tool + runner, prompt format, policy engine, credential broker, or OS sandbox. +- Replacing participant action contracts, SEM-210 visibility, SEM-211 + applicability/failure, SEM-214 context views, SEM-216 audience boundaries, + participant implementation provenance, or backend feature support. +- Redesigning control-plane authentication/authorization, persistence, audit, + diagnostics, schema publication, concept authority, observability/evidence + planes, or experiment-run provenance. +- Defining trajectories, demonstrations, budgets, quotas, full clock semantics, + reward/scoring, or evidence-capture adequacy beyond preserving their existing + boundaries. +- Publishing secrets, hidden truth, raw configuration, raw evidence, or + backend-private data as portable participant semantics. diff --git a/docs/decisions/issue-41-compiler-package-split-preflight.md b/docs/decisions/issue-41-compiler-package-split-preflight.md new file mode 100644 index 000000000..021998d14 --- /dev/null +++ b/docs/decisions/issue-41-compiler-package-split-preflight.md @@ -0,0 +1,183 @@ +# Issue 41 Compiler Package Split Preflight + +Date: 2026-07-15 + +Issue: #41. + +Requirement: none. The GitHub issue is the implementation contract. + +This note records architecture guardrails for converting +`aces_processor.compiler` from a module to a package. It does not implement the +split, change compiler behavior, drain the oversized allowlist, or alter tests. + +## Binding Decisions + +- The compiler is currently 2,677 lines, not the issue body's older 1,495 + lines. The split must account for the current domains and call graph. +- ADR-015 and `tools/policy/adr_policy.yaml` now enforce a 500-line cap, not + 600 lines. Every Python file in the replacement package, including + `__init__.py`, must stay at or below 500 lines. +- ADR-036 keeps compilation in `aces_processor` and makes + `aces_processor.compiler` an approved cross-package public import. Child + compiler modules are implementation detail, not new public APIs. +- The repository imports only `compile_runtime_model` and + `compile_scenario_runtime_model` from the owning-package facade. The legacy + `aces.core.runtime.compiler` wrapper also delegates to that facade through + `aces._compat.reexport`; both paths must keep working without caller edits. +- Release-please owns `CHANGELOG.md`. Despite the stale issue acceptance + criterion, the implementation PR must not edit it or add a changelog + fragment; `.gc/plan-rules.md` is the current repository policy. + +## Architecture Boundary + +`compiler/__init__.py` is the public facade. It re-exports the two compiler +entry points and contains no compilation logic. The current module has no +`__all__`; any new `__all__` must be limited to the supported entry points and +must be checked against the legacy re-export behavior. Preserve public +signatures, defaults, annotations, docstrings, and deliberately observable +module metadata. + +Split by semantic ownership and dependency direction, not line ranges: + +- shared serialization, stable deduplication, canonical address construction, + and named-reference resolution form package-private foundations; +- provisioning owns templates, capability constraints, node/network, feature, + content, and account compilation; +- participant contracts own action contracts, observation/view projections, + and outcome-interpretation rules, while participant behavior/specification + compilation remains a separate cohesive concern; +- evaluation owns propositions, assertions, condition bindings, objective + windows, and objectives; +- orchestration owns injects, events, scripts, and stories; +- workflow compilation owns predicates, control steps, capability features, + result/execution contracts, and compensation; it may use more than one file + to remain below the cap, but must retain one ordered workflow coordinator; +- realization compilation owns SEM-218 designation lowering and typed + realization requirements; and +- one package-private assembly coordinator owns admission, analysis, compiler + invocation order, diagnostic accumulation order, and `RuntimeModel` + construction. + +Foundation modules must not import domain modules or the package facade. +Domain compilers consume foundations and existing SDL/model contracts; the +assembly coordinator passes prerequisite maps such as assertions and injects +explicitly, as the current module does. `__init__.py` imports only the public +entry-point owner. This direction prevents facade partial-initialization cycles +without introducing a registry, service container, or generic compiler +framework. + +The extension seam is the existing explicit subdomain function contract: +`InstantiatedScenario`, a shared ordered `list[Diagnostic]`, and only the +prerequisite compiled maps or semantic analyses that a domain needs. A future +compiled resource family belongs in its semantic owner and is wired once into +the assembly coordinator and `RuntimeModel`; it must not require caller imports +from child modules or a second orchestration workflow. + +## Required Incumbents + +- **Admission and phase validation:** `instantiate_scenario()`, + `admit_instantiated_scenario()`, their Pydantic/`SemanticValidator` checks, + and `build_declaration_index()` remain the only admission and collision + gates. Preserve their order, including re-admission of an already + instantiated artifact at the compiler boundary. +- **Canonical identity:** `aces_contracts.addressing.render_compiled_address` + remains the renderer. `RuntimeModel` and its resource DTOs continue to apply + `require_compiled_address`, map-key/address equality, and duplicate-address + checks. Do not add a second address grammar or parse rendered addresses back + into SDL references. +- **Schemas and DTOs:** retain `Scenario`, `ExpandedScenario`, + `InstantiatedScenario`, `RuntimeModel`, the existing processor model types, + neutral `aces_contracts` workflow/evaluation contracts, and + `aces_backend_protocols` capability enums. Do not copy, wrap, or redefine + them in compiler submodules. +- **Semantic analyzers:** continue to use `analyze_domain_topology()`, + `analyze_objective_window()`, `partition_objective_dependencies()`, + `workflow_step_semantic_contract()`, `resolve_realization_designation()`, and + `registered_realization_concerns()` as their existing single sources of + truth. +- **Serialization and determinism:** preserve + `model_dump(mode="json", by_alias=True)`, dict fallbacks, declaration + iteration order, order-preserving deduplication, the value-sorted feature + deduplication, and the current top-level compilation/diagnostic order. +- **Errors and observability:** continue to emit the existing + `aces_contracts.diagnostics.Diagnostic` identity through + `aces_processor.models`. Preserve diagnostic codes, domains, messages, + severity, ordering, and existing `ValueError`/SDL exception behavior. The + compiler adds no logging or alternate error envelope. +- **Repository workflow:** build discovery remains the existing Hatch package + configuration. ADR-015/ADR-036 policy is enforced by + `tools/check_repo_policy.py`; the canonical completion graph is the pinned + nox `verify` session from `.ground-control.yaml`. + +## Cross-Cutting Layers And Security + +- **Structural and semantic input gates:** public compiler entry points still + pass through closed Pydantic phase shapes, instantiation/admission semantic + validation, declaration collision checking, and domain analyzers before + emitting a model. Moving code must not bypass, duplicate, or reorder them. +- **Compiled contract gates:** constructed DTOs and `RuntimeModel.__post_init__` + continue to validate address syntax, embedded/map-key agreement, uniqueness, + workflow/evaluation contract shapes, and typed realization requirements. +- **Auth and secret handling:** compilation has no authorization decision, + credential lookup, secret store, or trust-policy responsibility. It must not + acquire one. Authored/bound values may enter returned `spec` payloads through + the existing serializer, but must not be copied into logs, diagnostics, or a + new summary surface. +- **Environment and configuration shapes:** the only variation seam remains + explicit `parameters` and `profile` arguments processed by + `instantiate_scenario()`. Do not bind compiler behavior from environment + variables or add configuration schemas. Repository YAML policy shapes remain + validated by the existing policy tooling. +- **OS/process/network/persistence exposure:** the compiler remains a pure + in-process transformation. The split introduces no subprocess, process-argv, + filesystem, network, database, cache, or control-plane operation, so no token + or payload may gain an OS-level exposure path. +- **Error-envelope leakage:** SDL admission exceptions and structured compiler + diagnostics remain the only error surfaces. Do not log raw scenarios, + parameter values, environment values, credentials, or tracebacks, and do not + convert collectable diagnostics into raised exceptions or vice versa. +- **Import and policy gates:** compiler submodules stay inside + `aces_processor` and may depend only on the ADR-036 incumbents + `aces_sdl`, `aces_contracts`, and `aces_backend_protocols`; they must not + import `aces_runtime`, backend implementations, CLI, conformance, or MCP. + +## Gotchas And Anti-Patterns + +- Do not change any external `from aces_processor.compiler ...` or + `import aces_processor.compiler` line. Keep the compatibility wrapper target + unchanged as well. +- Do not leave both `compiler.py` and `compiler/`; package conversion is one + replacement. Remove only the deleted path's allowlist entry, not the locked + reference in policy code. +- Do not make `__init__.py` a second compiler, expose child modules as supported + API, or route internal imports back through the facade. +- Do not merge participant visibility, evaluator truth, workflow state, and + realization requirements into a generic "runtime compiler" abstraction; + their schemas and diagnostic semantics are distinct. +- Do not optimize away the current admission/declaration-index calls, change + helper evaluation order, sort previously insertion-ordered mappings, or + collapse diagnostic lists. These are observable behavior under the existing + determinism and phase-contract tests. +- Do not duplicate reference indexes, address builders, analyzer issue maps, + DTOs, validation, diagnostics, or exception hierarchies merely to avoid a + package-private dependency. +- Update documentation that names the deleted `compiler.py` path or private + helper locations when the replacement paths exist; do not leave normative + and semantic-integrity references pointing at a deleted file. +- Do not modify pre-existing tests to accommodate changed behavior. The broad + compiler, phase, identifier, participant, objective, workflow, realization, + determinism, reference-processor, MCP, and runtime-manager tests are the + behavior oracle. + +## Non-Goals + +- No schema, SDL grammar, phase, validation, address, diagnostic, planning, + workflow, participant, objective, or realization behavior change. +- No new public compiler abstraction, plugin/registry system, DTO, exception, + logger, configuration surface, persistence layer, network API, or + compatibility shim. +- No movement of compilation responsibility into `aces_sdl`, `aces_runtime`, + `aces_contracts`, a backend package, or the legacy `implementations/python/src/aces` + compatibility tree. +- No incidental cleanup, performance rewrite, test relaxation, policy-code + edit, version edit, or manual changelog edit. diff --git a/docs/decisions/issue-541-sdl-spec-second-review-preflight.md b/docs/decisions/issue-541-sdl-spec-second-review-preflight.md new file mode 100644 index 000000000..c392ea4e1 --- /dev/null +++ b/docs/decisions/issue-541-sdl-spec-second-review-preflight.md @@ -0,0 +1,103 @@ +# Issue 541 SDL Specification Second-Review Preflight + +Date: 2026-07-14 + +Issue: #541. + +Requirement: none. The issue title, body, and acceptance criteria are the +contract. + +This note fixes the architecture boundary for the independent review. It does +not review or change SDL semantics, schemas, models, validators, diagnostics, or +workflow behavior. No ADR is needed: ADR-009, ADR-019, the issue-498 preflight, +and the issue-722 catalog-parity preflight already own the relevant decisions. + +## Revision-Aware Review Baseline + +The review evaluates the complete language at the repository revision under +review. Numeric and behavioral examples in issue #541 describe the #540 +baseline; they are not authority for rolling back later accepted SDL changes. +Three current differences are especially easy to misclassify as defects: + +- `specs/sdl/sections.md` and the published authoring schema currently enumerate + 32 top-level fields. The post-#541 `realization` composition field and + `identity_domains` authoring section account for the increase from 30. +- ADR-076 now defines dots as qualified-address syntax, not authored identifier + content. Authored node local ids keep the 35-character limit but use the + portable local-id grammar and therefore do not admit `.`. +- `specs/sdl/diagnostics.md` now states the accepted meaning-preservation + criterion that resolves IMP-3. A review must verify that boundary; it must not + restore the earlier pre-resolution wording that diagnostics only coordinates + with IMP-3. + +The durable comparison is structural and bidirectional, never count-only: +normative catalog rows, published schema members, and reference-implementation +evidence must agree at the same revision. Historical counts remain useful review +anchors, but a matching count cannot excuse a missing, renamed, or misshaped row. + +## Existing Boundaries To Reuse + +- Authority direction is fixed by ADR-009 and ADR-019: + `specs/sdl/` is language-neutral normative prose, + `contracts/schemas/sdl/*.json` is hand-governed schema authority, and + `implementations/` is conformance evidence. No one surface is generated from + another to make a discrepancy disappear. +- `tools/check_sdl_catalog_parity.py` is the canonical read-only three-way drift + gate. Its bounded Markdown table parsers, `PolicyFailure` records, deterministic + rendering, and exception mechanism are the workflow incumbents; semantic + validator behavior remains covered by its owning tests. +- `Scenario`/`SDLModel`, `_mapping_scopes.HASHMAP_SECTIONS`, the typed + declaration index and `SemanticValidator`, variable/instantiation admission, + and `RUNTIME_SERVICE_FAMILIES` are implementation evidence for their existing + concerns. Completion metadata is not validation authority, and the runtime + registry must not be duplicated in prose tooling. +- The existing `SDLParseError`, `SDLValidationError`, and + `SDLInstantiationError` surfaces and structured language diagnostics remain the + only SDL error boundary. Catalog drift is a repository-policy failure, not a + fourth SDL exception category. +- Schema edits, if an actual schema defect is found, remain governed by the + publication manifest and generated-schema parity. A prose-only review does not + authorize changing validation behavior to obtain agreement. + +The extensibility seam is the checked catalog row shape, parameterized by the +contract version/table heading and by explicit reference-domain tokens. The next +section, edge, runtime family, or contract version adds reconciled authority and +registry rows; it does not add a second schema, metamodel, resolver, or hard-coded +count source. + +## Cross-Cutting Security And Operational Guardrails + +- Source/config shape continues through the safe YAML source profile, mapping-key + collision preflight, operational/aggregate composition budgets, closed Pydantic + models, typed declaration collision checks, semantic validation, and + substitute-and-revalidate instantiation. Documentation corrections must not + bypass, duplicate, or weaken any layer. +- Secret handling remains governed by ADR-056/057: explicit redaction is + error-enforced and the name classifier is advisory. Review evidence and policy + failures may identify paths, rows, and symbols, but must not include scenario + values, parameter maps, source bodies, credentials, environment dumps, raw + framework inputs, or tracebacks. +- The review introduces no authentication surface, environment binding, network + call, process-argv data flow, temporary state, database, cache, or other + persistence. Nox remains the single workflow entry point and its existing + session reporting remains the observability surface. +- Authority and catalog tooling reads fixed repository-relative files as inert + data. Internal relative links may be normalized and existence-checked without + dereferencing them; tooling must not fetch external Markdown links, resolve SDL + imports, or accept caller-controlled paths as part of validation. + +## Gotchas And Non-Goals + +Avoid count-only confirmation, generating one normative authority from another, +using editor-completion metadata as semantic truth, treating every `_ref` field +as one generic symbol domain, conflating scenario- and node-scoped +`forwarding_agents`, or collapsing SDL symbols, workflow-local ids, controlled +vocabularies, contract ids, opaque profile refs, and runtime-family addresses +into one resolver. Do not turn prose discrepancies into semantic changes without +the owning authority and tests, introduce a new diagnostic envelope, or duplicate +policy commands in CI. + +This preflight does not perform the second review, prescribe an implementation +plan, change accepted ADRs, post a GitHub review summary, or authorize changes to +SDL syntax, runtime behavior, compiler behavior, APIs, storage, package versions, +or release metadata. diff --git a/docs/decisions/issue-716-asr-519-realization-honesty-conformance-preflight.md b/docs/decisions/issue-716-asr-519-realization-honesty-conformance-preflight.md new file mode 100644 index 000000000..273366709 --- /dev/null +++ b/docs/decisions/issue-716-asr-519-realization-honesty-conformance-preflight.md @@ -0,0 +1,456 @@ +# Issue 716 / ASR-519 Realization Honesty Conformance Preflight + +Date: 2026-07-14 + +Requirement source: Ground Control requirement ASR-519, selected by GitHub +issue #716. + +This note records repository-wide guardrails for the realization-honesty +conformance work. It is guidance only: it does not implement the runner, alter +an envelope, add probes or fixtures, certify a backend, or define an +implementation plan. + +No new ADR is required. ADR-021, ADR-036, ADR-066, and ADR-070 already own the +claim-evidence, package-boundary, evidence-plane, and realization-envelope +decisions. This issue must close the executable conformance gap without creating +parallel authority. + +## Binding Sources And Canonical Incumbents + +- ADR-070 and `specs/formal/realization/envelope-semantics.md` own the shared + envelope language and the `member()`, `subsumes()`, `witness()`, and + `generate_negative_probes()` relation in + `aces_sdl.realization_envelope`. Conformance must extend that relation's + deterministic probe coverage when necessary; it must not interpret domains, + closure, or paths again. +- The issue #100 preflight and the published + `BackendRealizationEnvelopeModel` own configuration/envelope identity, + `RealizationConcern`, `ConcernDisposition`, `TransformationKind`, and + `ObservationStrength`. The selected artifact under + `contracts/realization-envelopes/` is the value/disclosure authority; + `realization_support` and `ProvisionerCapabilities` remain separate coarse + gates. +- The issue #714 and #715 preflights own exact-or-reject admission, one + field-addressed concern inventory, daemon/guest observation, freshness, + rollback, and cleanup. `RealizationObservation`, `DriverResult`, the TechVault + concern validators, the guest observer, and the validated scenario-evidence + workflow are incumbents, not examples to copy. +- `run_fixture_suite()`, `run_target_conformance()`, + `ConformanceCaseResult`, `BackendConformanceReport`, and the existing + `bounded-probe-success` `BehavioralClaimBindingModel` are the conformance and + report family. Issue #716 evolves this family; it does not add an honesty-only + runner, claim relation, report schema, profile, or exception hierarchy. +- `RuntimeManager`, `RuntimeControlPlane`, `_call_backend_diagnostics()`, + `_call_backend_apply()`, `OperationReceipt`, `OperationStatus`, `ApplyResult`, + `RuntimeSnapshot`, `ControlPlaneStore`, and the closed plan/snapshot models are + the execution, validation, error, and portable persistence path. +- `interpret_provisioning_plan()`, `Realization`, `DomainSpec`, `NetworkSpec`, + and `Realization.placement_targets` are the libvirt plan interpretation and + aggregation seam. Conformance must account for the original plan operations; + a domain-level spec or handle cannot erase placement/binding obligations. +- `aces_operations.libvirt_evidence_run`, + `validate_libvirt_evidence_run_artifact()`, `redaction_violations()`, + `cleanup_native_snapshot()`, `run_artifact_path()`, and + `atomic_write_json_artifact()` are the native proof, redaction, cleanup, and + explicit report-output incumbents. +- ADR-009/ADR-061, `ContractModel`, `schema_bundle()`, the packaged corpus, + `contracts/schema-publication-manifest.json`, and + `specs/authority/authority-boundary.yaml` govern any unavoidable published + contract change. A local conformance report does not become a published + contract merely because it is JSON. + +## Architectural Diagnosis + +The current target probe proves only that a control-plane apply returned a +successful operation, non-empty changed addresses, and a schema-valid mutated +snapshot. Its libvirt test uses a daemon-free recording driver. That is useful +**hermetic target-adapter evidence**, but it is neither native-live nor evidence +that each realization concern was observed at its declared strength. + +Four existing shapes are insufficient by themselves: + +1. `SnapshotEntry.payload` is planned reconciliation state and can echo a plan + that the backend ignored. +2. `DomainHandle`, `NetworkHandle`, `changed_addresses`, and + `realized_addresses()` are completion/accounting receipts, not concern + observations. +3. `BackendConformanceReport` currently records a boolean per case but no + execution basis, envelope/configuration binding, observation inventory, + negative-probe mutation proof, or cleanup result. +4. The three published libvirt envelopes currently contain empty/open + expressions. `witness()` cannot derive a complete valid scenario from those + expressions. Conformance must fail visibly until an authoritative envelope + is sufficiently constructive; it must not fall back to + `_DEFAULT_CONFORMANCE_SCENARIO`, a caller-selected happy path, or a + libvirt-specific witness table. + +The existing `reference_scenario` bridge is therefore not an acceptable ASR-519 +certification path. It may remain for legacy adapter conformance, but its result +must be labelled `hermetic-live` and bounded to that supplied scenario, never +promoted to realization-envelope or native conformance. + +## Architecture Decisions And Guardrails + +### Keep one conformance runner and one report family + +- Extend `run_target_conformance()` (or a narrowly named helper it owns) and + `BackendConformanceReport`; keep fixture, target, realization, and cleanup + cases in one machine-readable report. Do not create `HonestyReport`, a + libvirt report DTO, or a second serializer. +- Keep `aces_conformance` backend-neutral. It may depend on neutral contracts, + SDL relations, processor planning, and runtime control, but it must not import + `aces_backend_libvirt`, `aces_operations`, or inspect driver classes. +- Native/backend-specific construction and observation remain behind an + injected, conformance-only harness assembled by operations code (and by test + fixtures for hermetic runs). The minimal harness supplies an already validated + `RuntimeTarget`, execution basis, addressed observations, an independent + mutation/state ledger, and cleanup/residual results. It is not a new runtime + role, manifest capability, `RuntimeTarget` component, control-plane endpoint, + or general backend protocol. +- `aces_backend_libvirt.driver.RealizationObservation` is a backend-local + readback DTO, not the backend-neutral evidence contract: it lacks probe, + operation, envelope/configuration, freshness, and cleanup binding. + `RealizationProvenanceEntry` is also not that contract; it records SEM-218 + origin without observed values or observation strength. Keep both meanings + intact. If the injected harness must pass evidence between + `aces_operations` and `aces_conformance`, ADR-036 puts the minimum neutral, + typed observation-evidence DTO in `aces_contracts`; the backend/operations + adapter maps its local readback into that DTO and the existing + `BackendConformanceReport` projects it. Do not copy the libvirt dataclass into + each backend, re-export it as if backend-local fields were neutral, or overload + runtime provenance. A neutral internal DTO does not by itself authorize a + published JSON Schema. +- Preserve the enforced import graph. `aces_conformance` must not import + `aces_operations` or `aces_backend_libvirt`, and `aces_operations` must not + import `aces_conformance`. The `aces_cli` composition root may wire the + structurally compatible operations harness into the conformance runner while + both sides exchange only `aces_contracts` DTOs. Any new public module prefix + must be added narrowly to `tools/policy/adr_policy.yaml`; do not evade the + policy with private-module imports or `Any` payloads. + +### Derive the probe set from the selected envelope + +- Load and validate the configuration-selected + `BackendRealizationEnvelopeModel` through the packaged corpus loader. Require + exact equality among manifest identity, offered artifact identity, plan + identity, target configuration, observer binding, and report binding before + any probe executes. +- Generate positive witnesses and negative variants through the shared envelope + relation. If one deterministic base witness does not cover a finite enum, + boolean, bounded interval boundary, governed reference, exact omission, or + closed child dimension, extend the shared generator over the same + `effective_constraints()`/domain engine. Do not put sampling logic in + `aces_conformance` or a backend. +- Every positive candidate must pass the normal closed SDL model, + instantiation, `SemanticValidator`, and `member()` checks before planning. + Every negative candidate must remain structurally/semantically safe and be + proven outside the offered envelope for exactly one reported variation. A + malformed request rejected by Pydantic is not evidence that the backend + enforces its envelope. +- Probe generation is deterministic and reportable: policy/seed, dimension, + path, variation, and a secret-free canonical probe digest are recorded. Raw + secret-capable values are not report identity. +- A dimension for which no safe, ordinary-contract-valid probe can be generated + is `unsupported`, not passed. Skipped and unsupported probes prevent the + corresponding claim from certifying. Open or genuinely unbounded dimensions + remain explicit nonclaims rather than silently increasing the denominator. + +### Require total operation and concern accounting + +- For each positive witness, derive the expected operation inventory from the + canonical `ProvisioningPlan`, not from driver handles or the post-apply + snapshot. Every non-`UNCHANGED` operation has exactly one terminal accounting + result: independently observed at the envelope-required strength, rejected + before mutation, or failed with an addressed diagnostic. +- Preserve both levels when the backend aggregates: a node/domain observation + may account for the node operation, but account/content/feature/service/ACL + placements and bindings require their own addressed concern observations. + Missing aggregated placements/bindings fail even when the parent domain and + every `changed_address` exist. +- `UNCHANGED` means no fresh mutation and supplies no fresh observation claim. + `DELETE` requires ownership-checked observed absence and complete cleanup; a + false absence handle is not enough. +- Compare authored/planned requirements with independently observed facts. An + exact value is equal or rejected. A transformed value passes only when the + selected envelope names the transformation, the input and output are both + admitted by an executable governed rule, and the report discloses it. The + current transformation-name enum is disclosure, not executable permission; + absent such a rule, use exact-or-reject. +- Missing disclosure, image substitution, resource clamping/defaulting, + descriptor substitution, synthesized services, planned-as-observed facts, + fabricated handles, duplicate/missing observations, stale evidence, and + unexpected augmentation are distinct failures with stable diagnostic codes. + +### Observation strength is a per-concern evidence requirement + +- Treat envelope observation strength as a required terminal level, not a label + copied onto a report. Driver-reported concerns require a fresh addressed + `RealizationObservation`, not merely a handle. Daemon-observed concerns require + ownership-correlated post-operation readback. Guest-observed concerns require + the issue #715 challenge-bound concern probe and its daemon correlation. +- Strength is ordered evidence, not interchangeable provenance. SEM-218 + `backend-realized`, SEM-225 augmentation, report execution basis, and + `driver`/`daemon`/`guest` observation are four different classifications. +- A guest terminal claim does not erase its prerequisite daemon binding. Reports + enumerate both layers and never upgrade driver/daemon evidence because the + overall run used a guest-capable or native driver. +- Every observation is bound to the current operation/probe, safe ACES address + and field path, concern, source, selected envelope/configuration digests, + observer/probe-policy version, and freshness evidence. A prior driver snapshot, + reused challenge, cross-operation fact, or unbound timestamp fails. + +### Negative probes prove refusal without mutation + +- Submit plan-valid negative probes through the ordinary planning/control-plane + boundary far enough to exercise the target's envelope/admission gate. They + must fail before `driver.realize()`, `driver.destroy()`, libvirt connection, + artifact creation, or any native mutation. +- Prove non-mutation in both planes: byte-equivalent portable baseline snapshot + (including envelope/provenance and persisted store round-trip where used) and + an independent harness ledger showing no driver/native mutation call and no + change in the owned native inventory. Backend self-report alone is not proof. +- Wrong-envelope tests pair each libvirt configuration with another published + envelope identity and assert refusal before IO. Generic, + `techvault-appliance`, and any selectable guest-certified configuration remain + separate target instances and reports; success in one must not certify the + abstract `libvirt-qemu` name or another configuration. + +### Execution basis, outcome, and certification scope stay distinct + +- Use exactly these execution-basis statuses in conformance results: + `fixture-only` (published corpus only), `hermetic-live` (real + processor/control-plane/provisioner path with injected hermetic driver and + observer), and `native-live` (real daemon/native mutation and verified + teardown). Only `native-live` may support a native-conformance claim. +- `guest-certified` is an observation capability/mode inside a `native-live` + run, not a fourth conformance basis. Existing evidence-source modes such as + `deterministic` also do not substitute for these statuses. +- Probe outcomes are `passed`, `failed`, `skipped`, or `unsupported`; only + `passed` contributes to an overall pass. ADR-021 evidence status + (`untested`/`partial`/`demonstrated`/`refuted`) and the behavioral relation + claim remain separate fields. +- Rename remaining user/report-facing “live provisioning” descriptions that + refer to a recording/fake driver to “hermetic target provisioning” or + equivalent. Historical function names may remain private only if they cannot + be mistaken for real-daemon evidence. + +### Claims and reports are configuration-bound + +- Bind every claim to backend implementation name and version, target instance, + profile, driver/configuration mode, envelope id/digest, configuration digest, + probe-set digest, execution basis, observer/probe-policy version, and evidence + artifact refs. `backend-target:libvirt-qemu` alone is too broad. +- Reuse `bounded-probe-success`; finite probes never establish universal + realizability, backend equivalence, or a broader abstract backend name. +- Extend the existing report projection so it enumerates each envelope concern, + generated probe, expected observation level, actual observations, operation + accounting, outcome, stable diagnostics, pre/post state proof, cleanup, and + residual category. Preserve failed and unsupported results; no serializer may + filter them from the claim's evidence boundary. Keep one canonical report + finalizer/serializer and validate its cross-field invariants before rendering; + do not create a parallel operations-only or libvirt-only report validator. +- Durable native evidence is written only after full report validation and the + shared redaction gate, using the existing safe run-id/root confinement and + atomic writer. A cleanup failure may produce a failed redacted report, never a + passing one. Fixture-only output remains stdout unless explicit output was + requested. + +## Dishonest Backend And Falsification Boundary + +- Model prohibited behaviors as a table-driven dishonest **test fixture** behind + the same `RuntimeTarget`/provisioner/harness boundary. Do not add dishonest + modes to the production reference backend or monkeypatch the conformance gate + under test. +- Each behavior is independently selectable and must fail for its expected + stable reason: schema-valid no-op/snapshot echo, omitted or mutated placement, + silent transformation, clamp/default, image substitution, missing disclosure, + planned-as-observed evidence, fabricated handle/observation, stale evidence, + wrong envelope/configuration, partial cleanup, and residual state. +- Assertions cover failed operation/probe outcome, empty changed addresses where + admission failed, unchanged portable and native baselines, expected diagnostic + code/address, no passing claim, and cleanup/residual result. A single + multi-fault fixture cannot prove diagnostic specificity. +- Hermetic mutation tests are mandatory in the default verification graph. Real + libvirt proof is separate and opt-in, but it is not allowed to self-skip and + then publish a passing native report. + +## Required Cross-Cutting Reuse + +- **SDL and envelope validation:** `Scenario`, `parse_sdl()`/`parse_sdl_file()`, + `instantiate_scenario()`, `SemanticValidator`, `member()`, `subsumes()`, + `witness()`, `generate_negative_probes()`, and their existing diagnostic + model. +- **Contract and corpus authority:** `BackendRealizationEnvelopeModel`, + `RealizationEnvelopeIdentityModel`, closed `ContractModel` descendants, + `schema_bundle()`, `corpus_family_root()`, schema/fixture/profile publication + rules, and canonical digest helpers. +- **Manifest and planning:** `backend_manifest_payload()`, + `BackendManifestV2Model`, supported-contract and controlled-vocabulary gates, + `realization_support_diagnostics()`, `realization_disclosure()`, + `RuntimeManager.plan()`, and the typed provisioning plan. +- **Execution and persistence:** `RuntimeControlPlane`, backend call guards, + operation receipt/status, `ApplyResult`, snapshot transition validation, + `RuntimeSnapshotEnvelopeModel`, `ControlPlaneStore`, and atomic store behavior. +- **Backend observation:** the existing plan interpreter, recording drivers, + `RealizationObservation`, TechVault daemon/guest observers, operation binding, + ownership stamps, safe absence detection, rollback, and cleanup helpers. +- **Errors and observability:** `Diagnostic`, `Severity`, audit events, stable + package-local codes, `BehavioralClaimBindingModel`, `SessionReporter`, and the + shared artifact redaction validator. Logs and nox stage summaries are + supplemental and never count as probe evidence. +- **Workflow:** the canonical Typer `aces conformance` family, explicit + destructive confirmation and noncredential URI validation for native runs, + the hermetic `nox verify` graph, separate real-daemon workflow, and repository + policy/governance checks from `.ground-control.yaml` and `.gc/plan-rules.md`. + +## Security And Whole-Path Gates + +- **Corpus/config shape:** envelope/profile ids resolve only through grammar- + checked, root-confined packaged corpus loaders. Payloads pass closed Pydantic + models, canonical digest checks, manifest/config/mode consistency, and + allowlisted `_validate_config_keys()` before target or observer construction. + Do not accept caller-supplied envelope paths or remote envelope URLs. +- **SDL/plan shape:** generated probes pass ordinary SDL structure, semantics, + envelope relation, compiler/planner, `ProvisioningPlanModel`, capability, and + target identity gates. A conformance shortcut must not instantiate backend + specs directly. +- **Authentication/authorization:** no new HTTP route is required; prefer the + in-process runner, whose result makes no authentication claim. If conformance + is ever driven through the existing HTTP submission route, retain + `ControlPlaneSecurityConfig.strict_defaults()`, verified bearer/proxy identity, + backend/operator role authorization, request-size limits, idempotency + fingerprints, and audit events. The current + `_ControlPlaneApiAuth._authenticate_request()` applies `identity.target_name` + only in the proxy-header branch because a valid bearer returns early. That + must be factored into one post-authentication scope check for both mechanisms + before an HTTP-driven result can count; issue #716 must not add a second auth + path or claim the current bearer path is target-scoped. +- **Secrets and environment binding:** credentials, bearer tokens, keys, + cloud-init/account material, environment dumps, connector/transport reprs, + host paths, native ids, and raw configuration never enter probes, digests, + diagnostics, snapshots, reports, or logs. The CLI's + `_noncredential_connection_uri()` check must become one shared library/config + validator used by both `TechVaultNativeLibvirtDriver` and the generic + `LibvirtDeploymentDriver`; Typer validation alone does not protect Python + callers, and the generic driver currently checks only non-emptiness. The + opt-in `ACES_REAL_LIBVIRT_URI` test input must pass that same validator before + driver or direct `libvirt.open()` construction; unset/invalid native input is + blocked or unsupported, never a passing `native-live` result. Do not add a + dotenv or generic environment-to-config binder. Never hash a + credential-bearing URI as a safe substitute and never place secrets in + process argv. Use injected connection/credential handles if a future + transport requires authentication. +- **Host/OS exposure:** fixture-only and hermetic-live are pure with respect to + libvirt/QEMU. Native-live remains opt-in, uses lazy libvirt import, structured + XML, fixed argv/no `shell=True`, bounded stage and overall deadlines, + ownership-confined workspaces, safe file modes/symlink checks, and no ambient + privilege escalation. Guest probing remains a fixed read-only fact channel, + never a general command runner. +- **Error envelope:** public failures carry stable code, safe address/path, + concern/observation level, and generic redacted message. Do not concatenate + backend diagnostic messages or `str(exc)`. Existing conformance paths that + render raw Pydantic/backend exception text (`_validate_payload()`, event + validation, participant probe handling, and target provisioning status + aggregation) must be sanitized if reused by the new report. The HTTP routes' + `HTTPException(detail=str(exc))` conflict responses are likewise not a safe + conformance error carrier. Apply the shared redaction gate to the complete + report before persistence and discard raw stdout/stderr, XML, probe details, + and tracebacks. +- **Portable persistence:** conformance does not add fields to + `RuntimeSnapshot.metadata` or `ApplyResult.details`, and it does not add a + native-state database. Runtime state continues through typed snapshots/store; + captured observation and cleanup proof live in the validated report/evidence + artifact. Rejected work writes no successful operation, snapshot mutation, or + realization claim. + +## Whole-Repository Surfaces In Scope + +- Normative: realization-envelope formal semantics; published realization + envelope, backend manifest, provisioning plan, operation status, and runtime + snapshot schemas/artifacts; backend profile and fixture corpora; authority and + schema-publication manifests if any published shape changes. +- Implementation: `aces_sdl.realization_envelope`, `aces_contracts` envelope and + runtime DTOs, `aces_conformance`, processor planning/realization disclosure, + runtime control plane/backend calls/store, libvirt target/provisioner/driver + observers, operations evidence/cleanup/writer, and the canonical CLI wiring. +- Verification: relation/property tests, contract/fixture tests, target and + control-plane tests, dishonest-backend mutation corpus, generic and TechVault + wrong-envelope tests, libvirt daemon/guest evidence validation, report drift + tests, redaction/security tests, and the opt-in reproducible real-daemon proof. +- Governance: repo policy, requirement governance/traceability, generated-schema + parity, schema publication, authority boundaries, ADR-036 module-boundary + policy, docs build, and full verify. + +## Extensibility Seam + +The required seam is a backend-neutral conformance engine parameterized by: + +- one validated target and configuration-bound envelope; +- deterministic probe policy/seed; +- execution basis (`fixture-only`, `hermetic-live`, `native-live`); +- one source-specific addressed observer; +- one independent mutation/state ledger; and +- one cleanup/residual verifier. + +A future backend, libvirt image family, remote libvirt policy, architecture, +guest transport, or stronger observation source supplies another target/harness +configuration. It must not require edits to the envelope relation, concern or +observation vocabularies, planner action model, runtime control plane, diagnostic +hierarchy, store, claim relation, or report writer. The probe policy is the seam +for future deterministic coverage strengthening; backend names and driver +classes are not dispatch keys in the conformance engine. + +## Gotchas And Anti-Patterns + +Avoid: + +- treating the current empty/open libvirt envelope expressions as constructive, + or silently substituting the old default/reference scenario; +- accepting one in-envelope witness as proof of a bounded dimension; +- counting a skipped, unsupported, unsafe, malformed, or unexecuted probe as + passed; +- using schema validity, receipt acceptance, snapshot mutation, changed + addresses, handles, or a planned payload echo as realization evidence; +- letting parent domain accounting hide aggregated placement/binding omissions; +- letting a claimed transformation name authorize clamp/default/substitution + without a governed executable rule and explicit report record; +- treating `backend-realized`, `native-live`, `guest-certified`, and + `guest-observed` as synonyms; +- trusting a backend's own “no mutation” or cleanup assertion without an + independent call/native-state ledger; +- allowing a fake/recording driver report to certify native conformance; +- treating an integration-test self-skip or an unset/invalid + `ACES_REAL_LIBVIRT_URI` as a passing native-conformance result; +- certifying `libvirt-qemu` broadly from one mode, envelope, driver, + configuration, or witness; +- importing libvirt/operations code into `aces_conformance`, adding conformance + to the universal runtime protocols, or duplicating the observation DTO; +- adding a conformance schema, profile, capability language, exception tree, + logger, store, endpoint, report writer, or secret/config parser when the + incumbents already exist; +- mutating canonical fixtures in place, monkeypatching the gate under test, or + putting dishonest behavior in a production backend; +- exposing credential-bearing URIs in argv or accepting them through unvalidated + library calls; and +- persisting a passing report before cleanup and full redaction/report + validation succeed. + +## Non-Goals And Implementation Boundaries + +- No implementation of issue #716 in this preflight and no certification result. +- No new SDL syntax, envelope language, solver, backend profile, universal + observation service, runtime role, HTTP route, persistence service, or public + report schema. +- No redesign of the planner, control plane, backend protocol, runtime snapshot, + experiment-core evidence contracts, or participant-observation semantics. +- No implementation of additional guest applications, OS/image families, + services, ACL mechanisms, or broader libvirt realizability merely to make a + probe pass. Unsupported concerns stay unsupported. +- No claim of universal envelope subsumption, backend equivalence, all-driver + conformance, all-configuration conformance, or final reference-scenario + certification (issue #717). +- No requirement that the hermetic verification graph install libvirt/QEMU, + access a daemon, hold credentials, use network access, or gain host privileges. +- No compatibility fallback for missing/stale/mismatched identity, incomplete + operation accounting, absent required observation, failed cleanup, or + residual native state. diff --git a/docs/decisions/issue-763-authored-domain-topology-preflight.md b/docs/decisions/issue-763-authored-domain-topology-preflight.md new file mode 100644 index 000000000..1497af205 --- /dev/null +++ b/docs/decisions/issue-763-authored-domain-topology-preflight.md @@ -0,0 +1,356 @@ +# Issue #763 — Authored Domain Topology Preflight + +Date: 2026-07-13 + +Issue: #763. + +Requirement: none. The issue title, body, and acceptance criteria are the +contract. + +This is implementation guidance only. It does not define final SDL field +spellings, publish a schema, change compilation or runtime behavior, or provide +an implementation plan. + +## Architectural Boundary + +Directory/domain **inventory** and authored domain **topology** are separate +contracts. + +- `Node.runtime.identity_authorities` and ADR-032 describe node-scoped logical + identity state that can be inventoried or compared after realization. It may + contain subjects, policies, services, and observed relationships. It is not a + provisioning instruction. +- The new surface describes the minimum desired graph a provisioner needs: + domain identity, authority principal, controller role, member join, and the + domain binding of a domain-scoped account feature. +- Runtime inventory may later be checked against authored topology, but it must + not be copied into the authored model, used as dispatch input, or treated as + proof that realization occurred. + +Do not silently amend ADR-032's accepted runtime-only decision. The schema work +must publish this authored boundary in a new ADR or an amendment governed by +ADR-059, with corresponding normative SDL specifications. + +## Semantic Decisions + +### One scenario-scoped domain declaration + +Introduce one map-keyed, scenario-scoped identity-domain declaration. The final +serialized section name is a schema-design choice; its semantic placement is +not. The map key is its portable SDL identity and belongs to the existing +`identities` concept family. + +The declaration owns: + +- a closed, realization-capable domain profile/kind; +- the canonical DNS domain identity; +- the NetBIOS identity required by the initial AD-compatible profile; and +- an authority-principal reference to an existing top-level `accounts` + declaration. + +The authority principal identifies *who* is authorized to register and read +back domain-scoped state. It is not a place to store a password, hash, keytab, +token, private key, environment-variable value, host path, or backend resolver +configuration. No repository-wide deploy-time secret-reference contract exists +today, so this issue must not invent a secret store or disguise an opaque +backend lookup key as a portable SDL reference. A provisioner that needs an +operator secret resolves it from target configuration, keyed by safe authored +domain/principal identities, outside the SDL and plan payload. + +Use a provider-neutral base with explicit profiles, not a lowest-common- +denominator bag. The initial profile can require DNS and NetBIOS semantics; +future Kerberos, LDAP, or cloud-directory profiles add discriminated typed +members only where their realization semantics truly match. `unknown` and +`other` are useful inventory values but are not dispatchable authored topology +kinds. + +### Roles and membership are typed topology edges + +Represent domain-controller role and domain membership through the existing +top-level `relationships` graph: + +- a controller edge has a node source and identity-domain target; +- a join edge has a member-node source and identity-domain target; and +- join detail carries the explicit controller/authority-node selection (or a + bounded ordered candidate set), whose entries must be controller nodes for + that same domain. + +The exact relationship spellings remain schema work. Their semantics must use +new governed relationship terms and typed detail models. Do not encode these +facts in `Relationship.properties`, node tags/roles, descriptions, image names, +scenario ids, usernames, `os_family`, or duplicated node fields. + +A controller edge also establishes the controller node's membership in that +domain; do not require a redundant join edge. Multiple controllers are valid. +For the initial AD-compatible profile a VM may not declare contradictory +controller/member roles or membership in multiple domains. A future profile +may relax that only through an explicit discriminated rule, not a global +special case. + +### Domain-scoped account features bind explicitly + +An account that exercises a domain-scoped feature must reference its authored +domain. `spn` is the initial governed domain-scoped account feature. Reuse the +existing `Account` declaration and `provisioner_account_features()` extraction; +do not create a second domain-account model or let a backend infer the domain +from account/node names, OS, a sole join, or a controller image. + +Semantic validation must establish that the account's node is a controller or +member of the referenced domain and that the domain has complete dispatchable +topology. The account reference names the principal whose domain state is being +realized; it remains distinct from the authority principal used to perform the +operation. + +### One authored authority, one compiled projection + +The instantiated scenario remains the authority for domain declarations and +relationships. The compiler analyzes that graph once and emits a typed, +normalized per-node topology binding in the provisioning node payload. This is +an intentional compiled projection, not a second authored schema. + +The projection must contain enough explicit evidence for a consumer to decide, +without heuristics: + +- whether the node is a controller, member, or unrelated; +- the referenced domain id, profile, DNS identity, and NetBIOS identity; +- the safe authority-principal identity; +- the explicitly admitted controller node(s); and +- ordering dependencies needed to establish controllers before members and + domain-scoped account placements. + +Keep bootstrap ordering acyclic: establish the controller/domain, then realize +the authority account, then join members, then realize domain-scoped account +features. The authority-principal reference is logical identity for later +registration/read-back; it must not make the controller node depend on an +account placement that itself depends on that controller. Any credential needed +to bootstrap the first controller is target configuration, not a hidden SDL +account or plan dependency. + +There is no `AcesPlanNode` contract in this repository. ACES owns +`NodeRuntime`, the provisioning `node`/`account-placement` payloads, +`ProvisioningPlanModel`, and `RuntimeSnapshot`. A downstream DTO must consume +the published ACES projection rather than becoming a second authority. + +The compiled binding needs one typed model and one shared plan-analysis helper. +Although `PlanOperationModel.payload` is currently an open mapping, topology +inside it must be shape-checked at compiler output, planner admission, direct +HTTP/control-plane admission, and backend validation. Scan both plan resources +and non-`DELETE` operations, following the existing capability-envelope +pattern; a direct submitted plan cannot rely on compiler-private +`RuntimeModel` metadata. + +Backend-returned snapshot entries preserve the realized normalized projection, +which provides the portable read-back surface. Extend SEM-218 realization +requirements/disclosure for exact authored identity and topology fields so a +backend cannot omit or silently approximate them. Do not treat a copied desired +payload as observed proof, and do not hide topology in plan metadata, +diagnostics, audit text, or backend-private DTOs. + +## Semantic Invariants And Shared Gate + +`SemanticValidator` owns cross-declaration checks and collects all failures in +the existing `SDLValidationError` envelope. Model validators own only local +shape/profile rules. Post-instantiation semantic validation must run the same +analysis after variables resolve. + +The shared analysis must reject: + +- non-portable ids, invalid DNS/NetBIOS forms, missing profile-required fields, + or unresolved variables at the instantiated boundary; +- dangling or ambiguous domain, node, account, authority-principal, or + controller refs; +- controller/join sources that are switches or otherwise non-realizable nodes; +- an authority principal that is not placed on a controller for the same + domain; +- a join whose selected controller is not declared as a controller of the same + domain; +- contradictory or duplicate controller/join facts, including a redundant + controller join; +- a dispatchable join with no controller, authority principal, or required + domain identity; +- a domain-scoped account feature without an explicit matching domain ref; +- dependency cycles introduced by controller, join, and account ordering; and +- any backend capability claim that cannot realize the domain profile and + domain-scoped feature together. + +Domain identity comparison rules must be explicit. Do not silently rewrite +authored DNS/NetBIOS spelling or use case-folding for SDL ids. If protocol names +have case-insensitive comparison semantics, preserve the authored spelling and +apply that comparison only in the owning typed validator. + +Incomplete or inconsistent topology is a fatal semantic/admission diagnostic, +not an advisory. Use `SDLParseError`, `SDLValidationError`, +`SDLInstantiationError`, and runtime `Diagnostic` as appropriate; do not add a +domain exception hierarchy. + +## Canonical Incumbents To Reuse + +- **SDL authority and phases:** `ScenarioContent`, `SDLModel(extra="forbid")`, + `load_sdl_yaml`, `instantiate_scenario`, `InstantiatedScenario`, ADR-078, + canonical ids/addresses, and post-instantiation revalidation. +- **Declarations and refs:** `_mapping_scopes.HASHMAP_SECTIONS`, + `_module_symbols.HASHMAP_SECTIONS`, `build_declaration_index()`, reference + targetability, `composition._namespace_payload()`, `_language_metadata`, and + the reference/language-service helpers. Extend these catalogs; do not build a + domain-only resolver or registry. +- **Relationships:** `Relationship`, governed `RelationshipType` terms, the + typed relationship-detail precedent, `_verify_relationships()`, and the + cross-edge agreement checks in `validator/_relationships.py`. Free-form + `properties` is not a topology contract. +- **Compilation and planning:** `_compile_node_runtimes()`, `NodeRuntime`, + `resource_payload()`, `AccountPlacement`, `_collect_resources()`, canonical + dependency addressing, and `ProvisioningPlan`. Preserve closed SDL phase + boundaries rather than passing an authoring model to a backend. +- **Feature/capability gates:** `provisioner_account_features()`, + `ProvisionerCapabilities`, controlled-vocabulary validation, + `planner._validate_manifest()`, and the table-driven libvirt + `capability_envelope_diagnostics()` pattern. Add one governed provisioner + domain-topology capability dimension; do not bury support in free-form + `constraints` or equate `supports_accounts` with domain support. +- **Realization fidelity:** `aces_sdl.explicitness`, realization-envelope + membership, `CompiledRealizationRequirement`, + `realization_support_diagnostics()`, and `realization_disclosure()`. Domain + topology is realization-relevant authored intent, not provenance metadata or + an open backend choice. +- **Runtime admission and read-back:** `_submitted_plan_diagnostics()`, + `Provisioner.validate()`, `backend_calls`, `RuntimeSnapshot`, `SnapshotEntry`, + and `ControlPlaneStore`. Extend the existing admission path and snapshot + payload; do not create a topology service, repository, or persistence file. +- **Authority/publication:** ADR-009/019/061/076, + `specs/authority/authority-boundary.yaml`, `specs/sdl/sections.md`, + `specs/sdl/references.md`, published SDL/plan schemas, + `schema_bundle()`, `contracts/schema-publication-manifest.json`, and the + schema/catalog parity tools. Generated schemas prove Python parity but do not + replace hand-governed contract review. +- **Concept authority:** the existing `identities` and `relationships` concept + families, reference-model catalog, semantic profiles, and controlled + vocabularies. Add an authored domain reference model if needed; do not create + a competing identity concept family. +- **Diagnostics and observability:** bounded parser diagnostics, + collect-all semantic errors, runtime `Diagnostic`, operation receipts/status, + control-plane audit summaries, and redacted API/backend exception handling. + No new logger or error envelope is needed. + +## Security And Cross-Cutting Layers + +The design passes every layer below and must satisfy each one. + +1. **Source/parser gate.** Domain topology is inert data processed by + `load_sdl_yaml`; existing source-size, safe-YAML, duplicate-key, key + normalization, variable mapping-key, and bounded Pydantic-diagnostic rules + remain in force. No name or field triggers network, LDAP, Kerberos, shell, or + filesystem activity. +2. **Closed shape/reference gate.** `SDLModel(extra="forbid")`, published + schemas, profile validators, portable ids, the declaration index, typed + relationships, and `SemanticValidator` reject unknown fields and invalid or + inconsistent refs. The same pure analysis is reused by compiler agreement + tests; validation is not duplicated in a backend. +3. **Instantiation/config gate.** Existing typed variables, allowed-value + constraints, substitution, unresolved-token rejection, and semantic + revalidation apply. Domain ids remain literal map keys. Do not add an SDL + environment binding, `.env` reader, CLI secret flag, or target-config parser. +4. **Plan-shape/admission gate.** A typed compiled topology binding validates + every materialized `node`/domain-scoped `account-placement` payload. The + shared gate handles in-process resources and non-`DELETE` operations, and is + invoked before `Provisioner.validate()` for direct API submissions. Missing + evidence fails before realization dispatch. +5. **Capability/non-approximation gate.** Provisioner capabilities declare the + governed domain profile/kind and account-feature support independently. + Planner and backend use the same payload extractor. SEM-218 support and + disclosure reject unsupported or silently changed exact topology and record + bounded realization provenance. +6. **API/auth gate.** Submission continues through + `ControlPlaneSecurityConfig.strict_defaults()`, verified bearer/proxy + identity, target-bound operator/backend roles, request-size limits, + idempotency fingerprints, and audit summaries. Control-plane bearer identity + is not a domain authority credential. Audit records must not copy plan + payloads or authority-resolution details. +7. **Secret and OS/process gate.** SDL, plan operations, snapshots, diagnostics, + logs, fixtures, process argv, command strings, and environment dumps carry no + operator credential material. Backend adapters resolve credentials at the + target boundary and use fixed argv/no shell, least privilege, and redacted + output where an OS command is eventually required. This issue itself adds no + subprocess or environment-variable surface. +8. **Persistence/read API gate.** `LocalControlPlaneStore` persists snapshot + payloads and exposes them to authorized readers, so only safe domain, + principal, and controller identities may enter the compiled projection. + Preserve atomic snapshot writes and existing authorization. Never persist a + resolved password, keytab, token, secret URI, host path, backend-native + object, traceback, stdout, or stderr. +9. **Error-envelope gate.** Parser and semantic errors name safe field paths and + stable ids, not raw values or documents. Runtime failures use bounded + `Diagnostic` codes. Backend exceptions remain type-only at the adapter, and + the API's generic internal-error envelope must not be bypassed by embedding + secret-bearing values in `ValueError` text. + +## Extensibility Seam + +The seam is a pure domain-topology analysis and projection parameterized by: + +- a governed, discriminated domain profile/kind; +- profile-specific identity validation (DNS/NetBIOS initially); +- controller-selection policy over an explicit authored candidate set; +- the governed set of account features that require a domain binding; and +- provisioner-supported domain-profile terms. + +The same analysis feeds semantic validation, compiled node/account bindings, +ordering dependencies, capability extraction, direct-plan admission, and +snapshot non-approximation. Adding one future join-capable directory profile, +controller failover policy, or domain-scoped account feature should add a +profile/term and adapter support, not another top-level schema, backend DTO, +validator, or inference rule. + +Do not claim LDAP bind, Kerberos service use, federation, cloud tenancy, or IAM +trust is a machine domain join merely to reuse the initial shape. Extend the +profile union only when controller, membership, authority, and read-back +semantics align; otherwise use the existing relationship vocabulary or a +separate deliberately governed semantic. + +## Required Contract And Test Evidence + +The implementation must follow the repository's existing schema/fixture and +agreement patterns. At minimum, evidence must cover: + +- valid single/multi-controller topology and explicit member/account bindings; +- invalid local profile shapes, dangling/ambiguous refs, switch roles, + cross-domain authority selections, duplicate/conflicting roles, missing + controllers, dependency cycles, and SPN without a matching domain ref; +- variable substitution followed by the same semantic decisions; +- module namespacing and canonical reference/language-service behavior; +- authoring, instantiated-scenario, and instantiated-scenario-snapshot + schema/catalog parity; +- compiler projection and validator agreement, including explicitness and + exact-value realization disclosure; +- in-process and direct-HTTP plan admission, non-`DELETE` operation coverage, + capability rejection, and backend validation agreement; +- runtime-snapshot contract round-trip/read-back without secret material; and +- bounded diagnostics/API errors that do not echo domain credentials or raw + payloads. + +Use the existing SDL model/parser/validator/phase/composition/catalog tests, +contract fixture corpus, runtime compiler/planner tests, +`test_backend_protocols_account_features.py`, control-plane/API tests, and +backend conformance patterns. Do not make a single downstream Shifter example +the contract proof. + +## Non-Goals And Anti-Patterns + +- Do not provision AD, LDAP, Kerberos, DNS, or cloud directories in this issue's + ACES implementation; SDL and plan contracts describe portable intent and + evidence, while provisioners realize it. +- Do not redesign runtime identity inventory, local identity, accounts, + control-plane authentication, secret storage, persistence, or logging. +- Do not copy runtime subjects/policies/services into authored topology or + synthesize runtime inventory from desired topology and call it observation. +- Do not import legacy `dc`, `join_domain`, or `dc_config` shorthands silently. + Any compatibility migration follows ADR-075 with explicit, loss-aware + diagnostics. +- Do not introduce a universal directory schema, an AD-only top-level section, + a free-form topology bag, raw plan dictionaries without shape admission, or a + backend-owned bridge as the canonical contract. +- Do not infer topology from ids, names, images, accounts, OS families, service + ports, runtime inventory, or the fact that only one candidate exists. +- Do not add implementation logic under `implementations/python/src/aces/` or + create duplicate schema generators, reference registries, validation passes, + exception classes, logging stacks, capability maps, or persistence stores. +- Do not edit changelogs or package versions as part of the implementation. diff --git a/docs/decisions/issue-776-domain-topology-realizer-preflight.md b/docs/decisions/issue-776-domain-topology-realizer-preflight.md new file mode 100644 index 000000000..d4174b777 --- /dev/null +++ b/docs/decisions/issue-776-domain-topology-realizer-preflight.md @@ -0,0 +1,364 @@ +# Issue #776 — Domain Topology Realizer Preflight + +Date: 2026-07-14 + +Issue: #776. + +Requirement: none. The issue title, body, and acceptance criteria are the +authoritative contract. + +This note is architecture guidance only. It does not implement a realizer, +change a capability declaration, publish an envelope, or provide an +implementation plan. + +No new ADR is required. ADR-082 owns authored identity-domain topology and its +portable compiled projection; ADR-063 owns the reference-emulation backend; +ADR-066 owns operational observation versus evidence; and ADR-070 plus the +issue #100/#715 preflights own configuration-bound libvirt claims and guest +proof. This note applies those decisions to the first operational domain +realizer. The issue's ADR-081 reference is stale; ADR-082 is authoritative. + +## Current-State Correction And Scope + +There are two distinct backends and their claims must not be conflated. + +- `reference-emulation` still declares `active_directory` and `spn`, but its + pure interpreter reduces placements to address/name/target records and its + drivers realize only networks and containers. Copying the plan payload into a + snapshot is not domain or account realization. The minimum honest correction + is to remove those two claims unless that backend independently gains real + behavior. This issue does not establish that its other account-feature claims + are evidence-backed; they need the same honesty audit. +- `libvirt-qemu` currently has no false public AD claim. Every published libvirt + envelope has an empty `supported_domain_profiles`, and each admitted account + feature set excludes `spn`. The marker-file branch in + `aces_backend_libvirt.realization` is therefore legacy, unreachable behavior + for an admitted current configuration; it must not be promoted into evidence. + +The real fix belongs in a new, explicitly selected **AD-capable libvirt material +configuration**, not in `reference-emulation` and not as a silent widening of +the generic, TechVault, or guest-certified appliance configurations. A +digest-pinned AD-compatible appliance is an acceptable reference mechanism if +its envelope states its actual OS/image/network bounds and native evidence +proves the required directory behavior. A Samba-based implementation may claim +the portable AD-compatible semantics it proves; it must not imply Windows +Server or Microsoft-product equivalence. + +## Architecture Decisions And Guardrails + +### Keep one portable topology authority + +`IdentityDomain`, typed controller/join relationships, the pure SDL topology +analyzer, and `DomainTopologyBinding` remain the only authored and compiled +authorities. The libvirt interpreter consumes the existing binding on node and +account-placement payloads. It must not reparse SDL, infer a domain from names, +OS/image, SPN, DNS, observed inventory, or create a second domain/account +schema. + +Use `domain_topology_plan_diagnostics()` at backend validation and apply +admission, with the real baseline snapshot and selected +`supported_domain_profiles`. Use `DomainTopologyBinding.from_mapping()`, +`domain_topology_profile()`, `provisioner_account_features()`, and the existing +libvirt payload helpers rather than duplicating shape, reference, or feature +logic. Any backend-private execution record is a projection of these admitted +values, not a public DTO or another validation authority. + +`RuntimeSnapshot` continues to preserve the normalized desired binding for +SEM-218 exact readback. That copy proves carrier fidelity only. It is never +operational evidence that promotion, join, DNS, or SPN registration succeeded, +and observed `Node.runtime.identity_authorities` must not be synthesized from +it. + +### Bind support to one material libvirt configuration + +Extend the existing `LibvirtDriverMode` -> published envelope -> manifest -> +provisioner -> driver identity chain with one separately versioned mode. Its +closed envelope is the single source of supported OS, image, network, resource, +content, account-feature, domain-profile, ACL, transformation, and observation +claims. Existing modes retain their current envelopes and claims. + +Only a mode that completes controller promotion, member join, genuine SPN +registration, authoritative readback, and cleanup may publish both +`supported_domain_profiles={"active_directory"}` and account feature `spn`. +The manifest must bind the domain-profile capability scope to the existing +`identities` concept family. `_validate_manifest_mode()` must compare +`supported_domain_profiles` as it already compares the other envelope-derived +dimensions. Construction must select the driver for the normalized mode from +one target factory and fail on a mode/driver/envelope mismatch; no mode may +fall through to `LibvirtDeploymentDriver` accidentally. + +Do not add a backend-local support vocabulary. Exact topology remains covered +by the existing SEM-218 `declared-capability-match` declaration, while the +governed profile and account feature are checked by `ProvisionerCapabilities`, +the shared plan analyzer, and the table-driven libvirt capability envelope. +Change a published schema or controlled vocabulary only if those incumbents +cannot express a portable claim. + +### Make directory readiness a commit invariant + +Plan ordering is not guest readiness. The current provisioner batches active +domain specs into one driver call, so tuple order or +`ordering_dependencies` alone cannot establish a usable controller. The +AD-capable driver must enforce these state invariants inside the existing +staged native-driver boundary: + +- libvirt networks and owned domains are defined before guest operations; +- one controller from the explicit authored controller set bootstraps the + domain, then passes bounded DNS/Kerberos/directory readiness and identity + readback; +- additional controllers join only after that gate and independently prove + controller state; +- each member uses only its authored, ordered `controller_addresses`, waits for + one of them to be ready, joins, and proves its machine identity and secure + channel; +- domain accounts and SPNs are applied only against the proven domain, and the + SPN is queried back from the authoritative directory on the exact target + principal; and +- the driver returns successful handles only after every required observation + and cleanup gate passes. + +The authored topology has no “primary controller” semantic. A v1 driver may +choose a deterministic bootstrap leader (for example, the lexically smallest +compiled controller address) from the explicit set, but that is a fixed, +versioned operational policy. It is not authored meaning, must not alter the +snapshot binding, and must never select an unlisted controller. Member +candidate order remains authored and must be respected. + +Incremental operations use the topology closure, not only the directly changed +node. A changed SPN/account or member may require an unchanged owned controller +for execution and proof without redefining it. Replays must observe and +converge exact state before acting; a conflicting foreign domain, principal, +machine account, or SPN fails rather than being adopted, overwritten, or moved. +Partial delete/update must unregister owned directory state before removing a +surviving member/account/controller when stale state would otherwise remain. +If the first mode cannot safely support an operation shape, backend admission +must reject it before mutation; the manifest cannot imply success and then +silently skip directory cleanup. + +### Keep bootstrap credentials outside portable state + +`authority_account_ref` is a logical account address, not a credential +reference. Control-plane bearer identity is also unrelated to domain authority. +For a self-contained reference appliance, generate strong, per-apply bootstrap +material only at the impure driver boundary. If an external provider is ever +needed, inject a credential resolver/handle at target construction; do not add +an SDL field, plan field, environment binding, `.env` convention, or CLI secret +option. + +Secret values, keytabs, tickets, join blobs, hashes, and recovery material must +never enter the SDL, plan, envelope/configuration digest, cloud-init payload +retained in a snapshot, process argv, command text, environment, diagnostic, +log, audit event, fixture, `ApplyResult.details`, runtime snapshot, or evidence +artifact. Deliver bootstrap and per-member join material through a private, +least-privilege file/device/stdin or offline-join channel, with restrictive +ownership/modes, bounded lifetime, detachment, deletion, and verified cleanup. +Use fixed argv, no shell, a controlled cwd/environment, and bounded timeouts; +parse bounded structured output and discard raw stdout/stderr. If no safe +channel is available, the configuration must fail closed and must not claim +support. + +### Require authoritative, fresh, bounded evidence + +Reuse `RealizationObservation`, `DriverResult.observations`, the existing +daemon-then-guest staging pattern, and the canonical libvirt evidence-run +artifact/validator. Do not add a domain evidence store, report schema, logger, +or exception hierarchy. Use the existing closed concerns: + +- `topology` for exact domain identity and controller/member role/secure-channel + facts; +- `account-placement` for the authority account, target principal, and exact SPN + registration; and +- `service` for the DNS/Kerberos/directory readiness facts the selected mode + claims. + +Controller evidence must read DNS name, NetBIOS name, forest/domain identity, +and controller role from the authoritative directory. Member evidence must read +domain membership and a working secure channel from the member. SPN evidence +must query the directory, identify the exact principal, enforce global +uniqueness, and return only the bounded expected value/correlation—not a +directory dump, ticket, or keytab. Package presence, a marker file, a process, +an open port, successful VM boot, cloud-init completion, or an echo of desired +input is insufficient. + +A fresh per-run non-secret challenge and ownership-verified native correlation +must bind observations to the current guests, selected envelope/configuration, +image/appliance digest, ACES addresses/field paths, and control-plane operation. +The operation id is joined by the existing operations/evidence layer after +submission, not added to `LibvirtDriver.realize()`. Operational facts belong in +the validated evidence artifact; portable desired bindings stay in the runtime +snapshot. + +### Preserve transactional and cleanup behavior + +The selected mode inherits the existing ownership UUIDs, collision checks, +rollback, bounded state directory, and verified-absence cleanup rules. A failed +promotion, join, registration, readback, timeout, or cleanup returns stable +`Diagnostic` values, no changed addresses, and the baseline snapshot. A later +stage never repairs or upgrades a failed earlier stage. + +Directory operations are not magically atomic. Track which run-owned native +and directory objects were created so compensation is ownership-scoped and +idempotent. Never delete by name prefix, treat lookup failure as absence, adopt +pre-existing directory objects, or report success while temporary credential +media, overlays, fact channels, domains, networks, machine accounts, or SPNs +remain in an unintended state. Cleanup uncertainty is a failed operation/proof, +not a warning. + +## Required Cross-Cutting Reuse + +- **SDL and compilation:** `IdentityDomain`, typed domain relationships, + `analyze_domain_topology()`, `DomainTopologyBinding`, compiler + `NodeRuntime`/`AccountPlacement`, canonical compiled addresses and dependency + ordering. ADR-082 remains the semantic authority. +- **Plan admission:** `domain_topology_plan_diagnostics()`, + `ProvisioningPlan`, resource plus non-`DELETE` operation materialization, + `_submitted_plan_diagnostics()`, and the normal planner/direct-control-plane + gates. Backend checks may be stricter but not weaker or parallel. +- **Capabilities and exactness:** `ProvisionerCapabilities`, + `provisioner_account_features()`, `domain_topology_profile()`, the libvirt + `_ENVELOPE_DIMENSIONS` table, `CompiledRealizationRequirement`, + `realization_support_diagnostics()`, `realization_disclosure()`, and the + configuration-bound envelope identity carried through plan and snapshot. +- **Libvirt execution:** `interpret_provisioning_plan()`, `Realization`, + `DomainSpec`, `NetworkSpec`, `LibvirtProvisioner`, the + `TechVaultNativeLibvirtDriver` staged extension hooks, structured XML + builders, deterministic ownership, image/seed workspace protections, + rollback, and safe absence checks. Extend these private carriers only enough + to preserve the admitted binding and domain-bound account intent. +- **Observation and evidence:** `RealizationObservation`, `DriverResult`, + `ObservationStrength`, `GuestFactTransport`'s bounded fact-channel pattern, + `libvirt_evidence_run`, `validate_libvirt_evidence_run_artifact()`, redaction + validation, run-artifact paths/atomic writes, existing cleanup, and the + explicit destructive-confirmation CLI convention. +- **Runtime, errors, and persistence:** `RuntimeControlPlane`, + `_call_backend_diagnostics()`, `_call_backend_apply()`, `Diagnostic`, + `ApplyResult`, `OperationReceipt`, `OperationStatus`, `RuntimeSnapshot`, + `LocalControlPlaneStore` atomic persistence, and existing audit summaries. + Public failures stay in those envelopes and backend exceptions stay type-only. +- **Contract governance:** the existing realization-envelope model/schema, + libvirt envelope corpus, manifest rendering and concept-binding validators, + schema publication manifest, packaged corpus, fixtures, digest/parity tests, + and authority-boundary checks. A published carrier change must update the + complete governed set; editing only generated JSON is forbidden. + +## Security And Whole-Path Gates + +The intended design passes every layer below. + +1. **SDL parser and closed shapes.** Existing safe YAML/source bounds, + `SDLModel(extra="forbid")`, profile/name validators, typed refs, semantic + analysis, instantiation, and post-instantiation validation remain unchanged. + Authored strings are inert and never trigger directory or host activity. +2. **Compiler and plan shape.** The compiler emits the one typed binding. + `DomainTopologyBinding.from_mapping()` and + `domain_topology_plan_diagnostics()` shape-check resources, non-delete + operations, and baseline state before backend IO. The libvirt interpreter + consumes only admitted plan payloads. +3. **Capability and configuration shape.** Planner capability checks, SEM-218, + `_validate_config_keys()`, `_selected_driver_mode()`, envelope loading and + digest validation, `_validate_manifest_mode()`, provisioner identity checks, + and driver admission all agree on one normalized mode. Unknown config, + profile, feature, image, topology, or mode mismatch fails before mutation. +4. **Authentication and authorization.** No new HTTP route is needed. + `ControlPlaneSecurityConfig.strict_defaults()`, request-size limits, verified + bearer/proxy identity, operator/backend mutation roles, target scope after + either authentication mechanism, idempotency fingerprints, and audit events + continue to guard submission. Domain authority material is never derived + from a caller token. +5. **Secret handling and env binding.** There is no incumbent libvirt env-secret + shape, so none is introduced. Secrets are generated or resolved behind an + injected driver boundary, represented only by opaque in-memory handles, and + excluded from target/manifest/envelope serialization, CLI args, environment, + portable payloads, logs, diagnostics, and evidence. +6. **Guest/host OS and network exposure.** Subprocess leaves use fixed argv, + no shell, bounded input/output/time, and no secret argv/environment. Temporary + media and files reuse symlink/ownership/mode safeguards and are removed after + use. Controller IPs come from ownership-correlated libvirt/guest observation, + never by interpreting ACES addresses. DNS resolution, clock agreement, + routing, required AD protocol reachability, and member/controller network + overlap are checked before join. No host port forwarding or public exposure + is added by default, and backend augmentation must not silently weaken an + authored ACL. +7. **Errors and observability.** Stable package-local diagnostic codes carry a + safe ACES address/stage and generic message. Raw native XML, paths, process + output, directory dumps, credentials, backend reprs, and `str(exc)` do not + cross the driver boundary. Existing type-only backend-call errors and generic + API 500 handling remain intact; logging is supplemental, not evidence. +8. **Persistence and evidence.** `LocalControlPlaneStore` persists full snapshot + and operation payloads, so only portable domain/principal/controller + identities may enter them. Guest facts pass redaction, binding, freshness, + completeness, and artifact validation before atomic evidence persistence. + No secret or raw observation is stored in snapshot metadata or result details. +9. **Cleanup and read authorization.** Success and failure both verify removal + of run-local secrets/artifacts and the intended state of owned directory and + libvirt objects. Existing snapshot/operation read authorization is not + widened by the new evidence path. + +## Extensibility Seam + +The seam is the existing normalized `driver_mode` selecting a versioned libvirt +envelope and staged driver implementation. The private realizer is parameterized +by the admitted `DomainTopologyBinding`, domain-bound account placements, the +explicit ordered controller candidates, bounded stage/overall deadlines, and +the mode's fixed image/network/guest-transport/bootstrap-leader policy. Material +policy is configuration identity and must be fixed by or represented in a new +envelope revision; it is not an unbound runtime toggle. + +The next reasonable variation—Windows AD instead of an AD-compatible Linux +appliance, a second architecture, another credential-free join transport, or a +different deterministic bootstrap policy—adds another driver mode/envelope (or +a governed revision) behind the same seam. It must not require SDL changes, a +new topology DTO, a new capability dimension, a new control-plane route, a new +exception/store/evidence hierarchy, or edits to existing modes. + +## Gotchas And Anti-Patterns To Avoid + +- Do not restore `spn` to a generic envelope while its implementation is a + marker file, and do not call snapshot payload preservation realization. +- Do not make `reference-emulation` claims stand in for `libvirt-qemu` behavior, + or give both backends a shared mutable manifest/capability table. +- Do not use controller tuple order as an undocumented authored “primary,” use + a controller outside the explicit candidate set, or infer failover from DNS. +- Do not assume dependency order, an active VM, ping, a listening port, package + presence, or cloud-init completion proves directory readiness or membership. +- Do not pass passwords to `setspn`, `samba-tool`, `realm`, `adcli`, PowerShell, + or any other process through argv, command text, environment, user-data, or + the kernel command line. Do not expose a general guest command channel. +- Do not silently override member DNS, routing, time, or ACL intent. Reject a + topology the selected mode cannot connect safely. +- Do not use an unbounded directory query or record tickets, keytabs, password + hashes, raw LDAP/Kerberos output, native ids, or full principal inventories as + evidence. +- Do not treat SPN assignment as a local account property. Enforce directory- + global uniqueness and exact principal ownership; a collision is a blocking + diagnostic, not permission to move or overwrite a foreign SPN. +- Do not ignore unchanged controllers needed by a changed member/SPN, or allow + partial delete/update to leave stale machine accounts and SPNs in a surviving + domain. +- Do not add a second validator, payload parser, concern enum, report writer, + credential service, logger, persistence store, or backend exception hierarchy. +- Do not let fake-driver tests or a self-skipped native test certify the claim. + Hermetic tests cover admission, stage ordering, replay, failure, mutation, + rollback, redaction, and cleanup; an opt-in real-libvirt run through the + production control-plane/evidence path supplies the claim-bearing proof. +- Do not copy the self-hosted proof host's permissive QEMU/root security + settings into production defaults. Host hardening exceptions belong only to + an isolated disposable proof environment and must be explicit. + +## Non-Goals And Implementation Boundaries + +- No implementation of issue #776 in this preflight. +- No change to ADR-082 topology meaning, SDL syntax, observed runtime identity, + account schema, plan schema, control-plane API, or participant visibility. +- No general secret store, credential distribution protocol, remote shell, + directory administration API, universal image registry, or backend-neutral + domain-realizer framework. +- No claim for trusts/forests, cross-domain joins, group policy, federation, + cloud directory tenancy, generic LDAP/Kerberos realization, arbitrary Windows + images, or workloads outside the selected envelope. +- No widening of generic, TechVault, guest-certified, or reference-emulation + configurations without independent operational evidence for each material + configuration. +- No implementation logic under `implementations/python/src/aces/`, no + changelog/version edits, and no partial edits to governed schemas or generated + artifacts. diff --git a/docs/explain/reference/backend-conformance.md b/docs/explain/reference/backend-conformance.md index 38a2d66b7..19853b36a 100644 --- a/docs/explain/reference/backend-conformance.md +++ b/docs/explain/reference/backend-conformance.md @@ -206,10 +206,30 @@ facts to daemon- or guest-observed evidence. Issue #100 publishes configuration-bound realization envelopes using the shared parameterized SDL semantics from #667 and the membership/subsumption relation from #668. Manifests, provisioning plans, and snapshots carry one immutable -envelope/configuration identity. Replacing the temporary -`reference_scenario` bridge with generated positive and negative probes remains -tracked by #716; contract, adapter, native-daemon, and guest conformance remain -distinct reportable dimensions. +envelope/configuration identity. For an envelope-bound target, target +conformance now derives deterministic positive witnesses and safe negative +probes through that shared relation. It does not fall back to +`_DEFAULT_CONFORMANCE_SCENARIO` when an envelope is non-constructive. + +Each realization case records its `fixture-only`, `hermetic-live`, or +`native-live` execution basis, probe and probe-set digests, exact envelope and +configuration digests, operation accounting, declared and observed strength, +state-mutation checks, cleanup status, residual state, evidence references, +outcome, and diagnostics. Skipped and unsupported cases are gating failures; +only a passing `native-live` run can set `native_conformance`. + +The execution seam is an injected operations-owned harness. It must supply an +independent expected-observation inventory and fresh addressed observations; +planned values, unbound or fabricated handles, stale samples, missing operation +results, silent transformations, and incomplete cleanup all fail closed. +Machine-readable reports are written only through the redaction-gated atomic +artifact path. The currently published open libvirt envelopes are deliberately +reported as non-constructive until the configuration-specific envelopes are +made executable; a caller-selected happy path cannot substitute for them. + +Contract, adapter, native-daemon, and guest conformance remain distinct +reportable dimensions. The repository-wide guardrails for this work are recorded in the +[`issue #716 realization-honesty preflight`](../../decisions/issue-716-asr-519-realization-honesty-conformance-preflight.md). ## Non-Goals diff --git a/docs/explain/reference/shared-concept-model.md b/docs/explain/reference/shared-concept-model.md index cd47cd5f2..972895098 100644 --- a/docs/explain/reference/shared-concept-model.md +++ b/docs/explain/reference/shared-concept-model.md @@ -152,6 +152,7 @@ For example, a backend manifest binds its provisioner vocabulary: {"scope": "capabilities.provisioner.supported_os_families", "family": "assets"}, {"scope": "capabilities.provisioner.supported_content_types", "family": "tools-and-artifacts"}, {"scope": "capabilities.provisioner.supported_account_features", "family": "identities"}, + {"scope": "capabilities.provisioner.supported_domain_profiles", "family": "identities"}, {"scope": "capabilities.orchestrator.supported_sections", "family": "actions-and-events"}, {"scope": "capabilities.evaluator.supported_sections", "family": "observables"} ] diff --git a/docs/explain/reference/shared-semantic-integrity.md b/docs/explain/reference/shared-semantic-integrity.md index 0398f781d..a3e446c4a 100644 --- a/docs/explain/reference/shared-semantic-integrity.md +++ b/docs/explain/reference/shared-semantic-integrity.md @@ -229,15 +229,15 @@ so they are tracked by their own requirements, not here. | Construct family | Owning requirement(s) | Phases covered | Realizing artifacts | Status | | --- | --- | --- | --- | --- | | Fail-closed semantic validation (cross-cutting gate) | SEM-201 | validation, instantiation | `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/tests/test_sdl_validator.py` | active | -| Stable identifiers, parameterized values, and qualified references | DSL-101, DSL-102, SEM-205 | authoring, validation, instantiation, compilation, planning, execution, observation | `docs/decisions/adrs/adr-076-portable-sdl-identifiers-and-canonical-addresses.md`, `specs/sdl/document-model.md`, `specs/sdl/references.md`, `implementations/python/packages/aces_sdl/_identifiers.py`, `implementations/python/packages/aces_sdl/_declarations.py`, `implementations/python/packages/aces_sdl/parser.py`, `implementations/python/packages/aces_sdl/composition.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_runtime/backend_calls.py`, `implementations/python/packages/aces_runtime/control_plane.py`, `implementations/python/tests/test_sdl_identifiers.py` | active | +| Stable identifiers, parameterized values, and qualified references | DSL-101, DSL-102, SEM-205 | authoring, validation, instantiation, compilation, planning, execution, observation | `docs/decisions/adrs/adr-076-portable-sdl-identifiers-and-canonical-addresses.md`, `specs/sdl/document-model.md`, `specs/sdl/references.md`, `implementations/python/packages/aces_sdl/_identifiers.py`, `implementations/python/packages/aces_sdl/_declarations.py`, `implementations/python/packages/aces_sdl/parser.py`, `implementations/python/packages/aces_sdl/composition.py`, `implementations/python/packages/aces_processor/compiler/__init__.py`, `implementations/python/packages/aces_runtime/backend_calls.py`, `implementations/python/packages/aces_runtime/control_plane.py`, `implementations/python/tests/test_sdl_identifiers.py` | active | | Deterministic module composition and canonical-identity stability across expansion | DSL-103, SEM-205 | authoring, validation, compilation | `implementations/python/packages/aces_sdl/composition.py`, `implementations/python/packages/aces_sdl/module_registry.py`, `specs/formal/composition-readiness.md`, `implementations/python/tests/test_sdl_module_registry.py` | active | | Instantiation, closed portable phase contracts, and revalidation of concrete scenarios | RUN-301 | instantiation, validation | `docs/decisions/adrs/adr-078-closed-sdl-phase-contracts-and-portable-derivation-evidence.md`, `specs/formal/sdl-phases/README.md`, `contracts/schemas/sdl/instantiated-scenario-v1.json`, `contracts/schemas/sdl/instantiated-scenario-snapshot-v1.json`, `implementations/python/packages/aces_sdl/phase_contracts.py`, `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/tests/test_sdl_phase_contracts.py`, `implementations/python/tests/test_instantiated_scenario_schema.py`, `implementations/python/tests/test_sdl_validator.py`, `implementations/python/tests/test_run_300_lifecycle.py` | active | | Objective windows, referenced scopes, reachability, and refresh | SEM-202 | validation, compilation, planning | `implementations/python/packages/aces_sdl/semantics/objectives.py`, `specs/formal/objectives/README.md`, `specs/formal/objectives/window-consistency.md`, `implementations/python/tests/test_semantics_objectives.py`, `implementations/python/tests/test_fm2_semantics.py` | active | -| Declarative objective actor binding, target resolution, success interpretation, and dependency ordering | DSL-112, SEM-207 | authoring, validation, instantiation, compilation, planning | `implementations/python/packages/aces_sdl/objectives.py`, `implementations/python/packages/aces_sdl/semantics/objective_semantics.py`, `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models/`, `specs/formal/objectives/README.md`, `specs/formal/objectives/declarative-objective-semantics.md`, `implementations/python/tests/test_semantics_objectives.py`, `implementations/python/tests/test_fm2_semantics.py`, `implementations/python/tests/test_sdl_validator.py` | active | +| Declarative objective actor binding, target resolution, success interpretation, and dependency ordering | DSL-112, SEM-207 | authoring, validation, instantiation, compilation, planning | `implementations/python/packages/aces_sdl/objectives.py`, `implementations/python/packages/aces_sdl/semantics/objective_semantics.py`, `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/packages/aces_processor/compiler/__init__.py`, `implementations/python/packages/aces_processor/models/`, `specs/formal/objectives/README.md`, `specs/formal/objectives/declarative-objective-semantics.md`, `implementations/python/tests/test_semantics_objectives.py`, `implementations/python/tests/test_fm2_semantics.py`, `implementations/python/tests/test_sdl_validator.py` | active | | Workflow control semantics (branching, joins, calling, retry, completion, history) | DSL-113, SEM-203 | authoring, validation, compilation, planning, execution, observation | `implementations/python/packages/aces_sdl/orchestration.py`, `implementations/python/packages/aces_sdl/semantics/workflow.py`, `specs/formal/workflows/README.md`, `specs/formal/workflows/state-machine.md`, `implementations/python/tests/test_sdl_validator.py`, `implementations/python/tests/test_runtime_models.py`, `implementations/python/tests/test_sdl_models.py` | active | | Workflow compensation semantics (registration, triggering, ordering, observation) | SEM-204 | validation, compilation, execution, observation | `implementations/python/packages/aces_sdl/semantics/workflow.py`, `specs/formal/workflows/compensation.md`, `implementations/python/tests/test_sdl_validator.py`, `implementations/python/tests/test_runtime_manager.py` | partial | -| Backend-neutral proposition truth and assertion-composed objective success (probe conditions remain separate implementation bindings; SDL scoring pipeline removed per ADR-073) | DSL-110, SEM-206 | authoring, validation, compilation, planning, execution, observation | `implementations/python/packages/aces_sdl/propositions.py`, `implementations/python/packages/aces_sdl/conditions.py`, `implementations/python/packages/aces_sdl/semantics/propositions.py`, `implementations/python/packages/aces_sdl/semantics/objective_semantics.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models/`, `specs/formal/objectives/proposition-and-assertion-semantics.md`, `implementations/python/tests/test_proposition_semantics.py`, `implementations/python/tests/test_truth_result_contracts.py`, `implementations/python/tests/test_fm2_semantics.py` | active | -| Runtime compiled representation and canonical addresses | RUN-302 | compilation | `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/tests/test_runtime_models.py`, `implementations/python/tests/test_fm2_semantics.py` | active | +| Backend-neutral proposition truth and assertion-composed objective success (probe conditions remain separate implementation bindings; SDL scoring pipeline removed per ADR-073) | DSL-110, SEM-206 | authoring, validation, compilation, planning, execution, observation | `implementations/python/packages/aces_sdl/propositions.py`, `implementations/python/packages/aces_sdl/conditions.py`, `implementations/python/packages/aces_sdl/semantics/propositions.py`, `implementations/python/packages/aces_sdl/semantics/objective_semantics.py`, `implementations/python/packages/aces_processor/compiler/__init__.py`, `implementations/python/packages/aces_processor/models/`, `specs/formal/objectives/proposition-and-assertion-semantics.md`, `implementations/python/tests/test_proposition_semantics.py`, `implementations/python/tests/test_truth_result_contracts.py`, `implementations/python/tests/test_fm2_semantics.py` | active | +| Runtime compiled representation and canonical addresses | RUN-302 | compilation | `implementations/python/packages/aces_processor/compiler/__init__.py`, `implementations/python/tests/test_runtime_models.py`, `implementations/python/tests/test_fm2_semantics.py` | active | | Planner dependency, ordering, refresh, and applicability semantics | RUN-303 | planning | `implementations/python/packages/aces_processor/semantics/planner.py`, `implementations/python/packages/aces_processor/planner.py`, `specs/formal/planner/README.md`, `specs/formal/planner/dependency-ordering.md`, `implementations/python/tests/test_semantics_planner.py`, `implementations/python/tests/test_runtime_planner.py` | active | | Live execution state and lifecycle (snapshots, results, history) | RUN-304, API-402 | execution, observation | `implementations/python/packages/aces_runtime/manager.py`, `implementations/python/packages/aces_runtime/result_contracts.py`, `implementations/python/packages/aces_processor/models/`, `implementations/python/tests/test_runtime_manager.py`, `implementations/python/tests/test_runtime_models.py` | active | | Runtime result and evaluator-result contracts | ASR-503, API-402 | execution, observation | `implementations/python/packages/aces_runtime/result_contracts.py`, `specs/formal/runtime-contracts/README.md`, `specs/formal/runtime-contracts/workflow-results.md`, `specs/formal/runtime-contracts/evaluator-results.md`, `implementations/python/tests/test_runtime_contracts.py`, `implementations/python/tests/test_run_311_participant_episode_lifecycle.py` | active | @@ -246,16 +246,17 @@ so they are tracked by their own requirements, not here. | Concept authority, controlled vocabularies, reference models, and semantic profiles (meta-layer) | GOV-920 | authoring, validation, compilation, planning, execution | `specs/concept-authority/concept-authority.md`, `specs/concept-authority/semantic-profiles.md`, `implementations/python/packages/aces_contracts/semantic_profiles.py`, `implementations/python/packages/aces_contracts/controlled_vocabularies.py`, `implementations/python/packages/aces_contracts/reference_models.py`, `docs/explain/reference/shared-concept-model.md`, `implementations/python/tests/test_concept_authority.py`, `implementations/python/tests/test_semantic_profiles.py` | active | | Participant episode lifecycle boundaries (initialization, reset, completion, timeout, truncation, interruption) | RUN-311, SEM-222 | execution, observation | `docs/decisions/adrs/adr-013-participant-episode-lifecycle-boundaries.md`, `implementations/python/tests/test_run_311_participant_episode_lifecycle.py` | partial | | Declarative participant framing (identity, role, starting conditions, authority anchors, operating scope) | ACT-601 | authoring, validation | `implementations/python/packages/aces_sdl/agents.py`, `implementations/python/packages/aces_sdl/validator/__init__.py`, `docs/decisions/adrs/adr-020-declarative-participant-framing-boundaries.md`, `implementations/python/tests/test_sdl_models.py`, `implementations/python/tests/test_sdl_validator.py` | active | -| Participant behavior semantics (actions, observations, state transitions) | ACT-602, SEM-208 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models/`, `implementations/python/tests/test_sem_208_participant_behavior.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | -| Multi-participant interaction and participant-local histories | SEM-209, SEM-226 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models/`, `implementations/python/packages/aces_conformance/conformance.py`, `implementations/python/tests/test_sem_208_participant_behavior.py`, `implementations/python/tests/test_runtime_conformance.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | -| Visibility and information-boundary semantics | SEM-210 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models/`, `implementations/python/tests/test_sem_208_participant_behavior.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | active | -| Participant preconditions, effects, failure, causality, and attribution semantics | SEM-211, SEM-212 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_action_semantics.py`, `implementations/python/packages/aces_sdl/participant_attribution_semantics.py`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models/`, `implementations/python/packages/aces_conformance/conformance.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/tests/test_sem_211_participant_action_semantics.py`, `implementations/python/tests/test_sem_212_participant_attribution_semantics.py`, `implementations/python/tests/test_runtime_conformance.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | -| Participant temporal, tool/affordance, and decision-surface semantics | SEM-213, SEM-219, SEM-220 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | +| Participant behavior semantics (actions, observations, state transitions) | ACT-602, SEM-208 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_processor/compiler/__init__.py`, `implementations/python/packages/aces_processor/models/`, `implementations/python/tests/test_sem_208_participant_behavior.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | +| Multi-participant interaction and participant-local histories | SEM-209 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/packages/aces_processor/compiler/__init__.py`, `implementations/python/packages/aces_processor/models/`, `implementations/python/packages/aces_conformance/conformance.py`, `implementations/python/tests/test_sem_208_participant_behavior.py`, `implementations/python/tests/test_runtime_conformance.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | +| Visibility and information-boundary semantics | SEM-210 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/packages/aces_processor/compiler/__init__.py`, `implementations/python/packages/aces_processor/models/`, `implementations/python/tests/test_sem_208_participant_behavior.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | active | +| Participant preconditions, effects, failure, causality, and attribution semantics | SEM-211, SEM-212 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_action_semantics.py`, `implementations/python/packages/aces_sdl/participant_attribution_semantics.py`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_processor/compiler/__init__.py`, `implementations/python/packages/aces_processor/models/`, `implementations/python/packages/aces_conformance/conformance.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/tests/test_sem_211_participant_action_semantics.py`, `implementations/python/tests/test_sem_212_participant_attribution_semantics.py`, `implementations/python/tests/test_runtime_conformance.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | +| Participant temporal semantics | SEM-213 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | +| Participant tool/affordance, decision-surface, and exposure/visibility-boundary semantics | SEM-219, SEM-220, SEM-226 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-083-participant-tool-decision-surface-and-exposure-semantics.md`, `docs/decisions/issue-119-sem-219-220-226-participant-decision-surface-preflight.md`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | | Participant reference trajectories, demonstrations, budgets, and quota/exhaustion semantics | SEM-221, SEM-223 | — | — | planned | -| Participant outcome interpretation | SEM-215 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_outcome_semantics.py`, `implementations/python/packages/aces_sdl/semantics/participant_outcome.py`, `implementations/python/packages/aces_processor/models/`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/tests/test_sem_215_participant_outcome_interpretation.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | active | +| Participant outcome interpretation | SEM-215 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_outcome_semantics.py`, `implementations/python/packages/aces_sdl/semantics/participant_outcome.py`, `implementations/python/packages/aces_processor/models/`, `implementations/python/packages/aces_processor/compiler/__init__.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/tests/test_sem_215_participant_outcome_interpretation.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | active | | Derived operational context views (portable meaning and comparability) | SEM-214 | execution, observation | `specs/formal/participant-semantics/README.md`, `specs/formal/runtime-contracts/participant-backend-contracts.md`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/packages/aces_runtime/participant_retrieval.py`, `implementations/python/packages/aces_runtime/control_plane_api_participant_retrieval.py`, `contracts/schemas/control-plane/participant-context-view-v1.json`, `implementations/python/tests/test_participant_backend_contracts.py`, `implementations/python/tests/test_runtime_control_plane.py`, `implementations/python/tests/test_runtime_control_plane_api.py` | active | | Boundary semantics for runtime-observable state, captured evidence, derived evaluations, analysis outputs, and audience-specific views | SEM-216 | execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/issue-248-sem-216-boundary-semantics-preflight.md`, `implementations/python/packages/aces_contracts/contracts.py`, `contracts/schemas/control-plane/participant-context-view-v1.json`, `contracts/schemas/experiment-core/experiment-evidence-record-v1.json`, `implementations/python/tests/test_sem_216_boundary_semantics.py`, `implementations/python/tests/test_participant_backend_contracts.py`, `implementations/python/tests/test_runtime_contracts.py` | active | | Evidence, evaluation, view-boundary, and observability-plane semantics | SEM-224, SEM-225, DSL-123, DSL-124 | authoring, validation, execution, observation | `docs/decisions/adrs/adr-066-observability-evidence-plane-separation.md`, `specs/formal/observability-evidence-plane.md`, `specs/sdl/observability-and-evidence.md` | partial | | External knowledge bindings semantics | SEM-217 | validation, execution | `specs/formal/participant-semantics/README.md`, `docs/explain/reference/shared-concept-model.md`, `implementations/python/packages/aces_contracts/semantic_binding_effects.py`, `implementations/python/tests/test_sem_217_knowledge_bindings.py` | active | -| Explicitness and realization semantics (binding declarations vs processor/backend realization) | SEM-218 | authoring, validation, instantiation, compilation, planning, execution, observation | `specs/formal/realization/explicitness-and-realization.md`, `specs/formal/realization/README.md`, `docs/explain/reference/explicitness-realization-semantics.md`, `implementations/python/packages/aces_sdl/explicitness.py`, `implementations/python/packages/aces_sdl/realization_designation.py`, `implementations/python/packages/aces_sdl/phase_contracts.py`, `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/packages/aces_contracts/apparatus.py`, `implementations/python/packages/aces_contracts/vocabulary.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/packages/aces_contracts/runtime_state.py`, `implementations/python/packages/aces_backend_protocols/manifest.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models/`, `implementations/python/packages/aces_processor/planner.py`, `implementations/python/packages/aces_processor/semantics/realization.py`, `implementations/python/packages/aces_runtime/backend_calls.py`, `implementations/python/packages/aces_runtime/manager.py`, `implementations/python/packages/aces_runtime/control_plane_store.py`, `implementations/python/packages/aces_runtime/control_plane_api_models.py`, `implementations/python/tests/test_sem_218_explicitness.py`, `implementations/python/tests/test_sem_218_realization.py`, `implementations/python/tests/test_sem_218_realization_designation.py`, `implementations/python/tests/test_sem_218_runtime_realization.py`, `implementations/python/tests/test_runtime_planner.py`, `implementations/python/tests/test_backend_manifest.py`, `implementations/python/tests/test_processor_manifest.py`, `implementations/python/tests/test_runtime_contracts.py` | active | +| Explicitness and realization semantics (binding declarations vs processor/backend realization) | SEM-218 | authoring, validation, instantiation, compilation, planning, execution, observation | `specs/formal/realization/explicitness-and-realization.md`, `specs/formal/realization/README.md`, `docs/explain/reference/explicitness-realization-semantics.md`, `implementations/python/packages/aces_sdl/explicitness.py`, `implementations/python/packages/aces_sdl/realization_designation.py`, `implementations/python/packages/aces_sdl/phase_contracts.py`, `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/packages/aces_contracts/apparatus.py`, `implementations/python/packages/aces_contracts/vocabulary.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/packages/aces_contracts/runtime_state.py`, `implementations/python/packages/aces_backend_protocols/manifest.py`, `implementations/python/packages/aces_processor/compiler/__init__.py`, `implementations/python/packages/aces_processor/models/`, `implementations/python/packages/aces_processor/planner.py`, `implementations/python/packages/aces_processor/semantics/realization.py`, `implementations/python/packages/aces_runtime/backend_calls.py`, `implementations/python/packages/aces_runtime/manager.py`, `implementations/python/packages/aces_runtime/control_plane_store.py`, `implementations/python/packages/aces_runtime/control_plane_api_models.py`, `implementations/python/tests/test_sem_218_explicitness.py`, `implementations/python/tests/test_sem_218_realization.py`, `implementations/python/tests/test_sem_218_realization_designation.py`, `implementations/python/tests/test_sem_218_runtime_realization.py`, `implementations/python/tests/test_runtime_planner.py`, `implementations/python/tests/test_backend_manifest.py`, `implementations/python/tests/test_processor_manifest.py`, `implementations/python/tests/test_runtime_contracts.py` | active | | Clock, time-domain, advancement/pacing/synchronization, and temporal ordering/causality semantics | SEM-227, SEM-228, SEM-229 | — | — | planned | diff --git a/docs/explain/sdl/sections.md b/docs/explain/sdl/sections.md index a6726efb5..725adba15 100644 --- a/docs/explain/sdl/sections.md +++ b/docs/explain/sdl/sections.md @@ -46,6 +46,7 @@ plane (ADR-055/064/069). Declarative `conditions` remain. |---------|------|---------|--------------| | `content` | `dict[str, Content]` | Data placed into systems (files, datasets, emails) | CyRIS `copy_content` | | `accounts` | `dict[str, Account]` | Curated scenario/provisioning accounts on nodes, not full runtime identity inventory | CyRIS `add_account` | +| `identity_domains` | `dict[str, IdentityDomain]` | Authored domain identity and authority for controller/join realization | ACES ADR-082 | | `relationships` | `dict[str, Relationship]` | Typed edges between elements (auth, trust, federation) | STIX Relationship SRO | | `forwarding_agents` | `list[RuntimeForwardingAgent]` | Scenario-level forwarding and shipping agents with element-carried identity | ACES ADR-050 | | `agents` | `dict[str, Agent]` | Autonomous participants (actions, knowledge, scope) | CybORG Agents, extended by ACES | @@ -1569,6 +1570,51 @@ model itself. --- +## Identity Domains + +Authored identity-domain realization intent. This is separate from observed +`nodes.*.runtime.identity_authorities` inventory. + +```yaml +identity_domains: + corp: + profile: active_directory + dns_name: corp.example + netbios_name: CORP + authority_account_ref: domain-admin + +accounts: + domain-admin: + username: Administrator + node: dc + web-service: + username: svc-web + node: workstation + spn: HTTP/workstation.corp.example + domain_ref: corp + +relationships: + dc-role: + type: domain_controller_for + source: dc + target: corp + domain_controller: {} + workstation-join: + type: joins_domain + source: workstation + target: corp + domain_join: + controller_refs: [dc] +``` + +Every domain has a VM controller, joins list explicit same-domain controller +candidates, the authority account lives on a controller, and domain-bound +accounts live on participating nodes. SPNs require `domain_ref`; the domain is +never inferred from the SPN or node operating system. See the +{download}`normative topology specification <../../../specs/sdl/authored-domain-topology.md>`. + +--- + ## Relationships Typed directed edges between any named scenario elements. Adapted from STIX Relationship SROs. @@ -1598,7 +1644,9 @@ relationships: properties: {protocol: tcp, port: "5432"} ``` -Types: `authenticates_with`, `trusts`, `federates_with`, `connects_to`, `depends_on`, `manages`, `replicates_to`. +Types: `authenticates_with`, `trusts`, `federates_with`, `connects_to`, +`depends_on`, `manages`, `replicates_to`, `domain_controller_for`, and +`joins_domain`. Relationship endpoints resolve against the scenario's named elements, including top-level section keys, nested entity dot-paths, variables, other diff --git a/examples/scenarios/hospital-ransomware-surgery-day.sdl.yaml b/examples/scenarios/hospital-ransomware-surgery-day.sdl.yaml index 98d0b5649..ed3cfe99f 100644 --- a/examples/scenarios/hospital-ransomware-surgery-day.sdl.yaml +++ b/examples/scenarios/hospital-ransomware-surgery-day.sdl.yaml @@ -1078,7 +1078,19 @@ content: path: C:\\Support\\session.ini text: tenant=hospital\naccess=vpn\nprofile=imaging-support sensitive: true +identity_domains: + hospital: + profile: active_directory + dns_name: hospital.local + netbios_name: HOSPITAL + authority_account_ref: domain-admin accounts: + domain-admin: + username: Administrator + node: ad01 + groups: + - Domain Admins + password_strength: strong nurse-user: username: nurse.jane node: exchange01 @@ -1105,6 +1117,7 @@ accounts: - AppServices password_strength: weak spn: HTTP/ehr.hospital.local + domain_ref: hospital svc-sql: username: ehrsql node: ehr-db @@ -1112,6 +1125,7 @@ accounts: - DBA password_strength: weak spn: POSTGRES/ehr-db.hospital.local + domain_ref: hospital backup-operator: username: backupops node: backup-vault @@ -1131,6 +1145,25 @@ accounts: - SOC password_strength: strong relationships: + ad01-controls-hospital-domain: + type: domain_controller_for + source: ad01 + target: hospital + domain_controller: {} + ehr-frontend-joins-hospital-domain: + type: joins_domain + source: ehr-frontend + target: hospital + domain_join: + controller_refs: + - ad01 + ehr-db-joins-hospital-domain: + type: joins_domain + source: ehr-db + target: hospital + domain_join: + controller_refs: + - ad01 exchange-auths-ad: type: authenticates_with source: exchange-mailbox diff --git a/implementations/python/packages/aces_backend_libvirt/capability_envelope.py b/implementations/python/packages/aces_backend_libvirt/capability_envelope.py index 2fda101ca..525d7db64 100644 --- a/implementations/python/packages/aces_backend_libvirt/capability_envelope.py +++ b/implementations/python/packages/aces_backend_libvirt/capability_envelope.py @@ -16,6 +16,7 @@ from aces_backend_protocols.account_features import provisioner_account_features from aces_backend_protocols.capabilities import ProvisionerCapabilities +from aces_backend_protocols.domain_topology import domain_topology_profile from aces_contracts.diagnostics import Diagnostic, Severity from aces_contracts.planning import ChangeAction, ProvisioningPlan, RuntimeDomain @@ -40,6 +41,7 @@ _CODE_UNSUPPORTED_OS_FAMILY = "libvirt-backend.realization.unsupported-os-family" _CODE_UNSUPPORTED_CONTENT_TYPE = "libvirt-backend.realization.unsupported-content-type" _CODE_UNSUPPORTED_ACCOUNT_FEATURE = "libvirt-backend.realization.unsupported-account-feature" +_CODE_UNSUPPORTED_DOMAIN_PROFILE = "libvirt-backend.realization.unsupported-domain-profile" @dataclass(frozen=True) @@ -95,9 +97,21 @@ class _EnvelopeDimension: extract=lambda payload: tuple(sorted(provisioner_account_features(_spec(payload)))), supported=lambda caps: caps.supported_account_features, ), + _EnvelopeDimension( + resource_types=frozenset({NODE_RESOURCE_TYPE, ACCOUNT_PLACEMENT_RESOURCE_TYPE}), + code=_CODE_UNSUPPORTED_DOMAIN_PROFILE, + noun="identity-domain profile", + extract=lambda payload: _requested_domain_profiles(payload), + supported=lambda caps: caps.supported_domain_profiles, + ), ) +def _requested_domain_profiles(payload: Mapping[str, object]) -> tuple[str, ...]: + profile = domain_topology_profile(payload) + return (profile,) if profile else () + + def capability_envelope_diagnostics( plan: ProvisioningPlan, capabilities: ProvisionerCapabilities, diff --git a/implementations/python/packages/aces_backend_libvirt/driver.py b/implementations/python/packages/aces_backend_libvirt/driver.py index e70681524..97689300c 100644 --- a/implementations/python/packages/aces_backend_libvirt/driver.py +++ b/implementations/python/packages/aces_backend_libvirt/driver.py @@ -6,7 +6,7 @@ from typing import Protocol from aces_contracts.diagnostics import Diagnostic -from aces_contracts.realization_envelope import ObservationStrength, RealizationConcern +from aces_contracts.realization_observation import RealizationObservation from .cloudinit import CloudInitSpec @@ -78,17 +78,6 @@ class DomainHandle: realized: bool = True -@dataclass(frozen=True) -class RealizationObservation: - """Bounded typed readback for one realized concern field.""" - - address: str - field_path: str - concern: RealizationConcern - source: ObservationStrength - value: object - - @dataclass(frozen=True) class DriverResult: """Aggregate portable result from a libvirt driver call.""" diff --git a/implementations/python/packages/aces_backend_libvirt/manifest.py b/implementations/python/packages/aces_backend_libvirt/manifest.py index 5251bcc49..f587c05d9 100644 --- a/implementations/python/packages/aces_backend_libvirt/manifest.py +++ b/implementations/python/packages/aces_backend_libvirt/manifest.py @@ -36,6 +36,7 @@ def _provisioner_capabilities(mode: LibvirtDriverMode) -> ProvisionerCapabilitie supported_os_families=frozenset(configuration.supported_os_families), supported_content_types=frozenset(configuration.supported_content_types), supported_account_features=account_features, + supported_domain_profiles=frozenset(configuration.supported_domain_profiles), max_total_nodes=None, supports_acls=configuration.supports_acls, supports_accounts=bool(account_features), @@ -166,6 +167,7 @@ def create_libvirt_manifest(**config: object) -> BackendManifest: ConceptBinding(scope="capabilities.provisioner.supported_os_families", family="assets"), ConceptBinding(scope="capabilities.provisioner.supported_content_types", family="tools-and-artifacts"), ConceptBinding(scope="capabilities.provisioner.supported_account_features", family="identities"), + ConceptBinding(scope="capabilities.provisioner.supported_domain_profiles", family="identities"), ), realization_support=( RealizationSupportDeclaration( diff --git a/implementations/python/packages/aces_backend_libvirt/target.py b/implementations/python/packages/aces_backend_libvirt/target.py index c7cca003f..44b1ef152 100644 --- a/implementations/python/packages/aces_backend_libvirt/target.py +++ b/implementations/python/packages/aces_backend_libvirt/target.py @@ -119,6 +119,7 @@ def _validate_manifest_mode(manifest: BackendManifest, mode: LibvirtDriverMode) "supported_os_families": frozenset(configuration.supported_os_families), "supported_content_types": frozenset(configuration.supported_content_types), "supported_account_features": frozenset(configuration.supported_account_features), + "supported_domain_profiles": frozenset(configuration.supported_domain_profiles), "supports_accounts": bool(configuration.supported_account_features), "supports_acls": configuration.supports_acls, } diff --git a/implementations/python/packages/aces_backend_protocols/__init__.py b/implementations/python/packages/aces_backend_protocols/__init__.py index c82eaf752..4bf684e56 100644 --- a/implementations/python/packages/aces_backend_protocols/__init__.py +++ b/implementations/python/packages/aces_backend_protocols/__init__.py @@ -1 +1,5 @@ """Backend-facing protocol and capability declarations.""" + +from .domain_topology import DomainTopologyBinding as DomainTopologyBinding + +__all__ = ["DomainTopologyBinding"] diff --git a/implementations/python/packages/aces_backend_protocols/capabilities.py b/implementations/python/packages/aces_backend_protocols/capabilities.py index 0176998e0..b273cfb32 100644 --- a/implementations/python/packages/aces_backend_protocols/capabilities.py +++ b/implementations/python/packages/aces_backend_protocols/capabilities.py @@ -18,6 +18,7 @@ OBSERVATION_CAPABILITY_CAPTURE_KIND_SCOPE = "capabilities.observation.supported_capture_kinds" OBSERVATION_CAPABILITY_CHANNEL_KIND_SCOPE = "capabilities.observation.supported_channel_kinds" OBSERVATION_CAPABILITY_SEALING_MODE_SCOPE = "capabilities.observation.supported_sealing_modes" +PROVISIONER_DOMAIN_PROFILE_SCOPE = "capabilities.provisioner.supported_domain_profiles" _PARTICIPANT_EPISODE_CONTRACTS = frozenset( { @@ -92,6 +93,7 @@ class ProvisionerCapabilities: supported_os_families: frozenset[str] = frozenset() supported_content_types: frozenset[str] = frozenset() supported_account_features: frozenset[str] = frozenset() + supported_domain_profiles: frozenset[str] = frozenset() max_total_nodes: int | None = None supports_acls: bool = False supports_accounts: bool = False @@ -112,6 +114,8 @@ def __post_init__(self) -> None: raise ValueError("ProvisionerCapabilities.supported_content_types must not contain empty strings") if any(not feature.strip() for feature in self.supported_account_features): raise ValueError("ProvisionerCapabilities.supported_account_features must not contain empty strings") + if any(not profile.strip() for profile in self.supported_domain_profiles): + raise ValueError("ProvisionerCapabilities.supported_domain_profiles must not contain empty strings") validate_controlled_vocabulary_scope_values( "capabilities.provisioner.supported_node_types", self.supported_node_types, @@ -128,6 +132,10 @@ def __post_init__(self) -> None: "capabilities.provisioner.supported_account_features", self.supported_account_features, ) + validate_controlled_vocabulary_scope_values( + PROVISIONER_DOMAIN_PROFILE_SCOPE, + self.supported_domain_profiles, + ) if self.max_total_nodes is not None and self.max_total_nodes < 1: raise ValueError("ProvisionerCapabilities.max_total_nodes must be positive when provided") if self.supports_accounts and not self.supported_account_features: diff --git a/implementations/python/packages/aces_backend_protocols/domain_topology.py b/implementations/python/packages/aces_backend_protocols/domain_topology.py new file mode 100644 index 000000000..2e9f237a7 --- /dev/null +++ b/implementations/python/packages/aces_backend_protocols/domain_topology.py @@ -0,0 +1,424 @@ +"""Portable compiled identity-domain topology binding.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from aces_contracts.addressing import require_compiled_address +from aces_contracts.diagnostics import Diagnostic, Severity +from aces_contracts.planning import ChangeAction, RuntimeDomain + +if TYPE_CHECKING: + from aces_contracts.planning import PlannedResource, PlanOperation, ProvisioningPlan + from aces_contracts.runtime_state import RuntimeSnapshot, SnapshotEntry + +DOMAIN_NODE_ROLES = frozenset({"controller", "member"}) + + +def domain_topology_profile(payload: Mapping[str, object]) -> str: + """Return the concrete domain profile carried by a resource payload.""" + + binding = payload.get("domain_topology") + if not isinstance(binding, Mapping): + return "" + profile = binding.get("profile") + return profile if isinstance(profile, str) else "" + + +@dataclass(frozen=True) +class DomainTopologyBinding: + """Normalized domain realization intent attached to a plan resource.""" + + domain_id: str + profile: str + dns_name: str + netbios_name: str + authority_account_address: str + role: str + controller_addresses: tuple[str, ...] + + def __post_init__(self) -> None: + for field_name in ("domain_id", "profile", "dns_name", "netbios_name"): + value = getattr(self, field_name) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"DomainTopologyBinding.{field_name} must be non-empty") + require_compiled_address( + self.authority_account_address, + field_name="DomainTopologyBinding.authority_account_address", + ) + if self.role not in DOMAIN_NODE_ROLES: + raise ValueError("DomainTopologyBinding.role must be 'controller' or 'member'") + if not self.controller_addresses: + raise ValueError("DomainTopologyBinding.controller_addresses must not be empty") + if len(self.controller_addresses) != len(set(self.controller_addresses)): + raise ValueError("DomainTopologyBinding.controller_addresses must be unique") + for address in self.controller_addresses: + require_compiled_address(address, field_name="DomainTopologyBinding.controller_addresses") + + @classmethod + def from_mapping(cls, payload: Mapping[str, object]) -> DomainTopologyBinding: + """Parse the plain-data plan carrier into its typed representation.""" + + controller_addresses = payload.get("controller_addresses", ()) + if isinstance(controller_addresses, (str, bytes, Mapping)): + raise ValueError("DomainTopologyBinding.controller_addresses must be a sequence") + try: + controllers = tuple(str(value) for value in controller_addresses) + except TypeError as error: + raise ValueError("DomainTopologyBinding.controller_addresses must be a sequence") from error + return cls( + domain_id=str(payload.get("domain_id", "")), + profile=str(payload.get("profile", "")), + dns_name=str(payload.get("dns_name", "")), + netbios_name=str(payload.get("netbios_name", "")), + authority_account_address=str(payload.get("authority_account_address", "")), + role=str(payload.get("role", "")), + controller_addresses=controllers, + ) + + +@dataclass(frozen=True) +class _MaterializedResource: + address: str + resource_type: str + payload: Mapping[str, object] + ordering_dependencies: tuple[str, ...] + refresh_dependencies: tuple[str, ...] + + +def _snapshot_resources(snapshot: RuntimeSnapshot | None) -> dict[str, _MaterializedResource]: + resources: dict[str, _MaterializedResource] = {} + if snapshot is None: + return resources + for entry in snapshot.entries.values(): + materialized = _materialize_snapshot_entry(entry) + if materialized is not None: + resources[entry.address] = materialized + return resources + + +def _materialize_snapshot_entry(entry: SnapshotEntry) -> _MaterializedResource | None: + if entry.domain is not RuntimeDomain.PROVISIONING or not isinstance(entry.payload, Mapping): + return None + return _MaterializedResource( + address=entry.address, + resource_type=entry.resource_type, + payload=entry.payload, + ordering_dependencies=entry.ordering_dependencies, + refresh_dependencies=entry.refresh_dependencies, + ) + + +def _materialize_planned_resource(resource: PlannedResource) -> _MaterializedResource | None: + if resource.domain is not RuntimeDomain.PROVISIONING or not isinstance(resource.payload, Mapping): + return None + return _MaterializedResource( + address=resource.address, + resource_type=resource.resource_type, + payload=resource.payload, + ordering_dependencies=resource.ordering_dependencies, + refresh_dependencies=resource.refresh_dependencies, + ) + + +def _materialize_operation(operation: PlanOperation) -> _MaterializedResource | None: + if not isinstance(operation.payload, Mapping): + return None + return _MaterializedResource( + address=operation.address, + resource_type=operation.resource_type, + payload=operation.payload, + ordering_dependencies=operation.ordering_dependencies, + refresh_dependencies=operation.refresh_dependencies, + ) + + +def _materialized_resources( + plan: ProvisioningPlan, + snapshot: RuntimeSnapshot | None, +) -> dict[str, _MaterializedResource]: + resources = _snapshot_resources(snapshot) + for resource in plan.resources.values(): + materialized = _materialize_planned_resource(resource) + if materialized is not None: + resources[resource.address] = materialized + for operation in plan.operations: + if operation.action is ChangeAction.DELETE: + resources.pop(operation.address, None) + continue + materialized = _materialize_operation(operation) + if materialized is not None: + resources[operation.address] = materialized + return resources + + +def _diagnostic(code: str, address: str, message: str) -> Diagnostic: + return Diagnostic( + code=code, + domain="provisioning", + address=address, + message=message, + severity=Severity.ERROR, + ) + + +def _parse_binding( + resource: _MaterializedResource, +) -> tuple[DomainTopologyBinding | None, Diagnostic | None]: + raw = resource.payload.get("domain_topology") + binding: DomainTopologyBinding | None = None + diagnostic: Diagnostic | None = None + if raw is not None: + if not isinstance(raw, Mapping): + diagnostic = _diagnostic( + "provisioning.domain-topology.binding-invalid", + resource.address, + "Domain topology binding must be a typed mapping.", + ) + else: + try: + binding = DomainTopologyBinding.from_mapping(raw) + except ValueError as error: + diagnostic = _diagnostic( + "provisioning.domain-topology.binding-invalid", + resource.address, + f"Domain topology binding is invalid: {error}.", + ) + return binding, diagnostic + + +def _binding_core(binding: DomainTopologyBinding) -> tuple[str, str, str, str, str]: + return ( + binding.domain_id, + binding.profile, + binding.dns_name, + binding.netbios_name, + binding.authority_account_address, + ) + + +def _account_spn(resource: _MaterializedResource) -> str: + spec = resource.payload.get("spec") + if not isinstance(spec, Mapping): + return "" + spn = spec.get("spn") + return spn if isinstance(spn, str) else "" + + +def _account_target(resource: _MaterializedResource) -> str: + target = resource.payload.get("target_address") + return target if isinstance(target, str) else "" + + +def _dedupe_diagnostics(diagnostics: list[Diagnostic]) -> list[Diagnostic]: + deduped: dict[tuple[str, str, str], Diagnostic] = {} + for diagnostic in diagnostics: + deduped.setdefault((diagnostic.code, diagnostic.address or "", diagnostic.message), diagnostic) + return list(deduped.values()) + + +def _collect_bindings( + resources: Mapping[str, _MaterializedResource], + supported_domain_profiles: frozenset[str] | None, +) -> tuple[dict[str, DomainTopologyBinding], list[Diagnostic]]: + bindings: dict[str, DomainTopologyBinding] = {} + diagnostics: list[Diagnostic] = [] + for address, resource in resources.items(): + binding, diagnostic = _parse_binding(resource) + if diagnostic is not None: + diagnostics.append(diagnostic) + elif binding is None: + if resource.resource_type == "account-placement" and _account_spn(resource): + diagnostics.append( + _diagnostic( + "provisioning.domain-topology.spn-binding-missing", + address, + "An account placement carrying an SPN requires an explicit domain topology binding.", + ) + ) + elif resource.resource_type not in {"node", "account-placement"}: + diagnostics.append( + _diagnostic( + "provisioning.domain-topology.carrier-invalid", + address, + "Domain topology bindings may appear only on node and account-placement resources.", + ) + ) + else: + bindings[address] = binding + if supported_domain_profiles is not None and binding.profile not in supported_domain_profiles: + diagnostics.append( + _diagnostic( + "provisioner.unsupported-domain-profile", + address, + f"Provisioner does not support identity-domain profile '{binding.profile}'.", + ) + ) + return bindings, diagnostics + + +def _domain_definition_diagnostics(bindings: Mapping[str, DomainTopologyBinding]) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + domain_cores: dict[str, tuple[str, str, str, str, str]] = {} + for address, binding in bindings.items(): + core = _binding_core(binding) + previous = domain_cores.setdefault(binding.domain_id, core) + if previous != core: + diagnostics.append( + _diagnostic( + "provisioning.domain-topology.domain-definition-conflict", + address, + f"Domain topology bindings disagree on the definition of domain '{binding.domain_id}'.", + ) + ) + return diagnostics + + +def _bindings_for_resource_type( + bindings: Mapping[str, DomainTopologyBinding], + resources: Mapping[str, _MaterializedResource], + resource_type: str, +) -> dict[str, DomainTopologyBinding]: + return { + address: binding for address, binding in bindings.items() if resources[address].resource_type == resource_type + } + + +def _controller_matches_domain( + controller: DomainTopologyBinding | None, + binding: DomainTopologyBinding, +) -> bool: + return ( + controller is not None + and controller.role == "controller" + and _binding_core(controller) == _binding_core(binding) + ) + + +def _node_binding_diagnostics( + resources: Mapping[str, _MaterializedResource], + node_bindings: Mapping[str, DomainTopologyBinding], +) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + for address, binding in node_bindings.items(): + resource = resources[address] + if binding.role == "controller" and address not in binding.controller_addresses: + diagnostics.append( + _diagnostic( + "provisioning.domain-topology.controller-self-missing", + address, + "A controller binding must include its own node address among the domain controllers.", + ) + ) + for controller_address in binding.controller_addresses: + if not _controller_matches_domain(node_bindings.get(controller_address), binding): + diagnostics.append( + _diagnostic( + "provisioning.domain-topology.controller-unbound", + address, + f"Controller address '{controller_address}' does not resolve to a controller for " + f"domain '{binding.domain_id}'.", + ) + ) + missing_dependencies = set(binding.controller_addresses) - set(resource.ordering_dependencies) + if binding.role == "member" and missing_dependencies: + diagnostics.append( + _diagnostic( + "provisioning.domain-topology.controller-dependency-missing", + address, + "A member node must order after every selected domain controller.", + ) + ) + return diagnostics + + +def _account_binding_diagnostics( + resources: Mapping[str, _MaterializedResource], + node_bindings: Mapping[str, DomainTopologyBinding], + account_bindings: Mapping[str, DomainTopologyBinding], +) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + for address, binding in account_bindings.items(): + target_address = _account_target(resources[address]) + if node_bindings.get(target_address) != binding: + diagnostics.append( + _diagnostic( + "provisioning.domain-topology.account-node-mismatch", + address, + "An account domain binding must exactly match its target node's domain binding.", + ) + ) + return diagnostics + + +def _authority_account_is_valid( + binding: DomainTopologyBinding, + authority: DomainTopologyBinding | None, + authority_resource: _MaterializedResource | None, +) -> bool: + authority_target = _account_target(authority_resource) if authority_resource is not None else "" + return ( + authority is not None + and _binding_core(authority) == _binding_core(binding) + and authority.role == "controller" + and authority_target in binding.controller_addresses + ) + + +def _authority_account_diagnostics( + resources: Mapping[str, _MaterializedResource], + node_bindings: Mapping[str, DomainTopologyBinding], + account_bindings: Mapping[str, DomainTopologyBinding], +) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + for address, binding in node_bindings.items(): + authority_address = binding.authority_account_address + if not _authority_account_is_valid( + binding, + account_bindings.get(authority_address), + resources.get(authority_address), + ): + diagnostics.append( + _diagnostic( + "provisioning.domain-topology.authority-account-invalid", + address, + "The domain authority account must resolve to an account placement on one of its controllers.", + ) + ) + return diagnostics + + +def domain_topology_plan_diagnostics( + plan: ProvisioningPlan, + *, + snapshot: RuntimeSnapshot | None = None, + supported_domain_profiles: frozenset[str] | None = None, +) -> list[Diagnostic]: + """Validate domain topology over resources, non-delete ops, and snapshot. + + Operations override same-address resources and admitted snapshot entries, + matching the materialized state that a direct control-plane submission asks + a provisioner to realize. + """ + + resources = _materialized_resources(plan, snapshot) + bindings, diagnostics = _collect_bindings(resources, supported_domain_profiles) + diagnostics.extend(_domain_definition_diagnostics(bindings)) + + node_bindings = _bindings_for_resource_type(bindings, resources, "node") + account_bindings = _bindings_for_resource_type(bindings, resources, "account-placement") + diagnostics.extend(_node_binding_diagnostics(resources, node_bindings)) + diagnostics.extend(_account_binding_diagnostics(resources, node_bindings, account_bindings)) + diagnostics.extend(_authority_account_diagnostics(resources, node_bindings, account_bindings)) + + return _dedupe_diagnostics(diagnostics) + + +__all__ = [ + "DOMAIN_NODE_ROLES", + "DomainTopologyBinding", + "domain_topology_plan_diagnostics", + "domain_topology_profile", +] diff --git a/implementations/python/packages/aces_backend_protocols/manifest.py b/implementations/python/packages/aces_backend_protocols/manifest.py index 2d168af4f..d8d2b57e9 100644 --- a/implementations/python/packages/aces_backend_protocols/manifest.py +++ b/implementations/python/packages/aces_backend_protocols/manifest.py @@ -85,6 +85,7 @@ def backend_manifest_v2_model(manifest: BackendManifest) -> BackendManifestV2Mod "supported_os_families": sorted(manifest.provisioner.supported_os_families), "supported_content_types": sorted(manifest.provisioner.supported_content_types), "supported_account_features": sorted(manifest.provisioner.supported_account_features), + "supported_domain_profiles": sorted(manifest.provisioner.supported_domain_profiles), "max_total_nodes": manifest.provisioner.max_total_nodes, "supports_acls": manifest.provisioner.supports_acls, "supports_accounts": manifest.provisioner.supports_accounts, diff --git a/implementations/python/packages/aces_backend_stubs/stubs.py b/implementations/python/packages/aces_backend_stubs/stubs.py index 4ef5a80f6..f89d60543 100644 --- a/implementations/python/packages/aces_backend_stubs/stubs.py +++ b/implementations/python/packages/aces_backend_stubs/stubs.py @@ -86,6 +86,7 @@ def create_stub_manifest( ConceptBinding(scope="capabilities.provisioner.supported_os_families", family="assets"), ConceptBinding(scope="capabilities.provisioner.supported_content_types", family="tools-and-artifacts"), ConceptBinding(scope="capabilities.provisioner.supported_account_features", family="identities"), + ConceptBinding(scope="capabilities.provisioner.supported_domain_profiles", family="identities"), ConceptBinding(scope="capabilities.orchestrator.supported_sections", family="actions-and-events"), ConceptBinding(scope="capabilities.evaluator.supported_sections", family="observables"), ) @@ -158,6 +159,7 @@ def create_stub_manifest( supported_account_features=frozenset( {"groups", "mail", "spn", "shell", "home", "disabled", "auth_method"} ), + supported_domain_profiles=frozenset({"active_directory"}), max_total_nodes=None, supports_acls=True, supports_accounts=True, diff --git a/implementations/python/packages/aces_cli/conformance.py b/implementations/python/packages/aces_cli/conformance.py index 7101f05b3..5b7c560fe 100644 --- a/implementations/python/packages/aces_cli/conformance.py +++ b/implementations/python/packages/aces_cli/conformance.py @@ -15,9 +15,9 @@ from aces_conformance.conformance import ( BackendCapabilityProfile, BackendConformanceReport, + backend_conformance_report_payload, run_fixture_suite, ) -from aces_contracts.diagnostics import Diagnostic _DEFAULT_PROFILE_ID = BackendCapabilityProfile.ORCHESTRATION_EVALUATION.value _KNOWN_PROFILE_HINT = ", ".join(sorted(profile.value for profile in BackendCapabilityProfile)) @@ -25,41 +25,8 @@ app = typer.Typer(help="Backend conformance suite and fixture corpus.") -def _serialize_diagnostic(diag: Diagnostic) -> dict[str, object]: - """Serialize a :class:`Diagnostic` with its stable identifying fields. - - CI gates wired to ``aces conformance backend`` need to distinguish - ``conformance.profile-load-failed`` from ``conformance.fixture-missing`` - from a case-level schema failure; flattening to ``message`` would force - brittle string-matching. Preserve the structured envelope. - """ - - return { - "code": diag.code, - "domain": diag.domain, - "address": diag.address, - "severity": diag.severity.value if hasattr(diag.severity, "value") else str(diag.severity), - "message": diag.message, - } - - def _report_payload(report: BackendConformanceReport) -> dict[str, object]: - return { - "profile": report.profile, - "passed": report.passed, - "claim": report.claim.model_dump(mode="json"), - "cases": [ - { - "name": case.name, - "contract_name": case.contract_name, - "valid": case.valid, - "passed": case.passed, - "diagnostics": [_serialize_diagnostic(diag) for diag in case.diagnostics], - } - for case in report.cases - ], - "diagnostics": [_serialize_diagnostic(diag) for diag in report.diagnostics], - } + return backend_conformance_report_payload(report) @app.command("backend") diff --git a/implementations/python/packages/aces_conformance/_realization_models.py b/implementations/python/packages/aces_conformance/_realization_models.py new file mode 100644 index 000000000..667facc0c --- /dev/null +++ b/implementations/python/packages/aces_conformance/_realization_models.py @@ -0,0 +1,132 @@ +"""Data contracts for backend-neutral realization conformance.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Protocol + +from aces_contracts.diagnostics import Diagnostic +from aces_contracts.planning import ProvisioningPlan +from aces_contracts.realization_envelope import RealizationConcern +from aces_contracts.realization_observation import RealizationObservation + + +class ExecutionBasis(str, Enum): + """Execution substrate used by one conformance case.""" + + FIXTURE_ONLY = "fixture-only" + HERMETIC_LIVE = "hermetic-live" + NATIVE_LIVE = "native-live" + + +class ProbeOutcome(str, Enum): + """Closed outcome vocabulary for realization probes.""" + + PASSED = "passed" + FAILED = "failed" + SKIPPED = "skipped" + UNSUPPORTED = "unsupported" + + +@dataclass(frozen=True) +class ExpectedRealizationObservation: + """Independent expected fact projected by the injected harness.""" + + address: str + field_path: str + concern: RealizationConcern + value: object + + +@dataclass(frozen=True) +class RealizationTransformation: + """One material difference between planned and observed realization.""" + + address: str + concern: RealizationConcern + kind: str + disclosed: bool + + +@dataclass(frozen=True) +class RealizationProbeRequest: + """Validated probe request supplied to an independent execution harness.""" + + probe_digest: str + probe_kind: str + payload: dict[str, object] + negative: bool + provisioning_plan: ProvisioningPlan | None + envelope_digest: str + configuration_digest: str + observer_version: str + + +@dataclass(frozen=True) +class RealizationProbeEvidence: + """Evidence returned by a backend-specific but independently-owned harness.""" + + accepted: bool + accounted_operations: tuple[str, ...] = () + changed_addresses: tuple[str, ...] = () + expected_observations: tuple[ExpectedRealizationObservation, ...] = () + observations: tuple[RealizationObservation, ...] = () + transformations: tuple[RealizationTransformation, ...] = () + driver_invoked: bool = False + native_mutated: bool = False + portable_state_before: str = "" + portable_state_after: str = "" + native_state_before: str = "" + native_state_after: str = "" + baseline_sequence: int = 0 + cleanup_verified: bool = False + residual_state: tuple[str, ...] = () + evidence_refs: tuple[str, ...] = () + diagnostics: tuple[Diagnostic, ...] = () + + +class RealizationConformanceHarness(Protocol): + """Narrow operations-owned execution, observation, ledger, and cleanup seam.""" + + def execute(self, request: RealizationProbeRequest) -> RealizationProbeEvidence: + """Execute one validated positive or negative probe.""" + ... + + +@dataclass(frozen=True) +class RealizationProbeCase: + """One realization probe in the existing backend conformance report.""" + + name: str + contract_name: str + valid: bool + passed: bool + diagnostics: tuple[Diagnostic, ...] = () + execution_basis: str = ExecutionBasis.FIXTURE_ONLY.value + outcome: str = ProbeOutcome.PASSED.value + probe_kind: str | None = None + probe_digest: str | None = None + probe_set_digest: str | None = None + envelope_digest: str | None = None + configuration_digest: str | None = None + target_binding: str | None = None + expected_operations: tuple[str, ...] = () + accounted_operations: tuple[str, ...] = () + expected_observation_strengths: tuple[str, ...] = () + actual_observation_strengths: tuple[str, ...] = () + portable_state_unchanged: bool | None = None + native_state_unchanged: bool | None = None + cleanup_verified: bool | None = None + residual_state: tuple[str, ...] = () + evidence_refs: tuple[str, ...] = () + + +@dataclass(frozen=True) +class RealizationConformanceRun: + """Internal aggregate returned to ``run_target_conformance``.""" + + cases: tuple[RealizationProbeCase, ...] = () + probe_set_digest: str | None = None + target_binding: str | None = None + native_conformance: bool = False diff --git a/implementations/python/packages/aces_conformance/_realization_validation.py b/implementations/python/packages/aces_conformance/_realization_validation.py new file mode 100644 index 000000000..c284a9041 --- /dev/null +++ b/implementations/python/packages/aces_conformance/_realization_validation.py @@ -0,0 +1,207 @@ +"""Validation helpers for realization-honesty evidence.""" + +from __future__ import annotations + +from aces_contracts.diagnostics import Diagnostic, Severity +from aces_contracts.planning import ProvisioningPlan +from aces_contracts.realization_envelope import ( + BackendRealizationEnvelopeModel, + ConcernDisposition, + ObservationStrength, + RealizationConcern, +) +from aces_contracts.realization_observation import RealizationObservation + +from ._realization_models import ( + ExpectedRealizationObservation, + RealizationProbeEvidence, + RealizationProbeRequest, +) + +_DOMAIN = "conformance" +_RESOURCE_CONCERNS: dict[str, frozenset[RealizationConcern]] = { + "network": frozenset({RealizationConcern.TOPOLOGY, RealizationConcern.NETWORK}), + "node": frozenset( + { + RealizationConcern.TOPOLOGY, + RealizationConcern.ARCHITECTURE, + RealizationConcern.IMAGE, + RealizationConcern.RESOURCE_ALLOCATION, + RealizationConcern.NETWORK, + RealizationConcern.SERVICE, + RealizationConcern.ACL, + } + ), + "content-placement": frozenset({RealizationConcern.CONTENT_PLACEMENT}), + "account-placement": frozenset({RealizationConcern.ACCOUNT_PLACEMENT}), + "feature-binding": frozenset({RealizationConcern.FEATURE_BINDING}), +} +_STRENGTH_RANK = { + ObservationStrength.NONE: 0, + ObservationStrength.DRIVER_REPORTED: 1, + ObservationStrength.DAEMON_OBSERVED: 2, + ObservationStrength.GUEST_OBSERVED: 3, +} + + +def diagnostic(code: str, address: str, message: str) -> Diagnostic: + """Return a sanitized conformance error diagnostic.""" + + return Diagnostic(code=code, domain=_DOMAIN, address=address, message=message, severity=Severity.ERROR) + + +def required_strengths( + envelope: BackendRealizationEnvelopeModel, +) -> dict[RealizationConcern, ObservationStrength]: + """Return required observation strength by supported concern.""" + + return { + disclosure.concern: disclosure.observation_strength + for disclosure in envelope.concerns + if disclosure.disposition is not ConcernDisposition.UNSUPPORTED + } + + +def operation_inventory_diagnostics( + plan: ProvisioningPlan, + evidence: RealizationProbeEvidence, + strengths: dict[RealizationConcern, ObservationStrength], +) -> list[Diagnostic]: + """Check exact operation accounting and expected-observation coverage.""" + + expected_operations = {operation.address for operation in plan.actionable_operations} + accounted = set(evidence.accounted_operations) + diagnostics: list[Diagnostic] = [] + if accounted != expected_operations or set(evidence.changed_addresses) != expected_operations: + diagnostics.append( + diagnostic( + "conformance.operation-accounting-incomplete", + "runtime.provisioning.operations", + "Every actionable provisioning operation must have exactly one terminal accounting result.", + ) + ) + inventory = {(item.address, item.concern) for item in evidence.expected_observations} + for operation in plan.actionable_operations: + required = _RESOURCE_CONCERNS.get(operation.resource_type, frozenset()) & strengths.keys() + if any((operation.address, concern) not in inventory for concern in required): + diagnostics.append( + diagnostic( + "conformance.observation-inventory-incomplete", + operation.address, + "The independent expected-observation inventory omits a required realization concern.", + ) + ) + return diagnostics + + +def observation_diagnostics( + request: RealizationProbeRequest, + evidence: RealizationProbeEvidence, + strengths: dict[RealizationConcern, ObservationStrength], +) -> list[Diagnostic]: + """Check observation value, strength, binding, provenance, and freshness.""" + + observed: dict[tuple[str, str, RealizationConcern], list[RealizationObservation]] = {} + for item in evidence.observations: + observed.setdefault((item.address, item.field_path, item.concern), []).append(item) + diagnostics: list[Diagnostic] = [] + for expected in evidence.expected_observations: + diagnostics.extend( + _expected_observation_diagnostics( + expected, + observed.get((expected.address, expected.field_path, expected.concern), []), + request, + evidence.baseline_sequence, + strengths, + ) + ) + return diagnostics + + +def _expected_observation_diagnostics( + expected: ExpectedRealizationObservation, + candidates: list[RealizationObservation], + request: RealizationProbeRequest, + baseline_sequence: int, + strengths: dict[RealizationConcern, ObservationStrength], +) -> list[Diagnostic]: + address = expected.address + if len(candidates) != 1 or candidates[0].value != expected.value: + return [ + diagnostic( + "conformance.observation-missing", + address, + "A required addressed realization observation is missing, duplicated, or mismatched.", + ) + ] + item = candidates[0] + diagnostics: list[Diagnostic] = [] + required = strengths.get(expected.concern, ObservationStrength.NONE) + if _STRENGTH_RANK[item.source] < _STRENGTH_RANK[required]: + diagnostics.append( + diagnostic( + "conformance.observation-strength-insufficient", + address, + "The realization observation is weaker than the configuration requires.", + ) + ) + if not _observation_binding_valid(item, address, request): + diagnostics.append( + diagnostic( + "conformance.observation-binding-invalid", + address, + "The realization observation is not bound to this operation, probe, envelope, and observer.", + ) + ) + if item.origin != "observed": + diagnostics.append( + diagnostic( + "conformance.observation-not-independent", + address, + "Planned or echoed state cannot satisfy an independent realization observation.", + ) + ) + if item.sequence is None or item.sequence <= baseline_sequence: + diagnostics.append( + diagnostic( + "conformance.observation-stale", + address, + "The realization observation is not fresh for this probe execution.", + ) + ) + return diagnostics + + +def _observation_binding_valid( + item: RealizationObservation, + address: str, + request: RealizationProbeRequest, +) -> bool: + return ( + item.operation_id == address + and item.probe_digest == request.probe_digest + and item.envelope_digest == request.envelope_digest + and item.configuration_digest == request.configuration_digest + and item.observer_version == request.observer_version + and item.binding_verified + ) + + +def transformation_diagnostics(evidence: RealizationProbeEvidence) -> list[Diagnostic]: + """Reject all material transformations lacking executable governed evidence.""" + + diagnostics: list[Diagnostic] = [] + for transformation in evidence.transformations: + code = ( + "conformance.transformation-undisclosed" + if not transformation.disclosed + else "conformance.transformation-unverified" + ) + diagnostics.append( + diagnostic( + code, + transformation.address, + "A material realization transformation lacks a governed executable rule and exact evidence.", + ) + ) + return diagnostics diff --git a/implementations/python/packages/aces_conformance/conformance.py b/implementations/python/packages/aces_conformance/conformance.py index 0ed951dec..3cc7e9d3f 100644 --- a/implementations/python/packages/aces_conformance/conformance.py +++ b/implementations/python/packages/aces_conformance/conformance.py @@ -4,7 +4,7 @@ import json from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from enum import Enum from pathlib import Path from textwrap import dedent @@ -93,6 +93,13 @@ ) from pydantic import ValidationError +from aces_conformance.realization import ( + ExecutionBasis, + RealizationConformanceHarness, + RealizationProbeCase, + run_realization_conformance, +) + _SEMANTIC_INVALID_DIAGNOSTIC_CODE = "conformance.semantic-invalid" _OBSERVABILITY_EVIDENCE_INVALID_DIAGNOSTIC_CODE = "conformance.observability-evidence-invalid" _PORTABLE_AUGMENTATION_CARRIER_KINDS = frozenset( @@ -222,6 +229,29 @@ class ConformanceCaseResult: valid: bool passed: bool diagnostics: tuple[Diagnostic, ...] = () + execution_basis: str = ExecutionBasis.FIXTURE_ONLY.value + outcome: str = "passed" + probe_kind: str | None = None + probe_digest: str | None = None + probe_set_digest: str | None = None + envelope_digest: str | None = None + configuration_digest: str | None = None + target_binding: str | None = None + expected_operations: tuple[str, ...] = () + accounted_operations: tuple[str, ...] = () + expected_observation_strengths: tuple[str, ...] = () + actual_observation_strengths: tuple[str, ...] = () + portable_state_unchanged: bool | None = None + native_state_unchanged: bool | None = None + cleanup_verified: bool | None = None + residual_state: tuple[str, ...] = () + evidence_refs: tuple[str, ...] = () + + def __post_init__(self) -> None: + """Keep the closed outcome vocabulary aligned with the gating boolean.""" + + if self.outcome == "passed" and not self.passed: + object.__setattr__(self, "outcome", "failed") @dataclass(frozen=True) @@ -243,6 +273,61 @@ class BackendConformanceReport: unsupported_contract_gaps: tuple[str, ...] = () unsupported_capability_gaps: tuple[str, ...] = () diagnostics: tuple[Diagnostic, ...] = () + probe_set_digest: str | None = None + native_conformance: bool = False + + +def _diagnostic_payload(diag: Diagnostic) -> dict[str, object]: + return { + "code": diag.code, + "domain": diag.domain, + "address": diag.address, + "severity": diag.severity.value if hasattr(diag.severity, "value") else str(diag.severity), + "message": diag.message, + } + + +def backend_conformance_report_payload(report: BackendConformanceReport) -> dict[str, object]: + """Render the single machine-readable backend conformance report projection.""" + + return { + "profile": report.profile, + "passed": report.passed, + "native_conformance": report.native_conformance, + "probe_set_digest": report.probe_set_digest, + "claim": report.claim.model_dump(mode="json"), + "contract_versions": dict(report.contract_versions), + "unsupported_contract_gaps": list(report.unsupported_contract_gaps), + "unsupported_capability_gaps": list(report.unsupported_capability_gaps), + "cases": [ + { + "name": case.name, + "contract_name": case.contract_name, + "valid": case.valid, + "passed": case.passed, + "execution_basis": case.execution_basis, + "outcome": case.outcome, + "probe_kind": case.probe_kind, + "probe_digest": case.probe_digest, + "probe_set_digest": case.probe_set_digest, + "envelope_digest": case.envelope_digest, + "configuration_digest": case.configuration_digest, + "target_binding": case.target_binding, + "expected_operations": list(case.expected_operations), + "accounted_operations": list(case.accounted_operations), + "expected_observation_strengths": list(case.expected_observation_strengths), + "actual_observation_strengths": list(case.actual_observation_strengths), + "portable_state_unchanged": case.portable_state_unchanged, + "native_state_unchanged": case.native_state_unchanged, + "cleanup_verified": case.cleanup_verified, + "residual_state": list(case.residual_state), + "evidence_refs": list(case.evidence_refs), + "diagnostics": [_diagnostic_payload(diag) for diag in case.diagnostics], + } + for case in report.cases + ], + "diagnostics": [_diagnostic_payload(diag) for diag in report.diagnostics], + } def _bounded_conformance_claim( @@ -277,6 +362,11 @@ def _bounded_conformance_claim( explicit_non_claims=[ "Does not establish trace equivalence or bisimulation.", "Does not establish strategic, epistemic, probabilistic, timed, or partial-order equivalence.", + ( + "Finite generated probes do not establish universal realizability " + "outside the recorded envelope dimensions." + ), + "Fixture-only and hermetic-live execution do not establish native-daemon conformance.", ], ) return validate_behavioral_claim_binding(binding) @@ -1262,93 +1352,68 @@ def _declared_contract_gaps( return tuple(sorted(required - manifest.supported_contract_versions)) -def run_target_conformance( +@dataclass(frozen=True) +class _TargetConformanceOptions: + profile: BackendProfileSelector | None = None + root: Path | None = None + profiles_root: Path | None = None + reference_scenario: ScenarioInput | None = None + realization_harness: RealizationConformanceHarness | None = None + execution_basis: ExecutionBasis = ExecutionBasis.HERMETIC_LIVE + realization_envelope: BackendRealizationEnvelopeModel | None = None + observer_version: str = "aces-realization-observer/v1" + native_conformance: bool = False + + +def _unknown_profile_report( target: RuntimeTarget, - *, - profile: BackendProfileSelector | None = None, - root: Path | None = None, - profiles_root: Path | None = None, - reference_scenario: ScenarioInput | None = None, + profile: BackendProfileSelector, + fixture_report: BackendConformanceReport, ) -> BackendConformanceReport: - """Run fixture conformance for a target's declared runtime surface. - - ``root`` overrides the fixtures tree and ``profiles_root`` overrides the - backend profile tree; both default to the canonical published roots. - - ``reference_scenario`` selects the scenario the live provisioning/snapshot - probes drive (issue #663). It defaults to a generic linux-vm scenario - (``_DEFAULT_CONFORMANCE_SCENARIO``). A fixed-topology emulation or bounded - simulation backend that cannot realize the generic default supplies a - scenario it *can* realize here, instead of being wrongly failed for not - realizing an arbitrary hard-coded scenario; the probe still requires full - realization (issue #606 mutation guard) of whichever scenario is selected. - This is a temporary runner-parameter bridge superseded by the - realizability-envelope design (#667/#668). - """ - - effective_profile = profile or profile_for_manifest(target.manifest) - fixture_report = run_fixture_suite(profile=effective_profile, root=root, profiles_root=profiles_root) - if any(diag.code == "conformance.profile-load-failed" for diag in fixture_report.diagnostics): - # The published profile is the contract set we are supposed to validate - # against. With no profile loaded we must NOT mutate the backend via - # ``_target_adapter_cases`` — there is nothing to validate against. The - # fixture report already carries the structured profile-load - # diagnostic and ``passed=False``; surface it as the conformance - # result for this target. - return fixture_report - if _to_known_profile(effective_profile) is None: - # Target conformance enforces runtime-surface gates (which capability - # roles the target must implement, which target probes to run). Those - # gates depend on knowing the profile's runtime surface contract. For - # an unknown profile id we have NO runtime-surface authority — letting - # the run continue would silently certify a target that's missing every - # required role (orchestrator/evaluator/participant_runtime) just - # because the diff added a profile JSON we don't yet understand. - # Refuse explicitly so the gap is visible to CI and the JSON corpus is - # forced to land its runtime-surface contract before target conformance - # can certify against it. - profile_id = _to_profile_id(effective_profile) - diagnostics = ( - *fixture_report.diagnostics, - _diagnostic( - "conformance.profile-runtime-surface-unknown", - profile_id, - ( - f"Target conformance cannot certify profile {profile_id!r}: this " - "implementation does not know the runtime-surface contract for the " - "profile. Known runtime surfaces: " - + ", ".join(sorted(p.value for p in BackendCapabilityProfile)) - + ". Use run_fixture_suite() for fixture-only validation, or extend " - "BackendCapabilityProfile to declare this profile's runtime surfaces." - ), + profile_id = _to_profile_id(profile) + diagnostics = ( + *fixture_report.diagnostics, + _diagnostic( + "conformance.profile-runtime-surface-unknown", + profile_id, + ( + f"Target conformance cannot certify profile {profile_id!r}: this " + "implementation does not know the runtime-surface contract for the " + "profile. Known runtime surfaces: " + + ", ".join(sorted(item.value for item in BackendCapabilityProfile)) + + ". Use run_fixture_suite() for fixture-only validation, or extend " + "BackendCapabilityProfile to declare this profile's runtime surfaces." ), - ) - return BackendConformanceReport( + ), + ) + return BackendConformanceReport( + profile=profile_id, + passed=False, + claim=_bounded_conformance_claim( profile=profile_id, - passed=False, - claim=_bounded_conformance_claim( - profile=profile_id, - cases=fixture_report.cases, - left_carrier_ref=f"backend-target:{target.name}", - ), cases=fixture_report.cases, - contract_versions=dict(fixture_report.contract_versions), - diagnostics=diagnostics, - ) - contract_gaps = _declared_contract_gaps(effective_profile, target.manifest, profiles_root=profiles_root) - surface_gaps = _capability_gaps(effective_profile, target) - participant_claim_gaps = participant_runtime_capability_contract_gaps(target.manifest) - observation_claim_gaps = observation_capability_contract_gaps(target.manifest) - claim_gaps = (*participant_claim_gaps, *observation_claim_gaps) - capability_gaps = (*surface_gaps, *claim_gaps) - passed = fixture_report.passed and not contract_gaps and not capability_gaps - diagnostics = list(fixture_report.diagnostics) + left_carrier_ref=f"backend-target:{target.name}", + ), + cases=fixture_report.cases, + contract_versions=dict(fixture_report.contract_versions), + diagnostics=diagnostics, + ) + + +def _gap_diagnostics( + target: RuntimeTarget, + profile: BackendProfileSelector, + contract_gaps: tuple[str, ...], + surface_gaps: tuple[str, ...], + claim_gaps: tuple[str, ...], +) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] if contract_gaps: diagnostics.append( _diagnostic( "conformance.unsupported-contract-declaration", target.name, - f"Target does not declare required contracts for {_to_profile_id(effective_profile)}: {', '.join(contract_gaps)}", + f"Target does not declare required contracts for {_to_profile_id(profile)}: {', '.join(contract_gaps)}", ) ) if surface_gaps: @@ -1368,23 +1433,104 @@ def run_target_conformance( + "; ".join(claim_gaps), ) ) - target_cases = _target_adapter_cases(target, effective_profile, reference_scenario=reference_scenario) - cases = (*fixture_report.cases, *target_cases) - passed = passed and all(case.passed for case in target_cases) + return diagnostics + + +def _known_profile_report( + target: RuntimeTarget, + profile: BackendProfileSelector, + fixture_report: BackendConformanceReport, + options: _TargetConformanceOptions, +) -> BackendConformanceReport: + contract_gaps = _declared_contract_gaps(profile, target.manifest, profiles_root=options.profiles_root) + surface_gaps = _capability_gaps(profile, target) + claim_gaps = ( + *participant_runtime_capability_contract_gaps(target.manifest), + *observation_capability_contract_gaps(target.manifest), + ) + capability_gaps = (*surface_gaps, *claim_gaps) + diagnostics = [ + *fixture_report.diagnostics, + *_gap_diagnostics(target, profile, contract_gaps, surface_gaps, claim_gaps), + ] + adapter_cases = _target_adapter_cases( + target, + profile, + reference_scenario=options.reference_scenario, + ) + if target.manifest.realization_envelope is not None: + adapter_cases = adapter_cases[:1] + target_cases = tuple(replace(case, execution_basis=options.execution_basis.value) for case in adapter_cases) + realization_run = run_realization_conformance( + target, + harness=options.realization_harness, + execution_basis=options.execution_basis, + envelope=options.realization_envelope, + observer_version=options.observer_version, + native_conformance=options.native_conformance, + ) + realization_cases = tuple(_realization_case_result(case) for case in realization_run.cases) + cases = (*fixture_report.cases, *target_cases, *realization_cases) + passed = ( + fixture_report.passed + and not contract_gaps + and not capability_gaps + and all(case.passed for case in (*target_cases, *realization_cases)) + ) + profile_id = _to_profile_id(profile) return BackendConformanceReport( - profile=_to_profile_id(effective_profile), + profile=profile_id, passed=passed, claim=_bounded_conformance_claim( - profile=_to_profile_id(effective_profile), + profile=profile_id, cases=cases, - left_carrier_ref=f"backend-target:{target.name}", + left_carrier_ref=realization_run.target_binding or f"backend-target:{target.name}", ), cases=cases, contract_versions=dict(fixture_report.contract_versions), unsupported_contract_gaps=contract_gaps, unsupported_capability_gaps=capability_gaps, diagnostics=tuple(diagnostics), + probe_set_digest=realization_run.probe_set_digest, + native_conformance=passed and realization_run.native_conformance, + ) + + +def run_target_conformance(target: RuntimeTarget, **option_values: Any) -> BackendConformanceReport: + """Run fixture conformance for a target's declared runtime surface. + + ``root`` overrides the fixtures tree and ``profiles_root`` overrides the + backend profile tree; both default to the canonical published roots. + + ``reference_scenario`` selects the scenario the hermetic target-adapter + provisioning/snapshot probes drive (issue #663). It defaults to a generic linux-vm scenario + (``_DEFAULT_CONFORMANCE_SCENARIO``). A fixed-topology emulation or bounded + simulation backend that cannot realize the generic default supplies a + scenario it *can* realize here, instead of being wrongly failed for not + realizing an arbitrary hard-coded scenario; the probe still requires full + realization (issue #606 mutation guard) of whichever scenario is selected. + This is a temporary runner-parameter bridge superseded by the + realizability-envelope design (#667/#668). + """ + + options = _TargetConformanceOptions(**option_values) + effective_profile = options.profile or profile_for_manifest(target.manifest) + fixture_report = run_fixture_suite( + profile=effective_profile, + root=options.root, + profiles_root=options.profiles_root, ) + if any(diag.code == "conformance.profile-load-failed" for diag in fixture_report.diagnostics): + return fixture_report + if _to_known_profile(effective_profile) is None: + return _unknown_profile_report(target, effective_profile, fixture_report) + return _known_profile_report(target, effective_profile, fixture_report, options) + + +def _realization_case_result(case: RealizationProbeCase) -> ConformanceCaseResult: + """Project the internal realization case into the one report case family.""" + + return ConformanceCaseResult(**case.__dict__) def _drive_participant_episode_probe( @@ -1518,7 +1664,7 @@ def _provisioning_probe_case( control_plane: RuntimeControlPlane, provisioning_plan: ProvisioningPlan, ) -> ConformanceCaseResult: - """Drive live provisioning and prove the operation genuinely realized state. + """Drive hermetic target-adapter provisioning and prove portable mutation. Backend-neutral (issue #606): every known profile requires a provisioner, so target conformance always submits the reference scenario's provisioning @@ -1570,8 +1716,8 @@ def _provisioning_probe_case( ) -def _live_snapshot_payload(control_plane: RuntimeControlPlane) -> dict[str, Any]: - """Serialize the live control-plane snapshot to its portable envelope shape.""" +def _hermetic_snapshot_payload(control_plane: RuntimeControlPlane) -> dict[str, Any]: + """Serialize the hermetic control-plane snapshot to its portable envelope shape.""" return { "schema_version": RuntimeSnapshotEnvelope().schema_version, @@ -1611,16 +1757,16 @@ def _live_snapshot_payload(control_plane: RuntimeControlPlane) -> dict[str, Any] } -def _live_snapshot_case(control_plane: RuntimeControlPlane) -> ConformanceCaseResult: +def _hermetic_snapshot_case(control_plane: RuntimeControlPlane) -> ConformanceCaseResult: """Validate the post-provisioning snapshot and prove it was mutated. - Runs the ``runtime-snapshot-v1`` schema + semantic checks on the live + Runs the ``runtime-snapshot-v1`` schema + semantic checks on the hermetic snapshot and additionally requires at least one provisioning-domain entry (issue #606), so a target cannot pass with a schema-valid but empty (unmutated) snapshot. """ - snapshot_payload = _live_snapshot_payload(control_plane) + snapshot_payload = _hermetic_snapshot_payload(control_plane) diagnostics = [ *_validate_payload("runtime-snapshot-v1", snapshot_payload), *_semantic_diagnostics("runtime-snapshot-v1", snapshot_payload), @@ -1634,7 +1780,7 @@ def _live_snapshot_case(control_plane: RuntimeControlPlane) -> ConformanceCaseRe "conformance.snapshot-not-mutated", "runtime.snapshot.entries", ( - "Live snapshot carries no provisioning-domain entry after the provisioning " + "Hermetic snapshot carries no provisioning-domain entry after the provisioning " "probe; the backend validated contracts without realizing runtime state." ), ) @@ -1705,5 +1851,5 @@ def _target_adapter_cases( participant_address="participant.conformance", ) ) - cases.append(_live_snapshot_case(control_plane)) + cases.append(_hermetic_snapshot_case(control_plane)) return tuple(cases) diff --git a/implementations/python/packages/aces_conformance/realization.py b/implementations/python/packages/aces_conformance/realization.py new file mode 100644 index 000000000..f8583dc95 --- /dev/null +++ b/implementations/python/packages/aces_conformance/realization.py @@ -0,0 +1,487 @@ +"""Backend-neutral realization-honesty cases for the conformance report family.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass + +from aces_contracts.diagnostics import Diagnostic +from aces_contracts.realization_envelope import BackendRealizationEnvelopeModel +from aces_processor.reference import run_reference_processor +from aces_runtime.registry import RuntimeTarget +from aces_sdl.realization_envelope import ( + NegativeProbe, + PositiveProbe, + generate_negative_probes, + generate_positive_probes, +) +from aces_sdl.scenario import Scenario + +from ._realization_models import ( + ExecutionBasis, + ExpectedRealizationObservation, + ProbeOutcome, + RealizationConformanceHarness, + RealizationConformanceRun, + RealizationProbeCase, + RealizationProbeEvidence, + RealizationProbeRequest, + RealizationTransformation, +) +from ._realization_validation import ( + diagnostic as _diagnostic, +) +from ._realization_validation import ( + observation_diagnostics as _observation_diagnostics, +) +from ._realization_validation import ( + operation_inventory_diagnostics as _operation_inventory_diagnostics, +) +from ._realization_validation import ( + required_strengths as _required_strengths, +) +from ._realization_validation import ( + transformation_diagnostics as _transformation_diagnostics, +) + +_CLEANUP_ADDRESS = "runtime.cleanup" + + +@dataclass(frozen=True) +class _CaseContext: + target: RuntimeTarget + envelope: BackendRealizationEnvelopeModel + harness: RealizationConformanceHarness | None + basis: ExecutionBasis + observer_version: str + probe_set_digest: str + target_binding: str + + +def _payload_digest(payload: dict[str, object]) -> str: + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + return "sha256:" + hashlib.sha256(canonical).hexdigest() + + +def _probe_set_digest(positives: tuple[PositiveProbe, ...], negatives: tuple[NegativeProbe, ...]) -> str: + material = [probe.digest for probe in positives] + [_payload_digest(probe.payload) for probe in negatives] + return _payload_digest({"probe_digests": material}) + + +def _execute(context: _CaseContext, request: RealizationProbeRequest) -> RealizationProbeEvidence: + if context.harness is None: + raise ValueError("realization conformance harness is required") + return context.harness.execute(request) + + +def _target_binding(target: RuntimeTarget, envelope: BackendRealizationEnvelopeModel) -> str: + return ( + f"backend-target:{target.manifest.name}@{target.manifest.version};" + f"mode={envelope.configuration.mode};envelope={envelope.digest};" + f"configuration={envelope.configuration.configuration_digest}" + ) + + +def _base_case( + *, + name: str, + outcome: ProbeOutcome, + passed: bool, + diagnostics: tuple[Diagnostic, ...], + context: _CaseContext, +) -> RealizationProbeCase: + return RealizationProbeCase( + name=name, + contract_name="realization-envelope-v1", + valid=True, + passed=passed, + diagnostics=diagnostics, + execution_basis=context.basis.value, + outcome=outcome.value, + probe_set_digest=context.probe_set_digest, + envelope_digest=context.envelope.digest, + configuration_digest=context.envelope.configuration.configuration_digest, + target_binding=context.target_binding, + ) + + +def _mismatch_run( + target: RuntimeTarget, + offered: BackendRealizationEnvelopeModel, + selected: BackendRealizationEnvelopeModel, + basis: ExecutionBasis, +) -> RealizationConformanceRun: + binding = _target_binding(target, offered) + context = _CaseContext( + target=target, + envelope=selected, + harness=None, + basis=basis, + observer_version="", + probe_set_digest="", + target_binding=binding, + ) + case = _base_case( + name="realization-envelope-binding", + outcome=ProbeOutcome.FAILED, + passed=False, + diagnostics=( + _diagnostic( + "conformance.realization-envelope-mismatch", + "runtime.target.realization-envelope", + "Selected realization envelope does not match the target configuration identity.", + ), + ), + context=context, + ) + return RealizationConformanceRun(cases=(case,), target_binding=binding) + + +def _constructive_failure( + target: RuntimeTarget, + envelope: BackendRealizationEnvelopeModel, + basis: ExecutionBasis, + diagnostics: tuple[Diagnostic, ...], +) -> RealizationConformanceRun: + binding = _target_binding(target, envelope) + context = _CaseContext( + target=target, + envelope=envelope, + harness=None, + basis=basis, + observer_version="", + probe_set_digest="", + target_binding=binding, + ) + case = _base_case( + name="realization-envelope-constructive", + outcome=ProbeOutcome.UNSUPPORTED, + passed=False, + diagnostics=diagnostics, + context=context, + ) + return RealizationConformanceRun(cases=(case,), target_binding=binding) + + +def _positive_case( + *, + index: int, + probe: PositiveProbe, + context: _CaseContext, +) -> RealizationProbeCase: + try: + plan = run_reference_processor( + Scenario.model_validate(probe.payload), context.target.manifest + ).execution_plan.provisioning + except Exception: + return _base_case( + name=f"realization-positive-{index}", + outcome=ProbeOutcome.FAILED, + passed=False, + diagnostics=( + _diagnostic( + "conformance.positive-probe-plan-failed", + probe.path, + "The generated positive probe did not pass the ordinary processor and planning boundary.", + ), + ), + context=context, + ) + request = RealizationProbeRequest( + probe_digest=probe.digest, + probe_kind="positive", + payload=probe.payload, + negative=False, + provisioning_plan=plan, + envelope_digest=context.envelope.digest, + configuration_digest=context.envelope.configuration.configuration_digest, + observer_version=context.observer_version, + ) + evidence = _execute(context, request) + strengths = _required_strengths(context.envelope) + diagnostics = list(evidence.diagnostics) + if not evidence.accepted: + diagnostics.append( + _diagnostic( + "conformance.positive-probe-rejected", + probe.path, + "The in-envelope positive probe was rejected.", + ) + ) + diagnostics.extend(_operation_inventory_diagnostics(plan, evidence, strengths)) + diagnostics.extend(_observation_diagnostics(request, evidence, strengths)) + diagnostics.extend(_transformation_diagnostics(evidence)) + if plan.actionable_operations and evidence.portable_state_before == evidence.portable_state_after: + diagnostics.append( + _diagnostic( + "conformance.positive-portable-state-unchanged", + "runtime.snapshot", + "A successful actionable positive probe did not mutate portable state.", + ) + ) + if not evidence.cleanup_verified: + diagnostics.append( + _diagnostic( + "conformance.cleanup-unverified", + _CLEANUP_ADDRESS, + "Probe cleanup was not independently verified.", + ) + ) + if evidence.residual_state: + diagnostics.append( + _diagnostic( + "conformance.residual-state", + _CLEANUP_ADDRESS, + "Probe cleanup left residual owned state.", + ) + ) + expected_operations = tuple(operation.address for operation in plan.actionable_operations) + return RealizationProbeCase( + name=f"realization-positive-{index}", + contract_name="realization-envelope-v1", + valid=True, + passed=not diagnostics, + diagnostics=tuple(diagnostics), + execution_basis=context.basis.value, + outcome=(ProbeOutcome.PASSED if not diagnostics else ProbeOutcome.FAILED).value, + probe_kind="positive", + probe_digest=probe.digest, + probe_set_digest=context.probe_set_digest, + envelope_digest=context.envelope.digest, + configuration_digest=context.envelope.configuration.configuration_digest, + target_binding=context.target_binding, + expected_operations=expected_operations, + accounted_operations=evidence.accounted_operations, + expected_observation_strengths=tuple(sorted({strength.value for strength in strengths.values()})), + actual_observation_strengths=tuple(sorted({item.source.value for item in evidence.observations})), + portable_state_unchanged=evidence.portable_state_before == evidence.portable_state_after, + native_state_unchanged=evidence.native_state_before == evidence.native_state_after, + cleanup_verified=evidence.cleanup_verified, + residual_state=evidence.residual_state, + evidence_refs=evidence.evidence_refs, + ) + + +def _negative_case( + *, + index: int, + probe: NegativeProbe, + context: _CaseContext, +) -> RealizationProbeCase: + digest = _payload_digest(probe.payload) + request = RealizationProbeRequest( + probe_digest=digest, + probe_kind="negative", + payload=probe.payload, + negative=True, + provisioning_plan=None, + envelope_digest=context.envelope.digest, + configuration_digest=context.envelope.configuration.configuration_digest, + observer_version=context.observer_version, + ) + evidence = _execute(context, request) + diagnostics = list(evidence.diagnostics) + if evidence.accepted: + diagnostics.append( + _diagnostic( + "conformance.negative-probe-accepted", + probe.path, + "The out-of-envelope probe was accepted.", + ) + ) + if evidence.driver_invoked: + diagnostics.append( + _diagnostic( + "conformance.negative-driver-invoked", + probe.path, + "The out-of-envelope probe reached driver invocation.", + ) + ) + if evidence.native_mutated: + diagnostics.append( + _diagnostic( + "conformance.negative-native-mutation", + probe.path, + "The out-of-envelope probe caused native mutation.", + ) + ) + portable_unchanged = evidence.portable_state_before == evidence.portable_state_after + native_unchanged = evidence.native_state_before == evidence.native_state_after + if not portable_unchanged: + diagnostics.append( + _diagnostic( + "conformance.negative-portable-state-mutated", + probe.path, + "The out-of-envelope probe changed portable runtime state.", + ) + ) + if not native_unchanged: + diagnostics.append( + _diagnostic( + "conformance.negative-native-state-mutated", + probe.path, + "The out-of-envelope probe changed independently inventoried native state.", + ) + ) + if not evidence.cleanup_verified: + diagnostics.append( + _diagnostic( + "conformance.cleanup-unverified", + _CLEANUP_ADDRESS, + "Negative-probe cleanup was not independently verified.", + ) + ) + if evidence.residual_state: + diagnostics.append( + _diagnostic( + "conformance.residual-state", + _CLEANUP_ADDRESS, + "Negative-probe cleanup left residual owned state.", + ) + ) + return RealizationProbeCase( + name=f"realization-negative-{index}", + contract_name="realization-envelope-v1", + valid=True, + passed=not diagnostics, + diagnostics=tuple(diagnostics), + execution_basis=context.basis.value, + outcome=(ProbeOutcome.PASSED if not diagnostics else ProbeOutcome.FAILED).value, + probe_kind="negative", + probe_digest=digest, + probe_set_digest=context.probe_set_digest, + envelope_digest=context.envelope.digest, + configuration_digest=context.envelope.configuration.configuration_digest, + target_binding=context.target_binding, + portable_state_unchanged=portable_unchanged, + native_state_unchanged=native_unchanged, + cleanup_verified=evidence.cleanup_verified, + residual_state=evidence.residual_state, + evidence_refs=evidence.evidence_refs, + ) + + +def _missing_harness_run(context: _CaseContext) -> RealizationConformanceRun: + case = _base_case( + name="realization-harness-required", + outcome=ProbeOutcome.UNSUPPORTED, + passed=False, + diagnostics=( + _diagnostic( + "conformance.realization-harness-missing", + "runtime.target.realization-conformance", + "Realization certification requires an independent execution and observation harness.", + ), + ), + context=context, + ) + return RealizationConformanceRun( + cases=(case,), + probe_set_digest=context.probe_set_digest, + target_binding=context.target_binding, + ) + + +def _probe_cases( + context: _CaseContext, + positive: tuple[PositiveProbe, ...], + negative: tuple[NegativeProbe, ...], + *, + native_conformance: bool, +) -> list[RealizationProbeCase]: + cases = [_positive_case(index=index, probe=probe, context=context) for index, probe in enumerate(positive, start=1)] + cases.extend( + _negative_case(index=index, probe=probe, context=context) for index, probe in enumerate(negative, start=1) + ) + if native_conformance and context.basis is not ExecutionBasis.NATIVE_LIVE: + cases.append( + _base_case( + name="native-conformance-basis", + outcome=ProbeOutcome.FAILED, + passed=False, + diagnostics=( + _diagnostic( + "conformance.native-basis-required", + "runtime.target.execution-basis", + "Only native-live execution may support a native conformance claim.", + ), + ), + context=context, + ) + ) + return cases + + +def _constructive_run( + context: _CaseContext, + positive: tuple[PositiveProbe, ...], + negative: tuple[NegativeProbe, ...], + *, + native_conformance: bool, +) -> RealizationConformanceRun: + if context.harness is None: + return _missing_harness_run(context) + cases = _probe_cases(context, positive, negative, native_conformance=native_conformance) + passed = all(case.passed for case in cases) + return RealizationConformanceRun( + cases=tuple(cases), + probe_set_digest=context.probe_set_digest, + target_binding=context.target_binding, + native_conformance=native_conformance and context.basis is ExecutionBasis.NATIVE_LIVE and passed, + ) + + +def run_realization_conformance( + target: RuntimeTarget, + *, + harness: RealizationConformanceHarness | None, + execution_basis: ExecutionBasis, + envelope: BackendRealizationEnvelopeModel | None = None, + observer_version: str = "aces-realization-observer/v1", + native_conformance: bool = False, +) -> RealizationConformanceRun: + """Return realization-honesty cases for one exact target configuration.""" + + result = RealizationConformanceRun() + offered = target.manifest.realization_envelope + if offered is not None: + selected = envelope or offered + if selected.identity != offered.identity: + result = _mismatch_run(target, offered, selected, execution_basis) + else: + positive, positive_diagnostics = generate_positive_probes(selected.expression) + negative, negative_diagnostics = generate_negative_probes(selected.expression) + diagnostics = (*positive_diagnostics, *negative_diagnostics) + if diagnostics or not positive: + result = _constructive_failure(target, selected, execution_basis, diagnostics) + else: + context = _CaseContext( + target=target, + envelope=selected, + harness=harness, + basis=execution_basis, + observer_version=observer_version, + probe_set_digest=_probe_set_digest(positive, negative), + target_binding=_target_binding(target, selected), + ) + result = _constructive_run( + context, + positive, + negative, + native_conformance=native_conformance, + ) + return result + + +__all__ = [ + "ExecutionBasis", + "ExpectedRealizationObservation", + "ProbeOutcome", + "RealizationConformanceHarness", + "RealizationProbeCase", + "RealizationProbeEvidence", + "RealizationProbeRequest", + "RealizationTransformation", + "run_realization_conformance", +] diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index 65125fd43..580714d3c 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -39,6 +39,7 @@ ) from aces_sdl.scenario import InstantiatedScenario, Scenario from aces_sdl.schema_catalogs import HASHMAP_SECTIONS, RUNTIME_SERVICE_FAMILIES, RuntimeReferenceChild +from aces_sdl.value_parsing import VARIABLE_REFERENCE_SCHEMA_MARKER from pydantic import BaseModel, ConfigDict, Field, GetJsonSchemaHandler, StrictInt, model_validator from pydantic.json_schema import JsonSchemaValue from pydantic_core import CoreSchema @@ -219,6 +220,7 @@ def _validate_claim_strength(self) -> BehavioralClaimBindingModel: "capabilities.provisioner.supported_os_families", "capabilities.provisioner.supported_content_types", "capabilities.provisioner.supported_account_features", + "capabilities.provisioner.supported_domain_profiles", "capabilities.orchestrator.supported_sections", "capabilities.evaluator.supported_sections", "capabilities.observation.supported_capture_kinds", @@ -702,18 +704,42 @@ def _child_subschemas(node: dict[str, Any]) -> list[Any]: def _forbid_variable_tokens_in_strings(node: object) -> None: - """Recursively forbid the ``${var}`` token on every free string subschema.""" + """Remove variable-only alternatives and forbid tokens in remaining strings.""" if isinstance(node, list): for item in node: _forbid_variable_tokens_in_strings(item) return if not isinstance(node, dict): return + _remove_variable_reference_union_branches(node) _apply_string_token_constraint(node) for child in _child_subschemas(node): _forbid_variable_tokens_in_strings(child) +def _remove_variable_reference_union_branches(node: dict[str, Any]) -> None: + """Collapse authoring unions to their concrete branches for phase artifacts.""" + for keyword in ("anyOf", "oneOf"): + branches = node.get(keyword) + if not isinstance(branches, list): + continue + retained = [ + branch + for branch in branches + if not (isinstance(branch, dict) and branch.get(VARIABLE_REFERENCE_SCHEMA_MARKER) is True) + ] + if len(retained) == len(branches): + continue + node.pop(keyword) + if len(retained) == 1 and isinstance(retained[0], dict): + outer_keywords = dict(node) + node.clear() + node.update(retained[0]) + node.update(outer_keywords) + else: + node[keyword] = retained or [{"not": {}}] + + def _attach_instantiation_invariants(contract_id: str, json_schema: dict[str, Any]) -> None: """Apply the no-substitution-token invariant to concrete SDL artifacts. @@ -2615,6 +2641,7 @@ class ProvisionerCapabilitiesModel(ContractModel): supported_os_families: list[NonEmptyString] = Field(min_length=1) supported_content_types: list[NonEmptyString] = Field(default_factory=list) supported_account_features: list[NonEmptyString] = Field(default_factory=list) + supported_domain_profiles: list[NonEmptyString] = Field(default_factory=list) max_total_nodes: int | None = Field(default=None, gt=0) supports_acls: bool = False supports_accounts: bool = False @@ -2638,6 +2665,10 @@ def _validate_account_support(self) -> ProvisionerCapabilitiesModel: "capabilities.provisioner.supported_account_features", self.supported_account_features, ) + _validate_controlled_vocabulary_terms( + "capabilities.provisioner.supported_domain_profiles", + self.supported_domain_profiles, + ) if self.supports_accounts and not self.supported_account_features: raise ValueError("provisioners that support accounts must declare supported_account_features") if not self.supports_accounts and self.supported_account_features: diff --git a/implementations/python/packages/aces_contracts/realization_envelope_carrier.py b/implementations/python/packages/aces_contracts/realization_envelope_carrier.py index 2420f546f..24d2c343c 100644 --- a/implementations/python/packages/aces_contracts/realization_envelope_carrier.py +++ b/implementations/python/packages/aces_contracts/realization_envelope_carrier.py @@ -86,6 +86,7 @@ class RealizerConfigurationModel(ContractModel): supported_os_families: list[NonEmptyString] = Field(min_length=1) supported_content_types: list[NonEmptyString] = Field(default_factory=list) supported_account_features: list[NonEmptyString] = Field(default_factory=list) + supported_domain_profiles: list[NonEmptyString] = Field(default_factory=list) supports_acls: bool = False memory_mib: IntegerBoundsModel vcpus: IntegerBoundsModel @@ -97,6 +98,7 @@ def _validate_unique_terms(self) -> RealizerConfigurationModel: "supported_os_families", "supported_content_types", "supported_account_features", + "supported_domain_profiles", ): values = getattr(self, field_name) if len(values) != len(set(values)): @@ -116,6 +118,7 @@ def __get_pydantic_json_schema__( "supported_os_families", "supported_content_types", "supported_account_features", + "supported_domain_profiles", ): properties[field_name]["uniqueItems"] = True return json_schema diff --git a/implementations/python/packages/aces_contracts/realization_observation.py b/implementations/python/packages/aces_contracts/realization_observation.py new file mode 100644 index 000000000..68fed6846 --- /dev/null +++ b/implementations/python/packages/aces_contracts/realization_observation.py @@ -0,0 +1,34 @@ +"""Neutral addressed realization-observation evidence DTOs.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from aces_contracts.realization_envelope import ObservationStrength, RealizationConcern + + +@dataclass(frozen=True) +class RealizationObservation: + """One independently read realization fact with optional conformance binding. + + Backend-local observers can keep using the five core fields. Conformance + requires every binding field below and rejects observations that omit them; + the defaults preserve the existing non-conformance driver boundary. + """ + + address: str + field_path: str + concern: RealizationConcern + source: ObservationStrength + value: object + operation_id: str | None = None + probe_digest: str | None = None + envelope_digest: str | None = None + configuration_digest: str | None = None + observer_version: str | None = None + sequence: int | None = None + origin: str = "observed" + binding_verified: bool = False + + +__all__ = ["RealizationObservation"] diff --git a/implementations/python/packages/aces_operations/realization_conformance.py b/implementations/python/packages/aces_operations/realization_conformance.py new file mode 100644 index 000000000..d52e18435 --- /dev/null +++ b/implementations/python/packages/aces_operations/realization_conformance.py @@ -0,0 +1,27 @@ +"""Validated persistence for machine-readable backend conformance evidence.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path + +from aces_operations._evidence_run_validation import redaction_violations +from aces_operations.run_artifacts import atomic_write_json_artifact, run_artifact_path + + +def write_backend_conformance_report( + payload: Mapping[str, object], + *, + output_dir: Path, + run_id: str, +) -> Path: + """Redaction-check and atomically persist one conformance report.""" + + if redaction_violations(payload): + raise ValueError("backend conformance report failed the redaction gate") + target = run_artifact_path(output_dir, run_id, "conformance", "backend-conformance.json") + atomic_write_json_artifact(target, payload) + return target + + +__all__ = ["write_backend_conformance_report"] diff --git a/implementations/python/packages/aces_processor/compiler.py b/implementations/python/packages/aces_processor/compiler.py deleted file mode 100644 index f04fc504c..000000000 --- a/implementations/python/packages/aces_processor/compiler.py +++ /dev/null @@ -1,2581 +0,0 @@ -"""SDL-to-runtime compiler.""" - -from collections.abc import Callable, Iterable, Mapping -from dataclasses import dataclass, field -from typing import Any - -from aces_backend_protocols.capabilities import ( - WorkflowFeature, - WorkflowStatePredicateFeature, -) -from aces_contracts.addressing import render_compiled_address -from aces_contracts.versions import WORKFLOW_STATE_SCHEMA_VERSION -from aces_sdl import build_declaration_index -from aces_sdl.entities import flatten_entities -from aces_sdl.explicitness import ExplicitnessClass, ExplicitnessProvenance -from aces_sdl.identifiers import QualifiedName -from aces_sdl.instantiate import admit_instantiated_scenario, instantiate_scenario -from aces_sdl.nodes import NodeType -from aces_sdl.orchestration import WorkflowStepType -from aces_sdl.participant_outcome_semantics import ( - OutcomeInterpretationSourceLayer, - OutcomeInterpretationTargetLayer, -) -from aces_sdl.realization_designation import resolve_realization_designation -from aces_sdl.scenario import ExpandedScenario, InstantiatedScenario, Scenario -from aces_sdl.semantics.objective_semantics import ( - OBJECTIVE_WINDOW_DEPENDENCY_ROLES, - partition_objective_dependencies, -) -from aces_sdl.semantics.objectives import analyze_objective_window -from aces_sdl.semantics.workflow import ( - workflow_step_semantic_contract, -) - -from .models import ( - AccountPlacement, - AssertionRuntime, - CompiledCapabilityConstraint, - ConditionBinding, - ContentPlacement, - Diagnostic, - EvaluationExecutionContract, - EvaluationResultContract, - EventRuntime, - FeatureBinding, - InjectBinding, - InjectRuntime, - NetworkRuntime, - NodeRuntime, - ObjectiveRuntime, - ObjectiveWindowReferenceRuntime, - ParticipantActionContractRuntime, - ParticipantBehaviorRuntime, - ParticipantBehaviorSpecificationRuntime, - ParticipantObservationBoundaryRuntime, - ParticipantOutcomeInterpretationRuleRuntime, - PropositionRuntime, - RuntimeModel, - RuntimeTemplate, - ScriptRuntime, - StoryRuntime, - WorkflowExecutionContract, - WorkflowPredicateRuntime, - WorkflowResultContract, - WorkflowRuntime, - WorkflowStepOutcome, - WorkflowStepRuntime, - WorkflowStepStatePredicateRuntime, - WorkflowSwitchCaseRuntime, -) -from .semantics.realization import ( - REALIZATION_DOMAIN, - CompiledRealizationRequirement, - registered_realization_concerns, -) - - -def _dump(model: Any) -> dict[str, Any]: - if hasattr(model, "model_dump"): - return model.model_dump(mode="json", by_alias=True) - if isinstance(model, dict): - return dict(model) - return {} - - -def _address(*parts: str) -> str: - return render_compiled_address(*parts) - - -def _dedupe(items: list[str]) -> tuple[str, ...]: - return tuple(dict.fromkeys(items)) - - -def _dedupe_by_value(items: list[Any]) -> tuple[Any, ...]: - ordered: dict[str, Any] = {} - for item in items: - key = getattr(item, "value", repr(item)) - ordered.setdefault(key, item) - return tuple(item for _, item in sorted(ordered.items())) - - -_VISIBLE_VIEW_DISPOSITIONS = frozenset({"observable", "discovered", "inferred", "disclosed", "deceptive"}) - - -def _initial_view_relation(*, view_rules: list[Any]) -> dict[str, str]: - view_relation: dict[str, str] = {} - for rule in view_rules: - if not isinstance(rule, dict): - continue - information_ref = rule.get("information_ref") - disposition = rule.get("disposition") - if not information_ref or not disposition: - continue - ref = str(information_ref) - view_relation[ref] = str(disposition) - return view_relation - - -def _view_relation_refs(view_relation: dict[str, str], dispositions: set[str] | frozenset[str]) -> tuple[str, ...]: - return tuple(ref for ref, disposition in sorted(view_relation.items()) if disposition in dispositions) - - -def _view_relation_snapshot( - *, - transition_id: str, - effective_from: str, - effective_order: int, - view_relation: dict[str, str], - transition: dict[str, Any] | None = None, -) -> dict[str, Any]: - snapshot = { - "transition_id": transition_id, - "effective_from": effective_from, - "effective_order": effective_order, - "view_relation": dict(sorted(view_relation.items())), - "visible_refs": _view_relation_refs(view_relation, _VISIBLE_VIEW_DISPOSITIONS), - "hidden_refs": _view_relation_refs(view_relation, {"hidden"}), - "evidence_only_refs": _view_relation_refs(view_relation, {"evidence_only"}), - "disclosed_refs": _view_relation_refs(view_relation, {"disclosed"}), - "discovered_refs": _view_relation_refs(view_relation, {"discovered"}), - "inferred_refs": _view_relation_refs(view_relation, {"inferred"}), - "concealed_refs": _view_relation_refs(view_relation, {"concealed"}), - "deceptive_refs": _view_relation_refs(view_relation, {"deceptive"}), - } - if transition is not None: - snapshot.update( - { - "transition_kind": str(transition.get("transition_kind") or ""), - "information_ref": str(transition.get("information_ref") or ""), - "history_event_type": str(transition.get("history_event_type") or ""), - "action_instance_id": ( - str(transition.get("action_instance_id")) - if transition.get("action_instance_id") is not None - else "" - ), - } - ) - return snapshot - - -def _ordered_view_transitions(view_transitions: list[Any]) -> tuple[dict[str, Any], ...]: - return tuple( - sorted( - (dict(transition) for transition in view_transitions if isinstance(transition, dict)), - key=lambda transition: int(transition.get("effective_order", 0)), - ) - ) - - -def _compile_view_relation_timeline( - *, - view_rules: list[Any], - view_transitions: list[Any], -) -> tuple[dict[str, Any], ...]: - view_relation = _initial_view_relation(view_rules=view_rules) - timeline: list[dict[str, Any]] = [ - _view_relation_snapshot( - transition_id="initial", - effective_from="initial", - effective_order=-1, - view_relation=view_relation, - ) - ] - for transition in _ordered_view_transitions(view_transitions): - information_ref = transition.get("information_ref") - to_disposition = transition.get("to_disposition") - if not information_ref or not to_disposition: - continue - view_relation[str(information_ref)] = str(to_disposition) - timeline.append( - _view_relation_snapshot( - transition_id=str(transition.get("transition_id") or ""), - effective_from=str(transition.get("effective_from") or ""), - effective_order=int(transition.get("effective_order", 0)), - view_relation=view_relation, - transition=transition, - ) - ) - return tuple(timeline) - - -def _template_address(kind: str, name: str) -> str: - return _address("template", kind, name) - - -def _network_address(name: str) -> str: - return _address("provision", "network", name) - - -def _node_address(name: str) -> str: - return _address("provision", "node", name) - - -def _feature_binding_address(node_name: str, feature_name: str) -> str: - return _address("provision", "feature", node_name, feature_name) - - -def _content_address(name: str) -> str: - return _address("provision", "content", name) - - -def _content_item_address(content_name: str, item_name: str) -> str: - return _address("provision", "content", content_name, "items", item_name) - - -def _account_address(name: str) -> str: - return _address("provision", "account", name) - - -def _service_address(node_name: str, service_name: str) -> str: - return _address("provision", "node", node_name, "service", service_name) - - -def _resolve_node_service_ref( - scenario: InstantiatedScenario, - ref: object, -) -> tuple[str, str] | None: - if not isinstance(ref, str): - return None - for node_name, node in scenario.nodes.items(): - for service in node.services: - if service.name and ref == f"nodes.{node_name}.services.{service.name}": - return node_name, service.name - return None - - -def _action_contract_address(name: str) -> str: - return _address("participant", "action-contract", name) - - -def _observation_boundary_address(name: str) -> str: - return _address("participant", "observation-boundary", name) - - -def _outcome_interpretation_rule_address(name: str) -> str: - return _address("participant", "outcome-interpretation-rule", name) - - -def _participant_behavior_address(name: str) -> str: - return _address("participant", "behavior", name) - - -def _behavior_specification_address(name: str) -> str: - return _address("participant", "behavior-specification", name) - - -def _condition_binding_address(node_name: str, condition_name: str) -> str: - return _address("evaluation", "condition", node_name, condition_name) - - -def _proposition_address(name: str) -> str: - return _address("evaluation", "proposition", name) - - -def _assertion_address(name: str) -> str: - return _address("evaluation", "assertion", name) - - -def _inject_address(name: str) -> str: - return _address("orchestration", "inject", name) - - -def _inject_binding_address(node_name: str, inject_name: str) -> str: - return _address("orchestration", "inject-binding", node_name, inject_name) - - -def _event_address(name: str) -> str: - return _address("orchestration", "event", name) - - -def _script_address(name: str) -> str: - return _address("orchestration", "script", name) - - -def _story_address(name: str) -> str: - return _address("orchestration", "story", name) - - -def _workflow_address(name: str) -> str: - return _address("orchestration", "workflow", name) - - -def _evaluation_address(name: str) -> str: - # Address form for the experiment/evaluator-plane EVALUATION_RESULT - # interpretation layer (SEM-215). Per ADR-073 the SDL no longer authors an - # ``evaluations`` section; this address no longer resolves an SDL resource. - return _address("evaluation", "evaluation", name) - - -def _objective_address(name: str) -> str: - return _address("evaluation", "objective", name) - - -def _resource_address_for_node(scenario: Scenario, node_name: str) -> str: - node = scenario.nodes.get(node_name) - if node is not None and node.type == NodeType.SWITCH: - return _network_address(node_name) - return _node_address(node_name) - - -def _add_alias(index: dict[str, set[str]], alias: str, address: str) -> None: - if alias: - index.setdefault(alias, set()).add(address) - - -def _add_node_aliases(index: dict[str, set[str]], scenario: InstantiatedScenario) -> None: - for node_name, node in scenario.nodes.items(): - address = _resource_address_for_node(scenario, node_name) - _add_alias(index, node_name, address) - _add_alias(index, f"nodes.{node_name}", address) - if node.type == NodeType.SWITCH: - _add_alias(index, f"infrastructure.{node_name}", address) - - for service in node.services: - service_name = service.name - if not service_name: - continue - service_address = _service_address(node_name, service_name) - _add_alias(index, service_name, service_address) - _add_alias(index, f"nodes.{node_name}.services.{service_name}", service_address) - - -def _add_infrastructure_aliases(index: dict[str, set[str]], scenario: InstantiatedScenario) -> None: - for infra_name in scenario.infrastructure: - node = scenario.nodes.get(infra_name) - if node is None: - continue - address = _resource_address_for_node(scenario, infra_name) - _add_alias(index, f"infrastructure.{infra_name}", address) - if node.type == NodeType.SWITCH: - _add_alias(index, infra_name, address) - - -def _add_content_aliases(index: dict[str, set[str]], scenario: InstantiatedScenario) -> None: - for content_name, content in scenario.content.items(): - content_address = _content_address(content_name) - _add_alias(index, content_name, content_address) - _add_alias(index, f"content.{content_name}", content_address) - for item in content.items: - if not item.name: - continue - item_address = _content_item_address(content_name, item.name) - _add_alias(index, item.name, item_address) - _add_alias(index, f"content.{content_name}.items.{item.name}", item_address) - - -def _add_qualified_aliases( - index: dict[str, set[str]], - names: Iterable[str], - *, - address_for: Callable[[str], str], - qualified_prefix: str, -) -> None: - for name in names: - address = address_for(name) - _add_alias(index, name, address) - _add_alias(index, f"{qualified_prefix}.{name}", address) - - -def _runtime_addressable_ref_index(scenario: InstantiatedScenario) -> dict[str, set[str]]: - """Map SDL authority/scope refs to compiled runtime addresses. - - This deliberately omits semantic-only anchors such as entities and - relationships. The raw refs stay on participant runtime records; only - refs backed by runtime-addressable surfaces become addresses/dependencies. - """ - index: dict[str, set[str]] = {} - _add_node_aliases(index, scenario) - _add_infrastructure_aliases(index, scenario) - _add_content_aliases(index, scenario) - _add_qualified_aliases( - index, - scenario.accounts, - address_for=_account_address, - qualified_prefix="accounts", - ) - _add_qualified_aliases( - index, - scenario.conditions, - address_for=lambda name: _template_address("condition", name), - qualified_prefix="conditions", - ) - _add_qualified_aliases( - index, - scenario.features, - address_for=lambda name: _template_address("feature", name), - qualified_prefix="features", - ) - _add_qualified_aliases( - index, - scenario.vulnerabilities, - address_for=lambda name: _template_address("vulnerability", name), - qualified_prefix="vulnerabilities", - ) - _add_qualified_aliases( - index, - scenario.action_contracts, - address_for=_action_contract_address, - qualified_prefix="action_contracts", - ) - _add_qualified_aliases( - index, - scenario.observation_boundaries, - address_for=_observation_boundary_address, - qualified_prefix="observation_boundaries", - ) - _add_qualified_aliases( - index, - scenario.outcome_interpretation_rules, - address_for=_outcome_interpretation_rule_address, - qualified_prefix="outcome_interpretation_rules", - ) - _add_qualified_aliases( - index, - scenario.behavior_specifications, - address_for=_behavior_specification_address, - qualified_prefix="behavior_specifications", - ) - return index - - -def _runtime_addresses_for_refs( - refs: list[str], - *, - addressable_ref_index: dict[str, set[str]], -) -> tuple[str, ...]: - addresses: list[str] = [] - for ref in dict.fromkeys(refs): - matches = addressable_ref_index.get(ref, ()) - if len(matches) == 1: - addresses.extend(matches) - return _dedupe(addresses) - - -def _account_addresses_for_refs(scenario: InstantiatedScenario, refs: list[str]) -> tuple[str, ...]: - addresses: list[str] = [] - for ref in dict.fromkeys(refs): - if ref in scenario.accounts: - addresses.append(_account_address(ref)) - return _dedupe(addresses) - - -def _condition_addresses_for_refs(scenario: InstantiatedScenario, refs: list[str]) -> tuple[str, ...]: - addresses: list[str] = [] - for ref in dict.fromkeys(refs): - condition_name = ref.removeprefix("conditions.") - if condition_name in scenario.conditions: - addresses.append(_template_address("condition", condition_name)) - return _dedupe(addresses) - - -def _service_addresses_for_refs(scenario: InstantiatedScenario, refs: list[str]) -> tuple[str, ...]: - addresses: list[str] = [] - for ref in dict.fromkeys(refs): - split = _resolve_node_service_ref(scenario, ref) - if split is not None: - node_name, service_name = split - node = scenario.nodes.get(node_name) - if node is None: - continue - if any(service.name == service_name for service in node.services): - addresses.append(_service_address(node_name, service_name)) - continue - for node_name, node in scenario.nodes.items(): - if any(service.name == ref for service in node.services): - addresses.append(_service_address(node_name, ref)) - return _dedupe(addresses) - - -def _initial_knowledge_values(initial_knowledge: object, attribute: str) -> tuple[object, ...]: - return tuple(getattr(initial_knowledge, attribute, ()) or ()) - - -def _initial_knowledge_host_addresses( - scenario: InstantiatedScenario, - initial_knowledge: object, -) -> list[str]: - addresses: list[str] = [] - for host in _initial_knowledge_values(initial_knowledge, "hosts"): - if host in scenario.nodes: - addresses.append(_resource_address_for_node(scenario, str(host))) - return addresses - - -def _initial_knowledge_subnet_addresses( - scenario: InstantiatedScenario, - initial_knowledge: object, -) -> list[str]: - addresses: list[str] = [] - for subnet in _initial_knowledge_values(initial_knowledge, "subnets"): - if subnet in scenario.infrastructure and subnet in scenario.nodes: - addresses.append(_resource_address_for_node(scenario, str(subnet))) - return addresses - - -def _initial_knowledge_service_addresses( - scenario: InstantiatedScenario, - initial_knowledge: object, -) -> tuple[str, ...]: - return _service_addresses_for_refs( - scenario, - [str(service) for service in _initial_knowledge_values(initial_knowledge, "services")], - ) - - -def _initial_knowledge_account_addresses( - scenario: InstantiatedScenario, - initial_knowledge: object, -) -> tuple[str, ...]: - return _account_addresses_for_refs( - scenario, - [str(account) for account in _initial_knowledge_values(initial_knowledge, "accounts")], - ) - - -def _initial_knowledge_addresses( - scenario: InstantiatedScenario, - initial_knowledge: object | None, -) -> tuple[str, ...]: - if initial_knowledge is None: - return () - addresses: list[str] = [] - addresses.extend(_initial_knowledge_host_addresses(scenario, initial_knowledge)) - addresses.extend(_initial_knowledge_subnet_addresses(scenario, initial_knowledge)) - addresses.extend(_initial_knowledge_service_addresses(scenario, initial_knowledge)) - addresses.extend(_initial_knowledge_account_addresses(scenario, initial_knowledge)) - return _dedupe(addresses) - - -def _evaluation_contracts( - resource_type: str, -) -> tuple[EvaluationResultContract, EvaluationExecutionContract]: - if resource_type in { - "condition-binding", - "objective", - }: - return ( - EvaluationResultContract( - resource_type=resource_type, - supports_passed=True, - ), - EvaluationExecutionContract(resource_type=resource_type), - ) - return ( - EvaluationResultContract(resource_type=resource_type), - EvaluationExecutionContract(resource_type=resource_type), - ) - - -def _resolve_binding_ref( - bindings: dict[str, Any], - *, - ref_name: str, - owner_address: str, - domain: str, - code_prefix: str, - binding_attr: str, - binding_label: str, -) -> tuple[tuple[str, ...], list[Diagnostic]]: - matches = tuple( - sorted(address for address, binding in bindings.items() if getattr(binding, binding_attr) == ref_name) - ) - if len(matches) == 1: - return matches, [] - - if not matches: - return (), [ - Diagnostic( - code=f"{code_prefix}-unbound", - domain=domain, - address=owner_address, - message=(f"Reference '{ref_name}' does not resolve to a bound {binding_label}."), - ) - ] - - joined = ", ".join(matches) - return (), [ - Diagnostic( - code=f"{code_prefix}-ambiguous", - domain=domain, - address=owner_address, - message=(f"Reference '{ref_name}' resolves to multiple bound {binding_label}s: {joined}."), - ) - ] - - -def _resolve_binding_refs( - bindings: dict[str, Any], - *, - ref_names: list[str], - owner_address: str, - domain: str, - code_prefix: str, - binding_attr: str, - binding_label: str, -) -> tuple[tuple[str, ...], list[Diagnostic]]: - resolved: list[str] = [] - diagnostics: list[Diagnostic] = [] - for ref_name in dict.fromkeys(ref_names): - addresses, ref_diagnostics = _resolve_binding_ref( - bindings, - ref_name=ref_name, - owner_address=owner_address, - domain=domain, - code_prefix=code_prefix, - binding_attr=binding_attr, - binding_label=binding_label, - ) - resolved.extend(addresses) - diagnostics.extend(ref_diagnostics) - return _dedupe(resolved), diagnostics - - -def _resolve_resource_refs( - resources: dict[str, Any], - *, - ref_names: list[str], - owner_address: str, - domain: str, - code_prefix: str, - resource_label: str, -) -> tuple[tuple[str, ...], list[Diagnostic]]: - resolved: list[str] = [] - diagnostics: list[Diagnostic] = [] - for ref_name in dict.fromkeys(ref_names): - matched_addresses = sorted(address for address, resource in resources.items() if resource.name == ref_name) - if not matched_addresses: - diagnostics.append( - Diagnostic( - code=f"{code_prefix}-unbound", - domain=domain, - address=owner_address, - message=(f"Reference '{ref_name}' does not resolve to a defined {resource_label}."), - ) - ) - continue - if len(matched_addresses) > 1: - diagnostics.append( - Diagnostic( - code=f"{code_prefix}-ambiguous", - domain=domain, - address=owner_address, - message=( - f"Reference '{ref_name}' resolves to multiple {resource_label}s: " - f"{', '.join(matched_addresses)}." - ), - ) - ) - continue - resolved.append(matched_addresses[0]) - return _dedupe(resolved), diagnostics - - -def _resolve_named_refs( - *, - ref_names: list[str], - available_names: set[str], - address_builder: Callable[[str], str], - owner_address: str, - domain: str, - code_prefix: str, - resource_label: str, -) -> tuple[tuple[str, ...], list[Diagnostic]]: - resolved: list[str] = [] - diagnostics: list[Diagnostic] = [] - for ref_name in dict.fromkeys(ref_names): - if ref_name not in available_names: - diagnostics.append( - Diagnostic( - code=f"{code_prefix}-unbound", - domain=domain, - address=owner_address, - message=(f"Reference '{ref_name}' does not resolve to a defined {resource_label}."), - ) - ) - continue - resolved.append(address_builder(ref_name)) - return _dedupe(resolved), diagnostics - - -def _resolve_node_ref( - scenario: Scenario, - *, - ref_name: str, - owner_address: str, - domain: str, - code_prefix: str, - node_label: str, - require_vm: bool = False, - require_switch: bool = False, -) -> tuple[str | None, list[Diagnostic]]: - node = scenario.nodes.get(ref_name) - if node is None: - return None, [ - Diagnostic( - code=f"{code_prefix}-unbound", - domain=domain, - address=owner_address, - message=(f"Reference '{ref_name}' does not resolve to a defined {node_label}."), - ) - ] - - if require_vm and node.type != NodeType.VM: - return None, [ - Diagnostic( - code=f"{code_prefix}-invalid-type", - domain=domain, - address=owner_address, - message=(f"Reference '{ref_name}' must resolve to a VM node for {node_label}."), - ) - ] - - if require_switch and node.type != NodeType.SWITCH: - return None, [ - Diagnostic( - code=f"{code_prefix}-invalid-type", - domain=domain, - address=owner_address, - message=(f"Reference '{ref_name}' must resolve to a switch/network node for {node_label}."), - ) - ] - - return _resource_address_for_node(scenario, ref_name), [] - - -@dataclass(frozen=True) -class _ObjectiveWindowCompilation: - story_addresses: tuple[str, ...] = () - script_addresses: tuple[str, ...] = () - event_addresses: tuple[str, ...] = () - workflow_addresses: tuple[str, ...] = () - step_refs: tuple[str, ...] = () - step_workflow_addresses: tuple[str, ...] = () - references: tuple[ObjectiveWindowReferenceRuntime, ...] = () - - -@dataclass(frozen=True) -class _WorkflowPredicateCompilation: - predicate: WorkflowPredicateRuntime - assertion_addresses: tuple[str, ...] - predicate_addresses: tuple[str, ...] - objective_addresses: tuple[str, ...] - step_state_predicates: tuple[WorkflowStepStatePredicateRuntime, ...] - - -@dataclass -class _WorkflowCompilationState: - join_owners: dict[str, str] - control_steps: dict[str, WorkflowStepRuntime] = field(default_factory=dict) - control_edges: dict[str, tuple[str, ...]] = field(default_factory=dict) - referenced_objectives: list[str] = field(default_factory=list) - step_assertion_addresses: dict[str, tuple[str, ...]] = field(default_factory=dict) - step_predicate_addresses: dict[str, tuple[str, ...]] = field(default_factory=dict) - required_features: list[WorkflowFeature] = field(default_factory=list) - required_state_predicate_features: list[WorkflowStatePredicateFeature] = field(default_factory=list) - compensation_targets: dict[str, str] = field(default_factory=dict) - - -_OBJECTIVE_WINDOW_ISSUE_DIAGNOSTICS = { - "story-unbound": ("evaluation.story-ref-unbound", "Reference '{ref}' does not resolve to a defined story."), - "script-unbound": ("evaluation.script-ref-unbound", "Reference '{ref}' does not resolve to a defined script."), - "script-outside-window-stories": ( - "evaluation.script-ref-outside-window-stories", - "Reference '{ref}' is not included by the objective window's referenced stories.", - ), - "event-unbound": ("evaluation.event-ref-unbound", "Reference '{ref}' does not resolve to a defined event."), - "event-outside-window-scripts": ( - "evaluation.event-ref-outside-window-scripts", - "Reference '{ref}' is not included by the objective window's referenced scripts.", - ), - "workflow-unbound": ( - "evaluation.workflow-ref-unbound", - "Reference '{ref}' does not resolve to a defined workflow.", - ), - "step-requires-workflow-window": ( - "evaluation.workflow-step-ref-window-missing-workflow", - "Workflow step references require at least one referenced workflow.", - ), - "step-invalid-format": ( - "evaluation.workflow-step-ref-invalid-format", - "Reference '{ref}' must use '.' syntax.", - ), - "step-workflow-unbound": ( - "evaluation.workflow-step-ref-workflow-unbound", - "Reference '{ref}' does not resolve to a defined workflow.", - ), - "step-workflow-outside-window": ( - "evaluation.workflow-step-ref-workflow-outside-window", - "Reference '{ref}' is not part of the objective window's referenced workflows.", - ), - "step-unbound": ( - "evaluation.workflow-step-ref-step-unbound", - "Reference '{ref}' does not resolve to a defined workflow step.", - ), -} - -_WORKFLOW_STEP_TYPE_FEATURES = { - WorkflowStepType.DECISION: WorkflowFeature.DECISION, - WorkflowStepType.SWITCH: WorkflowFeature.SWITCH, - WorkflowStepType.PARALLEL: WorkflowFeature.PARALLEL_BARRIER, - WorkflowStepType.RETRY: WorkflowFeature.RETRY, - WorkflowStepType.CALL: WorkflowFeature.CALL, -} - - -def _compile_templates( - scenario: InstantiatedScenario, -) -> tuple[ - dict[str, RuntimeTemplate], - dict[str, RuntimeTemplate], - dict[str, RuntimeTemplate], - dict[str, RuntimeTemplate], -]: - feature_templates = { - name: RuntimeTemplate(address=_template_address("feature", name), name=name, spec=_dump(template)) - for name, template in scenario.features.items() - } - condition_templates = { - name: RuntimeTemplate(address=_template_address("condition", name), name=name, spec=_dump(template)) - for name, template in scenario.conditions.items() - } - inject_templates = { - name: RuntimeTemplate(address=_template_address("inject", name), name=name, spec=_dump(template)) - for name, template in scenario.injects.items() - } - vulnerability_templates = { - name: RuntimeTemplate(address=_template_address("vulnerability", name), name=name, spec=_dump(template)) - for name, template in scenario.vulnerabilities.items() - } - return feature_templates, condition_templates, inject_templates, vulnerability_templates - - -def _metadata_specs( - scenario: InstantiatedScenario, -) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: - entity_specs = {name: _dump(entity) for name, entity in flatten_entities(scenario.entities).items()} - agent_specs = {name: _dump(agent) for name, agent in scenario.agents.items()} - relationship_specs = {name: _dump(relationship) for name, relationship in scenario.relationships.items()} - return entity_specs, agent_specs, relationship_specs - - -def _compile_capability_constraints( - scenario: InstantiatedScenario, -) -> tuple[CompiledCapabilityConstraint, ...]: - compiled: list[CompiledCapabilityConstraint] = [] - for constraint in scenario.instantiation_provenance.capability_constraints: - parts = constraint.field_pointer.split("/") - section_name, encoded_name, field_name = parts[1:] - node_name = encoded_name.replace("~1", "/").replace("~0", "~") - node = scenario.nodes[node_name] - address = _network_address(node_name) if node.type == NodeType.SWITCH else _node_address(node_name) - compiled.append( - CompiledCapabilityConstraint( - address=address, - concern=f"{section_name}.{field_name}", - parameter=constraint.parameter, - allowed_values=constraint.allowed_values, - ) - ) - return tuple(compiled) - - -def _node_dependency_addresses( - scenario: InstantiatedScenario, - *, - node_name: str, - ref_names: list[str], - code_prefix: str, - node_label: str, - diagnostics: list[Diagnostic], - require_switch: bool = False, -) -> list[str]: - addresses: list[str] = [] - for ref_name in ref_names: - dep_address, dep_diagnostics = _resolve_node_ref( - scenario, - ref_name=ref_name, - owner_address=_resource_address_for_node(scenario, node_name), - domain="provisioning", - code_prefix=code_prefix, - node_label=node_label, - require_switch=require_switch, - ) - diagnostics.extend(dep_diagnostics) - if dep_address is not None: - addresses.append(dep_address) - return addresses - - -def _compile_node_runtimes( - scenario: InstantiatedScenario, - diagnostics: list[Diagnostic], -) -> tuple[dict[str, NetworkRuntime], dict[str, NodeRuntime]]: - networks: dict[str, NetworkRuntime] = {} - node_deployments: dict[str, NodeRuntime] = {} - for node_name, node in scenario.nodes.items(): - node_spec = _dump(node) - infra = scenario.infrastructure.get(node_name) - infra_spec = _dump(infra) if infra is not None else {} - dependency_addresses: list[str] = [] - if infra is not None: - dependency_addresses.extend( - _node_dependency_addresses( - scenario, - node_name=node_name, - ref_names=list(infra.dependencies), - code_prefix="provisioning.infrastructure-dependency-ref", - node_label="infrastructure dependency", - diagnostics=diagnostics, - ) - ) - dependency_addresses.extend( - _node_dependency_addresses( - scenario, - node_name=node_name, - ref_names=list(infra.links), - code_prefix="provisioning.infrastructure-link-ref", - node_label="infrastructure link", - diagnostics=diagnostics, - require_switch=True, - ) - ) - _record_node_runtime( - node_name=node_name, - node_type=node.type, - node_spec=node_spec, - infra_spec=infra_spec, - dependency_addresses=dependency_addresses, - networks=networks, - node_deployments=node_deployments, - ) - return networks, node_deployments - - -def _record_node_runtime( - *, - node_name: str, - node_type: NodeType, - node_spec: dict[str, Any], - infra_spec: dict[str, Any], - dependency_addresses: list[str], - networks: dict[str, NetworkRuntime], - node_deployments: dict[str, NodeRuntime], -) -> None: - spec = {"node": node_spec, "infrastructure": infra_spec} - if node_type == NodeType.SWITCH: - networks[_network_address(node_name)] = NetworkRuntime( - address=_network_address(node_name), - name=node_name, - node_name=node_name, - spec=spec, - ordering_dependencies=_dedupe(dependency_addresses), - refresh_dependencies=_dedupe(dependency_addresses), - ) - return - node_deployments[_node_address(node_name)] = NodeRuntime( - address=_node_address(node_name), - name=node_name, - node_name=node_name, - node_type=node_spec.get("type", ""), - os_family=node_spec.get("os", "") or "", - count=infra_spec.get("count"), - spec=spec, - ordering_dependencies=_dedupe(dependency_addresses), - refresh_dependencies=_dedupe(dependency_addresses), - ) - - -def _feature_dependency_addresses( - node: Any, - feature: Any, - *, - feature_name: str, - node_name: str, - address: str, - diagnostics: list[Diagnostic], -) -> list[str]: - dep_addresses = [_node_address(node_name)] - for dep_name in feature.dependencies: - if dep_name in node.features: - dep_addresses.append(_feature_binding_address(node_name, dep_name)) - continue - diagnostics.append( - Diagnostic( - code="provisioning.feature-dependency-binding-missing", - domain="provisioning", - address=address, - message=( - f"Feature binding '{feature_name}' on node '{node_name}' " - f"requires feature dependency '{dep_name}' to also be bound on the same node." - ), - ) - ) - return dep_addresses - - -def _compile_feature_bindings( - scenario: InstantiatedScenario, - feature_templates: dict[str, RuntimeTemplate], - diagnostics: list[Diagnostic], -) -> dict[str, FeatureBinding]: - feature_bindings: dict[str, FeatureBinding] = {} - for node_name, node in scenario.nodes.items(): - if node.type != NodeType.VM: - continue - node_addr = _node_address(node_name) - for feature_name, role_name in node.features.items(): - template = feature_templates.get(feature_name) - feature = scenario.features.get(feature_name) - if template is None or feature is None: - diagnostics.append( - Diagnostic( - code="provisioning.feature-template-ref-unbound", - domain="provisioning", - address=node_addr, - message=( - f"Feature binding '{feature_name}' on node '{node_name}' " - "does not resolve to a declared feature template." - ), - ) - ) - continue - address = _feature_binding_address(node_name, feature_name) - dep_addresses = _feature_dependency_addresses( - node, - feature, - feature_name=feature_name, - node_name=node_name, - address=address, - diagnostics=diagnostics, - ) - feature_bindings[address] = FeatureBinding( - address=address, - name=feature_name, - node_name=node_name, - node_address=node_addr, - feature_name=feature_name, - template_address=template.address, - role_name=role_name, - ordering_dependencies=_dedupe(dep_addresses), - refresh_dependencies=_dedupe(dep_addresses), - spec={"binding": {"node": node_name, "role": role_name}, "template": template.spec}, - ) - return feature_bindings - - -def _compile_propositions( - scenario: InstantiatedScenario, -) -> dict[str, PropositionRuntime]: - address_index = _runtime_addressable_ref_index(scenario) - return { - _proposition_address(name): PropositionRuntime( - address=_proposition_address(name), - name=name, - spec=_dump(proposition), - subject_addresses=_runtime_addresses_for_refs( - list(proposition.subjects), - addressable_ref_index=address_index, - ), - predicate_kind=proposition.predicate.kind, - evaluation_basis=proposition.basis.value, - evidence_requirement_refs=tuple(proposition.evidence_requirements), - ) - for name, proposition in scenario.propositions.items() - } - - -def _compile_assertions( - scenario: InstantiatedScenario, -) -> dict[str, AssertionRuntime]: - return { - _assertion_address(name): AssertionRuntime( - address=_assertion_address(name), - name=name, - spec=_dump(assertion), - proposition_address=_proposition_address(assertion.proposition), - role=assertion.role.value, - polarity=assertion.polarity.value, - ordering_dependencies=(_proposition_address(assertion.proposition),), - refresh_dependencies=(_proposition_address(assertion.proposition),), - ) - for name, assertion in scenario.assertions.items() - } - - -def _compile_condition_bindings( - scenario: InstantiatedScenario, - condition_templates: dict[str, RuntimeTemplate], - propositions: dict[str, PropositionRuntime], - diagnostics: list[Diagnostic], -) -> dict[str, ConditionBinding]: - condition_bindings: dict[str, ConditionBinding] = {} - for node_name, node in scenario.nodes.items(): - if node.type != NodeType.VM: - continue - node_addr = _node_address(node_name) - for condition_name, role_name in node.conditions.items(): - template = condition_templates.get(condition_name) - if template is None: - diagnostics.append( - Diagnostic( - code="evaluation.condition-template-ref-unbound", - domain="evaluation", - address=node_addr, - message=( - f"Condition binding '{condition_name}' on node '{node_name}' " - "does not resolve to a declared condition template." - ), - ) - ) - continue - address = _condition_binding_address(node_name, condition_name) - proposition_address = ( - _proposition_address(template.spec["proposition"]) if template.spec.get("proposition") else "" - ) - proposition_dependencies = (proposition_address,) if proposition_address in propositions else () - result_contract, execution_contract = _evaluation_contracts("condition-binding") - condition_bindings[address] = ConditionBinding( - address=address, - name=condition_name, - node_name=node_name, - node_address=node_addr, - condition_name=condition_name, - template_address=template.address, - role_name=role_name, - proposition_address=proposition_address, - ordering_dependencies=proposition_dependencies, - refresh_dependencies=_dedupe([node_addr, *proposition_dependencies]), - spec={"binding": {"node": node_name, "role": role_name}, "template": template.spec}, - result_contract=result_contract, - execution_contract=execution_contract, - ) - return condition_bindings - - -def _compile_inject_runtimes(inject_templates: dict[str, RuntimeTemplate]) -> dict[str, InjectRuntime]: - return { - _inject_address(name): InjectRuntime(address=_inject_address(name), name=name, spec=template.spec) - for name, template in inject_templates.items() - } - - -def _compile_inject_bindings( - scenario: InstantiatedScenario, - inject_templates: dict[str, RuntimeTemplate], - diagnostics: list[Diagnostic], -) -> dict[str, InjectBinding]: - inject_bindings: dict[str, InjectBinding] = {} - for node_name, node in scenario.nodes.items(): - if node.type != NodeType.VM: - continue - node_addr = _node_address(node_name) - for inject_name, role_name in node.injects.items(): - template = inject_templates.get(inject_name) - if template is None: - diagnostics.append( - Diagnostic( - code="orchestration.inject-template-ref-unbound", - domain="orchestration", - address=node_addr, - message=( - f"Inject binding '{inject_name}' on node '{node_name}' " - "does not resolve to a declared inject template." - ), - ) - ) - continue - inject_address = _inject_address(inject_name) - address = _inject_binding_address(node_name, inject_name) - inject_bindings[address] = InjectBinding( - address=address, - name=inject_name, - node_name=node_name, - node_address=node_addr, - inject_name=inject_name, - template_address=template.address, - role_name=role_name, - ordering_dependencies=(inject_address,), - refresh_dependencies=(node_addr, inject_address), - spec={"binding": {"node": node_name, "role": role_name}, "inject_address": inject_address}, - ) - return inject_bindings - - -def _compile_content_placements( - scenario: InstantiatedScenario, - diagnostics: list[Diagnostic], -) -> dict[str, ContentPlacement]: - content_placements: dict[str, ContentPlacement] = {} - for name, content in scenario.content.items(): - address = _content_address(name) - target_address, target_diagnostics = _resolve_node_ref( - scenario, - ref_name=content.target, - owner_address=address, - domain="provisioning", - code_prefix="provisioning.content-target-ref", - node_label="content target", - require_vm=True, - ) - diagnostics.extend(target_diagnostics) - if target_address is None: - continue - content_placements[address] = ContentPlacement( - address=address, - name=name, - content_name=name, - target_node=content.target, - target_address=target_address, - ordering_dependencies=(target_address,), - refresh_dependencies=(target_address,), - spec=_dump(content), - ) - return content_placements - - -def _compile_account_placements( - scenario: InstantiatedScenario, - diagnostics: list[Diagnostic], -) -> dict[str, AccountPlacement]: - account_placements: dict[str, AccountPlacement] = {} - for name, account in scenario.accounts.items(): - address = _account_address(name) - target_address, target_diagnostics = _resolve_node_ref( - scenario, - ref_name=account.node, - owner_address=address, - domain="provisioning", - code_prefix="provisioning.account-node-ref", - node_label="account node", - require_vm=True, - ) - diagnostics.extend(target_diagnostics) - if target_address is None: - continue - account_placements[address] = AccountPlacement( - address=address, - name=name, - account_name=name, - node_name=account.node, - target_address=target_address, - ordering_dependencies=(target_address,), - refresh_dependencies=(target_address,), - spec=_dump(account), - ) - return account_placements - - -def _compile_action_contracts(scenario: InstantiatedScenario) -> dict[str, ParticipantActionContractRuntime]: - action_contracts: dict[str, ParticipantActionContractRuntime] = {} - for name, contract in scenario.action_contracts.items(): - contract_spec = _dump(contract) - interactions = contract_spec.get("interactions", []) - temporal_contracts = contract_spec.get("temporal_contracts", []) - interaction_classes = _dedupe( - [ - str(interaction.get("interaction_class", "")) - for interaction in interactions - if isinstance(interaction, dict) and interaction.get("interaction_class") - ] - ) - shared_state_refs = _dedupe( - [ - str(ref) - for interaction in interactions - if isinstance(interaction, dict) - for ref in interaction.get("shared_state_refs", []) - ] - ) - precondition_classes = _dedupe( - [ - str(precondition.get("precondition_class", "")) - for precondition in contract_spec.get("preconditions", []) - if isinstance(precondition, dict) and precondition.get("precondition_class") - ] - ) - effect_classes = _dedupe( - [ - str(effect.get("effect_class", "")) - for effect in contract_spec.get("effects", []) - if isinstance(effect, dict) and effect.get("effect_class") - ] - ) - failure_classes = _dedupe(str(failure_class) for failure_class in contract_spec.get("failure_classes", [])) - backend_failure_mappings = tuple( - { - "backend_error_code": str(mapping.get("backend_error_code", "")), - "failure_class": str(mapping.get("failure_class", "")), - "diagnostic": str(mapping.get("diagnostic", "")), - } - for mapping in contract_spec.get("backend_failure_mappings", []) - if isinstance(mapping, dict) - ) - temporal_contract_ids = _dedupe( - [ - str(temporal_contract.get("temporal_id", "")) - for temporal_contract in temporal_contracts - if isinstance(temporal_contract, dict) and temporal_contract.get("temporal_id") - ] - ) - temporal_kinds = _dedupe( - [ - str(temporal_contract.get("temporal_kind", "")) - for temporal_contract in temporal_contracts - if isinstance(temporal_contract, dict) and temporal_contract.get("temporal_kind") - ] - ) - time_domains = _dedupe( - [ - str(temporal_contract.get("time_domain", "")) - for temporal_contract in temporal_contracts - if isinstance(temporal_contract, dict) and temporal_contract.get("time_domain") - ] - ) - clock_authorities = _dedupe( - [ - str(temporal_contract.get("clock_authority", "")) - for temporal_contract in temporal_contracts - if isinstance(temporal_contract, dict) and temporal_contract.get("clock_authority") - ] - ) - backend_timing_disclosures = tuple( - { - "disclosure_id": str(disclosure.get("disclosure_id", "")), - "disclosure_kind": str(disclosure.get("disclosure_kind", "")), - "support_mode": str(disclosure.get("support_mode", "")), - "description": str(disclosure.get("description", "")), - "affected_temporal_ids": [ - str(temporal_id) for temporal_id in disclosure.get("affected_temporal_ids", []) - ], - "limitations": [str(limitation) for limitation in disclosure.get("limitations", [])], - } - for disclosure in contract_spec.get("backend_timing_disclosures", []) - if isinstance(disclosure, dict) - ) - action_contracts[_action_contract_address(name)] = ParticipantActionContractRuntime( - address=_action_contract_address(name), - name=name, - action_name=name, - semantic_version=str(contract_spec.get("semantic_version", "")), - lifecycle_state=str(contract_spec.get("lifecycle_state", "")), - behavioral_granularity=str(contract_spec.get("behavioral_granularity", "")), - precondition_classes=precondition_classes, - effect_classes=effect_classes, - failure_classes=failure_classes, - backend_failure_mappings=backend_failure_mappings, - interaction_classes=interaction_classes, - shared_state_refs=shared_state_refs, - temporal_contract_ids=temporal_contract_ids, - temporal_kinds=temporal_kinds, - time_domains=time_domains, - clock_authorities=clock_authorities, - backend_timing_disclosures=backend_timing_disclosures, - spec=contract_spec, - ) - return action_contracts - - -def _compile_observation_boundaries(scenario: InstantiatedScenario) -> dict[str, ParticipantObservationBoundaryRuntime]: - observation_boundaries: dict[str, ParticipantObservationBoundaryRuntime] = {} - for name, boundary in scenario.observation_boundaries.items(): - boundary_spec = _dump(boundary) - view_rules = boundary_spec.get("view_rules", []) - view_transitions = boundary_spec.get("view_transitions", []) - initial_view_relation = _initial_view_relation(view_rules=view_rules) - disclosed_refs = _view_relation_refs(initial_view_relation, {"disclosed"}) - evidence_only_refs = _view_relation_refs(initial_view_relation, {"evidence_only"}) - discovered_refs = _view_relation_refs(initial_view_relation, {"discovered"}) - inferred_refs = _view_relation_refs(initial_view_relation, {"inferred"}) - concealed_refs = _view_relation_refs(initial_view_relation, {"concealed"}) - deceptive_refs = _view_relation_refs(initial_view_relation, {"deceptive"}) - view_relation_timeline = _compile_view_relation_timeline( - view_rules=view_rules, - view_transitions=view_transitions, - ) - ordered_view_transitions = _ordered_view_transitions(view_transitions) - observation_boundaries[_observation_boundary_address(name)] = ParticipantObservationBoundaryRuntime( - address=_observation_boundary_address(name), - name=name, - boundary_name=name, - projection_basis=str(boundary_spec.get("projection_basis", "")), - hidden_refs=tuple(str(ref) for ref in boundary_spec.get("hidden_refs", [])), - observable_refs=tuple(str(ref) for ref in boundary_spec.get("observable_refs", [])), - evidence_refs=tuple(str(ref) for ref in boundary_spec.get("evidence_refs", [])), - disclosed_refs=disclosed_refs, - evidence_only_refs=evidence_only_refs, - discovered_refs=discovered_refs, - inferred_refs=inferred_refs, - concealed_refs=concealed_refs, - deceptive_refs=deceptive_refs, - view_transitions=ordered_view_transitions, - view_relation_timeline=view_relation_timeline, - realized_view_disclosure=str(boundary_spec.get("realized_view_disclosure") or ""), - spec=boundary_spec, - ) - return observation_boundaries - - -def _outcome_source_ref_address(source_layer: str, ref: str) -> str: - if source_layer == OutcomeInterpretationSourceLayer.PARTICIPANT_ACTION_OUTCOME.value: - return _action_contract_address(ref) - if source_layer == OutcomeInterpretationSourceLayer.OBJECTIVE_RESULT.value: - return _objective_address(ref) - if source_layer == OutcomeInterpretationSourceLayer.WORKFLOW_RESULT.value: - return _workflow_address(ref) - if source_layer == OutcomeInterpretationSourceLayer.EVALUATION_RESULT.value: - return _evaluation_address(ref) - return ref - - -def _outcome_target_ref_address(target_layer: str, ref: str) -> str: - if target_layer == OutcomeInterpretationTargetLayer.OBJECTIVE_RESULT.value: - return _objective_address(ref) - if target_layer == OutcomeInterpretationTargetLayer.WORKFLOW_RESULT.value: - return _workflow_address(ref) - if target_layer == OutcomeInterpretationTargetLayer.EVALUATION_RESULT.value: - return _evaluation_address(ref) - return ref - - -def _compile_outcome_interpretation_rules( - scenario: InstantiatedScenario, -) -> dict[str, ParticipantOutcomeInterpretationRuleRuntime]: - rules: dict[str, ParticipantOutcomeInterpretationRuleRuntime] = {} - for name, rule in scenario.outcome_interpretation_rules.items(): - rule_spec = _dump(rule) - sources = tuple(source for source in rule_spec.get("source_bindings", ()) if isinstance(source, dict)) - targets = tuple(target for target in rule_spec.get("target_bindings", ()) if isinstance(target, dict)) - source_layers = tuple(str(source.get("source_layer", "")) for source in sources) - target_layers = tuple(str(target.get("target_layer", "")) for target in targets) - source_refs = tuple( - _outcome_source_ref_address(str(source.get("source_layer", "")), str(source.get("ref", ""))) - for source in sources - ) - target_refs = tuple( - _outcome_target_ref_address(str(target.get("target_layer", "")), str(target.get("ref", ""))) - for target in targets - ) - address = _outcome_interpretation_rule_address(name) - rules[address] = ParticipantOutcomeInterpretationRuleRuntime( - address=address, - name=name, - rule_name=name, - semantic_version=str(rule_spec.get("semantic_version", "")), - participant_scope=str(rule_spec.get("participant_scope", "")), - observation_point_basis=str(rule_spec.get("observation_point_basis", "")), - interpretation_basis=str(rule_spec.get("interpretation_basis", "")), - source_layers=source_layers, - source_refs=source_refs, - target_layers=target_layers, - target_refs=target_refs, - evidence_refs=tuple(str(ref) for ref in rule_spec.get("evidence_refs", ())), - limitations=tuple(str(item) for item in rule_spec.get("limitations", ())), - spec=rule_spec, - ) - return rules - - -def _participant_action_addresses( - scenario: InstantiatedScenario, - *, - participant_name: str, - action_names: list[str], - diagnostics: list[Diagnostic], -) -> list[str]: - action_addresses: list[str] = [] - if not scenario.action_contracts: - return action_addresses - for action_name in dict.fromkeys(action_names): - if action_name in scenario.action_contracts: - action_addresses.append(_action_contract_address(action_name)) - continue - if action_name: - diagnostics.append( - Diagnostic( - code="participant.action-contract-ref-unbound", - domain="participant", - address=_participant_behavior_address(participant_name), - message=f"Reference '{action_name}' does not resolve to a declared participant action contract.", - ) - ) - return action_addresses - - -def _participant_observation_addresses( - scenario: InstantiatedScenario, - *, - participant_name: str, - boundary_names: list[str], - diagnostics: list[Diagnostic], -) -> list[str]: - observation_addresses: list[str] = [] - for boundary_name in dict.fromkeys(boundary_names): - if boundary_name in scenario.observation_boundaries: - observation_addresses.append(_observation_boundary_address(boundary_name)) - continue - if boundary_name: - diagnostics.append( - Diagnostic( - code="participant.observation-boundary-ref-unbound", - domain="participant", - address=_participant_behavior_address(participant_name), - message=( - f"Reference '{boundary_name}' does not resolve to a declared participant observation boundary." - ), - ) - ) - return observation_addresses - - -def _compile_participant_behaviors( - scenario: InstantiatedScenario, - diagnostics: list[Diagnostic], -) -> dict[str, ParticipantBehaviorRuntime]: - participant_behaviors: dict[str, ParticipantBehaviorRuntime] = {} - addressable_ref_index = _runtime_addressable_ref_index(scenario) - for name, agent in scenario.agents.items(): - action_addresses = _participant_action_addresses( - scenario, - participant_name=name, - action_names=list(agent.actions), - diagnostics=diagnostics, - ) - observation_addresses = _participant_observation_addresses( - scenario, - participant_name=name, - boundary_names=list(agent.observation_boundaries), - diagnostics=diagnostics, - ) - starting_account_refs = tuple(agent.starting_accounts) - starting_account_addresses = _account_addresses_for_refs(scenario, list(agent.starting_accounts)) - initial_knowledge_addresses = _initial_knowledge_addresses( - scenario, - agent.initial_knowledge, - ) - starting_assertion_refs = tuple(agent.starting_assertions) - starting_assertion_addresses = tuple(_assertion_address(ref) for ref in agent.starting_assertions) - authority_anchor_refs = tuple(agent.authority_anchors) - authority_anchor_addresses = _runtime_addresses_for_refs( - list(agent.authority_anchors), - addressable_ref_index=addressable_ref_index, - ) - operating_scope_refs = tuple(agent.operating_scope) - operating_scope_addresses = _runtime_addresses_for_refs( - list(agent.operating_scope), - addressable_ref_index=addressable_ref_index, - ) - dependency_addresses = _dedupe( - [ - *action_addresses, - *observation_addresses, - *starting_account_addresses, - *initial_knowledge_addresses, - *starting_assertion_addresses, - *authority_anchor_addresses, - *operating_scope_addresses, - ] - ) - participant_behaviors[_participant_behavior_address(name)] = ParticipantBehaviorRuntime( - address=_participant_behavior_address(name), - name=name, - participant_name=name, - entity_name=agent.entity, - starting_account_refs=starting_account_refs, - starting_account_addresses=starting_account_addresses, - initial_knowledge_addresses=initial_knowledge_addresses, - starting_assertion_refs=starting_assertion_refs, - starting_assertion_addresses=starting_assertion_addresses, - authority_anchor_refs=authority_anchor_refs, - authority_anchor_addresses=authority_anchor_addresses, - operating_scope_refs=operating_scope_refs, - operating_scope_addresses=operating_scope_addresses, - action_contract_addresses=tuple(action_addresses), - observation_boundary_addresses=tuple(observation_addresses), - refresh_dependencies=dependency_addresses, - spec={"agent": _dump(agent), "interpretation_mode": "role-neutral-projection"}, - ) - return participant_behaviors - - -def _resolve_behavior_spec_refs( - *, - refs: list[str], - declared: Mapping[str, object], - address_for_ref: Callable[[str], str], - owner_address: str, - diagnostic_code: str, - diagnostic_label: str, - diagnostics: list[Diagnostic], -) -> tuple[str, ...]: - addresses: list[str] = [] - for ref in dict.fromkeys(refs): - if ref in declared: - addresses.append(address_for_ref(ref)) - continue - if ref: - diagnostics.append( - Diagnostic( - code=diagnostic_code, - domain="participant", - address=owner_address, - message=f"Reference '{ref}' does not resolve to a declared {diagnostic_label}.", - ) - ) - return tuple(addresses) - - -def _compile_behavior_specifications( - scenario: InstantiatedScenario, - diagnostics: list[Diagnostic], -) -> dict[str, ParticipantBehaviorSpecificationRuntime]: - behavior_specifications: dict[str, ParticipantBehaviorSpecificationRuntime] = {} - addressable_ref_index = _runtime_addressable_ref_index(scenario) - for name, behavior_spec in scenario.behavior_specifications.items(): - address = _behavior_specification_address(name) - spec = _dump(behavior_spec) - participant_addresses = _resolve_behavior_spec_refs( - refs=list(behavior_spec.participant_refs), - declared=scenario.agents, - address_for_ref=_participant_behavior_address, - owner_address=address, - diagnostic_code="participant.behavior-specification-participant-ref-unbound", - diagnostic_label="agent", - diagnostics=diagnostics, - ) - action_addresses = _resolve_behavior_spec_refs( - refs=list(behavior_spec.action_contract_refs), - declared=scenario.action_contracts, - address_for_ref=_action_contract_address, - owner_address=address, - diagnostic_code="participant.behavior-specification-action-contract-ref-unbound", - diagnostic_label="participant action contract", - diagnostics=diagnostics, - ) - observation_addresses = _resolve_behavior_spec_refs( - refs=list(behavior_spec.observation_boundary_refs), - declared=scenario.observation_boundaries, - address_for_ref=_observation_boundary_address, - owner_address=address, - diagnostic_code="participant.behavior-specification-observation-boundary-ref-unbound", - diagnostic_label="participant observation boundary", - diagnostics=diagnostics, - ) - outcome_rule_addresses = _resolve_behavior_spec_refs( - refs=list(behavior_spec.outcome_interpretation_rule_refs), - declared=scenario.outcome_interpretation_rules, - address_for_ref=_outcome_interpretation_rule_address, - owner_address=address, - diagnostic_code="participant.behavior-specification-outcome-rule-ref-unbound", - diagnostic_label="participant outcome interpretation rule", - diagnostics=diagnostics, - ) - authority_scope_addresses = _runtime_addresses_for_refs( - list(behavior_spec.authority_scope_refs), - addressable_ref_index=addressable_ref_index, - ) - dependencies = _dedupe( - [ - *participant_addresses, - *action_addresses, - *observation_addresses, - *outcome_rule_addresses, - *authority_scope_addresses, - ] - ) - behavior_specifications[address] = ParticipantBehaviorSpecificationRuntime( - address=address, - name=name, - spec_name=name, - semantic_version=str(behavior_spec.semantic_version), - lifecycle_state=str(getattr(behavior_spec.lifecycle_state, "value", behavior_spec.lifecycle_state)), - participant_addresses=participant_addresses, - participant_role_refs=tuple(behavior_spec.participant_role_refs), - action_contract_addresses=action_addresses, - observation_boundary_addresses=observation_addresses, - outcome_interpretation_rule_addresses=outcome_rule_addresses, - authority_scope_refs=tuple(behavior_spec.authority_scope_refs), - authority_scope_addresses=authority_scope_addresses, - behavior_mode=str(behavior_spec.behavior_mode or ""), - ai_offensive_behavior_refs=tuple(behavior_spec.ai_offensive_behavior_refs), - offensive_behavior_refs=tuple(behavior_spec.offensive_behavior_refs), - realization_profile_ref=str(behavior_spec.realization_profile_ref or ""), - backend_feature_support_refs=tuple(behavior_spec.backend_feature_support_refs), - evidence_contract_refs=tuple(behavior_spec.evidence_contract_refs), - extension_policy=str(behavior_spec.extension_policy), - extension_keys=tuple(sorted(behavior_spec.extensions)), - refresh_dependencies=dependencies, - spec=spec, - ) - return behavior_specifications - - -def _compile_events( - scenario: InstantiatedScenario, - assertions: dict[str, AssertionRuntime], - injects: dict[str, InjectRuntime], - inject_bindings: dict[str, InjectBinding], - diagnostics: list[Diagnostic], -) -> dict[str, EventRuntime]: - events: dict[str, EventRuntime] = {} - for name, event in scenario.events.items(): - event_address = _event_address(name) - assertion_names = list(event.assertions) - inject_names = list(event.injects) - assertion_addresses, assertion_diagnostics = _resolve_named_refs( - ref_names=assertion_names, - available_names={assertion.name for assertion in assertions.values()}, - address_builder=_assertion_address, - owner_address=event_address, - domain="orchestration", - code_prefix="orchestration.assertion-ref", - resource_label="assertion", - ) - inject_addresses, inject_diagnostics = _resolve_resource_refs( - injects, - ref_names=inject_names, - owner_address=event_address, - domain="orchestration", - code_prefix="orchestration.inject-ref", - resource_label="inject", - ) - diagnostics.extend(assertion_diagnostics) - diagnostics.extend(inject_diagnostics) - inject_binding_ordering_dependencies = [ - address for address, binding in inject_bindings.items() if binding.inject_name in inject_names - ] - events[event_address] = EventRuntime( - address=event_address, - name=name, - assertion_names=tuple(assertion_names), - assertion_addresses=assertion_addresses, - inject_names=tuple(inject_names), - inject_addresses=inject_addresses, - ordering_dependencies=_dedupe([*inject_addresses, *inject_binding_ordering_dependencies]), - refresh_dependencies=_dedupe( - [*assertion_addresses, *inject_addresses, *inject_binding_ordering_dependencies] - ), - spec=_dump(event), - ) - return events - - -def _compile_scripts( - scenario: InstantiatedScenario, - diagnostics: list[Diagnostic], -) -> dict[str, ScriptRuntime]: - scripts: dict[str, ScriptRuntime] = {} - for name, script in scenario.scripts.items(): - script_address = _script_address(name) - event_addresses, script_diagnostics = _resolve_named_refs( - ref_names=list(script.events), - available_names=set(scenario.events), - address_builder=_event_address, - owner_address=script_address, - domain="orchestration", - code_prefix="orchestration.event-ref", - resource_label="event", - ) - diagnostics.extend(script_diagnostics) - scripts[script_address] = ScriptRuntime( - address=script_address, - name=name, - event_addresses=event_addresses, - ordering_dependencies=event_addresses, - refresh_dependencies=event_addresses, - spec=_dump(script), - ) - return scripts - - -def _compile_stories( - scenario: InstantiatedScenario, - diagnostics: list[Diagnostic], -) -> dict[str, StoryRuntime]: - stories: dict[str, StoryRuntime] = {} - for name, story in scenario.stories.items(): - story_address = _story_address(name) - script_addresses, story_diagnostics = _resolve_named_refs( - ref_names=list(story.scripts), - available_names=set(scenario.scripts), - address_builder=_script_address, - owner_address=story_address, - domain="orchestration", - code_prefix="orchestration.script-ref", - resource_label="script", - ) - diagnostics.extend(story_diagnostics) - stories[story_address] = StoryRuntime( - address=story_address, - name=name, - script_addresses=script_addresses, - ordering_dependencies=script_addresses, - refresh_dependencies=script_addresses, - spec=_dump(story), - ) - return stories - - -def _objective_success_addresses( - assertions: dict[str, AssertionRuntime], - objective: Any, - objective_address: str, - diagnostics: list[Diagnostic], -) -> list[str]: - assertion_addresses, assertion_diagnostics = _resolve_named_refs( - ref_names=list(objective.success.assertions), - available_names={assertion.name for assertion in assertions.values()}, - address_builder=_assertion_address, - owner_address=objective_address, - domain="evaluation", - code_prefix="evaluation.assertion-ref", - resource_label="assertion", - ) - diagnostics.extend(assertion_diagnostics) - return list(assertion_addresses) - - -def _objective_dependency_addresses( - scenario: InstantiatedScenario, - objective: Any, - objective_address: str, - diagnostics: list[Diagnostic], -) -> tuple[str, ...]: - objective_dependencies, objective_dependency_diagnostics = _resolve_named_refs( - ref_names=list(objective.depends_on), - available_names=set(scenario.objectives), - address_builder=_objective_address, - owner_address=objective_address, - domain="evaluation", - code_prefix="evaluation.objective-ref", - resource_label="objective", - ) - diagnostics.extend(objective_dependency_diagnostics) - return objective_dependencies - - -def _objective_window_issue_diagnostic(issue: Any, objective_address: str) -> Diagnostic | None: - spec = _OBJECTIVE_WINDOW_ISSUE_DIAGNOSTICS.get(issue.code) - if spec is None: - return None - code, message_template = spec - return Diagnostic( - code=code, - domain="evaluation", - address=objective_address, - message=message_template.format(ref=issue.ref), - ) - - -def _compile_objective_window( - scenario: InstantiatedScenario, - objective: Any, - objective_address: str, - diagnostics: list[Diagnostic], -) -> _ObjectiveWindowCompilation: - if objective.window is None: - return _ObjectiveWindowCompilation() - window_analysis = analyze_objective_window( - story_refs=list(objective.window.stories), - script_refs=list(objective.window.scripts), - event_refs=list(objective.window.events), - workflow_refs=list(objective.window.workflows), - step_refs=list(objective.window.steps), - stories_by_name=scenario.stories, - scripts_by_name=scenario.scripts, - events_by_name=scenario.events, - workflows_by_name=scenario.workflows, - ) - for issue in window_analysis.issues: - diagnostic = _objective_window_issue_diagnostic(issue, objective_address) - if diagnostic is not None: - diagnostics.append(diagnostic) - window_role_values = tuple(role.value for role in OBJECTIVE_WINDOW_DEPENDENCY_ROLES) - return _ObjectiveWindowCompilation( - story_addresses=_dedupe([_story_address(name) for name in window_analysis.story_names]), - script_addresses=_dedupe([_script_address(name) for name in window_analysis.script_names]), - event_addresses=_dedupe([_event_address(name) for name in window_analysis.event_names]), - workflow_addresses=_dedupe([_workflow_address(name) for name in window_analysis.workflow_names]), - step_refs=window_analysis.workflow_step_refs, - step_workflow_addresses=_dedupe( - [_workflow_address(workflow_name) for workflow_name in window_analysis.refresh_workflow_names] - ), - references=tuple( - ObjectiveWindowReferenceRuntime( - raw=ref.raw, - canonical_name=ref.canonical_name, - reference_kind=ref.reference_kind.value, - dependency_roles=window_role_values, - workflow_name=ref.workflow_name or "", - step_name=ref.step_name or "", - namespace_path=ref.namespace_path, - ) - for ref in window_analysis.references - ), - ) - - -def _compile_objectives( - scenario: InstantiatedScenario, - assertions: dict[str, AssertionRuntime], - diagnostics: list[Diagnostic], -) -> dict[str, ObjectiveRuntime]: - objectives: dict[str, ObjectiveRuntime] = {} - for name, objective in scenario.objectives.items(): - objective_address = _objective_address(name) - success_addresses = _objective_success_addresses( - assertions, - objective, - objective_address, - diagnostics, - ) - objective_dependencies = _objective_dependency_addresses(scenario, objective, objective_address, diagnostics) - window = _compile_objective_window(scenario, objective, objective_address, diagnostics) - ordering_dependencies, refresh_dependencies = partition_objective_dependencies( - success_refs=success_addresses, - dependency_refs=objective_dependencies, - window_refresh_refs=[ - *window.story_addresses, - *window.script_addresses, - *window.event_addresses, - *window.workflow_addresses, - *window.step_workflow_addresses, - ], - ) - result_contract, execution_contract = _evaluation_contracts("objective") - objectives[objective_address] = ObjectiveRuntime( - address=objective_address, - name=name, - actor_type="agent" if objective.agent else "entity", - actor_name=objective.agent or objective.entity, - success_addresses=tuple(success_addresses), - objective_dependencies=objective_dependencies, - window_story_addresses=window.story_addresses, - window_script_addresses=window.script_addresses, - window_event_addresses=window.event_addresses, - window_workflow_addresses=window.workflow_addresses, - window_step_refs=window.step_refs, - window_step_workflow_addresses=window.step_workflow_addresses, - window_references=window.references, - ordering_dependencies=ordering_dependencies, - refresh_dependencies=refresh_dependencies, - spec=_dump(objective), - result_contract=result_contract, - execution_contract=execution_contract, - ) - return objectives - - -def _compile_workflow_predicate( - predicate_source: Any, - *, - scenario: InstantiatedScenario, - assertions: dict[str, AssertionRuntime], - predicate_address: str, - diagnostics: list[Diagnostic], -) -> _WorkflowPredicateCompilation: - assertion_addresses, workflow_diagnostics = _resolve_named_refs( - ref_names=list(predicate_source.assertions), - available_names={assertion.name for assertion in assertions.values()}, - address_builder=_assertion_address, - owner_address=predicate_address, - domain="orchestration", - code_prefix="orchestration.assertion-ref", - resource_label="assertion", - ) - objective_addresses, objective_diagnostics = _resolve_named_refs( - ref_names=list(predicate_source.objectives), - available_names=set(scenario.objectives), - address_builder=_objective_address, - owner_address=predicate_address, - domain="orchestration", - code_prefix="orchestration.objective-ref", - resource_label="objective", - ) - diagnostics.extend( - [ - *workflow_diagnostics, - *objective_diagnostics, - ] - ) - step_state_predicates = tuple( - WorkflowStepStatePredicateRuntime( - step_name=ref.step, - outcomes=tuple(WorkflowStepOutcome(outcome.value) for outcome in ref.outcomes), - min_attempts=ref.min_attempts, - ) - for ref in predicate_source.steps - if isinstance(ref.step, str) and ref.step - ) - predicate_addresses = _dedupe( - [ - *assertion_addresses, - *objective_addresses, - ] - ) - return _WorkflowPredicateCompilation( - predicate=WorkflowPredicateRuntime( - assertion_addresses=assertion_addresses, - objective_addresses=tuple(objective_addresses), - step_state_predicates=step_state_predicates, - ), - assertion_addresses=assertion_addresses, - predicate_addresses=predicate_addresses, - objective_addresses=tuple(objective_addresses), - step_state_predicates=step_state_predicates, - ) - - -def _workflow_step_edges_and_features(step: Any) -> tuple[tuple[str, ...], tuple[WorkflowFeature, ...]]: - edge_values = { - WorkflowStepType.OBJECTIVE: (step.on_success, step.on_failure), - WorkflowStepType.DECISION: (step.then_step, step.else_step), - WorkflowStepType.SWITCH: (*[case.next_step for case in step.cases], step.default_step), - WorkflowStepType.PARALLEL: (*step.branches, step.on_failure), - WorkflowStepType.JOIN: (step.next,), - WorkflowStepType.RETRY: (step.on_success, step.on_exhausted), - WorkflowStepType.CALL: (step.on_success, step.on_failure), - }.get(step.type, ()) - feature = _WORKFLOW_STEP_TYPE_FEATURES.get(step.type) - return _dedupe([edge for edge in edge_values if edge]), (() if feature is None else (feature,)) - - -def _workflow_cross_cutting_features(step: Any, workflow: Any) -> tuple[WorkflowFeature, ...]: - features: list[WorkflowFeature] = [] - if step.on_failure or step.on_exhausted: - features.append(WorkflowFeature.FAILURE_TRANSITIONS) - if workflow.timeout is not None: - features.append(WorkflowFeature.TIMEOUTS) - if workflow.compensation is not None and workflow.compensation.mode.value != "disabled": - features.append(WorkflowFeature.COMPENSATION) - return tuple(features) - - -def _workflow_step_primary_addresses( - scenario: InstantiatedScenario, - *, - workflow_address: str, - step: Any, - state: _WorkflowCompilationState, - diagnostics: list[Diagnostic], -) -> tuple[str, str]: - objective_address = "" - called_workflow_address = "" - if step.objective: - objective_addresses, objective_diagnostics = _resolve_named_refs( - ref_names=[step.objective], - available_names=set(scenario.objectives), - address_builder=_objective_address, - owner_address=workflow_address, - domain="orchestration", - code_prefix="orchestration.objective-ref", - resource_label="objective", - ) - diagnostics.extend(objective_diagnostics) - state.referenced_objectives.extend(objective_addresses) - objective_address = objective_addresses[0] if objective_addresses else "" - elif step.workflow: - workflow_addresses, workflow_diagnostics = _resolve_named_refs( - ref_names=[step.workflow], - available_names=set(scenario.workflows), - address_builder=_workflow_address, - owner_address=workflow_address, - domain="orchestration", - code_prefix="orchestration.workflow-ref", - resource_label="workflow", - ) - diagnostics.extend(workflow_diagnostics) - called_workflow_address = workflow_addresses[0] if workflow_addresses else "" - return objective_address, called_workflow_address - - -def _workflow_step_compensation_address( - scenario: InstantiatedScenario, - *, - workflow_address: str, - step_name: str, - step: Any, - state: _WorkflowCompilationState, - diagnostics: list[Diagnostic], -) -> str: - if not step.compensate_with: - return "" - workflow_addresses, workflow_diagnostics = _resolve_named_refs( - ref_names=[step.compensate_with], - available_names=set(scenario.workflows), - address_builder=_workflow_address, - owner_address=workflow_address, - domain="orchestration", - code_prefix="orchestration.workflow-ref", - resource_label="workflow", - ) - diagnostics.extend(workflow_diagnostics) - compensation_workflow_address = workflow_addresses[0] if workflow_addresses else "" - if compensation_workflow_address: - state.compensation_targets[step_name] = compensation_workflow_address - state.required_features.append(WorkflowFeature.COMPENSATION) - return compensation_workflow_address - - -def _apply_workflow_predicate_compilation( - state: _WorkflowCompilationState, - step_name: str, - compilation: _WorkflowPredicateCompilation, -) -> None: - state.referenced_objectives.extend(compilation.objective_addresses) - state.step_assertion_addresses[step_name] = compilation.assertion_addresses - state.step_predicate_addresses[step_name] = compilation.predicate_addresses - _apply_step_state_predicate_features(state, compilation.step_state_predicates) - - -def _apply_step_state_predicate_features( - state: _WorkflowCompilationState, - step_state_predicates: tuple[WorkflowStepStatePredicateRuntime, ...], -) -> None: - if step_state_predicates: - state.required_state_predicate_features.append(WorkflowStatePredicateFeature.OUTCOME_MATCHING) - if any(state_predicate.min_attempts is not None for state_predicate in step_state_predicates): - state.required_state_predicate_features.append(WorkflowStatePredicateFeature.ATTEMPT_COUNTS) - - -def _compile_switch_cases( - scenario: InstantiatedScenario, - *, - workflow_address: str, - step_name: str, - step: Any, - state: _WorkflowCompilationState, - assertions: dict[str, AssertionRuntime], - diagnostics: list[Diagnostic], -) -> tuple[WorkflowSwitchCaseRuntime, ...]: - compiled_cases: list[WorkflowSwitchCaseRuntime] = [] - switch_assertion_addresses: list[str] = [] - switch_predicate_addresses: list[str] = [] - for case_index, case in enumerate(step.cases): - compilation = _compile_workflow_predicate( - case.when, - scenario=scenario, - assertions=assertions, - predicate_address=_address(workflow_address, "step", step_name, "case", str(case_index)), - diagnostics=diagnostics, - ) - state.referenced_objectives.extend(compilation.objective_addresses) - switch_assertion_addresses.extend(compilation.assertion_addresses) - switch_predicate_addresses.extend(compilation.predicate_addresses) - _apply_step_state_predicate_features(state, compilation.step_state_predicates) - compiled_cases.append( - WorkflowSwitchCaseRuntime(case_index=case_index, predicate=compilation.predicate, next_step=case.next_step) - ) - if switch_assertion_addresses: - state.step_assertion_addresses[step_name] = _dedupe(switch_assertion_addresses) - if switch_predicate_addresses: - state.step_predicate_addresses[step_name] = _dedupe(switch_predicate_addresses) - return tuple(compiled_cases) - - -def _compile_workflow_step_predicates( - scenario: InstantiatedScenario, - *, - workflow_address: str, - step_name: str, - step: Any, - state: _WorkflowCompilationState, - assertions: dict[str, AssertionRuntime], - diagnostics: list[Diagnostic], -) -> tuple[WorkflowPredicateRuntime | None, tuple[WorkflowSwitchCaseRuntime, ...]]: - if step.when is not None: - compilation = _compile_workflow_predicate( - step.when, - scenario=scenario, - assertions=assertions, - predicate_address=_address(workflow_address, "step", step_name), - diagnostics=diagnostics, - ) - _apply_workflow_predicate_compilation(state, step_name, compilation) - return compilation.predicate, () - if step.type != WorkflowStepType.SWITCH: - return None, () - return None, _compile_switch_cases( - scenario, - workflow_address=workflow_address, - step_name=step_name, - step=step, - state=state, - assertions=assertions, - diagnostics=diagnostics, - ) - - -def _compile_workflow_step( - scenario: InstantiatedScenario, - *, - workflow: Any, - workflow_address: str, - step_name: str, - step: Any, - state: _WorkflowCompilationState, - assertions: dict[str, AssertionRuntime], - diagnostics: list[Diagnostic], -) -> None: - edges, type_features = _workflow_step_edges_and_features(step) - state.control_edges[step_name] = edges - state.required_features.extend(type_features) - objective_address, called_workflow_address = _workflow_step_primary_addresses( - scenario, - workflow_address=workflow_address, - step=step, - state=state, - diagnostics=diagnostics, - ) - compensation_workflow_address = _workflow_step_compensation_address( - scenario, - workflow_address=workflow_address, - step_name=step_name, - step=step, - state=state, - diagnostics=diagnostics, - ) - predicate, switch_cases = _compile_workflow_step_predicates( - scenario, - workflow_address=workflow_address, - step_name=step_name, - step=step, - state=state, - assertions=assertions, - diagnostics=diagnostics, - ) - state.required_features.extend(_workflow_cross_cutting_features(step, workflow)) - state.control_steps[step_name] = WorkflowStepRuntime( - name=step_name, - step_type=step.type.value, - objective_address=objective_address, - predicate=predicate, - next_step=step.next, - on_success=step.on_success, - on_failure=step.on_failure, - on_exhausted=step.on_exhausted, - then_step=step.then_step, - else_step=step.else_step, - switch_cases=switch_cases, - default_step=step.default_step, - branches=tuple(step.branches), - join_step=step.join, - owning_parallel_step=state.join_owners.get(step_name, ""), - called_workflow_address=called_workflow_address, - compensation_workflow_address=compensation_workflow_address, - max_attempts=step.max_attempts, - state_contract=workflow_step_semantic_contract(step.type.value), - ) - - -def _workflow_timeout_seconds(workflow: Any) -> int | None: - if workflow.timeout is None or not isinstance(workflow.timeout.seconds, int): - return None - return workflow.timeout.seconds - - -def _workflow_join_owners(workflow: Any) -> dict[str, str]: - return { - step.join: step_name - for step_name, step in workflow.steps.items() - if step.type == WorkflowStepType.PARALLEL and step.join - } - - -def _workflow_result_contract_steps( - control_steps: dict[str, WorkflowStepRuntime], -) -> dict[str, Any]: - return { - step_name: step_runtime.state_contract - for step_name, step_runtime in control_steps.items() - if step_runtime.state_contract.state_observable - } - - -def _workflow_predicate_dependency_addresses(state: _WorkflowCompilationState) -> tuple[str, ...]: - return _dedupe([address for addresses in state.step_predicate_addresses.values() for address in addresses]) - - -def _workflow_compensation_mode(workflow: Any) -> str: - return workflow.compensation.mode.value if workflow.compensation is not None else "disabled" - - -def _workflow_compensation_triggers(workflow: Any) -> tuple[str, ...]: - return tuple(trigger.value for trigger in (workflow.compensation.on if workflow.compensation is not None else [])) - - -def _workflow_compensation_ordering(workflow: Any) -> str: - return workflow.compensation.order if workflow.compensation is not None else "reverse_completion" - - -def _workflow_compensation_failure_policy(workflow: Any) -> str: - if workflow.compensation is None: - return "fail_workflow" - return workflow.compensation.failure_policy.value - - -def _workflow_execution_contract( - workflow: Any, - state: _WorkflowCompilationState, - result_contract_steps: dict[str, Any], -) -> WorkflowExecutionContract: - return WorkflowExecutionContract( - state_schema_version=WORKFLOW_STATE_SCHEMA_VERSION, - start_step=workflow.start, - timeout_seconds=_workflow_timeout_seconds(workflow), - steps={step_name: step_runtime.state_contract for step_name, step_runtime in state.control_steps.items()}, - step_types={step_name: step_runtime.step_type for step_name, step_runtime in state.control_steps.items()}, - control_edges=state.control_edges, - join_owners=state.join_owners, - call_steps={ - step_name: step_runtime.called_workflow_address - for step_name, step_runtime in state.control_steps.items() - if step_runtime.called_workflow_address - }, - compensation_mode=_workflow_compensation_mode(workflow), - compensation_triggers=_workflow_compensation_triggers(workflow), - compensation_targets=state.compensation_targets, - compensation_ordering=_workflow_compensation_ordering(workflow), - compensation_failure_policy=_workflow_compensation_failure_policy(workflow), - observable_steps=tuple(sorted(result_contract_steps)), - ) - - -def _compile_workflow_runtime( - scenario: InstantiatedScenario, - *, - name: str, - workflow: Any, - assertions: dict[str, AssertionRuntime], - diagnostics: list[Diagnostic], -) -> WorkflowRuntime: - workflow_address = _workflow_address(name) - state = _WorkflowCompilationState(join_owners=_workflow_join_owners(workflow)) - for step_name, step in workflow.steps.items(): - _compile_workflow_step( - scenario, - workflow=workflow, - workflow_address=workflow_address, - step_name=step_name, - step=step, - state=state, - assertions=assertions, - diagnostics=diagnostics, - ) - objective_addresses = _dedupe(state.referenced_objectives) - result_contract_steps = _workflow_result_contract_steps(state.control_steps) - predicate_dependency_addresses = _workflow_predicate_dependency_addresses(state) - return WorkflowRuntime( - address=workflow_address, - name=name, - start_step=workflow.start, - referenced_objective_addresses=objective_addresses, - control_steps=state.control_steps, - control_edges=state.control_edges, - join_owners=state.join_owners, - step_assertion_addresses=state.step_assertion_addresses, - step_predicate_addresses=state.step_predicate_addresses, - required_features=_dedupe_by_value(state.required_features), - required_state_predicate_features=_dedupe_by_value(state.required_state_predicate_features), - result_contract=WorkflowResultContract( - state_schema_version=WORKFLOW_STATE_SCHEMA_VERSION, - observable_steps=result_contract_steps, - ), - execution_contract=_workflow_execution_contract(workflow, state, result_contract_steps), - refresh_dependencies=_dedupe([*objective_addresses, *predicate_dependency_addresses]), - spec=_dump(workflow), - ) - - -def _compile_workflows( - scenario: InstantiatedScenario, - assertions: dict[str, AssertionRuntime], - diagnostics: list[Diagnostic], -) -> dict[str, WorkflowRuntime]: - return { - _workflow_address(name): _compile_workflow_runtime( - scenario, - name=name, - workflow=workflow, - assertions=assertions, - diagnostics=diagnostics, - ) - for name, workflow in scenario.workflows.items() - } - - -def _realization_requirement_address( - scenario: InstantiatedScenario, - *, - section_name: str, - declaration_name: str, -) -> str: - """Resolve the compiled resource address for a realization-concern path.""" - - if section_name == "nodes" and declaration_name in scenario.nodes: - node = scenario.nodes[declaration_name] - return _network_address(declaration_name) if node.type == NodeType.SWITCH else _node_address(declaration_name) - if section_name == "content" and declaration_name in scenario.content: - return _content_address(declaration_name) - raise ValueError("realization concern must resolve to one compiled resource address") - - -def _compile_realization_requirements( - scenario: InstantiatedScenario, -) -> tuple[CompiledRealizationRequirement, ...]: - """SEM-218 typed compiler emission: lower each authored realization concern - into a compiled requirement carrying its classifier explicitness class. - - Explicit leaves always win. Missing admitted concerns are lowered through - the typed lexical designation cascade; omitted designation preserves the - legacy closed fallback while explicit root delegation remains typed. - """ - - requirements: list[CompiledRealizationRequirement] = [] - explicitness = scenario.explicitness - for section_name, declaration_name, field_name, concern_kind in registered_realization_concerns( - declaration_names={"nodes": scenario.nodes, "content": scenario.content} - ): - field_path = f"{section_name}.{declaration_name}.{field_name}" - encoded_name = declaration_name.replace("~", "~0").replace("/", "~1") - field_pointer = f"/{section_name}/{encoded_name}/{field_name}" - owner_namespace = QualifiedName.parse(declaration_name).parts[:-1] - record = explicitness.get(field_path) - if record is None: - resolution = resolve_realization_designation( - scenario.instantiation_provenance.realization_designations, - field_pointer=field_pointer, - owner_namespace=owner_namespace, - ) - if resolution.source == "legacy-default" or ( - resolution.closure is not None - and resolution.closure.value == "closed-world" - and not resolution.delegated - ): - continue - requirement_explicitness = ( - ExplicitnessClass.OPEN - if resolution.closure is not None and resolution.closure.value == "open-world" - else None - ) - provenance = ExplicitnessProvenance.AUTHOR_DECLARED - governing_scope = resolution.governing_scope - delegated = resolution.delegated - else: - requirement_explicitness = record.classification - provenance = record.provenance - governing_scope = f"#{field_pointer}" - delegated = False - requirements.append( - CompiledRealizationRequirement( - field_path=field_path, - address=_realization_requirement_address( - scenario, - section_name=section_name, - declaration_name=declaration_name, - ), - domain=REALIZATION_DOMAIN, - requirement_kind=concern_kind, - explicitness=requirement_explicitness, - provenance=provenance, - governing_scope=governing_scope, - delegated=delegated, - ) - ) - return tuple(requirements) - - -def compile_scenario_runtime_model( - scenario: Scenario | ExpandedScenario | InstantiatedScenario, - *, - parameters: Mapping[str, object] | None = None, - profile: str | None = None, -) -> RuntimeModel: - """Instantiate an SDL scenario and compile it into runtime artifacts.""" - - concrete_scenario = ( - scenario - if isinstance(scenario, InstantiatedScenario) - else instantiate_scenario(scenario, parameters=parameters, profile=profile) - ) - return compile_runtime_model(concrete_scenario) - - -def compile_runtime_model(scenario: Scenario | ExpandedScenario | InstantiatedScenario) -> RuntimeModel: - """Compile an SDL scenario into bound runtime objects.""" - - scenario = ( - admit_instantiated_scenario(scenario) - if isinstance(scenario, InstantiatedScenario) - else instantiate_scenario(scenario) - ) - build_declaration_index(scenario) - diagnostics: list[Diagnostic] = [] - - ( - feature_templates, - condition_templates, - inject_templates, - vulnerability_templates, - ) = _compile_templates(scenario) - entity_specs, agent_specs, relationship_specs = _metadata_specs(scenario) - - networks, node_deployments = _compile_node_runtimes(scenario, diagnostics) - feature_bindings = _compile_feature_bindings(scenario, feature_templates, diagnostics) - propositions = _compile_propositions(scenario) - assertions = _compile_assertions(scenario) - condition_bindings = _compile_condition_bindings( - scenario, - condition_templates, - propositions, - diagnostics, - ) - injects = _compile_inject_runtimes(inject_templates) - inject_bindings = _compile_inject_bindings(scenario, inject_templates, diagnostics) - content_placements = _compile_content_placements(scenario, diagnostics) - account_placements = _compile_account_placements(scenario, diagnostics) - action_contracts = _compile_action_contracts(scenario) - observation_boundaries = _compile_observation_boundaries(scenario) - outcome_interpretation_rules = _compile_outcome_interpretation_rules(scenario) - participant_behaviors = _compile_participant_behaviors(scenario, diagnostics) - behavior_specifications = _compile_behavior_specifications(scenario, diagnostics) - events = _compile_events(scenario, assertions, injects, inject_bindings, diagnostics) - scripts = _compile_scripts(scenario, diagnostics) - stories = _compile_stories(scenario, diagnostics) - objectives = _compile_objectives(scenario, assertions, diagnostics) - workflows = _compile_workflows(scenario, assertions, diagnostics) - - return RuntimeModel( - scenario_name=scenario.name, - feature_templates=feature_templates, - condition_templates=condition_templates, - inject_templates=inject_templates, - vulnerability_templates=vulnerability_templates, - entity_specs=entity_specs, - agent_specs=agent_specs, - relationship_specs=relationship_specs, - capability_constraints=_compile_capability_constraints(scenario), - networks=networks, - node_deployments=node_deployments, - feature_bindings=feature_bindings, - propositions=propositions, - assertions=assertions, - condition_bindings=condition_bindings, - injects=injects, - inject_bindings=inject_bindings, - content_placements=content_placements, - account_placements=account_placements, - action_contracts=action_contracts, - observation_boundaries=observation_boundaries, - outcome_interpretation_rules=outcome_interpretation_rules, - participant_behaviors=participant_behaviors, - behavior_specifications=behavior_specifications, - events=events, - scripts=scripts, - stories=stories, - workflows=workflows, - objectives=objectives, - diagnostics=diagnostics, - realization_requirements=_compile_realization_requirements(scenario), - realization_instance=scenario, - ) diff --git a/implementations/python/packages/aces_processor/compiler/__init__.py b/implementations/python/packages/aces_processor/compiler/__init__.py new file mode 100644 index 000000000..a18efc23f --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/__init__.py @@ -0,0 +1,11 @@ +"""SDL-to-runtime compiler. + +Public facade for the compiler package. The compilation logic is split across +package-private submodules (foundations -> domain compilers -> pipeline); this +module re-exports the stable public API so ``aces_processor.compiler`` keeps its +external contract. +""" + +from .pipeline import compile_runtime_model, compile_scenario_runtime_model + +__all__ = ["compile_runtime_model", "compile_scenario_runtime_model"] diff --git a/implementations/python/packages/aces_processor/compiler/addresses.py b/implementations/python/packages/aces_processor/compiler/addresses.py new file mode 100644 index 000000000..8050cbd55 --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/addresses.py @@ -0,0 +1,165 @@ +"""Canonical compiled-address builders and address-resolution helpers.""" + +from collections.abc import Mapping + +from aces_backend_protocols.domain_topology import DomainTopologyBinding +from aces_sdl.nodes import NodeType +from aces_sdl.scenario import InstantiatedScenario, Scenario +from aces_sdl.semantics.domain_topology import ( + DomainNodeBinding, +) + +from .support import _address + + +def _template_address(kind: str, name: str) -> str: + return _address("template", kind, name) + + +def _network_address(name: str) -> str: + return _address("provision", "network", name) + + +def _node_address(name: str) -> str: + return _address("provision", "node", name) + + +def _feature_binding_address(node_name: str, feature_name: str) -> str: + return _address("provision", "feature", node_name, feature_name) + + +def _content_address(name: str) -> str: + return _address("provision", "content", name) + + +def _content_item_address(content_name: str, item_name: str) -> str: + return _address("provision", "content", content_name, "items", item_name) + + +def _account_address(name: str) -> str: + return _address("provision", "account", name) + + +def _section_ref_name(ref: str, section: str, declarations: Mapping[str, object]) -> str: + """Return the declaration key denoted by a bare or section-qualified ref.""" + + if ref in declarations: + return ref + prefix = f"{section}." + candidate = ref[len(prefix) :] if ref.startswith(prefix) else "" + if candidate in declarations: + return candidate + raise ValueError(f"validated {section} reference must resolve") + + +def _compiled_domain_binding( + scenario: InstantiatedScenario, + binding: DomainNodeBinding, +) -> DomainTopologyBinding: + domain = scenario.identity_domains[binding.domain_name] + authority_name = _section_ref_name( + domain.authority_account_ref, + "accounts", + scenario.accounts, + ) + profile = getattr(domain.profile, "value", domain.profile) + return DomainTopologyBinding( + domain_id=binding.domain_name, + profile=str(profile), + dns_name=domain.dns_name, + netbios_name=domain.netbios_name, + authority_account_address=_account_address(authority_name), + role=binding.role.value, + controller_addresses=tuple(_node_address(name) for name in binding.controller_names), + ) + + +def _service_address(node_name: str, service_name: str) -> str: + return _address("provision", "node", node_name, "service", service_name) + + +def _resolve_node_service_ref( + scenario: InstantiatedScenario, + ref: object, +) -> tuple[str, str] | None: + if not isinstance(ref, str): + return None + for node_name, node in scenario.nodes.items(): + for service in node.services: + if service.name and ref == f"nodes.{node_name}.services.{service.name}": + return node_name, service.name + return None + + +def _action_contract_address(name: str) -> str: + return _address("participant", "action-contract", name) + + +def _observation_boundary_address(name: str) -> str: + return _address("participant", "observation-boundary", name) + + +def _outcome_interpretation_rule_address(name: str) -> str: + return _address("participant", "outcome-interpretation-rule", name) + + +def _participant_behavior_address(name: str) -> str: + return _address("participant", "behavior", name) + + +def _behavior_specification_address(name: str) -> str: + return _address("participant", "behavior-specification", name) + + +def _condition_binding_address(node_name: str, condition_name: str) -> str: + return _address("evaluation", "condition", node_name, condition_name) + + +def _proposition_address(name: str) -> str: + return _address("evaluation", "proposition", name) + + +def _assertion_address(name: str) -> str: + return _address("evaluation", "assertion", name) + + +def _inject_address(name: str) -> str: + return _address("orchestration", "inject", name) + + +def _inject_binding_address(node_name: str, inject_name: str) -> str: + return _address("orchestration", "inject-binding", node_name, inject_name) + + +def _event_address(name: str) -> str: + return _address("orchestration", "event", name) + + +def _script_address(name: str) -> str: + return _address("orchestration", "script", name) + + +def _story_address(name: str) -> str: + return _address("orchestration", "story", name) + + +def _workflow_address(name: str) -> str: + return _address("orchestration", "workflow", name) + + +def _evaluation_address(name: str) -> str: + # Address form for the experiment/evaluator-plane EVALUATION_RESULT + # interpretation layer (SEM-215). Per ADR-073 the SDL no longer authors an + # ``evaluations`` section; this address no longer resolves an SDL resource. + return _address("evaluation", "evaluation", name) + + +def _objective_address(name: str) -> str: + return _address("evaluation", "objective", name) + + +def _resource_address_for_node(scenario: Scenario, node_name: str) -> str: + node = scenario.nodes.get(node_name) + if node is not None and node.type == NodeType.SWITCH: + return _network_address(node_name) + return _node_address(node_name) diff --git a/implementations/python/packages/aces_processor/compiler/alias_index.py b/implementations/python/packages/aces_processor/compiler/alias_index.py new file mode 100644 index 000000000..7effe8176 --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/alias_index.py @@ -0,0 +1,241 @@ +"""Runtime-addressable reference index and initial-knowledge address resolution.""" + +from collections.abc import Callable, Iterable + +from aces_sdl.nodes import NodeType +from aces_sdl.scenario import InstantiatedScenario + +from .addresses import ( + _account_address, + _action_contract_address, + _behavior_specification_address, + _content_address, + _content_item_address, + _observation_boundary_address, + _outcome_interpretation_rule_address, + _resolve_node_service_ref, + _resource_address_for_node, + _service_address, + _template_address, +) +from .support import _dedupe + + +def _add_alias(index: dict[str, set[str]], alias: str, address: str) -> None: + if alias: + index.setdefault(alias, set()).add(address) + + +def _add_node_aliases(index: dict[str, set[str]], scenario: InstantiatedScenario) -> None: + for node_name, node in scenario.nodes.items(): + address = _resource_address_for_node(scenario, node_name) + _add_alias(index, node_name, address) + _add_alias(index, f"nodes.{node_name}", address) + if node.type == NodeType.SWITCH: + _add_alias(index, f"infrastructure.{node_name}", address) + + for service in node.services: + service_name = service.name + if not service_name: + continue + service_address = _service_address(node_name, service_name) + _add_alias(index, service_name, service_address) + _add_alias(index, f"nodes.{node_name}.services.{service_name}", service_address) + + +def _add_infrastructure_aliases(index: dict[str, set[str]], scenario: InstantiatedScenario) -> None: + for infra_name in scenario.infrastructure: + node = scenario.nodes.get(infra_name) + if node is None: + continue + address = _resource_address_for_node(scenario, infra_name) + _add_alias(index, f"infrastructure.{infra_name}", address) + if node.type == NodeType.SWITCH: + _add_alias(index, infra_name, address) + + +def _add_content_aliases(index: dict[str, set[str]], scenario: InstantiatedScenario) -> None: + for content_name, content in scenario.content.items(): + content_address = _content_address(content_name) + _add_alias(index, content_name, content_address) + _add_alias(index, f"content.{content_name}", content_address) + for item in content.items: + if not item.name: + continue + item_address = _content_item_address(content_name, item.name) + _add_alias(index, item.name, item_address) + _add_alias(index, f"content.{content_name}.items.{item.name}", item_address) + + +def _add_qualified_aliases( + index: dict[str, set[str]], + names: Iterable[str], + *, + address_for: Callable[[str], str], + qualified_prefix: str, +) -> None: + for name in names: + address = address_for(name) + _add_alias(index, name, address) + _add_alias(index, f"{qualified_prefix}.{name}", address) + + +def _runtime_addressable_ref_index(scenario: InstantiatedScenario) -> dict[str, set[str]]: + """Map SDL authority/scope refs to compiled runtime addresses. + + This deliberately omits semantic-only anchors such as entities and + relationships. The raw refs stay on participant runtime records; only + refs backed by runtime-addressable surfaces become addresses/dependencies. + """ + index: dict[str, set[str]] = {} + _add_node_aliases(index, scenario) + _add_infrastructure_aliases(index, scenario) + _add_content_aliases(index, scenario) + _add_qualified_aliases( + index, + scenario.accounts, + address_for=_account_address, + qualified_prefix="accounts", + ) + _add_qualified_aliases( + index, + scenario.conditions, + address_for=lambda name: _template_address("condition", name), + qualified_prefix="conditions", + ) + _add_qualified_aliases( + index, + scenario.features, + address_for=lambda name: _template_address("feature", name), + qualified_prefix="features", + ) + _add_qualified_aliases( + index, + scenario.vulnerabilities, + address_for=lambda name: _template_address("vulnerability", name), + qualified_prefix="vulnerabilities", + ) + _add_qualified_aliases( + index, + scenario.action_contracts, + address_for=_action_contract_address, + qualified_prefix="action_contracts", + ) + _add_qualified_aliases( + index, + scenario.observation_boundaries, + address_for=_observation_boundary_address, + qualified_prefix="observation_boundaries", + ) + _add_qualified_aliases( + index, + scenario.outcome_interpretation_rules, + address_for=_outcome_interpretation_rule_address, + qualified_prefix="outcome_interpretation_rules", + ) + _add_qualified_aliases( + index, + scenario.behavior_specifications, + address_for=_behavior_specification_address, + qualified_prefix="behavior_specifications", + ) + return index + + +def _runtime_addresses_for_refs( + refs: list[str], + *, + addressable_ref_index: dict[str, set[str]], +) -> tuple[str, ...]: + addresses: list[str] = [] + for ref in dict.fromkeys(refs): + matches = addressable_ref_index.get(ref, ()) + if len(matches) == 1: + addresses.extend(matches) + return _dedupe(addresses) + + +def _account_addresses_for_refs(scenario: InstantiatedScenario, refs: list[str]) -> tuple[str, ...]: + addresses: list[str] = [] + for ref in dict.fromkeys(refs): + if ref in scenario.accounts: + addresses.append(_account_address(ref)) + return _dedupe(addresses) + + +def _service_addresses_for_refs(scenario: InstantiatedScenario, refs: list[str]) -> tuple[str, ...]: + addresses: list[str] = [] + for ref in dict.fromkeys(refs): + split = _resolve_node_service_ref(scenario, ref) + if split is not None: + node_name, service_name = split + node = scenario.nodes.get(node_name) + if node is None: + continue + if any(service.name == service_name for service in node.services): + addresses.append(_service_address(node_name, service_name)) + continue + for node_name, node in scenario.nodes.items(): + if any(service.name == ref for service in node.services): + addresses.append(_service_address(node_name, ref)) + return _dedupe(addresses) + + +def _initial_knowledge_values(initial_knowledge: object, attribute: str) -> tuple[object, ...]: + return tuple(getattr(initial_knowledge, attribute, ()) or ()) + + +def _initial_knowledge_host_addresses( + scenario: InstantiatedScenario, + initial_knowledge: object, +) -> list[str]: + addresses: list[str] = [] + for host in _initial_knowledge_values(initial_knowledge, "hosts"): + if host in scenario.nodes: + addresses.append(_resource_address_for_node(scenario, str(host))) + return addresses + + +def _initial_knowledge_subnet_addresses( + scenario: InstantiatedScenario, + initial_knowledge: object, +) -> list[str]: + addresses: list[str] = [] + for subnet in _initial_knowledge_values(initial_knowledge, "subnets"): + if subnet in scenario.infrastructure and subnet in scenario.nodes: + addresses.append(_resource_address_for_node(scenario, str(subnet))) + return addresses + + +def _initial_knowledge_service_addresses( + scenario: InstantiatedScenario, + initial_knowledge: object, +) -> tuple[str, ...]: + return _service_addresses_for_refs( + scenario, + [str(service) for service in _initial_knowledge_values(initial_knowledge, "services")], + ) + + +def _initial_knowledge_account_addresses( + scenario: InstantiatedScenario, + initial_knowledge: object, +) -> tuple[str, ...]: + return _account_addresses_for_refs( + scenario, + [str(account) for account in _initial_knowledge_values(initial_knowledge, "accounts")], + ) + + +def _initial_knowledge_addresses( + scenario: InstantiatedScenario, + initial_knowledge: object | None, +) -> tuple[str, ...]: + if initial_knowledge is None: + return () + addresses: list[str] = [] + addresses.extend(_initial_knowledge_host_addresses(scenario, initial_knowledge)) + addresses.extend(_initial_knowledge_subnet_addresses(scenario, initial_knowledge)) + addresses.extend(_initial_knowledge_service_addresses(scenario, initial_knowledge)) + addresses.extend(_initial_knowledge_account_addresses(scenario, initial_knowledge)) + return _dedupe(addresses) diff --git a/implementations/python/packages/aces_processor/compiler/evaluation.py b/implementations/python/packages/aces_processor/compiler/evaluation.py new file mode 100644 index 000000000..1767f64eb --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/evaluation.py @@ -0,0 +1,105 @@ +"""Evaluation-domain compilation: propositions, assertions, condition bindings.""" + +from aces_sdl.nodes import NodeType +from aces_sdl.scenario import InstantiatedScenario + +from ..models import ( + AssertionRuntime, + ConditionBinding, + Diagnostic, + PropositionRuntime, + RuntimeTemplate, +) +from .addresses import _assertion_address, _condition_binding_address, _node_address, _proposition_address +from .alias_index import _runtime_addressable_ref_index, _runtime_addresses_for_refs +from .ref_resolution import _evaluation_contracts +from .support import _dedupe, _dump + + +def _compile_propositions( + scenario: InstantiatedScenario, +) -> dict[str, PropositionRuntime]: + address_index = _runtime_addressable_ref_index(scenario) + return { + _proposition_address(name): PropositionRuntime( + address=_proposition_address(name), + name=name, + spec=_dump(proposition), + subject_addresses=_runtime_addresses_for_refs( + list(proposition.subjects), + addressable_ref_index=address_index, + ), + predicate_kind=proposition.predicate.kind, + evaluation_basis=proposition.basis.value, + evidence_requirement_refs=tuple(proposition.evidence_requirements), + ) + for name, proposition in scenario.propositions.items() + } + + +def _compile_assertions( + scenario: InstantiatedScenario, +) -> dict[str, AssertionRuntime]: + return { + _assertion_address(name): AssertionRuntime( + address=_assertion_address(name), + name=name, + spec=_dump(assertion), + proposition_address=_proposition_address(assertion.proposition), + role=assertion.role.value, + polarity=assertion.polarity.value, + ordering_dependencies=(_proposition_address(assertion.proposition),), + refresh_dependencies=(_proposition_address(assertion.proposition),), + ) + for name, assertion in scenario.assertions.items() + } + + +def _compile_condition_bindings( + scenario: InstantiatedScenario, + condition_templates: dict[str, RuntimeTemplate], + propositions: dict[str, PropositionRuntime], + diagnostics: list[Diagnostic], +) -> dict[str, ConditionBinding]: + condition_bindings: dict[str, ConditionBinding] = {} + for node_name, node in scenario.nodes.items(): + if node.type != NodeType.VM: + continue + node_addr = _node_address(node_name) + for condition_name, role_name in node.conditions.items(): + template = condition_templates.get(condition_name) + if template is None: + diagnostics.append( + Diagnostic( + code="evaluation.condition-template-ref-unbound", + domain="evaluation", + address=node_addr, + message=( + f"Condition binding '{condition_name}' on node '{node_name}' " + "does not resolve to a declared condition template." + ), + ) + ) + continue + address = _condition_binding_address(node_name, condition_name) + proposition_address = ( + _proposition_address(template.spec["proposition"]) if template.spec.get("proposition") else "" + ) + proposition_dependencies = (proposition_address,) if proposition_address in propositions else () + result_contract, execution_contract = _evaluation_contracts("condition-binding") + condition_bindings[address] = ConditionBinding( + address=address, + name=condition_name, + node_name=node_name, + node_address=node_addr, + condition_name=condition_name, + template_address=template.address, + role_name=role_name, + proposition_address=proposition_address, + ordering_dependencies=proposition_dependencies, + refresh_dependencies=_dedupe([node_addr, *proposition_dependencies]), + spec={"binding": {"node": node_name, "role": role_name}, "template": template.spec}, + result_contract=result_contract, + execution_contract=execution_contract, + ) + return condition_bindings diff --git a/implementations/python/packages/aces_processor/compiler/objectives.py b/implementations/python/packages/aces_processor/compiler/objectives.py new file mode 100644 index 000000000..e4a5c7990 --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/objectives.py @@ -0,0 +1,228 @@ +"""Objective compilation, including objective-window resolution.""" + +from dataclasses import dataclass + +from aces_sdl.objectives import Objective +from aces_sdl.scenario import InstantiatedScenario +from aces_sdl.semantics.objective_semantics import ( + OBJECTIVE_WINDOW_DEPENDENCY_ROLES, + partition_objective_dependencies, +) +from aces_sdl.semantics.objectives import ObjectiveWindowIssue, analyze_objective_window + +from ..models import ( + AssertionRuntime, + Diagnostic, + ObjectiveRuntime, + ObjectiveWindowReferenceRuntime, +) +from .addresses import ( + _assertion_address, + _event_address, + _objective_address, + _script_address, + _story_address, + _workflow_address, +) +from .ref_resolution import _evaluation_contracts, _resolve_named_refs +from .support import _dedupe, _dump + + +@dataclass(frozen=True) +class _ObjectiveWindowCompilation: + story_addresses: tuple[str, ...] = () + script_addresses: tuple[str, ...] = () + event_addresses: tuple[str, ...] = () + workflow_addresses: tuple[str, ...] = () + step_refs: tuple[str, ...] = () + step_workflow_addresses: tuple[str, ...] = () + references: tuple[ObjectiveWindowReferenceRuntime, ...] = () + + +_OBJECTIVE_WINDOW_ISSUE_DIAGNOSTICS = { + "story-unbound": ("evaluation.story-ref-unbound", "Reference '{ref}' does not resolve to a defined story."), + "script-unbound": ("evaluation.script-ref-unbound", "Reference '{ref}' does not resolve to a defined script."), + "script-outside-window-stories": ( + "evaluation.script-ref-outside-window-stories", + "Reference '{ref}' is not included by the objective window's referenced stories.", + ), + "event-unbound": ("evaluation.event-ref-unbound", "Reference '{ref}' does not resolve to a defined event."), + "event-outside-window-scripts": ( + "evaluation.event-ref-outside-window-scripts", + "Reference '{ref}' is not included by the objective window's referenced scripts.", + ), + "workflow-unbound": ( + "evaluation.workflow-ref-unbound", + "Reference '{ref}' does not resolve to a defined workflow.", + ), + "step-requires-workflow-window": ( + "evaluation.workflow-step-ref-window-missing-workflow", + "Workflow step references require at least one referenced workflow.", + ), + "step-invalid-format": ( + "evaluation.workflow-step-ref-invalid-format", + "Reference '{ref}' must use '.' syntax.", + ), + "step-workflow-unbound": ( + "evaluation.workflow-step-ref-workflow-unbound", + "Reference '{ref}' does not resolve to a defined workflow.", + ), + "step-workflow-outside-window": ( + "evaluation.workflow-step-ref-workflow-outside-window", + "Reference '{ref}' is not part of the objective window's referenced workflows.", + ), + "step-unbound": ( + "evaluation.workflow-step-ref-step-unbound", + "Reference '{ref}' does not resolve to a defined workflow step.", + ), +} + + +def _objective_success_addresses( + assertions: dict[str, AssertionRuntime], + objective: Objective, + objective_address: str, + diagnostics: list[Diagnostic], +) -> list[str]: + assertion_addresses, assertion_diagnostics = _resolve_named_refs( + ref_names=list(objective.success.assertions), + available_names={assertion.name for assertion in assertions.values()}, + address_builder=_assertion_address, + owner_address=objective_address, + domain="evaluation", + code_prefix="evaluation.assertion-ref", + resource_label="assertion", + ) + diagnostics.extend(assertion_diagnostics) + return list(assertion_addresses) + + +def _objective_dependency_addresses( + scenario: InstantiatedScenario, + objective: Objective, + objective_address: str, + diagnostics: list[Diagnostic], +) -> tuple[str, ...]: + objective_dependencies, objective_dependency_diagnostics = _resolve_named_refs( + ref_names=list(objective.depends_on), + available_names=set(scenario.objectives), + address_builder=_objective_address, + owner_address=objective_address, + domain="evaluation", + code_prefix="evaluation.objective-ref", + resource_label="objective", + ) + diagnostics.extend(objective_dependency_diagnostics) + return objective_dependencies + + +def _objective_window_issue_diagnostic(issue: ObjectiveWindowIssue, objective_address: str) -> Diagnostic | None: + spec = _OBJECTIVE_WINDOW_ISSUE_DIAGNOSTICS.get(issue.code) + if spec is None: + return None + code, message_template = spec + return Diagnostic( + code=code, + domain="evaluation", + address=objective_address, + message=message_template.format(ref=issue.ref), + ) + + +def _compile_objective_window( + scenario: InstantiatedScenario, + objective: Objective, + objective_address: str, + diagnostics: list[Diagnostic], +) -> _ObjectiveWindowCompilation: + if objective.window is None: + return _ObjectiveWindowCompilation() + window_analysis = analyze_objective_window( + story_refs=list(objective.window.stories), + script_refs=list(objective.window.scripts), + event_refs=list(objective.window.events), + workflow_refs=list(objective.window.workflows), + step_refs=list(objective.window.steps), + stories_by_name=scenario.stories, + scripts_by_name=scenario.scripts, + events_by_name=scenario.events, + workflows_by_name=scenario.workflows, + ) + for issue in window_analysis.issues: + diagnostic = _objective_window_issue_diagnostic(issue, objective_address) + if diagnostic is not None: + diagnostics.append(diagnostic) + window_role_values = tuple(role.value for role in OBJECTIVE_WINDOW_DEPENDENCY_ROLES) + return _ObjectiveWindowCompilation( + story_addresses=_dedupe([_story_address(name) for name in window_analysis.story_names]), + script_addresses=_dedupe([_script_address(name) for name in window_analysis.script_names]), + event_addresses=_dedupe([_event_address(name) for name in window_analysis.event_names]), + workflow_addresses=_dedupe([_workflow_address(name) for name in window_analysis.workflow_names]), + step_refs=window_analysis.workflow_step_refs, + step_workflow_addresses=_dedupe( + [_workflow_address(workflow_name) for workflow_name in window_analysis.refresh_workflow_names] + ), + references=tuple( + ObjectiveWindowReferenceRuntime( + raw=ref.raw, + canonical_name=ref.canonical_name, + reference_kind=ref.reference_kind.value, + dependency_roles=window_role_values, + workflow_name=ref.workflow_name or "", + step_name=ref.step_name or "", + namespace_path=ref.namespace_path, + ) + for ref in window_analysis.references + ), + ) + + +def _compile_objectives( + scenario: InstantiatedScenario, + assertions: dict[str, AssertionRuntime], + diagnostics: list[Diagnostic], +) -> dict[str, ObjectiveRuntime]: + objectives: dict[str, ObjectiveRuntime] = {} + for name, objective in scenario.objectives.items(): + objective_address = _objective_address(name) + success_addresses = _objective_success_addresses( + assertions, + objective, + objective_address, + diagnostics, + ) + objective_dependencies = _objective_dependency_addresses(scenario, objective, objective_address, diagnostics) + window = _compile_objective_window(scenario, objective, objective_address, diagnostics) + ordering_dependencies, refresh_dependencies = partition_objective_dependencies( + success_refs=success_addresses, + dependency_refs=objective_dependencies, + window_refresh_refs=[ + *window.story_addresses, + *window.script_addresses, + *window.event_addresses, + *window.workflow_addresses, + *window.step_workflow_addresses, + ], + ) + result_contract, execution_contract = _evaluation_contracts("objective") + objectives[objective_address] = ObjectiveRuntime( + address=objective_address, + name=name, + actor_type="agent" if objective.agent else "entity", + actor_name=objective.agent or objective.entity, + success_addresses=tuple(success_addresses), + objective_dependencies=objective_dependencies, + window_story_addresses=window.story_addresses, + window_script_addresses=window.script_addresses, + window_event_addresses=window.event_addresses, + window_workflow_addresses=window.workflow_addresses, + window_step_refs=window.step_refs, + window_step_workflow_addresses=window.step_workflow_addresses, + window_references=window.references, + ordering_dependencies=ordering_dependencies, + refresh_dependencies=refresh_dependencies, + spec=_dump(objective), + result_contract=result_contract, + execution_contract=execution_contract, + ) + return objectives diff --git a/implementations/python/packages/aces_processor/compiler/orchestration.py b/implementations/python/packages/aces_processor/compiler/orchestration.py new file mode 100644 index 000000000..ebd35b186 --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/orchestration.py @@ -0,0 +1,181 @@ +"""Orchestration-domain compilation: injects, events, scripts, stories.""" + +from aces_sdl.nodes import NodeType +from aces_sdl.scenario import InstantiatedScenario + +from ..models import ( + AssertionRuntime, + Diagnostic, + EventRuntime, + InjectBinding, + InjectRuntime, + RuntimeTemplate, + ScriptRuntime, + StoryRuntime, +) +from .addresses import ( + _assertion_address, + _event_address, + _inject_address, + _inject_binding_address, + _node_address, + _script_address, + _story_address, +) +from .ref_resolution import _resolve_named_refs, _resolve_resource_refs +from .support import _dedupe, _dump + + +def _compile_inject_runtimes(inject_templates: dict[str, RuntimeTemplate]) -> dict[str, InjectRuntime]: + return { + _inject_address(name): InjectRuntime(address=_inject_address(name), name=name, spec=template.spec) + for name, template in inject_templates.items() + } + + +def _compile_inject_bindings( + scenario: InstantiatedScenario, + inject_templates: dict[str, RuntimeTemplate], + diagnostics: list[Diagnostic], +) -> dict[str, InjectBinding]: + inject_bindings: dict[str, InjectBinding] = {} + for node_name, node in scenario.nodes.items(): + if node.type != NodeType.VM: + continue + node_addr = _node_address(node_name) + for inject_name, role_name in node.injects.items(): + template = inject_templates.get(inject_name) + if template is None: + diagnostics.append( + Diagnostic( + code="orchestration.inject-template-ref-unbound", + domain="orchestration", + address=node_addr, + message=( + f"Inject binding '{inject_name}' on node '{node_name}' " + "does not resolve to a declared inject template." + ), + ) + ) + continue + inject_address = _inject_address(inject_name) + address = _inject_binding_address(node_name, inject_name) + inject_bindings[address] = InjectBinding( + address=address, + name=inject_name, + node_name=node_name, + node_address=node_addr, + inject_name=inject_name, + template_address=template.address, + role_name=role_name, + ordering_dependencies=(inject_address,), + refresh_dependencies=(node_addr, inject_address), + spec={"binding": {"node": node_name, "role": role_name}, "inject_address": inject_address}, + ) + return inject_bindings + + +def _compile_events( + scenario: InstantiatedScenario, + assertions: dict[str, AssertionRuntime], + injects: dict[str, InjectRuntime], + inject_bindings: dict[str, InjectBinding], + diagnostics: list[Diagnostic], +) -> dict[str, EventRuntime]: + events: dict[str, EventRuntime] = {} + for name, event in scenario.events.items(): + event_address = _event_address(name) + assertion_names = list(event.assertions) + inject_names = list(event.injects) + assertion_addresses, assertion_diagnostics = _resolve_named_refs( + ref_names=assertion_names, + available_names={assertion.name for assertion in assertions.values()}, + address_builder=_assertion_address, + owner_address=event_address, + domain="orchestration", + code_prefix="orchestration.assertion-ref", + resource_label="assertion", + ) + inject_addresses, inject_diagnostics = _resolve_resource_refs( + injects, + ref_names=inject_names, + owner_address=event_address, + domain="orchestration", + code_prefix="orchestration.inject-ref", + resource_label="inject", + ) + diagnostics.extend(assertion_diagnostics) + diagnostics.extend(inject_diagnostics) + inject_binding_ordering_dependencies = [ + address for address, binding in inject_bindings.items() if binding.inject_name in inject_names + ] + events[event_address] = EventRuntime( + address=event_address, + name=name, + assertion_names=tuple(assertion_names), + assertion_addresses=assertion_addresses, + inject_names=tuple(inject_names), + inject_addresses=inject_addresses, + ordering_dependencies=_dedupe([*inject_addresses, *inject_binding_ordering_dependencies]), + refresh_dependencies=_dedupe( + [*assertion_addresses, *inject_addresses, *inject_binding_ordering_dependencies] + ), + spec=_dump(event), + ) + return events + + +def _compile_scripts( + scenario: InstantiatedScenario, + diagnostics: list[Diagnostic], +) -> dict[str, ScriptRuntime]: + scripts: dict[str, ScriptRuntime] = {} + for name, script in scenario.scripts.items(): + script_address = _script_address(name) + event_addresses, script_diagnostics = _resolve_named_refs( + ref_names=list(script.events), + available_names=set(scenario.events), + address_builder=_event_address, + owner_address=script_address, + domain="orchestration", + code_prefix="orchestration.event-ref", + resource_label="event", + ) + diagnostics.extend(script_diagnostics) + scripts[script_address] = ScriptRuntime( + address=script_address, + name=name, + event_addresses=event_addresses, + ordering_dependencies=event_addresses, + refresh_dependencies=event_addresses, + spec=_dump(script), + ) + return scripts + + +def _compile_stories( + scenario: InstantiatedScenario, + diagnostics: list[Diagnostic], +) -> dict[str, StoryRuntime]: + stories: dict[str, StoryRuntime] = {} + for name, story in scenario.stories.items(): + story_address = _story_address(name) + script_addresses, story_diagnostics = _resolve_named_refs( + ref_names=list(story.scripts), + available_names=set(scenario.scripts), + address_builder=_script_address, + owner_address=story_address, + domain="orchestration", + code_prefix="orchestration.script-ref", + resource_label="script", + ) + diagnostics.extend(story_diagnostics) + stories[story_address] = StoryRuntime( + address=story_address, + name=name, + script_addresses=script_addresses, + ordering_dependencies=script_addresses, + refresh_dependencies=script_addresses, + spec=_dump(story), + ) + return stories diff --git a/implementations/python/packages/aces_processor/compiler/participant_behaviors.py b/implementations/python/packages/aces_processor/compiler/participant_behaviors.py new file mode 100644 index 000000000..44b6c7ef9 --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/participant_behaviors.py @@ -0,0 +1,260 @@ +"""Participant behavior and behavior-specification compilation.""" + +from collections.abc import Callable, Mapping + +from aces_sdl.scenario import InstantiatedScenario + +from ..models import ( + Diagnostic, + ParticipantBehaviorRuntime, + ParticipantBehaviorSpecificationRuntime, +) +from .addresses import ( + _action_contract_address, + _assertion_address, + _behavior_specification_address, + _observation_boundary_address, + _outcome_interpretation_rule_address, + _participant_behavior_address, +) +from .alias_index import ( + _account_addresses_for_refs, + _initial_knowledge_addresses, + _runtime_addressable_ref_index, + _runtime_addresses_for_refs, +) +from .support import _dedupe, _dump + + +def _participant_action_addresses( + scenario: InstantiatedScenario, + *, + participant_name: str, + action_names: list[str], + diagnostics: list[Diagnostic], +) -> list[str]: + action_addresses: list[str] = [] + if not scenario.action_contracts: + return action_addresses + for action_name in dict.fromkeys(action_names): + if action_name in scenario.action_contracts: + action_addresses.append(_action_contract_address(action_name)) + continue + if action_name: + diagnostics.append( + Diagnostic( + code="participant.action-contract-ref-unbound", + domain="participant", + address=_participant_behavior_address(participant_name), + message=f"Reference '{action_name}' does not resolve to a declared participant action contract.", + ) + ) + return action_addresses + + +def _participant_observation_addresses( + scenario: InstantiatedScenario, + *, + participant_name: str, + boundary_names: list[str], + diagnostics: list[Diagnostic], +) -> list[str]: + observation_addresses: list[str] = [] + for boundary_name in dict.fromkeys(boundary_names): + if boundary_name in scenario.observation_boundaries: + observation_addresses.append(_observation_boundary_address(boundary_name)) + continue + if boundary_name: + diagnostics.append( + Diagnostic( + code="participant.observation-boundary-ref-unbound", + domain="participant", + address=_participant_behavior_address(participant_name), + message=( + f"Reference '{boundary_name}' does not resolve to a declared participant observation boundary." + ), + ) + ) + return observation_addresses + + +def _compile_participant_behaviors( + scenario: InstantiatedScenario, + diagnostics: list[Diagnostic], +) -> dict[str, ParticipantBehaviorRuntime]: + participant_behaviors: dict[str, ParticipantBehaviorRuntime] = {} + addressable_ref_index = _runtime_addressable_ref_index(scenario) + for name, agent in scenario.agents.items(): + action_addresses = _participant_action_addresses( + scenario, + participant_name=name, + action_names=list(agent.actions), + diagnostics=diagnostics, + ) + observation_addresses = _participant_observation_addresses( + scenario, + participant_name=name, + boundary_names=list(agent.observation_boundaries), + diagnostics=diagnostics, + ) + starting_account_refs = tuple(agent.starting_accounts) + starting_account_addresses = _account_addresses_for_refs(scenario, list(agent.starting_accounts)) + initial_knowledge_addresses = _initial_knowledge_addresses( + scenario, + agent.initial_knowledge, + ) + starting_assertion_refs = tuple(agent.starting_assertions) + starting_assertion_addresses = tuple(_assertion_address(ref) for ref in agent.starting_assertions) + authority_anchor_refs = tuple(agent.authority_anchors) + authority_anchor_addresses = _runtime_addresses_for_refs( + list(agent.authority_anchors), + addressable_ref_index=addressable_ref_index, + ) + operating_scope_refs = tuple(agent.operating_scope) + operating_scope_addresses = _runtime_addresses_for_refs( + list(agent.operating_scope), + addressable_ref_index=addressable_ref_index, + ) + dependency_addresses = _dedupe( + [ + *action_addresses, + *observation_addresses, + *starting_account_addresses, + *initial_knowledge_addresses, + *starting_assertion_addresses, + *authority_anchor_addresses, + *operating_scope_addresses, + ] + ) + participant_behaviors[_participant_behavior_address(name)] = ParticipantBehaviorRuntime( + address=_participant_behavior_address(name), + name=name, + participant_name=name, + entity_name=agent.entity, + starting_account_refs=starting_account_refs, + starting_account_addresses=starting_account_addresses, + initial_knowledge_addresses=initial_knowledge_addresses, + starting_assertion_refs=starting_assertion_refs, + starting_assertion_addresses=starting_assertion_addresses, + authority_anchor_refs=authority_anchor_refs, + authority_anchor_addresses=authority_anchor_addresses, + operating_scope_refs=operating_scope_refs, + operating_scope_addresses=operating_scope_addresses, + action_contract_addresses=tuple(action_addresses), + observation_boundary_addresses=tuple(observation_addresses), + refresh_dependencies=dependency_addresses, + spec={"agent": _dump(agent), "interpretation_mode": "role-neutral-projection"}, + ) + return participant_behaviors + + +def _resolve_behavior_spec_refs( + *, + refs: list[str], + declared: Mapping[str, object], + address_for_ref: Callable[[str], str], + owner_address: str, + diagnostic_code: str, + diagnostic_label: str, + diagnostics: list[Diagnostic], +) -> tuple[str, ...]: + addresses: list[str] = [] + for ref in dict.fromkeys(refs): + if ref in declared: + addresses.append(address_for_ref(ref)) + continue + if ref: + diagnostics.append( + Diagnostic( + code=diagnostic_code, + domain="participant", + address=owner_address, + message=f"Reference '{ref}' does not resolve to a declared {diagnostic_label}.", + ) + ) + return tuple(addresses) + + +def _compile_behavior_specifications( + scenario: InstantiatedScenario, + diagnostics: list[Diagnostic], +) -> dict[str, ParticipantBehaviorSpecificationRuntime]: + behavior_specifications: dict[str, ParticipantBehaviorSpecificationRuntime] = {} + addressable_ref_index = _runtime_addressable_ref_index(scenario) + for name, behavior_spec in scenario.behavior_specifications.items(): + address = _behavior_specification_address(name) + spec = _dump(behavior_spec) + participant_addresses = _resolve_behavior_spec_refs( + refs=list(behavior_spec.participant_refs), + declared=scenario.agents, + address_for_ref=_participant_behavior_address, + owner_address=address, + diagnostic_code="participant.behavior-specification-participant-ref-unbound", + diagnostic_label="agent", + diagnostics=diagnostics, + ) + action_addresses = _resolve_behavior_spec_refs( + refs=list(behavior_spec.action_contract_refs), + declared=scenario.action_contracts, + address_for_ref=_action_contract_address, + owner_address=address, + diagnostic_code="participant.behavior-specification-action-contract-ref-unbound", + diagnostic_label="participant action contract", + diagnostics=diagnostics, + ) + observation_addresses = _resolve_behavior_spec_refs( + refs=list(behavior_spec.observation_boundary_refs), + declared=scenario.observation_boundaries, + address_for_ref=_observation_boundary_address, + owner_address=address, + diagnostic_code="participant.behavior-specification-observation-boundary-ref-unbound", + diagnostic_label="participant observation boundary", + diagnostics=diagnostics, + ) + outcome_rule_addresses = _resolve_behavior_spec_refs( + refs=list(behavior_spec.outcome_interpretation_rule_refs), + declared=scenario.outcome_interpretation_rules, + address_for_ref=_outcome_interpretation_rule_address, + owner_address=address, + diagnostic_code="participant.behavior-specification-outcome-rule-ref-unbound", + diagnostic_label="participant outcome interpretation rule", + diagnostics=diagnostics, + ) + authority_scope_addresses = _runtime_addresses_for_refs( + list(behavior_spec.authority_scope_refs), + addressable_ref_index=addressable_ref_index, + ) + dependencies = _dedupe( + [ + *participant_addresses, + *action_addresses, + *observation_addresses, + *outcome_rule_addresses, + *authority_scope_addresses, + ] + ) + behavior_specifications[address] = ParticipantBehaviorSpecificationRuntime( + address=address, + name=name, + spec_name=name, + semantic_version=str(behavior_spec.semantic_version), + lifecycle_state=str(getattr(behavior_spec.lifecycle_state, "value", behavior_spec.lifecycle_state)), + participant_addresses=participant_addresses, + participant_role_refs=tuple(behavior_spec.participant_role_refs), + action_contract_addresses=action_addresses, + observation_boundary_addresses=observation_addresses, + outcome_interpretation_rule_addresses=outcome_rule_addresses, + authority_scope_refs=tuple(behavior_spec.authority_scope_refs), + authority_scope_addresses=authority_scope_addresses, + behavior_mode=str(behavior_spec.behavior_mode or ""), + ai_offensive_behavior_refs=tuple(behavior_spec.ai_offensive_behavior_refs), + offensive_behavior_refs=tuple(behavior_spec.offensive_behavior_refs), + realization_profile_ref=str(behavior_spec.realization_profile_ref or ""), + backend_feature_support_refs=tuple(behavior_spec.backend_feature_support_refs), + evidence_contract_refs=tuple(behavior_spec.evidence_contract_refs), + extension_policy=str(behavior_spec.extension_policy), + extension_keys=tuple(sorted(behavior_spec.extensions)), + refresh_dependencies=dependencies, + spec=spec, + ) + return behavior_specifications diff --git a/implementations/python/packages/aces_processor/compiler/participant_contracts.py b/implementations/python/packages/aces_processor/compiler/participant_contracts.py new file mode 100644 index 000000000..da9c3350f --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/participant_contracts.py @@ -0,0 +1,204 @@ +"""Participant action contracts, observation boundaries, outcome-interpretation rules.""" + +from collections.abc import Callable + +from aces_sdl.participant_outcome_semantics import ( + OutcomeInterpretationSourceLayer, + OutcomeInterpretationTargetLayer, +) +from aces_sdl.scenario import InstantiatedScenario + +from ..models import ( + ParticipantActionContractRuntime, + ParticipantObservationBoundaryRuntime, + ParticipantOutcomeInterpretationRuleRuntime, +) +from .addresses import ( + _action_contract_address, + _evaluation_address, + _objective_address, + _observation_boundary_address, + _outcome_interpretation_rule_address, + _workflow_address, +) +from .support import _dedupe, _dump +from .view_relations import ( + _compile_view_relation_timeline, + _initial_view_relation, + _ordered_view_transitions, + _view_relation_refs, +) + + +def _dedupe_field(items: list[object], key: str) -> tuple[str, ...]: + return _dedupe([str(item.get(key, "")) for item in items if isinstance(item, dict) and item.get(key)]) + + +def _shared_state_refs(interactions: list[object]) -> tuple[str, ...]: + return _dedupe( + [ + str(ref) + for interaction in interactions + if isinstance(interaction, dict) + for ref in interaction.get("shared_state_refs", []) + ] + ) + + +def _backend_failure_mappings(contract_spec: dict[str, object]) -> tuple[dict[str, str], ...]: + return tuple( + { + "backend_error_code": str(mapping.get("backend_error_code", "")), + "failure_class": str(mapping.get("failure_class", "")), + "diagnostic": str(mapping.get("diagnostic", "")), + } + for mapping in contract_spec.get("backend_failure_mappings", []) + if isinstance(mapping, dict) + ) + + +def _backend_timing_disclosures(contract_spec: dict[str, object]) -> tuple[dict[str, object], ...]: + return tuple( + { + "disclosure_id": str(disclosure.get("disclosure_id", "")), + "disclosure_kind": str(disclosure.get("disclosure_kind", "")), + "support_mode": str(disclosure.get("support_mode", "")), + "description": str(disclosure.get("description", "")), + "affected_temporal_ids": [str(temporal_id) for temporal_id in disclosure.get("affected_temporal_ids", [])], + "limitations": [str(limitation) for limitation in disclosure.get("limitations", [])], + } + for disclosure in contract_spec.get("backend_timing_disclosures", []) + if isinstance(disclosure, dict) + ) + + +def _compile_action_contracts(scenario: InstantiatedScenario) -> dict[str, ParticipantActionContractRuntime]: + action_contracts: dict[str, ParticipantActionContractRuntime] = {} + for name, contract in scenario.action_contracts.items(): + contract_spec = _dump(contract) + interactions = contract_spec.get("interactions", []) + temporal_contracts = contract_spec.get("temporal_contracts", []) + address = _action_contract_address(name) + action_contracts[address] = ParticipantActionContractRuntime( + address=address, + name=name, + action_name=name, + semantic_version=str(contract_spec.get("semantic_version", "")), + lifecycle_state=str(contract_spec.get("lifecycle_state", "")), + behavioral_granularity=str(contract_spec.get("behavioral_granularity", "")), + precondition_classes=_dedupe_field(contract_spec.get("preconditions", []), "precondition_class"), + effect_classes=_dedupe_field(contract_spec.get("effects", []), "effect_class"), + failure_classes=_dedupe(str(failure_class) for failure_class in contract_spec.get("failure_classes", [])), + backend_failure_mappings=_backend_failure_mappings(contract_spec), + interaction_classes=_dedupe_field(interactions, "interaction_class"), + shared_state_refs=_shared_state_refs(interactions), + temporal_contract_ids=_dedupe_field(temporal_contracts, "temporal_id"), + temporal_kinds=_dedupe_field(temporal_contracts, "temporal_kind"), + time_domains=_dedupe_field(temporal_contracts, "time_domain"), + clock_authorities=_dedupe_field(temporal_contracts, "clock_authority"), + backend_timing_disclosures=_backend_timing_disclosures(contract_spec), + spec=contract_spec, + ) + return action_contracts + + +def _compile_observation_boundaries(scenario: InstantiatedScenario) -> dict[str, ParticipantObservationBoundaryRuntime]: + observation_boundaries: dict[str, ParticipantObservationBoundaryRuntime] = {} + for name, boundary in scenario.observation_boundaries.items(): + boundary_spec = _dump(boundary) + view_rules = boundary_spec.get("view_rules", []) + view_transitions = boundary_spec.get("view_transitions", []) + initial_view_relation = _initial_view_relation(view_rules=view_rules) + disclosed_refs = _view_relation_refs(initial_view_relation, {"disclosed"}) + evidence_only_refs = _view_relation_refs(initial_view_relation, {"evidence_only"}) + discovered_refs = _view_relation_refs(initial_view_relation, {"discovered"}) + inferred_refs = _view_relation_refs(initial_view_relation, {"inferred"}) + concealed_refs = _view_relation_refs(initial_view_relation, {"concealed"}) + deceptive_refs = _view_relation_refs(initial_view_relation, {"deceptive"}) + view_relation_timeline = _compile_view_relation_timeline( + view_rules=view_rules, + view_transitions=view_transitions, + ) + ordered_view_transitions = _ordered_view_transitions(view_transitions) + observation_boundaries[_observation_boundary_address(name)] = ParticipantObservationBoundaryRuntime( + address=_observation_boundary_address(name), + name=name, + boundary_name=name, + projection_basis=str(boundary_spec.get("projection_basis", "")), + hidden_refs=tuple(str(ref) for ref in boundary_spec.get("hidden_refs", [])), + observable_refs=tuple(str(ref) for ref in boundary_spec.get("observable_refs", [])), + evidence_refs=tuple(str(ref) for ref in boundary_spec.get("evidence_refs", [])), + disclosed_refs=disclosed_refs, + evidence_only_refs=evidence_only_refs, + discovered_refs=discovered_refs, + inferred_refs=inferred_refs, + concealed_refs=concealed_refs, + deceptive_refs=deceptive_refs, + view_transitions=ordered_view_transitions, + view_relation_timeline=view_relation_timeline, + realized_view_disclosure=str(boundary_spec.get("realized_view_disclosure") or ""), + spec=boundary_spec, + ) + return observation_boundaries + + +_OUTCOME_SOURCE_LAYER_ADDRESS: dict[str, Callable[[str], str]] = { + OutcomeInterpretationSourceLayer.PARTICIPANT_ACTION_OUTCOME.value: _action_contract_address, + OutcomeInterpretationSourceLayer.OBJECTIVE_RESULT.value: _objective_address, + OutcomeInterpretationSourceLayer.WORKFLOW_RESULT.value: _workflow_address, + OutcomeInterpretationSourceLayer.EVALUATION_RESULT.value: _evaluation_address, +} + +_OUTCOME_TARGET_LAYER_ADDRESS: dict[str, Callable[[str], str]] = { + OutcomeInterpretationTargetLayer.OBJECTIVE_RESULT.value: _objective_address, + OutcomeInterpretationTargetLayer.WORKFLOW_RESULT.value: _workflow_address, + OutcomeInterpretationTargetLayer.EVALUATION_RESULT.value: _evaluation_address, +} + + +def _outcome_source_ref_address(source_layer: str, ref: str) -> str: + builder = _OUTCOME_SOURCE_LAYER_ADDRESS.get(source_layer) + return builder(ref) if builder is not None else ref + + +def _outcome_target_ref_address(target_layer: str, ref: str) -> str: + builder = _OUTCOME_TARGET_LAYER_ADDRESS.get(target_layer) + return builder(ref) if builder is not None else ref + + +def _compile_outcome_interpretation_rules( + scenario: InstantiatedScenario, +) -> dict[str, ParticipantOutcomeInterpretationRuleRuntime]: + rules: dict[str, ParticipantOutcomeInterpretationRuleRuntime] = {} + for name, rule in scenario.outcome_interpretation_rules.items(): + rule_spec = _dump(rule) + sources = tuple(source for source in rule_spec.get("source_bindings", ()) if isinstance(source, dict)) + targets = tuple(target for target in rule_spec.get("target_bindings", ()) if isinstance(target, dict)) + source_layers = tuple(str(source.get("source_layer", "")) for source in sources) + target_layers = tuple(str(target.get("target_layer", "")) for target in targets) + source_refs = tuple( + _outcome_source_ref_address(str(source.get("source_layer", "")), str(source.get("ref", ""))) + for source in sources + ) + target_refs = tuple( + _outcome_target_ref_address(str(target.get("target_layer", "")), str(target.get("ref", ""))) + for target in targets + ) + address = _outcome_interpretation_rule_address(name) + rules[address] = ParticipantOutcomeInterpretationRuleRuntime( + address=address, + name=name, + rule_name=name, + semantic_version=str(rule_spec.get("semantic_version", "")), + participant_scope=str(rule_spec.get("participant_scope", "")), + observation_point_basis=str(rule_spec.get("observation_point_basis", "")), + interpretation_basis=str(rule_spec.get("interpretation_basis", "")), + source_layers=source_layers, + source_refs=source_refs, + target_layers=target_layers, + target_refs=target_refs, + evidence_refs=tuple(str(ref) for ref in rule_spec.get("evidence_refs", ())), + limitations=tuple(str(item) for item in rule_spec.get("limitations", ())), + spec=rule_spec, + ) + return rules diff --git a/implementations/python/packages/aces_processor/compiler/pipeline.py b/implementations/python/packages/aces_processor/compiler/pipeline.py new file mode 100644 index 000000000..5da4a9874 --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/pipeline.py @@ -0,0 +1,144 @@ +"""Top-level compile pipeline: assemble the RuntimeModel from domain compilers.""" + +from collections.abc import Mapping + +from aces_sdl import build_declaration_index +from aces_sdl.instantiate import admit_instantiated_scenario, instantiate_scenario +from aces_sdl.scenario import ExpandedScenario, InstantiatedScenario, Scenario +from aces_sdl.semantics.domain_topology import ( + analyze_domain_topology, +) +from aces_sdl.value_parsing import is_variable_ref + +from ..models import ( + Diagnostic, + RuntimeModel, +) +from .evaluation import _compile_assertions, _compile_condition_bindings, _compile_propositions +from .objectives import _compile_objectives +from .orchestration import ( + _compile_events, + _compile_inject_bindings, + _compile_inject_runtimes, + _compile_scripts, + _compile_stories, +) +from .participant_behaviors import _compile_behavior_specifications, _compile_participant_behaviors +from .participant_contracts import ( + _compile_action_contracts, + _compile_observation_boundaries, + _compile_outcome_interpretation_rules, +) +from .placement import _compile_account_placements, _compile_content_placements +from .provisioning import ( + _compile_capability_constraints, + _compile_feature_bindings, + _compile_node_runtimes, + _compile_templates, + _metadata_specs, +) +from .realization_requirements import _compile_realization_requirements +from .workflows import _compile_workflows + + +def compile_scenario_runtime_model( + scenario: Scenario | ExpandedScenario | InstantiatedScenario, + *, + parameters: Mapping[str, object] | None = None, + profile: str | None = None, +) -> RuntimeModel: + """Instantiate an SDL scenario and compile it into runtime artifacts.""" + + concrete_scenario = ( + scenario + if isinstance(scenario, InstantiatedScenario) + else instantiate_scenario(scenario, parameters=parameters, profile=profile) + ) + return compile_runtime_model(concrete_scenario) + + +def compile_runtime_model(scenario: Scenario | ExpandedScenario | InstantiatedScenario) -> RuntimeModel: + """Compile an SDL scenario into bound runtime objects.""" + + scenario = ( + admit_instantiated_scenario(scenario) + if isinstance(scenario, InstantiatedScenario) + else instantiate_scenario(scenario) + ) + build_declaration_index(scenario) + diagnostics: list[Diagnostic] = [] + domain_analysis = analyze_domain_topology( + identity_domains=scenario.identity_domains, + nodes=scenario.nodes, + accounts=scenario.accounts, + relationships=scenario.relationships, + is_unresolved=is_variable_ref, + ) + + ( + feature_templates, + condition_templates, + inject_templates, + vulnerability_templates, + ) = _compile_templates(scenario) + entity_specs, agent_specs, relationship_specs = _metadata_specs(scenario) + + networks, node_deployments = _compile_node_runtimes(scenario, diagnostics, domain_analysis) + feature_bindings = _compile_feature_bindings(scenario, feature_templates, diagnostics) + propositions = _compile_propositions(scenario) + assertions = _compile_assertions(scenario) + condition_bindings = _compile_condition_bindings( + scenario, + condition_templates, + propositions, + diagnostics, + ) + injects = _compile_inject_runtimes(inject_templates) + inject_bindings = _compile_inject_bindings(scenario, inject_templates, diagnostics) + content_placements = _compile_content_placements(scenario, diagnostics) + account_placements = _compile_account_placements(scenario, diagnostics, domain_analysis) + action_contracts = _compile_action_contracts(scenario) + observation_boundaries = _compile_observation_boundaries(scenario) + outcome_interpretation_rules = _compile_outcome_interpretation_rules(scenario) + participant_behaviors = _compile_participant_behaviors(scenario, diagnostics) + behavior_specifications = _compile_behavior_specifications(scenario, diagnostics) + events = _compile_events(scenario, assertions, injects, inject_bindings, diagnostics) + scripts = _compile_scripts(scenario, diagnostics) + stories = _compile_stories(scenario, diagnostics) + objectives = _compile_objectives(scenario, assertions, diagnostics) + workflows = _compile_workflows(scenario, assertions, diagnostics) + + return RuntimeModel( + scenario_name=scenario.name, + feature_templates=feature_templates, + condition_templates=condition_templates, + inject_templates=inject_templates, + vulnerability_templates=vulnerability_templates, + entity_specs=entity_specs, + agent_specs=agent_specs, + relationship_specs=relationship_specs, + capability_constraints=_compile_capability_constraints(scenario), + networks=networks, + node_deployments=node_deployments, + feature_bindings=feature_bindings, + propositions=propositions, + assertions=assertions, + condition_bindings=condition_bindings, + injects=injects, + inject_bindings=inject_bindings, + content_placements=content_placements, + account_placements=account_placements, + action_contracts=action_contracts, + observation_boundaries=observation_boundaries, + outcome_interpretation_rules=outcome_interpretation_rules, + participant_behaviors=participant_behaviors, + behavior_specifications=behavior_specifications, + events=events, + scripts=scripts, + stories=stories, + workflows=workflows, + objectives=objectives, + diagnostics=diagnostics, + realization_requirements=_compile_realization_requirements(scenario, domain_analysis), + realization_instance=scenario, + ) diff --git a/implementations/python/packages/aces_processor/compiler/placement.py b/implementations/python/packages/aces_processor/compiler/placement.py new file mode 100644 index 000000000..121cc87ea --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/placement.py @@ -0,0 +1,90 @@ +"""Content and account placement compilation.""" + +from aces_sdl.nodes import NodeType +from aces_sdl.scenario import InstantiatedScenario +from aces_sdl.semantics.domain_topology import ( + DomainTopologyAnalysis, +) + +from ..models import ( + AccountPlacement, + ContentPlacement, + Diagnostic, +) +from .addresses import _account_address, _compiled_domain_binding, _content_address +from .ref_resolution import _resolve_node_ref +from .support import _dump + + +def _compile_content_placements( + scenario: InstantiatedScenario, + diagnostics: list[Diagnostic], +) -> dict[str, ContentPlacement]: + content_placements: dict[str, ContentPlacement] = {} + for name, content in scenario.content.items(): + address = _content_address(name) + target_address, target_diagnostics = _resolve_node_ref( + scenario, + ref_name=content.target, + owner_address=address, + domain="provisioning", + code_prefix="provisioning.content-target-ref", + node_label="content target", + required_type=NodeType.VM, + ) + diagnostics.extend(target_diagnostics) + if target_address is None: + continue + content_placements[address] = ContentPlacement( + address=address, + name=name, + content_name=name, + target_node=content.target, + target_address=target_address, + ordering_dependencies=(target_address,), + refresh_dependencies=(target_address,), + spec=_dump(content), + ) + return content_placements + + +def _compile_account_placements( + scenario: InstantiatedScenario, + diagnostics: list[Diagnostic], + domain_analysis: DomainTopologyAnalysis, +) -> dict[str, AccountPlacement]: + account_placements: dict[str, AccountPlacement] = {} + for name, account in scenario.accounts.items(): + address = _account_address(name) + target_address, target_diagnostics = _resolve_node_ref( + scenario, + ref_name=account.node, + owner_address=address, + domain="provisioning", + code_prefix="provisioning.account-node-ref", + node_label="account node", + required_type=NodeType.VM, + ) + diagnostics.extend(target_diagnostics) + if target_address is None: + continue + account_domain_binding = domain_analysis.account_bindings.get(name) + node_domain_binding = ( + domain_analysis.node_bindings.get(account_domain_binding.node_name) + if account_domain_binding is not None + else None + ) + account_placements[address] = AccountPlacement( + address=address, + name=name, + account_name=name, + node_name=account.node, + target_address=target_address, + domain_topology=( + _compiled_domain_binding(scenario, node_domain_binding) if node_domain_binding is not None else None + ), + ordering_dependencies=(target_address,), + refresh_dependencies=(target_address,), + spec=_dump(account), + ) + return account_placements diff --git a/implementations/python/packages/aces_processor/compiler/provisioning.py b/implementations/python/packages/aces_processor/compiler/provisioning.py new file mode 100644 index 000000000..794a3f556 --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/provisioning.py @@ -0,0 +1,261 @@ +"""Provisioning-domain compilation: templates, nodes, networks, feature bindings.""" + +from dataclasses import dataclass +from typing import Any + +from aces_backend_protocols.domain_topology import DomainTopologyBinding +from aces_sdl.entities import flatten_entities +from aces_sdl.features import Feature +from aces_sdl.nodes import Node, NodeType +from aces_sdl.scenario import InstantiatedScenario +from aces_sdl.semantics.domain_topology import ( + DomainNodeRole, + DomainTopologyAnalysis, +) + +from ..models import ( + CompiledCapabilityConstraint, + Diagnostic, + FeatureBinding, + NetworkRuntime, + NodeRuntime, + RuntimeTemplate, +) +from .addresses import ( + _compiled_domain_binding, + _feature_binding_address, + _network_address, + _node_address, + _template_address, +) +from .ref_resolution import _node_dependency_addresses +from .support import _dedupe, _dump + + +def _compile_templates( + scenario: InstantiatedScenario, +) -> tuple[ + dict[str, RuntimeTemplate], + dict[str, RuntimeTemplate], + dict[str, RuntimeTemplate], + dict[str, RuntimeTemplate], +]: + feature_templates = { + name: RuntimeTemplate(address=_template_address("feature", name), name=name, spec=_dump(template)) + for name, template in scenario.features.items() + } + condition_templates = { + name: RuntimeTemplate(address=_template_address("condition", name), name=name, spec=_dump(template)) + for name, template in scenario.conditions.items() + } + inject_templates = { + name: RuntimeTemplate(address=_template_address("inject", name), name=name, spec=_dump(template)) + for name, template in scenario.injects.items() + } + vulnerability_templates = { + name: RuntimeTemplate(address=_template_address("vulnerability", name), name=name, spec=_dump(template)) + for name, template in scenario.vulnerabilities.items() + } + return feature_templates, condition_templates, inject_templates, vulnerability_templates + + +def _metadata_specs( + scenario: InstantiatedScenario, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + entity_specs = {name: _dump(entity) for name, entity in flatten_entities(scenario.entities).items()} + agent_specs = {name: _dump(agent) for name, agent in scenario.agents.items()} + relationship_specs = {name: _dump(relationship) for name, relationship in scenario.relationships.items()} + return entity_specs, agent_specs, relationship_specs + + +def _compile_capability_constraints( + scenario: InstantiatedScenario, +) -> tuple[CompiledCapabilityConstraint, ...]: + compiled: list[CompiledCapabilityConstraint] = [] + for constraint in scenario.instantiation_provenance.capability_constraints: + parts = constraint.field_pointer.split("/") + section_name, encoded_name, field_name = parts[1:] + node_name = encoded_name.replace("~1", "/").replace("~0", "~") + node = scenario.nodes[node_name] + address = _network_address(node_name) if node.type == NodeType.SWITCH else _node_address(node_name) + compiled.append( + CompiledCapabilityConstraint( + address=address, + concern=f"{section_name}.{field_name}", + parameter=constraint.parameter, + allowed_values=constraint.allowed_values, + ) + ) + return tuple(compiled) + + +@dataclass(frozen=True) +class _NodeRuntimeTargets: + networks: dict[str, NetworkRuntime] + node_deployments: dict[str, NodeRuntime] + + +def _compile_node_runtimes( + scenario: InstantiatedScenario, + diagnostics: list[Diagnostic], + domain_analysis: DomainTopologyAnalysis, +) -> tuple[dict[str, NetworkRuntime], dict[str, NodeRuntime]]: + networks: dict[str, NetworkRuntime] = {} + node_deployments: dict[str, NodeRuntime] = {} + targets = _NodeRuntimeTargets(networks=networks, node_deployments=node_deployments) + for node_name, node in scenario.nodes.items(): + node_spec = _dump(node) + infra = scenario.infrastructure.get(node_name) + infra_spec = _dump(infra) if infra is not None else {} + dependency_addresses: list[str] = [] + if infra is not None: + dependency_addresses.extend( + _node_dependency_addresses( + scenario, + node_name=node_name, + ref_names=list(infra.dependencies), + code_prefix="provisioning.infrastructure-dependency-ref", + node_label="infrastructure dependency", + diagnostics=diagnostics, + ) + ) + dependency_addresses.extend( + _node_dependency_addresses( + scenario, + node_name=node_name, + ref_names=list(infra.links), + code_prefix="provisioning.infrastructure-link-ref", + node_label="infrastructure link", + diagnostics=diagnostics, + require_switch=True, + ) + ) + domain_binding = domain_analysis.node_bindings.get(node_name) + compiled_domain_binding = ( + _compiled_domain_binding(scenario, domain_binding) if domain_binding is not None else None + ) + if domain_binding is not None and domain_binding.role is DomainNodeRole.MEMBER: + dependency_addresses.extend(_node_address(name) for name in domain_binding.controller_names) + _record_node_runtime( + node_name=node_name, + node_type=node.type, + node_spec=node_spec, + infra_spec=infra_spec, + dependency_addresses=dependency_addresses, + domain_topology=compiled_domain_binding, + targets=targets, + ) + return networks, node_deployments + + +def _record_node_runtime( + *, + node_name: str, + node_type: NodeType, + node_spec: dict[str, Any], + infra_spec: dict[str, Any], + dependency_addresses: list[str], + domain_topology: DomainTopologyBinding | None, + targets: _NodeRuntimeTargets, +) -> None: + spec = {"node": node_spec, "infrastructure": infra_spec} + if node_type == NodeType.SWITCH: + targets.networks[_network_address(node_name)] = NetworkRuntime( + address=_network_address(node_name), + name=node_name, + node_name=node_name, + spec=spec, + ordering_dependencies=_dedupe(dependency_addresses), + refresh_dependencies=_dedupe(dependency_addresses), + ) + return + targets.node_deployments[_node_address(node_name)] = NodeRuntime( + address=_node_address(node_name), + name=node_name, + node_name=node_name, + node_type=node_spec.get("type", ""), + os_family=node_spec.get("os", "") or "", + count=infra_spec.get("count"), + domain_topology=domain_topology, + spec=spec, + ordering_dependencies=_dedupe(dependency_addresses), + refresh_dependencies=_dedupe(dependency_addresses), + ) + + +def _feature_dependency_addresses( + node: Node, + feature: Feature, + *, + feature_name: str, + node_name: str, + address: str, + diagnostics: list[Diagnostic], +) -> list[str]: + dep_addresses = [_node_address(node_name)] + for dep_name in feature.dependencies: + if dep_name in node.features: + dep_addresses.append(_feature_binding_address(node_name, dep_name)) + continue + diagnostics.append( + Diagnostic( + code="provisioning.feature-dependency-binding-missing", + domain="provisioning", + address=address, + message=( + f"Feature binding '{feature_name}' on node '{node_name}' " + f"requires feature dependency '{dep_name}' to also be bound on the same node." + ), + ) + ) + return dep_addresses + + +def _compile_feature_bindings( + scenario: InstantiatedScenario, + feature_templates: dict[str, RuntimeTemplate], + diagnostics: list[Diagnostic], +) -> dict[str, FeatureBinding]: + feature_bindings: dict[str, FeatureBinding] = {} + for node_name, node in scenario.nodes.items(): + if node.type != NodeType.VM: + continue + node_addr = _node_address(node_name) + for feature_name, role_name in node.features.items(): + template = feature_templates.get(feature_name) + feature = scenario.features.get(feature_name) + if template is None or feature is None: + diagnostics.append( + Diagnostic( + code="provisioning.feature-template-ref-unbound", + domain="provisioning", + address=node_addr, + message=( + f"Feature binding '{feature_name}' on node '{node_name}' " + "does not resolve to a declared feature template." + ), + ) + ) + continue + address = _feature_binding_address(node_name, feature_name) + dep_addresses = _feature_dependency_addresses( + node, + feature, + feature_name=feature_name, + node_name=node_name, + address=address, + diagnostics=diagnostics, + ) + feature_bindings[address] = FeatureBinding( + address=address, + name=feature_name, + node_name=node_name, + node_address=node_addr, + feature_name=feature_name, + template_address=template.address, + role_name=role_name, + ordering_dependencies=_dedupe(dep_addresses), + refresh_dependencies=_dedupe(dep_addresses), + spec={"binding": {"node": node_name, "role": role_name}, "template": template.spec}, + ) + return feature_bindings diff --git a/implementations/python/packages/aces_processor/compiler/realization_requirements.py b/implementations/python/packages/aces_processor/compiler/realization_requirements.py new file mode 100644 index 000000000..050f4b2a3 --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/realization_requirements.py @@ -0,0 +1,120 @@ +"""Realization-requirement compilation (SEM-218).""" + +from aces_sdl.explicitness import ExplicitnessClass, ExplicitnessProvenance +from aces_sdl.identifiers import QualifiedName +from aces_sdl.nodes import NodeType +from aces_sdl.realization_designation import resolve_realization_designation +from aces_sdl.scenario import InstantiatedScenario +from aces_sdl.semantics.domain_topology import ( + DomainTopologyAnalysis, +) + +from ..semantics.realization import ( + REALIZATION_DOMAIN, + CompiledRealizationRequirement, + registered_realization_concerns, +) +from .addresses import _account_address, _content_address, _network_address, _node_address + + +def _realization_requirement_address( + scenario: InstantiatedScenario, + *, + section_name: str, + declaration_name: str, +) -> str: + """Resolve the compiled resource address for a realization-concern path.""" + + if section_name == "nodes" and declaration_name in scenario.nodes: + node = scenario.nodes[declaration_name] + return _network_address(declaration_name) if node.type == NodeType.SWITCH else _node_address(declaration_name) + if section_name == "content" and declaration_name in scenario.content: + return _content_address(declaration_name) + raise ValueError("realization concern must resolve to one compiled resource address") + + +def _compile_realization_requirements( + scenario: InstantiatedScenario, + domain_analysis: DomainTopologyAnalysis, +) -> tuple[CompiledRealizationRequirement, ...]: + """SEM-218 typed compiler emission: lower each authored realization concern + into a compiled requirement carrying its classifier explicitness class. + + Explicit leaves always win. Missing admitted concerns are lowered through + the typed lexical designation cascade; omitted designation preserves the + legacy closed fallback while explicit root delegation remains typed. + """ + + requirements: list[CompiledRealizationRequirement] = [] + explicitness = scenario.explicitness + for section_name, declaration_name, field_name, concern_kind in registered_realization_concerns( + declaration_names={"nodes": scenario.nodes, "content": scenario.content} + ): + field_path = f"{section_name}.{declaration_name}.{field_name}" + encoded_name = declaration_name.replace("~", "~0").replace("/", "~1") + field_pointer = f"/{section_name}/{encoded_name}/{field_name}" + owner_namespace = QualifiedName.parse(declaration_name).parts[:-1] + record = explicitness.get(field_path) + if record is None: + resolution = resolve_realization_designation( + scenario.instantiation_provenance.realization_designations, + field_pointer=field_pointer, + owner_namespace=owner_namespace, + ) + if resolution.source == "legacy-default" or ( + resolution.closure is not None + and resolution.closure.value == "closed-world" + and not resolution.delegated + ): + continue + requirement_explicitness = ( + ExplicitnessClass.OPEN + if resolution.closure is not None and resolution.closure.value == "open-world" + else None + ) + provenance = ExplicitnessProvenance.AUTHOR_DECLARED + governing_scope = resolution.governing_scope + delegated = resolution.delegated + else: + requirement_explicitness = record.classification + provenance = record.provenance + governing_scope = f"#{field_pointer}" + delegated = False + requirements.append( + CompiledRealizationRequirement( + field_path=field_path, + address=_realization_requirement_address( + scenario, + section_name=section_name, + declaration_name=declaration_name, + ), + domain=REALIZATION_DOMAIN, + requirement_kind=concern_kind, + explicitness=requirement_explicitness, + provenance=provenance, + governing_scope=governing_scope, + delegated=delegated, + ) + ) + domain_carriers = [ + *( + (_node_address(node_name), binding.domain_name) + for node_name, binding in domain_analysis.node_bindings.items() + ), + *( + (_account_address(account_name), binding.domain_name) + for account_name, binding in domain_analysis.account_bindings.items() + ), + ] + for address, domain_name in domain_carriers: + requirements.append( + CompiledRealizationRequirement( + field_path=f"identity_domains.{domain_name}.topology", + address=address, + domain=REALIZATION_DOMAIN, + requirement_kind="domain-topology", + explicitness=ExplicitnessClass.EXACT, + provenance=ExplicitnessProvenance.PROCESSOR_DERIVED, + ) + ) + return tuple(requirements) diff --git a/implementations/python/packages/aces_processor/compiler/ref_resolution.py b/implementations/python/packages/aces_processor/compiler/ref_resolution.py new file mode 100644 index 000000000..9f9aa85cd --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/ref_resolution.py @@ -0,0 +1,152 @@ +"""Diagnostic-producing reference resolvers and evaluation-contract helpers.""" + +from collections.abc import Callable +from typing import Any + +from aces_sdl.nodes import NodeType +from aces_sdl.scenario import InstantiatedScenario, Scenario + +from ..models import ( + Diagnostic, + EvaluationExecutionContract, + EvaluationResultContract, +) +from .addresses import _resource_address_for_node +from .support import _dedupe + + +def _evaluation_contracts( + resource_type: str, +) -> tuple[EvaluationResultContract, EvaluationExecutionContract]: + if resource_type in { + "condition-binding", + "objective", + }: + return ( + EvaluationResultContract( + resource_type=resource_type, + supports_passed=True, + ), + EvaluationExecutionContract(resource_type=resource_type), + ) + return ( + EvaluationResultContract(resource_type=resource_type), + EvaluationExecutionContract(resource_type=resource_type), + ) + + +def _resolve_resource_refs( + resources: dict[str, Any], + *, + ref_names: list[str], + owner_address: str, + domain: str, + code_prefix: str, + resource_label: str, +) -> tuple[tuple[str, ...], list[Diagnostic]]: + resolved: list[str] = [] + diagnostics: list[Diagnostic] = [] + for ref_name in dict.fromkeys(ref_names): + matched_addresses = sorted(address for address, resource in resources.items() if resource.name == ref_name) + if not matched_addresses: + diagnostics.append( + Diagnostic( + code=f"{code_prefix}-unbound", + domain=domain, + address=owner_address, + message=(f"Reference '{ref_name}' does not resolve to a defined {resource_label}."), + ) + ) + continue + if len(matched_addresses) > 1: + diagnostics.append( + Diagnostic( + code=f"{code_prefix}-ambiguous", + domain=domain, + address=owner_address, + message=( + f"Reference '{ref_name}' resolves to multiple {resource_label}s: " + f"{', '.join(matched_addresses)}." + ), + ) + ) + continue + resolved.append(matched_addresses[0]) + return _dedupe(resolved), diagnostics + + +def _resolve_named_refs( + *, + ref_names: list[str], + available_names: set[str], + address_builder: Callable[[str], str], + owner_address: str, + domain: str, + code_prefix: str, + resource_label: str, +) -> tuple[tuple[str, ...], list[Diagnostic]]: + resolved: list[str] = [] + diagnostics: list[Diagnostic] = [] + for ref_name in dict.fromkeys(ref_names): + if ref_name not in available_names: + diagnostics.append( + Diagnostic( + code=f"{code_prefix}-unbound", + domain=domain, + address=owner_address, + message=(f"Reference '{ref_name}' does not resolve to a defined {resource_label}."), + ) + ) + continue + resolved.append(address_builder(ref_name)) + return _dedupe(resolved), diagnostics + + +def _resolve_node_ref( + scenario: Scenario, + *, + ref_name: str, + owner_address: str, + domain: str, + code_prefix: str, + node_label: str, + required_type: NodeType | None = None, +) -> tuple[str | None, list[Diagnostic]]: + node = scenario.nodes.get(ref_name) + if node is None: + code = f"{code_prefix}-unbound" + message = f"Reference '{ref_name}' does not resolve to a defined {node_label}." + elif required_type is not None and node.type != required_type: + expected = "a VM node" if required_type == NodeType.VM else "a switch/network node" + code = f"{code_prefix}-invalid-type" + message = f"Reference '{ref_name}' must resolve to {expected} for {node_label}." + else: + return _resource_address_for_node(scenario, ref_name), [] + return None, [Diagnostic(code=code, domain=domain, address=owner_address, message=message)] + + +def _node_dependency_addresses( + scenario: InstantiatedScenario, + *, + node_name: str, + ref_names: list[str], + code_prefix: str, + node_label: str, + diagnostics: list[Diagnostic], + require_switch: bool = False, +) -> list[str]: + addresses: list[str] = [] + for ref_name in ref_names: + dep_address, dep_diagnostics = _resolve_node_ref( + scenario, + ref_name=ref_name, + owner_address=_resource_address_for_node(scenario, node_name), + domain="provisioning", + code_prefix=code_prefix, + node_label=node_label, + required_type=NodeType.SWITCH if require_switch else None, + ) + diagnostics.extend(dep_diagnostics) + if dep_address is not None: + addresses.append(dep_address) + return addresses diff --git a/implementations/python/packages/aces_processor/compiler/support.py b/implementations/python/packages/aces_processor/compiler/support.py new file mode 100644 index 000000000..a63b06fe3 --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/support.py @@ -0,0 +1,29 @@ +"""Generic compiler primitives: serialization, address rendering, dedup helpers.""" + +from typing import Any + +from aces_contracts.addressing import render_compiled_address + + +def _dump(model: object) -> dict[str, Any]: + if hasattr(model, "model_dump"): + return model.model_dump(mode="json", by_alias=True) + if isinstance(model, dict): + return dict(model) + return {} + + +def _address(*parts: str) -> str: + return render_compiled_address(*parts) + + +def _dedupe(items: list[str]) -> tuple[str, ...]: + return tuple(dict.fromkeys(items)) + + +def _dedupe_by_value(items: list[Any]) -> tuple[Any, ...]: + ordered: dict[str, Any] = {} + for item in items: + key = getattr(item, "value", repr(item)) + ordered.setdefault(key, item) + return tuple(item for _, item in sorted(ordered.items())) diff --git a/implementations/python/packages/aces_processor/compiler/view_relations.py b/implementations/python/packages/aces_processor/compiler/view_relations.py new file mode 100644 index 000000000..2f04f5a3e --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/view_relations.py @@ -0,0 +1,102 @@ +"""Observation-boundary view-relation timeline compilation helpers.""" + +from typing import Any + +_VISIBLE_VIEW_DISPOSITIONS = frozenset({"observable", "discovered", "inferred", "disclosed", "deceptive"}) + + +def _initial_view_relation(*, view_rules: list[Any]) -> dict[str, str]: + view_relation: dict[str, str] = {} + for rule in view_rules: + if not isinstance(rule, dict): + continue + information_ref = rule.get("information_ref") + disposition = rule.get("disposition") + if not information_ref or not disposition: + continue + ref = str(information_ref) + view_relation[ref] = str(disposition) + return view_relation + + +def _view_relation_refs(view_relation: dict[str, str], dispositions: set[str] | frozenset[str]) -> tuple[str, ...]: + return tuple(ref for ref, disposition in sorted(view_relation.items()) if disposition in dispositions) + + +def _view_relation_snapshot( + *, + transition_id: str, + effective_from: str, + effective_order: int, + view_relation: dict[str, str], + transition: dict[str, Any] | None = None, +) -> dict[str, Any]: + snapshot = { + "transition_id": transition_id, + "effective_from": effective_from, + "effective_order": effective_order, + "view_relation": dict(sorted(view_relation.items())), + "visible_refs": _view_relation_refs(view_relation, _VISIBLE_VIEW_DISPOSITIONS), + "hidden_refs": _view_relation_refs(view_relation, {"hidden"}), + "evidence_only_refs": _view_relation_refs(view_relation, {"evidence_only"}), + "disclosed_refs": _view_relation_refs(view_relation, {"disclosed"}), + "discovered_refs": _view_relation_refs(view_relation, {"discovered"}), + "inferred_refs": _view_relation_refs(view_relation, {"inferred"}), + "concealed_refs": _view_relation_refs(view_relation, {"concealed"}), + "deceptive_refs": _view_relation_refs(view_relation, {"deceptive"}), + } + if transition is not None: + snapshot.update( + { + "transition_kind": str(transition.get("transition_kind") or ""), + "information_ref": str(transition.get("information_ref") or ""), + "history_event_type": str(transition.get("history_event_type") or ""), + "action_instance_id": ( + str(transition.get("action_instance_id")) + if transition.get("action_instance_id") is not None + else "" + ), + } + ) + return snapshot + + +def _ordered_view_transitions(view_transitions: list[Any]) -> tuple[dict[str, Any], ...]: + return tuple( + sorted( + (dict(transition) for transition in view_transitions if isinstance(transition, dict)), + key=lambda transition: int(transition.get("effective_order", 0)), + ) + ) + + +def _compile_view_relation_timeline( + *, + view_rules: list[Any], + view_transitions: list[Any], +) -> tuple[dict[str, Any], ...]: + view_relation = _initial_view_relation(view_rules=view_rules) + timeline: list[dict[str, Any]] = [ + _view_relation_snapshot( + transition_id="initial", + effective_from="initial", + effective_order=-1, + view_relation=view_relation, + ) + ] + for transition in _ordered_view_transitions(view_transitions): + information_ref = transition.get("information_ref") + to_disposition = transition.get("to_disposition") + if not information_ref or not to_disposition: + continue + view_relation[str(information_ref)] = str(to_disposition) + timeline.append( + _view_relation_snapshot( + transition_id=str(transition.get("transition_id") or ""), + effective_from=str(transition.get("effective_from") or ""), + effective_order=int(transition.get("effective_order", 0)), + view_relation=view_relation, + transition=transition, + ) + ) + return tuple(timeline) diff --git a/implementations/python/packages/aces_processor/compiler/workflow_steps.py b/implementations/python/packages/aces_processor/compiler/workflow_steps.py new file mode 100644 index 000000000..1d2023d7d --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/workflow_steps.py @@ -0,0 +1,362 @@ +"""Per-step workflow compilation machinery and workflow compilation state.""" + +from dataclasses import dataclass, field + +from aces_backend_protocols.capabilities import ( + WorkflowFeature, + WorkflowStatePredicateFeature, +) +from aces_sdl.orchestration import Workflow, WorkflowPredicate, WorkflowStep, WorkflowStepType +from aces_sdl.scenario import InstantiatedScenario +from aces_sdl.semantics.workflow import ( + workflow_step_semantic_contract, +) + +from ..models import ( + AssertionRuntime, + Diagnostic, + WorkflowPredicateRuntime, + WorkflowStepOutcome, + WorkflowStepRuntime, + WorkflowStepStatePredicateRuntime, + WorkflowSwitchCaseRuntime, +) +from .addresses import _assertion_address, _objective_address, _workflow_address +from .ref_resolution import ( + _resolve_named_refs, +) +from .support import _address, _dedupe + + +@dataclass(frozen=True) +class _WorkflowPredicateCompilation: + predicate: WorkflowPredicateRuntime + assertion_addresses: tuple[str, ...] + predicate_addresses: tuple[str, ...] + objective_addresses: tuple[str, ...] + step_state_predicates: tuple[WorkflowStepStatePredicateRuntime, ...] + + +@dataclass +class _WorkflowCompilationState: + join_owners: dict[str, str] + control_steps: dict[str, WorkflowStepRuntime] = field(default_factory=dict) + control_edges: dict[str, tuple[str, ...]] = field(default_factory=dict) + referenced_objectives: list[str] = field(default_factory=list) + step_assertion_addresses: dict[str, tuple[str, ...]] = field(default_factory=dict) + step_predicate_addresses: dict[str, tuple[str, ...]] = field(default_factory=dict) + required_features: list[WorkflowFeature] = field(default_factory=list) + required_state_predicate_features: list[WorkflowStatePredicateFeature] = field(default_factory=list) + compensation_targets: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class _WorkflowStepContext: + scenario: InstantiatedScenario + workflow: Workflow + workflow_address: str + state: _WorkflowCompilationState + assertions: dict[str, AssertionRuntime] + diagnostics: list[Diagnostic] + + +_WORKFLOW_STEP_TYPE_FEATURES = { + WorkflowStepType.DECISION: WorkflowFeature.DECISION, + WorkflowStepType.SWITCH: WorkflowFeature.SWITCH, + WorkflowStepType.PARALLEL: WorkflowFeature.PARALLEL_BARRIER, + WorkflowStepType.RETRY: WorkflowFeature.RETRY, + WorkflowStepType.CALL: WorkflowFeature.CALL, +} + + +def _compile_workflow_predicate( + predicate_source: WorkflowPredicate, + *, + scenario: InstantiatedScenario, + assertions: dict[str, AssertionRuntime], + predicate_address: str, + diagnostics: list[Diagnostic], +) -> _WorkflowPredicateCompilation: + assertion_addresses, workflow_diagnostics = _resolve_named_refs( + ref_names=list(predicate_source.assertions), + available_names={assertion.name for assertion in assertions.values()}, + address_builder=_assertion_address, + owner_address=predicate_address, + domain="orchestration", + code_prefix="orchestration.assertion-ref", + resource_label="assertion", + ) + objective_addresses, objective_diagnostics = _resolve_named_refs( + ref_names=list(predicate_source.objectives), + available_names=set(scenario.objectives), + address_builder=_objective_address, + owner_address=predicate_address, + domain="orchestration", + code_prefix="orchestration.objective-ref", + resource_label="objective", + ) + diagnostics.extend( + [ + *workflow_diagnostics, + *objective_diagnostics, + ] + ) + step_state_predicates = tuple( + WorkflowStepStatePredicateRuntime( + step_name=ref.step, + outcomes=tuple(WorkflowStepOutcome(outcome.value) for outcome in ref.outcomes), + min_attempts=ref.min_attempts, + ) + for ref in predicate_source.steps + if isinstance(ref.step, str) and ref.step + ) + predicate_addresses = _dedupe( + [ + *assertion_addresses, + *objective_addresses, + ] + ) + return _WorkflowPredicateCompilation( + predicate=WorkflowPredicateRuntime( + assertion_addresses=assertion_addresses, + objective_addresses=tuple(objective_addresses), + step_state_predicates=step_state_predicates, + ), + assertion_addresses=assertion_addresses, + predicate_addresses=predicate_addresses, + objective_addresses=tuple(objective_addresses), + step_state_predicates=step_state_predicates, + ) + + +def _workflow_step_edges_and_features(step: WorkflowStep) -> tuple[tuple[str, ...], tuple[WorkflowFeature, ...]]: + edge_values = { + WorkflowStepType.OBJECTIVE: (step.on_success, step.on_failure), + WorkflowStepType.DECISION: (step.then_step, step.else_step), + WorkflowStepType.SWITCH: (*[case.next_step for case in step.cases], step.default_step), + WorkflowStepType.PARALLEL: (*step.branches, step.on_failure), + WorkflowStepType.JOIN: (step.next,), + WorkflowStepType.RETRY: (step.on_success, step.on_exhausted), + WorkflowStepType.CALL: (step.on_success, step.on_failure), + }.get(step.type, ()) + feature = _WORKFLOW_STEP_TYPE_FEATURES.get(step.type) + return _dedupe([edge for edge in edge_values if edge]), (() if feature is None else (feature,)) + + +def _workflow_cross_cutting_features(step: WorkflowStep, workflow: Workflow) -> tuple[WorkflowFeature, ...]: + features: list[WorkflowFeature] = [] + if step.on_failure or step.on_exhausted: + features.append(WorkflowFeature.FAILURE_TRANSITIONS) + if workflow.timeout is not None: + features.append(WorkflowFeature.TIMEOUTS) + if workflow.compensation is not None and workflow.compensation.mode.value != "disabled": + features.append(WorkflowFeature.COMPENSATION) + return tuple(features) + + +def _workflow_step_primary_addresses( + scenario: InstantiatedScenario, + *, + workflow_address: str, + step: WorkflowStep, + state: _WorkflowCompilationState, + diagnostics: list[Diagnostic], +) -> tuple[str, str]: + objective_address = "" + called_workflow_address = "" + if step.objective: + objective_addresses, objective_diagnostics = _resolve_named_refs( + ref_names=[step.objective], + available_names=set(scenario.objectives), + address_builder=_objective_address, + owner_address=workflow_address, + domain="orchestration", + code_prefix="orchestration.objective-ref", + resource_label="objective", + ) + diagnostics.extend(objective_diagnostics) + state.referenced_objectives.extend(objective_addresses) + objective_address = objective_addresses[0] if objective_addresses else "" + elif step.workflow: + workflow_addresses, workflow_diagnostics = _resolve_named_refs( + ref_names=[step.workflow], + available_names=set(scenario.workflows), + address_builder=_workflow_address, + owner_address=workflow_address, + domain="orchestration", + code_prefix="orchestration.workflow-ref", + resource_label="workflow", + ) + diagnostics.extend(workflow_diagnostics) + called_workflow_address = workflow_addresses[0] if workflow_addresses else "" + return objective_address, called_workflow_address + + +def _workflow_step_compensation_address( + scenario: InstantiatedScenario, + *, + workflow_address: str, + step_name: str, + step: WorkflowStep, + state: _WorkflowCompilationState, + diagnostics: list[Diagnostic], +) -> str: + if not step.compensate_with: + return "" + workflow_addresses, workflow_diagnostics = _resolve_named_refs( + ref_names=[step.compensate_with], + available_names=set(scenario.workflows), + address_builder=_workflow_address, + owner_address=workflow_address, + domain="orchestration", + code_prefix="orchestration.workflow-ref", + resource_label="workflow", + ) + diagnostics.extend(workflow_diagnostics) + compensation_workflow_address = workflow_addresses[0] if workflow_addresses else "" + if compensation_workflow_address: + state.compensation_targets[step_name] = compensation_workflow_address + state.required_features.append(WorkflowFeature.COMPENSATION) + return compensation_workflow_address + + +def _apply_workflow_predicate_compilation( + state: _WorkflowCompilationState, + step_name: str, + compilation: _WorkflowPredicateCompilation, +) -> None: + state.referenced_objectives.extend(compilation.objective_addresses) + state.step_assertion_addresses[step_name] = compilation.assertion_addresses + state.step_predicate_addresses[step_name] = compilation.predicate_addresses + _apply_step_state_predicate_features(state, compilation.step_state_predicates) + + +def _apply_step_state_predicate_features( + state: _WorkflowCompilationState, + step_state_predicates: tuple[WorkflowStepStatePredicateRuntime, ...], +) -> None: + if step_state_predicates: + state.required_state_predicate_features.append(WorkflowStatePredicateFeature.OUTCOME_MATCHING) + if any(state_predicate.min_attempts is not None for state_predicate in step_state_predicates): + state.required_state_predicate_features.append(WorkflowStatePredicateFeature.ATTEMPT_COUNTS) + + +def _compile_switch_cases( + scenario: InstantiatedScenario, + *, + workflow_address: str, + step_name: str, + step: WorkflowStep, + state: _WorkflowCompilationState, + assertions: dict[str, AssertionRuntime], + diagnostics: list[Diagnostic], +) -> tuple[WorkflowSwitchCaseRuntime, ...]: + compiled_cases: list[WorkflowSwitchCaseRuntime] = [] + switch_assertion_addresses: list[str] = [] + switch_predicate_addresses: list[str] = [] + for case_index, case in enumerate(step.cases): + compilation = _compile_workflow_predicate( + case.when, + scenario=scenario, + assertions=assertions, + predicate_address=_address(workflow_address, "step", step_name, "case", str(case_index)), + diagnostics=diagnostics, + ) + state.referenced_objectives.extend(compilation.objective_addresses) + switch_assertion_addresses.extend(compilation.assertion_addresses) + switch_predicate_addresses.extend(compilation.predicate_addresses) + _apply_step_state_predicate_features(state, compilation.step_state_predicates) + compiled_cases.append( + WorkflowSwitchCaseRuntime(case_index=case_index, predicate=compilation.predicate, next_step=case.next_step) + ) + if switch_assertion_addresses: + state.step_assertion_addresses[step_name] = _dedupe(switch_assertion_addresses) + if switch_predicate_addresses: + state.step_predicate_addresses[step_name] = _dedupe(switch_predicate_addresses) + return tuple(compiled_cases) + + +def _compile_workflow_step_predicates( + scenario: InstantiatedScenario, + *, + workflow_address: str, + step_name: str, + step: WorkflowStep, + state: _WorkflowCompilationState, + assertions: dict[str, AssertionRuntime], + diagnostics: list[Diagnostic], +) -> tuple[WorkflowPredicateRuntime | None, tuple[WorkflowSwitchCaseRuntime, ...]]: + if step.when is not None: + compilation = _compile_workflow_predicate( + step.when, + scenario=scenario, + assertions=assertions, + predicate_address=_address(workflow_address, "step", step_name), + diagnostics=diagnostics, + ) + _apply_workflow_predicate_compilation(state, step_name, compilation) + return compilation.predicate, () + if step.type != WorkflowStepType.SWITCH: + return None, () + return None, _compile_switch_cases( + scenario, + workflow_address=workflow_address, + step_name=step_name, + step=step, + state=state, + assertions=assertions, + diagnostics=diagnostics, + ) + + +def _compile_workflow_step(context: _WorkflowStepContext, *, step_name: str, step: WorkflowStep) -> None: + state = context.state + edges, type_features = _workflow_step_edges_and_features(step) + state.control_edges[step_name] = edges + state.required_features.extend(type_features) + objective_address, called_workflow_address = _workflow_step_primary_addresses( + context.scenario, + workflow_address=context.workflow_address, + step=step, + state=state, + diagnostics=context.diagnostics, + ) + compensation_workflow_address = _workflow_step_compensation_address( + context.scenario, + workflow_address=context.workflow_address, + step_name=step_name, + step=step, + state=state, + diagnostics=context.diagnostics, + ) + predicate, switch_cases = _compile_workflow_step_predicates( + context.scenario, + workflow_address=context.workflow_address, + step_name=step_name, + step=step, + state=state, + assertions=context.assertions, + diagnostics=context.diagnostics, + ) + state.required_features.extend(_workflow_cross_cutting_features(step, context.workflow)) + state.control_steps[step_name] = WorkflowStepRuntime( + name=step_name, + step_type=step.type.value, + objective_address=objective_address, + predicate=predicate, + next_step=step.next, + on_success=step.on_success, + on_failure=step.on_failure, + on_exhausted=step.on_exhausted, + then_step=step.then_step, + else_step=step.else_step, + switch_cases=switch_cases, + default_step=step.default_step, + branches=tuple(step.branches), + join_step=step.join, + owning_parallel_step=state.join_owners.get(step_name, ""), + called_workflow_address=called_workflow_address, + compensation_workflow_address=compensation_workflow_address, + max_attempts=step.max_attempts, + state_contract=workflow_step_semantic_contract(step.type.value), + ) diff --git a/implementations/python/packages/aces_processor/compiler/workflows.py b/implementations/python/packages/aces_processor/compiler/workflows.py new file mode 100644 index 000000000..dbb4e6054 --- /dev/null +++ b/implementations/python/packages/aces_processor/compiler/workflows.py @@ -0,0 +1,154 @@ +"""Workflow-runtime assembly and execution-contract compilation.""" + +from typing import Any + +from aces_contracts.versions import WORKFLOW_STATE_SCHEMA_VERSION +from aces_sdl.orchestration import Workflow, WorkflowStepType +from aces_sdl.scenario import InstantiatedScenario + +from ..models import ( + AssertionRuntime, + Diagnostic, + WorkflowExecutionContract, + WorkflowResultContract, + WorkflowRuntime, + WorkflowStepRuntime, +) +from .addresses import _workflow_address +from .support import _dedupe, _dedupe_by_value, _dump +from .workflow_steps import _compile_workflow_step, _WorkflowCompilationState, _WorkflowStepContext + + +def _workflow_timeout_seconds(workflow: Workflow) -> int | None: + if workflow.timeout is None or not isinstance(workflow.timeout.seconds, int): + return None + return workflow.timeout.seconds + + +def _workflow_join_owners(workflow: Workflow) -> dict[str, str]: + return { + step.join: step_name + for step_name, step in workflow.steps.items() + if step.type == WorkflowStepType.PARALLEL and step.join + } + + +def _workflow_result_contract_steps( + control_steps: dict[str, WorkflowStepRuntime], +) -> dict[str, Any]: + return { + step_name: step_runtime.state_contract + for step_name, step_runtime in control_steps.items() + if step_runtime.state_contract.state_observable + } + + +def _workflow_predicate_dependency_addresses(state: _WorkflowCompilationState) -> tuple[str, ...]: + return _dedupe([address for addresses in state.step_predicate_addresses.values() for address in addresses]) + + +def _workflow_compensation_mode(workflow: Workflow) -> str: + return workflow.compensation.mode.value if workflow.compensation is not None else "disabled" + + +def _workflow_compensation_triggers(workflow: Workflow) -> tuple[str, ...]: + return tuple(trigger.value for trigger in (workflow.compensation.on if workflow.compensation is not None else [])) + + +def _workflow_compensation_ordering(workflow: Workflow) -> str: + return workflow.compensation.order if workflow.compensation is not None else "reverse_completion" + + +def _workflow_compensation_failure_policy(workflow: Workflow) -> str: + if workflow.compensation is None: + return "fail_workflow" + return workflow.compensation.failure_policy.value + + +def _workflow_execution_contract( + workflow: Workflow, + state: _WorkflowCompilationState, + result_contract_steps: dict[str, Any], +) -> WorkflowExecutionContract: + return WorkflowExecutionContract( + state_schema_version=WORKFLOW_STATE_SCHEMA_VERSION, + start_step=workflow.start, + timeout_seconds=_workflow_timeout_seconds(workflow), + steps={step_name: step_runtime.state_contract for step_name, step_runtime in state.control_steps.items()}, + step_types={step_name: step_runtime.step_type for step_name, step_runtime in state.control_steps.items()}, + control_edges=state.control_edges, + join_owners=state.join_owners, + call_steps={ + step_name: step_runtime.called_workflow_address + for step_name, step_runtime in state.control_steps.items() + if step_runtime.called_workflow_address + }, + compensation_mode=_workflow_compensation_mode(workflow), + compensation_triggers=_workflow_compensation_triggers(workflow), + compensation_targets=state.compensation_targets, + compensation_ordering=_workflow_compensation_ordering(workflow), + compensation_failure_policy=_workflow_compensation_failure_policy(workflow), + observable_steps=tuple(sorted(result_contract_steps)), + ) + + +def _compile_workflow_runtime( + scenario: InstantiatedScenario, + *, + name: str, + workflow: Workflow, + assertions: dict[str, AssertionRuntime], + diagnostics: list[Diagnostic], +) -> WorkflowRuntime: + workflow_address = _workflow_address(name) + state = _WorkflowCompilationState(join_owners=_workflow_join_owners(workflow)) + context = _WorkflowStepContext( + scenario=scenario, + workflow=workflow, + workflow_address=workflow_address, + state=state, + assertions=assertions, + diagnostics=diagnostics, + ) + for step_name, step in workflow.steps.items(): + _compile_workflow_step(context, step_name=step_name, step=step) + objective_addresses = _dedupe(state.referenced_objectives) + result_contract_steps = _workflow_result_contract_steps(state.control_steps) + predicate_dependency_addresses = _workflow_predicate_dependency_addresses(state) + return WorkflowRuntime( + address=workflow_address, + name=name, + start_step=workflow.start, + referenced_objective_addresses=objective_addresses, + control_steps=state.control_steps, + control_edges=state.control_edges, + join_owners=state.join_owners, + step_assertion_addresses=state.step_assertion_addresses, + step_predicate_addresses=state.step_predicate_addresses, + required_features=_dedupe_by_value(state.required_features), + required_state_predicate_features=_dedupe_by_value(state.required_state_predicate_features), + result_contract=WorkflowResultContract( + state_schema_version=WORKFLOW_STATE_SCHEMA_VERSION, + observable_steps=result_contract_steps, + ), + execution_contract=_workflow_execution_contract(workflow, state, result_contract_steps), + refresh_dependencies=_dedupe([*objective_addresses, *predicate_dependency_addresses]), + spec=_dump(workflow), + ) + + +def _compile_workflows( + scenario: InstantiatedScenario, + assertions: dict[str, AssertionRuntime], + diagnostics: list[Diagnostic], +) -> dict[str, WorkflowRuntime]: + return { + _workflow_address(name): _compile_workflow_runtime( + scenario, + name=name, + workflow=workflow, + assertions=assertions, + diagnostics=diagnostics, + ) + for name, workflow in scenario.workflows.items() + } diff --git a/implementations/python/packages/aces_processor/models/resources.py b/implementations/python/packages/aces_processor/models/resources.py index 3a9d635bd..14b63ce0b 100644 --- a/implementations/python/packages/aces_processor/models/resources.py +++ b/implementations/python/packages/aces_processor/models/resources.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any +from aces_backend_protocols.domain_topology import DomainTopologyBinding from aces_contracts.diagnostics import Diagnostic from aces_contracts.evaluation import EvaluationExecutionContract, EvaluationResultContract from aces_contracts.participant_episode import PARTICIPANT_EPISODE_CONTROL_EVENTS, PARTICIPANT_EPISODE_TERMINAL_EVENTS @@ -58,6 +59,7 @@ class NodeRuntime(ResolvedResource): node_type: str = "" os_family: str = "" count: int | str | None = None + domain_topology: DomainTopologyBinding | None = None @dataclass(frozen=True) @@ -140,6 +142,7 @@ class AccountPlacement(ResolvedResource): account_name: str = "" node_name: str = "" target_address: str = "" + domain_topology: DomainTopologyBinding | None = None @dataclass(frozen=True) diff --git a/implementations/python/packages/aces_processor/planner.py b/implementations/python/packages/aces_processor/planner.py index 34b36bd34..f2b5f0099 100644 --- a/implementations/python/packages/aces_processor/planner.py +++ b/implementations/python/packages/aces_processor/planner.py @@ -4,6 +4,7 @@ from aces_backend_protocols.account_features import provisioner_account_features from aces_backend_protocols.capabilities import BackendManifest +from aces_backend_protocols.domain_topology import domain_topology_plan_diagnostics from aces_sdl.infrastructure import MINIMUM_NODE_COUNT from aces_sdl.nodes import OSFamily from aces_sdl.realization_envelope import member @@ -791,6 +792,13 @@ def plan( actions, deleted_entries = _build_operations(resources, snapshot) provisioning = _build_provisioning_plan(resources, actions, deleted_entries, manifest) + topology_diagnostics = domain_topology_plan_diagnostics( + provisioning, + snapshot=snapshot, + supported_domain_profiles=manifest.provisioner.supported_domain_profiles, + ) + diagnostics.extend(topology_diagnostics) + provisioning.diagnostics.extend(topology_diagnostics) orchestration = _build_orchestration_plan(resources, actions, deleted_entries) evaluation = _build_evaluation_plan(resources, actions, deleted_entries) diff --git a/implementations/python/packages/aces_processor/semantics/realization.py b/implementations/python/packages/aces_processor/semantics/realization.py index 73d38567b..d7b411c2a 100644 --- a/implementations/python/packages/aces_processor/semantics/realization.py +++ b/implementations/python/packages/aces_processor/semantics/realization.py @@ -74,6 +74,7 @@ "os-family": ("os_family",), "node-type": ("node_type",), "content-type": ("spec", "type"), + "domain-topology": ("domain_topology",), } diff --git a/implementations/python/packages/aces_reference_backend/manifest.py b/implementations/python/packages/aces_reference_backend/manifest.py index e131fbf95..16efd058a 100644 --- a/implementations/python/packages/aces_reference_backend/manifest.py +++ b/implementations/python/packages/aces_reference_backend/manifest.py @@ -60,6 +60,7 @@ def _concept_bindings() -> tuple[ConceptBinding, ...]: ConceptBinding(scope="capabilities.provisioner.supported_os_families", family="assets"), ConceptBinding(scope="capabilities.provisioner.supported_content_types", family="tools-and-artifacts"), ConceptBinding(scope="capabilities.provisioner.supported_account_features", family="identities"), + ConceptBinding(scope="capabilities.provisioner.supported_domain_profiles", family="identities"), ConceptBinding(scope="capabilities.orchestrator.supported_sections", family="actions-and-events"), ConceptBinding(scope="capabilities.evaluator.supported_sections", family="observables"), ConceptBinding( @@ -123,7 +124,8 @@ def _capabilities() -> BackendCapabilitySet: supported_node_types=frozenset({"vm", "switch"}), supported_os_families=frozenset({"linux", "windows", "macos", "freebsd", "other"}), supported_content_types=frozenset({"file", "dataset", "directory"}), - supported_account_features=frozenset({"groups", "mail", "spn", "shell", "home", "disabled", "auth_method"}), + supported_account_features=frozenset({"groups", "mail", "shell", "home", "disabled", "auth_method"}), + supported_domain_profiles=frozenset(), max_total_nodes=None, supports_acls=False, supports_accounts=True, diff --git a/implementations/python/packages/aces_runtime/control_plane.py b/implementations/python/packages/aces_runtime/control_plane.py index 18eb871be..6ed7292f3 100644 --- a/implementations/python/packages/aces_runtime/control_plane.py +++ b/implementations/python/packages/aces_runtime/control_plane.py @@ -11,6 +11,7 @@ from datetime import UTC, datetime from uuid import uuid4 +from aces_backend_protocols.domain_topology import domain_topology_plan_diagnostics from aces_contracts.diagnostics import Diagnostic from aces_contracts.planning import ( EvaluationPlan, @@ -71,6 +72,7 @@ def _submitted_plan_diagnostics( plan: ProvisioningPlan | OrchestrationPlan | EvaluationPlan, domain: RuntimeDomain, snapshot: RuntimeSnapshot, + supported_domain_profiles: frozenset[str] | None = None, ) -> list[Diagnostic]: admitted = set(snapshot.entries) | {operation.address for operation in plan.operations} diagnostic: Diagnostic | None = None @@ -78,7 +80,16 @@ def _submitted_plan_diagnostics( diagnostic = _submitted_operation_diagnostic(operation, domain, snapshot, admitted) if diagnostic is not None: break - return [diagnostic] if diagnostic is not None else [] + 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, + ) + return topology_diagnostics[:1] + return [] def _submitted_operation_diagnostic( @@ -169,7 +180,12 @@ def submit_provisioning( idempotency_key: str = "", request_fingerprint: str = "", ) -> OperationReceipt: - diagnostics = _submitted_plan_diagnostics(plan, RuntimeDomain.PROVISIONING, self._snapshot) + diagnostics = _submitted_plan_diagnostics( + plan, + RuntimeDomain.PROVISIONING, + self._snapshot, + self._target.manifest.provisioner.supported_domain_profiles, + ) if diagnostics: return self._reject_diagnostics( domain=RuntimeDomain.PROVISIONING, diff --git a/implementations/python/packages/aces_sdl/_base.py b/implementations/python/packages/aces_sdl/_base.py index 546c1a6f5..048891eeb 100644 --- a/implementations/python/packages/aces_sdl/_base.py +++ b/implementations/python/packages/aces_sdl/_base.py @@ -2,9 +2,9 @@ import re from enum import Enum -from typing import Any +from typing import Annotated, Any -from pydantic import BaseModel, ConfigDict +from pydantic import AfterValidator, BaseModel, ConfigDict, WithJsonSchema from ._identifiers import PORTABLE_IDENTIFIER_PATTERN @@ -31,6 +31,7 @@ class SDLModel(BaseModel): VARIABLE_TOKEN_PATTERN = r"\$\{(" + _VARIABLE_NAME_PATTERN + r")\}" VARIABLE_TOKEN_RE = re.compile(VARIABLE_TOKEN_PATTERN) _VARIABLE_REF_RE = re.compile(r"^" + VARIABLE_TOKEN_PATTERN + r"$") +VARIABLE_REFERENCE_SCHEMA_MARKER = "x-aces-variable-reference" def is_variable_ref(v: Any) -> bool: @@ -38,6 +39,26 @@ def is_variable_ref(v: Any) -> bool: return isinstance(v, str) and _VARIABLE_REF_RE.fullmatch(v) is not None +def _validate_whole_field_variable_reference(value: str) -> str: + if not is_variable_ref(value): + raise ValueError("value must be a whole-field ${name} variable reference") + return value + + +WholeFieldVariableReference = Annotated[ + str, + AfterValidator(_validate_whole_field_variable_reference), + WithJsonSchema( + { + "type": "string", + "pattern": "^" + VARIABLE_TOKEN_PATTERN + "$", + "not": {"pattern": r"[\r\n]"}, + VARIABLE_REFERENCE_SCHEMA_MARKER: True, + } + ), +] + + def is_variable_name(v: object) -> bool: """Return whether ``v`` is a syntactically valid SDL variable name.""" return isinstance(v, str) and VARIABLE_NAME_RE.fullmatch(v) is not None diff --git a/implementations/python/packages/aces_sdl/_declarations.py b/implementations/python/packages/aces_sdl/_declarations.py index 5e4344b94..9eec05c3f 100644 --- a/implementations/python/packages/aces_sdl/_declarations.py +++ b/implementations/python/packages/aces_sdl/_declarations.py @@ -275,6 +275,7 @@ def _add_node_declarations(index: DeclarationIndex, scenario: ScenarioContent) - "scripts", "stories", "accounts", + "identity_domains", "relationships", "agents", "action_contracts", diff --git a/implementations/python/packages/aces_sdl/_language_metadata.py b/implementations/python/packages/aces_sdl/_language_metadata.py index c26afc088..0cf203b5d 100644 --- a/implementations/python/packages/aces_sdl/_language_metadata.py +++ b/implementations/python/packages/aces_sdl/_language_metadata.py @@ -20,8 +20,11 @@ ("stories", "scripts"): "scripts", ("content", "target"): "nodes", ("accounts", "node"): "nodes", + ("accounts", "domain_ref"): "identity_domains", + ("identity_domains", "authority_account_ref"): "accounts", ("relationships", "source"): "targetable", ("relationships", "target"): "targetable", + ("relationships", "controller_refs"): "nodes", ("agents", "entity"): "entities", ("agents", "starting_accounts"): "accounts", ("agents", "starting_assertions"): "assertions", @@ -69,8 +72,16 @@ "scripts": ("start_time", "end_time", "speed", "events"), "stories": ("speed", "scripts"), "content": ("type", "target", "format", "source", "path", "items"), - "accounts": ("username", "node", "password_strength"), - "relationships": ("type", "source", "target", "properties"), + "accounts": ("username", "node", "password_strength", "spn", "domain_ref"), + "identity_domains": ("profile", "dns_name", "netbios_name", "authority_account_ref"), + "relationships": ( + "type", + "source", + "target", + "properties", + "domain_controller", + "domain_join", + ), "agents": ("entity", "actions", "starting_accounts", "starting_assertions", "initial_knowledge"), "behavior_specifications": ( "semantic_version", diff --git a/implementations/python/packages/aces_sdl/_mapping_scopes.py b/implementations/python/packages/aces_sdl/_mapping_scopes.py index c7382d40f..317ef0afe 100644 --- a/implementations/python/packages/aces_sdl/_mapping_scopes.py +++ b/implementations/python/packages/aces_sdl/_mapping_scopes.py @@ -28,6 +28,7 @@ class MappingScope(str, Enum): "stories", "content", "accounts", + "identity_domains", "relationships", "agents", "action_contracts", diff --git a/implementations/python/packages/aces_sdl/_module_symbols.py b/implementations/python/packages/aces_sdl/_module_symbols.py index 11af02ea7..d7b2e560a 100644 --- a/implementations/python/packages/aces_sdl/_module_symbols.py +++ b/implementations/python/packages/aces_sdl/_module_symbols.py @@ -31,6 +31,7 @@ "stories", "content", "accounts", + "identity_domains", "relationships", "agents", "action_contracts", @@ -206,6 +207,7 @@ def symbol_index( "stories": section_maps.get("stories", {}), "content": section_maps.get("content", {}), "accounts": section_maps.get("accounts", {}), + "identity_domains": section_maps.get("identity_domains", {}), "relationships": section_maps.get("relationships", {}), "agents": section_maps.get("agents", {}), "action_contracts": section_maps.get("action_contracts", {}), diff --git a/implementations/python/packages/aces_sdl/_realization_envelope_domains.py b/implementations/python/packages/aces_sdl/_realization_envelope_domains.py index 805de2425..559158689 100644 --- a/implementations/python/packages/aces_sdl/_realization_envelope_domains.py +++ b/implementations/python/packages/aces_sdl/_realization_envelope_domains.py @@ -11,6 +11,7 @@ from __future__ import annotations from collections.abc import Callable +from enum import Enum from aces_contracts.realization_envelope import ( BooleanDomain, @@ -149,6 +150,33 @@ def default_witness_value(domain: DomainDescriptor) -> WitnessSelection: return selector(domain) +def positive_probe_values(domain: DomainDescriptor) -> list[DomainScalar]: + """Deterministic safe values that cover one bounded scalar domain. + + Finite domains enumerate every member. Numeric intervals contribute their + admissible boundaries; for an open real boundary, where no portable epsilon + exists, the deterministic witness supplies the safe interior representative. + """ + + members = finite_members(domain) + if members is not None: + values = sorted(members, key=_enum_sort_key) + elif not isinstance(domain, NumericIntervalDomain): + values = [] + elif domain.numeric_type is NumericType.INTEGER: + lower = int(domain.lower) + (0 if domain.lower_closed else 1) + upper = int(domain.upper) - (0 if domain.upper_closed else 1) + values = [lower] if lower == upper else [lower, upper] + else: + values = [] + if domain.lower_closed: + values.append(domain.lower) + values.append((domain.lower + domain.upper) / 2) + if domain.upper_closed: + values.append(domain.upper) + return list(dict.fromkeys(values)) + + # --------------------------------------------------------------------------- # # Out-of-envelope variation (R6) # # --------------------------------------------------------------------------- # @@ -195,3 +223,21 @@ def out_of_domain_value(domain: DomainDescriptor) -> object: factory = _OUT_OF_DOMAIN.get(type(domain)) return factory(domain) if factory is not None else _MISSING + + +def out_of_domain_candidates(domain: DomainDescriptor, current: object) -> list[object]: + """Candidate scalars outside ``domain``, preferring SDL enum members. + + A domain-blind synthetic string is not a safe negative probe when the SDL + field is itself a closed enum. The instantiated witness exposes that enum + type, so enumerate its other legal members before falling back to the + domain-kind perturbation used for open scalar fields. + """ + + candidates: list[object] = [] + if isinstance(current, Enum): + candidates.extend(member.value for member in type(current) if not scalar_in_domain(member.value, domain)) + fallback = out_of_domain_value(domain) + if fallback is not _MISSING: + candidates.append(fallback) + return list(dict.fromkeys(candidates)) diff --git a/implementations/python/packages/aces_sdl/_realization_envelope_probe_payloads.py b/implementations/python/packages/aces_sdl/_realization_envelope_probe_payloads.py new file mode 100644 index 000000000..f6f0121a1 --- /dev/null +++ b/implementations/python/packages/aces_sdl/_realization_envelope_probe_payloads.py @@ -0,0 +1,127 @@ +"""Structurally plausible payload candidates for realization-envelope probes.""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass +from enum import Enum + +from aces_contracts.realization_envelope import Posture +from pydantic import BaseModel + +from ._realization_envelope_domains import out_of_domain_candidates +from ._realization_envelope_engine import ( + LeafConstraint, + assign_path, + fresh_extra_key, + navigate, + remove_path, + tokenize_path, +) +from .scenario import InstantiatedScenario + + +@dataclass(frozen=True) +class ProbePayloadCandidate: + """One not-yet-validated negative-probe payload.""" + + path: str + domain_kind: str + variation: str + payload: dict[str, object] + + +def _minimal_discriminator_payload( + base_payload: dict[str, object], path: str, variation_value: object +) -> dict[str, object] | None: + """Return a minimal variant for a nested ``type`` discriminator.""" + + tokens = tokenize_path(path) + if len(tokens) < 2 or tokens[-1] != "type": + return None + payload = deepcopy(base_payload) + if assign_path(payload, tokens[:-1], {"type": variation_value}) is not None: + return None + return payload + + +def value_probe_payloads( + base_payload: dict[str, object], + base_scenario: InstantiatedScenario, + path: str, + constraint: LeafConstraint, +) -> list[ProbePayloadCandidate]: + """Build candidates for one constrained scalar path.""" + + probes: list[ProbePayloadCandidate] = [] + tokens = tokenize_path(path) + found, current = navigate(base_scenario, tokens) + for variation_value in out_of_domain_candidates(constraint.domain, current if found else None): + payload = deepcopy(base_payload) + if assign_path(payload, tokens, variation_value) is None: + probes.append(ProbePayloadCandidate(path, constraint.domain.kind, "value-outside-domain", payload)) + minimal = _minimal_discriminator_payload(base_payload, path, variation_value) + if minimal is not None: + probes.append(ProbePayloadCandidate(path, constraint.domain.kind, "value-outside-domain", minimal)) + if constraint.posture is Posture.EXACT: + omitted = deepcopy(base_payload) + if remove_path(omitted, tokens): + probes.append(ProbePayloadCandidate(path, constraint.domain.kind, "omitted-required-exact", omitted)) + return probes + + +def _extra_dimension_values(current: object) -> list[object]: + if isinstance(current, Enum): + values: list[object] = [member.value for member in type(current) if member is not current] + elif isinstance(current, bool): + values = [not current] + elif isinstance(current, (int, float)): + values = [current + 1] + elif isinstance(current, str): + values = ["out-of-envelope" if current != "out-of-envelope" else "out-of-envelope-x"] + elif isinstance(current, dict): + values = [{"out-of-envelope": "out-of-envelope"}] + elif isinstance(current, list): + values = [["out-of-envelope"]] + else: + values = [] + return values + + +def _model_extra_dimension_payloads( + base_payload: dict[str, object], + scope_path: str, + scope_value: BaseModel, + admitted: set[str], +) -> list[ProbePayloadCandidate]: + probes: list[ProbePayloadCandidate] = [] + for field_name in sorted(set(type(scope_value).model_fields) - admitted): + for variation_value in _extra_dimension_values(getattr(scope_value, field_name)): + payload = deepcopy(base_payload) + tokens = tokenize_path(scope_path) + [field_name] if scope_path else [field_name] + if assign_path(payload, tokens, variation_value) is None: + address = f"{scope_path}.{field_name}" if scope_path else field_name + probes.append(ProbePayloadCandidate(address, "closed-scope", "extra-dimension", payload)) + return probes + + +def closed_scope_probe_payloads( + base_payload: dict[str, object], + base_scenario: InstantiatedScenario, + closed: dict[str, set[str]], +) -> list[ProbePayloadCandidate]: + """Build candidates for SDL fields excluded by each closed scope.""" + + probes: list[ProbePayloadCandidate] = [] + for scope_path in sorted(closed): + scope_value = base_scenario if not scope_path else navigate(base_scenario, tokenize_path(scope_path))[1] + if isinstance(scope_value, BaseModel): + probes.extend(_model_extra_dimension_payloads(base_payload, scope_path, scope_value, closed[scope_path])) + continue + extra_key = fresh_extra_key(closed[scope_path]) + payload = deepcopy(base_payload) + tokens = tokenize_path(scope_path) + [extra_key] if scope_path else [extra_key] + if assign_path(payload, tokens, "out-of-envelope") is None: + address = f"{scope_path}.{extra_key}" if scope_path else extra_key + probes.append(ProbePayloadCandidate(address, "closed-scope", "extra-dimension", payload)) + return probes diff --git a/implementations/python/packages/aces_sdl/accounts.py b/implementations/python/packages/aces_sdl/accounts.py index 62af8f63f..41fd8e1b4 100644 --- a/implementations/python/packages/aces_sdl/accounts.py +++ b/implementations/python/packages/aces_sdl/accounts.py @@ -39,6 +39,7 @@ class Account(SDLModel): description: str = "" mail: str = "" spn: str = "" + domain_ref: str = "" shell: str = "" home: str = "" disabled: bool | str = False diff --git a/implementations/python/packages/aces_sdl/composition.py b/implementations/python/packages/aces_sdl/composition.py index 0025da72f..cb7feb05e 100644 --- a/implementations/python/packages/aces_sdl/composition.py +++ b/implementations/python/packages/aces_sdl/composition.py @@ -234,14 +234,31 @@ def _namespace_payload( if isinstance(content, dict) and content.get("target"): content["target"] = _maybe_rename(str(content["target"]), symbols["nodes"]) for account in namespaced.get("accounts", {}).values(): - if isinstance(account, dict) and account.get("node"): - account["node"] = _maybe_rename(str(account["node"]), symbols["nodes"]) + if isinstance(account, dict): + if account.get("node"): + account["node"] = _maybe_rename(str(account["node"]), symbols["nodes"]) + if account.get("domain_ref"): + account["domain_ref"] = _maybe_rename( + str(account["domain_ref"]), + symbols["identity_domains"], + ) + for identity_domain in namespaced.get("identity_domains", {}).values(): + if isinstance(identity_domain, dict) and identity_domain.get("authority_account_ref"): + identity_domain["authority_account_ref"] = _maybe_rename( + str(identity_domain["authority_account_ref"]), + symbols["accounts"], + ) for relationship in namespaced.get("relationships", {}).values(): if isinstance(relationship, dict): if relationship.get("source"): relationship["source"] = _maybe_rename(str(relationship["source"]), symbols["named"]) if relationship.get("target"): relationship["target"] = _maybe_rename(str(relationship["target"]), symbols["named"]) + domain_join = relationship.get("domain_join") + if isinstance(domain_join, dict): + domain_join["controller_refs"] = [ + _maybe_rename(name, symbols["nodes"]) for name in domain_join.get("controller_refs", []) + ] forwarding_edge = relationship.get("forwarding_edge") if isinstance(forwarding_edge, dict) and forwarding_edge.get("forwarder_ref"): forwarding_edge["forwarder_ref"] = _maybe_rename( diff --git a/implementations/python/packages/aces_sdl/identity_domains.py b/implementations/python/packages/aces_sdl/identity_domains.py new file mode 100644 index 000000000..0f3908299 --- /dev/null +++ b/implementations/python/packages/aces_sdl/identity_domains.py @@ -0,0 +1,83 @@ +"""Authored identity-domain declarations and typed topology details. + +This authoring surface is realization intent. It is intentionally separate +from :mod:`aces_sdl.runtime_directory_identity`, which records runtime +inventory observed on a node. +""" + +import re +from enum import Enum + +from pydantic import Field, field_validator + +from ._base import SDLModel +from .value_parsing import WholeFieldVariableReference, is_variable_ref, parse_enum_or_var + +_DNS_LABEL_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$") +_NETBIOS_NAME_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,13}[A-Za-z0-9])?$") + + +class IdentityDomainProfile(str, Enum): + """Closed profiles whose controller/join semantics are realizable.""" + + ACTIVE_DIRECTORY = "active_directory" + + +class IdentityDomain(SDLModel): + """Scenario-scoped authored identity domain.""" + + profile: IdentityDomainProfile | WholeFieldVariableReference + dns_name: str = Field(min_length=1) + netbios_name: str = Field(min_length=1) + authority_account_ref: str = Field(min_length=1) + + @field_validator("profile", mode="before") + @classmethod + def normalize_profile(cls, value: str) -> IdentityDomainProfile | WholeFieldVariableReference: + return parse_enum_or_var(value, IdentityDomainProfile, field_name="profile") + + @field_validator("dns_name") + @classmethod + def validate_dns_name(cls, value: str) -> str: + if is_variable_ref(value): + return value + labels = value.split(".") + if len(value) > 253 or any(not _DNS_LABEL_RE.fullmatch(label) for label in labels): + raise ValueError("dns_name must be a valid DNS domain name") + return value + + @field_validator("netbios_name") + @classmethod + def validate_netbios_name(cls, value: str) -> str: + if is_variable_ref(value): + return value + if len(value) > 15 or _NETBIOS_NAME_RE.fullmatch(value) is None: + raise ValueError("netbios_name must be a valid NetBIOS domain name of at most 15 characters") + return value + + +class RelationshipDomainController(SDLModel): + """Typed marker for a node-to-domain controller-role edge.""" + + +class RelationshipDomainJoin(SDLModel): + """Typed member join with explicit ordered controller candidates.""" + + controller_refs: list[str] = Field(min_length=1) + + @field_validator("controller_refs") + @classmethod + def validate_controller_refs(cls, values: list[str]) -> list[str]: + if any(not isinstance(value, str) or not value.strip() for value in values): + raise ValueError("controller_refs must contain non-empty references") + if len(values) != len(set(values)): + raise ValueError("controller_refs must be unique") + return values + + +__all__ = [ + "IdentityDomain", + "IdentityDomainProfile", + "RelationshipDomainController", + "RelationshipDomainJoin", +] diff --git a/implementations/python/packages/aces_sdl/realization_envelope.py b/implementations/python/packages/aces_sdl/realization_envelope.py index fa614066d..5ad00b845 100644 --- a/implementations/python/packages/aces_sdl/realization_envelope.py +++ b/implementations/python/packages/aces_sdl/realization_envelope.py @@ -30,38 +30,41 @@ from __future__ import annotations +import hashlib +import json from copy import deepcopy from dataclasses import dataclass from aces_contracts.diagnostics import Diagnostic, Severity -from aces_contracts.realization_envelope import Posture, RealizationEnvelopeModel, WitnessPolicy, scalar_in_domain +from aces_contracts.realization_envelope import RealizationEnvelopeModel, WitnessPolicy, scalar_in_domain from pydantic import ValidationError from ._errors import SDLInstantiationError, SDLValidationError -from ._realization_envelope_domains import _MISSING, domain_subset, out_of_domain_value +from ._realization_envelope_domains import domain_subset, positive_probe_values from ._realization_envelope_engine import ( LeafConstraint, assign_path, effective_constraints, - fresh_extra_key, navigate, normalize_scalar, overridability_violations, present_children, - remove_path, tokenize_path, witness_value, ) +from ._realization_envelope_probe_payloads import closed_scope_probe_payloads, value_probe_payloads from .instantiate import instantiate_scenario from .scenario import InstantiatedScenario, Scenario from .validator import SemanticValidator __all__ = [ "NegativeProbe", + "PositiveProbe", "RelationKind", "RelationResult", "WitnessResult", "generate_negative_probes", + "generate_positive_probes", "effective_constraints", "member", "subsumes", @@ -107,6 +110,17 @@ class NegativeProbe: payload: dict[str, object] +@dataclass(frozen=True) +class PositiveProbe: + """A validated in-envelope witness covering one bounded variation.""" + + path: str + domain_kind: str + variation: str + digest: str + payload: dict[str, object] + + def _diag(code: str, address: str, message: str, severity: Severity = Severity.ERROR) -> Diagnostic: return Diagnostic(code=f"{_DOMAIN}.{code}", domain=_DOMAIN, address=address, message=message, severity=severity) @@ -351,34 +365,77 @@ def witness(envelope: RealizationEnvelopeModel, policy: WitnessPolicy | None = N # --------------------------------------------------------------------------- # -# Negative probes (R6) # +# Positive probes (ASR-519) # # --------------------------------------------------------------------------- # -def _value_probes_for(base_payload: dict[str, object], path: str, constraint: LeafConstraint) -> list[NegativeProbe]: - probes: list[NegativeProbe] = [] - variation_value = out_of_domain_value(constraint.domain) - if variation_value is not _MISSING: - payload = deepcopy(base_payload) - if assign_path(payload, tokenize_path(path), variation_value) is None: - probes.append(NegativeProbe(path, constraint.domain.kind, "value-outside-domain", payload)) - if constraint.posture is Posture.EXACT: - omitted = deepcopy(base_payload) - if remove_path(omitted, tokenize_path(path)): - probes.append(NegativeProbe(path, constraint.domain.kind, "omitted-required-exact", omitted)) - return probes +def _probe_digest(payload: dict[str, object]) -> str: + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + return "sha256:" + hashlib.sha256(canonical).hexdigest() -def _closed_scope_probes(base_payload: dict[str, object], closed: dict[str, set[str]]) -> list[NegativeProbe]: - probes: list[NegativeProbe] = [] - for scope_path in sorted(closed): - extra_key = fresh_extra_key(closed[scope_path]) - payload = deepcopy(base_payload) - tokens = tokenize_path(scope_path) + [extra_key] if scope_path else [extra_key] - if assign_path(payload, tokens, "out-of-envelope") is None: - address = f"{scope_path}.{extra_key}" if scope_path else extra_key - probes.append(NegativeProbe(address, "closed-scope", "extra-dimension", payload)) - return probes +def _positive_probe(payload: dict[str, object], *, path: str, domain_kind: str, variation: str) -> PositiveProbe: + return PositiveProbe(path, domain_kind, variation, _probe_digest(payload), payload) + + +def generate_positive_probes( + envelope: RealizationEnvelopeModel, +) -> tuple[tuple[PositiveProbe, ...], tuple[Diagnostic, ...]]: + """Derive deterministic, structurally valid witnesses for bounded dimensions. + + The base witness is always retained. Each finite member and safe numeric + boundary is substituted through the shared path engine, then revalidated by + the ordinary SDL and envelope-membership gates. Invalid candidates remain + explicit diagnostics rather than silently increasing conformance coverage. + """ + + base_payload, build_diagnostics = _build_witness_payload(envelope, None) + base = _validate_witness_payload(base_payload, envelope) if not build_diagnostics else WitnessResult(None) + if base.scenario is None: + return (), ( + _diag( + "positive-probe.no-witness", + envelope.id, + "cannot derive positive probes from a non-constructive envelope", + ), + ) + probes: dict[str, PositiveProbe] = {} + initial = _positive_probe(base_payload, path=envelope.id, domain_kind="witness", variation="base-witness") + probes[initial.digest] = initial + diagnostics: list[Diagnostic] = [] + constraints, _closed = effective_constraints(envelope) + for path in sorted(constraints): + constraint = constraints[path] + for value in positive_probe_values(constraint.domain): + payload = deepcopy(base_payload) + if assign_path(payload, tokenize_path(path), value) is not None: + diagnostics.append( + _diag("positive-probe.invalid-candidate", path, "bounded variation could not be assigned") + ) + continue + candidate = _validate_witness_payload(payload, envelope) + if candidate.scenario is None or not member(candidate.scenario, envelope).holds: + diagnostics.append( + _diag( + "positive-probe.invalid-candidate", + path, + "bounded variation did not pass ordinary SDL and membership validation", + ) + ) + continue + probe = _positive_probe( + payload, + path=path, + domain_kind=constraint.domain.kind, + variation="bounded-member", + ) + probes.setdefault(probe.digest, probe) + return tuple(probes.values()), tuple(diagnostics) + + +# --------------------------------------------------------------------------- # +# Negative probes (R6) # +# --------------------------------------------------------------------------- # def generate_negative_probes( @@ -386,7 +443,8 @@ def generate_negative_probes( ) -> tuple[tuple[NegativeProbe, ...], tuple[Diagnostic, ...]]: """Derive out-of-envelope probes for the envelope's closed dimensions (R6).""" - base = witness(envelope) + base_payload, build_diagnostics = _build_witness_payload(envelope, None) + base = _validate_witness_payload(base_payload, envelope) if not build_diagnostics else WitnessResult(None) if base.scenario is None: return (), ( _diag( @@ -396,12 +454,17 @@ def generate_negative_probes( ), ) - # ``mode="json"`` yields plain JSON scalars (enums as their string value), so - # each probe payload is a portable, re-parseable scenario request. - base_payload = base.scenario.model_dump(mode="json", by_alias=True) constraints, closed = effective_constraints(envelope) - probes: list[NegativeProbe] = [] + probes = [] for path in sorted(constraints): - probes.extend(_value_probes_for(base_payload, path, constraints[path])) - probes.extend(_closed_scope_probes(base_payload, closed)) - return tuple(probes), () + probes.extend(value_probe_payloads(base_payload, base.scenario, path, constraints[path])) + probes.extend(closed_scope_probe_payloads(base_payload, base.scenario, closed)) + safe: list[NegativeProbe] = [] + seen: set[tuple[str, str]] = set() + for probe in probes: + candidate = _validate_witness_payload(probe.payload, envelope) + key = (probe.path, _probe_digest(probe.payload)) + if candidate.scenario is not None and not member(candidate.scenario, envelope).holds and key not in seen: + safe.append(NegativeProbe(probe.path, probe.domain_kind, probe.variation, probe.payload)) + seen.add(key) + return tuple(safe), () diff --git a/implementations/python/packages/aces_sdl/relationships.py b/implementations/python/packages/aces_sdl/relationships.py index a9604812c..1cb55e7b3 100644 --- a/implementations/python/packages/aces_sdl/relationships.py +++ b/implementations/python/packages/aces_sdl/relationships.py @@ -15,6 +15,7 @@ from pydantic import Field, field_validator from ._base import SDLModel, normalize_enum_value +from .identity_domains import RelationshipDomainController, RelationshipDomainJoin from .runtime_application import RelationshipProxyUpstream from .runtime_database import RelationshipDatabaseAccess from .runtime_forwarding_agent import RelationshipForwardingEdge @@ -32,6 +33,8 @@ class RelationshipType(str, Enum): DEPENDS_ON = "depends_on" MANAGES = "manages" REPLICATES_TO = "replicates_to" + DOMAIN_CONTROLLER_FOR = "domain_controller_for" + JOINS_DOMAIN = "joins_domain" class Relationship(SDLModel): @@ -67,6 +70,8 @@ class Relationship(SDLModel): forwarding_edge: RelationshipForwardingEdge | None = None service_integration: RelationshipServiceIntegration | None = None proxy_upstream: RelationshipProxyUpstream | None = None + domain_controller: RelationshipDomainController | None = None + domain_join: RelationshipDomainJoin | None = None @field_validator("type", mode="before") @classmethod diff --git a/implementations/python/packages/aces_sdl/scenario.py b/implementations/python/packages/aces_sdl/scenario.py index f98cf16b9..289991249 100644 --- a/implementations/python/packages/aces_sdl/scenario.py +++ b/implementations/python/packages/aces_sdl/scenario.py @@ -34,6 +34,7 @@ from .evidence_requirements import EvidenceRequirement from .explicitness import ExplicitnessRecord from .features import Feature +from .identity_domains import IdentityDomain from .infrastructure import InfraNode from .nodes import Node from .objectives import Objective @@ -272,6 +273,7 @@ class ScenarioContent(SDLModel): # --- Extended sections --- content: dict[str, Content] = Field(default_factory=dict) accounts: dict[str, Account] = Field(default_factory=dict) + identity_domains: dict[str, IdentityDomain] = Field(default_factory=dict) relationships: dict[str, Relationship] = Field(default_factory=dict) forwarding_agents: list[RuntimeForwardingAgent] = Field(default_factory=list) agents: dict[str, Agent] = Field(default_factory=dict) diff --git a/implementations/python/packages/aces_sdl/semantics/_domain_topology_bindings.py b/implementations/python/packages/aces_sdl/semantics/_domain_topology_bindings.py new file mode 100644 index 000000000..f065170aa --- /dev/null +++ b/implementations/python/packages/aces_sdl/semantics/_domain_topology_bindings.py @@ -0,0 +1,351 @@ +"""Membership, account, and authority binding helpers for domain topology.""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Callable, Mapping + +from ..identity_domains import IdentityDomainProfile +from ._domain_topology_types import ( + DomainAccountBinding, + DomainNodeBinding, + DomainNodeRole, + DomainTopologyIssue, + resolve_section_ref, + topology_issue, +) + + +def _domain_profile(value: object) -> str: + return value.value if isinstance(value, IdentityDomainProfile) else str(value) + + +def controllers_by_domain( + identity_domains: Mapping[str, object], + controller_edges: Mapping[str, list[tuple[str, str]]], +) -> dict[str, tuple[str, ...]]: + """Project controller edges into stable per-domain node lists.""" + + return { + domain_name: tuple(node_name for _relationship_name, node_name in controller_edges.get(domain_name, ())) + for domain_name in identity_domains + } + + +def missing_controller_issues( + domain_controllers: Mapping[str, tuple[str, ...]], +) -> list[DomainTopologyIssue]: + """Report identity domains without an authored controller edge.""" + + issues: list[DomainTopologyIssue] = [] + for domain_name, controllers in domain_controllers.items(): + if not controllers: + issues.append( + topology_issue( + "domain.controller.missing", + f"Identity domain '{domain_name}' has no controller relationship", + ) + ) + return issues + + +def join_controller_issues( + join_edges: Mapping[str, list[tuple[str, str, tuple[str, ...]]]], + domain_controllers: Mapping[str, tuple[str, ...]], +) -> list[DomainTopologyIssue]: + """Report join candidates that do not control the joined domain.""" + + issues: list[DomainTopologyIssue] = [] + for domain_name, joins in join_edges.items(): + valid_controllers = set(domain_controllers.get(domain_name, ())) + for relationship_name, _node_name, controller_names in joins: + for controller_name in controller_names: + if controller_name not in valid_controllers: + issues.append( + topology_issue( + "domain.join.controller-wrong-domain", + f"Relationship '{relationship_name}' controller_ref '{controller_name}' does not " + f"control the same domain '{domain_name}'", + ) + ) + return issues + + +def build_memberships( + domain_controllers: Mapping[str, tuple[str, ...]], + join_edges: Mapping[str, list[tuple[str, str, tuple[str, ...]]]], +) -> dict[str, list[DomainNodeBinding]]: + """Build normalized controller and member bindings by node.""" + + memberships: dict[str, list[DomainNodeBinding]] = defaultdict(list) + for domain_name, controllers in domain_controllers.items(): + for node_name in controllers: + memberships[node_name].append( + DomainNodeBinding( + node_name=node_name, + domain_name=domain_name, + role=DomainNodeRole.CONTROLLER, + controller_names=controllers, + ) + ) + for domain_name, joins in join_edges.items(): + for _relationship_name, node_name, controller_names in joins: + memberships[node_name].append( + DomainNodeBinding( + node_name=node_name, + domain_name=domain_name, + role=DomainNodeRole.MEMBER, + controller_names=controller_names, + ) + ) + return memberships + + +def _redundant_join_issues( + node_name: str, + bindings: list[DomainNodeBinding], +) -> list[DomainTopologyIssue]: + issues: list[DomainTopologyIssue] = [] + for domain_name in {binding.domain_name for binding in bindings}: + roles = {binding.role for binding in bindings if binding.domain_name == domain_name} + if DomainNodeRole.CONTROLLER in roles and DomainNodeRole.MEMBER in roles: + issues.append( + topology_issue( + "domain.node.redundant-join", + f"Node '{node_name}' is a controller for domain '{domain_name}' and must not declare " + "a redundant join", + ) + ) + return issues + + +def _multiple_active_directory_issues( + node_name: str, + bindings: list[DomainNodeBinding], + identity_domains: Mapping[str, object], +) -> list[DomainTopologyIssue]: + issues: list[DomainTopologyIssue] = [] + active_directory_domains = { + binding.domain_name + for binding in bindings + if _domain_profile(getattr(identity_domains[binding.domain_name], "profile", "")) + == IdentityDomainProfile.ACTIVE_DIRECTORY.value + } + if len(active_directory_domains) > 1: + issues.append( + topology_issue( + "domain.node.multiple-active-directory-domains", + f"Node '{node_name}' belongs to multiple active_directory domains: " + f"{', '.join(sorted(active_directory_domains))}", + ) + ) + return issues + + +def membership_issues( + memberships: Mapping[str, list[DomainNodeBinding]], + identity_domains: Mapping[str, object], +) -> list[DomainTopologyIssue]: + """Report contradictory or unsupported domain memberships.""" + + issues: list[DomainTopologyIssue] = [] + for node_name, bindings in memberships.items(): + issues.extend(_redundant_join_issues(node_name, bindings)) + issues.extend(_multiple_active_directory_issues(node_name, bindings, identity_domains)) + return issues + + +def _account_domain( + account_name: str, + account: object, + identity_domains: Mapping[str, object], + is_unresolved: Callable[[object], bool], +) -> tuple[str | None, list[DomainTopologyIssue]]: + issues: list[DomainTopologyIssue] = [] + domain_name = None + domain_ref = getattr(account, "domain_ref", "") + if getattr(account, "spn", "") and not domain_ref: + issues.append( + topology_issue( + "domain.account.spn-without-domain", + f"Account '{account_name}' declares an SPN and requires explicit domain_ref", + ) + ) + if domain_ref and not is_unresolved(domain_ref): + domain_name = resolve_section_ref(domain_ref, "identity_domains", identity_domains) + if domain_name is None: + issues.append( + topology_issue( + "domain.account.domain-unbound", + f"Account '{account_name}' domain_ref '{domain_ref}' does not resolve to an identity domain", + ) + ) + return domain_name, issues + + +def _account_node( + account: object, + nodes: Mapping[str, object], + is_unresolved: Callable[[object], bool], +) -> str | None: + node_ref = getattr(account, "node", "") + if is_unresolved(node_ref): + return None + return resolve_section_ref(node_ref, "nodes", nodes) + + +def _account_binding( + account_name: str, + account: object, + identity_domains: Mapping[str, object], + nodes: Mapping[str, object], + memberships: Mapping[str, list[DomainNodeBinding]], + is_unresolved: Callable[[object], bool], +) -> tuple[DomainAccountBinding | None, list[DomainTopologyIssue]]: + domain_name, issues = _account_domain(account_name, account, identity_domains, is_unresolved) + binding = None + if domain_name is not None: + node_name = _account_node(account, nodes, is_unresolved) + if node_name is not None: + if any(member.domain_name == domain_name for member in memberships.get(node_name, ())): + binding = DomainAccountBinding( + account_name=account_name, + node_name=node_name, + domain_name=domain_name, + ) + else: + issues.append( + topology_issue( + "domain.account.node-outside-domain", + f"Account '{account_name}' is placed on node '{node_name}', which does not belong to " + f"domain '{domain_name}'", + ) + ) + return binding, issues + + +def account_bindings( + accounts: Mapping[str, object], + identity_domains: Mapping[str, object], + nodes: Mapping[str, object], + memberships: Mapping[str, list[DomainNodeBinding]], + is_unresolved: Callable[[object], bool], +) -> tuple[dict[str, DomainAccountBinding], list[DomainTopologyIssue]]: + """Normalize account-domain bindings and return their issues.""" + + bindings: dict[str, DomainAccountBinding] = {} + issues: list[DomainTopologyIssue] = [] + for account_name, account in accounts.items(): + binding, account_issues = _account_binding( + account_name, + account, + identity_domains, + nodes, + memberships, + is_unresolved, + ) + issues.extend(account_issues) + if binding is not None: + bindings[account_name] = binding + return bindings, issues + + +def _authority_account( + domain_name: str, + domain: object, + accounts: Mapping[str, object], + is_unresolved: Callable[[object], bool], +) -> tuple[str | None, list[DomainTopologyIssue]]: + issues: list[DomainTopologyIssue] = [] + authority_name = None + authority_ref = getattr(domain, "authority_account_ref", "") + if not is_unresolved(authority_ref): + authority_name = resolve_section_ref(authority_ref, "accounts", accounts) + if authority_name is None: + issues.append( + topology_issue( + "domain.authority.account-unbound", + f"Identity domain '{domain_name}' authority account '{authority_ref}' is not declared", + ) + ) + return authority_name, issues + + +def _authority_binding( + domain_name: str, + authority_name: str, + accounts: Mapping[str, object], + nodes: Mapping[str, object], + domain_controllers: Mapping[str, tuple[str, ...]], + bound_accounts: Mapping[str, DomainAccountBinding], +) -> tuple[DomainAccountBinding | None, list[DomainTopologyIssue]]: + issues: list[DomainTopologyIssue] = [] + binding = None + authority_node_ref = getattr(accounts[authority_name], "node", "") + authority_node = resolve_section_ref(authority_node_ref, "nodes", nodes) + if authority_node not in set(domain_controllers.get(domain_name, ())): + issues.append( + topology_issue( + "domain.authority.not-on-controller", + f"Identity domain '{domain_name}' authority account '{authority_name}' must be placed on " + "one of its controller nodes", + ) + ) + else: + existing_binding = bound_accounts.get(authority_name) + if existing_binding is not None and existing_binding.domain_name != domain_name: + issues.append( + topology_issue( + "domain.authority.domain-conflict", + f"Identity domain '{domain_name}' authority account '{authority_name}' is already bound to " + f"domain '{existing_binding.domain_name}'", + ) + ) + else: + binding = DomainAccountBinding( + account_name=authority_name, + node_name=authority_node, + domain_name=domain_name, + ) + return binding, issues + + +def apply_authority_bindings( + identity_domains: Mapping[str, object], + accounts: Mapping[str, object], + nodes: Mapping[str, object], + domain_controllers: Mapping[str, tuple[str, ...]], + bound_accounts: dict[str, DomainAccountBinding], + is_unresolved: Callable[[object], bool], +) -> list[DomainTopologyIssue]: + """Validate authority placement and add its normalized account bindings.""" + + issues: list[DomainTopologyIssue] = [] + for domain_name, domain in identity_domains.items(): + authority_name, authority_issues = _authority_account(domain_name, domain, accounts, is_unresolved) + issues.extend(authority_issues) + if authority_name is None: + continue + binding, binding_issues = _authority_binding( + domain_name, + authority_name, + accounts, + nodes, + domain_controllers, + bound_accounts, + ) + issues.extend(binding_issues) + if binding is not None: + bound_accounts[authority_name] = binding + return issues + + +__all__ = [ + "account_bindings", + "apply_authority_bindings", + "build_memberships", + "controllers_by_domain", + "join_controller_issues", + "membership_issues", + "missing_controller_issues", +] diff --git a/implementations/python/packages/aces_sdl/semantics/_domain_topology_types.py b/implementations/python/packages/aces_sdl/semantics/_domain_topology_types.py new file mode 100644 index 000000000..90039d380 --- /dev/null +++ b/implementations/python/packages/aces_sdl/semantics/_domain_topology_types.py @@ -0,0 +1,84 @@ +"""Shared value types for authored identity-domain topology analysis.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import Enum + + +class DomainNodeRole(str, Enum): + """A node's authored role within an identity domain.""" + + CONTROLLER = "controller" + MEMBER = "member" + + +@dataclass(frozen=True) +class DomainTopologyIssue: + """Machine-readable authored-topology consistency issue.""" + + code: str + message: str + + +@dataclass(frozen=True) +class DomainNodeBinding: + """Normalized name-level binding for one domain-participating node.""" + + node_name: str + domain_name: str + role: DomainNodeRole + controller_names: tuple[str, ...] = () + + +@dataclass(frozen=True) +class DomainAccountBinding: + """Normalized name-level binding for one domain-scoped account.""" + + account_name: str + node_name: str + domain_name: str + + +@dataclass(frozen=True) +class DomainTopologyAnalysis: + """Normalized topology facts and fail-closed authoring issues.""" + + node_bindings: Mapping[str, DomainNodeBinding] = field(default_factory=dict) + account_bindings: Mapping[str, DomainAccountBinding] = field(default_factory=dict) + controllers_by_domain: Mapping[str, tuple[str, ...]] = field(default_factory=dict) + issues: tuple[DomainTopologyIssue, ...] = () + + @property + def has_issues(self) -> bool: + return bool(self.issues) + + +def resolve_section_ref(ref: object, section: str, declarations: Mapping[str, object]) -> str | None: + """Resolve a local or section-qualified authored reference.""" + + if not isinstance(ref, str): + return None + if ref in declarations: + return ref + prefix = f"{section}." + qualified = ref[len(prefix) :] if ref.startswith(prefix) else "" + return qualified if qualified in declarations else None + + +def topology_issue(code: str, message: str) -> DomainTopologyIssue: + """Build a normalized authored-topology issue.""" + + return DomainTopologyIssue(code=code, message=message) + + +__all__ = [ + "DomainAccountBinding", + "DomainNodeBinding", + "DomainNodeRole", + "DomainTopologyAnalysis", + "DomainTopologyIssue", + "resolve_section_ref", + "topology_issue", +] diff --git a/implementations/python/packages/aces_sdl/semantics/domain_topology.py b/implementations/python/packages/aces_sdl/semantics/domain_topology.py new file mode 100644 index 000000000..0720c5713 --- /dev/null +++ b/implementations/python/packages/aces_sdl/semantics/domain_topology.py @@ -0,0 +1,384 @@ +"""Pure authored identity-domain topology analysis. + +The analyzer owns the name-level invariants for controller roles, member joins, +and domain-bound accounts. It deliberately describes authored realization +intent and does not inspect observed runtime identity inventory. +""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field + +from ..nodes import NodeType +from ..relationships import RelationshipType +from ._domain_topology_bindings import ( + account_bindings as _account_bindings, +) +from ._domain_topology_bindings import ( + apply_authority_bindings as _apply_authority_bindings, +) +from ._domain_topology_bindings import ( + build_memberships as _build_memberships, +) +from ._domain_topology_bindings import ( + controllers_by_domain as _controllers_by_domain, +) +from ._domain_topology_bindings import ( + join_controller_issues as _join_controller_issues, +) +from ._domain_topology_bindings import ( + membership_issues as _membership_issues, +) +from ._domain_topology_bindings import ( + missing_controller_issues as _missing_controller_issues, +) +from ._domain_topology_types import ( + DomainAccountBinding, + DomainNodeBinding, + DomainNodeRole, + DomainTopologyAnalysis, + DomainTopologyIssue, +) +from ._domain_topology_types import ( + resolve_section_ref as _resolve_section_ref, +) +from ._domain_topology_types import ( + topology_issue as _issue, +) + + +def _relationship_type(value: object) -> str: + return value.value if isinstance(value, RelationshipType) else str(value) + + +_RELATIONSHIP_DETAIL_MISMATCH = "domain.relationship.detail-mismatch" + + +@dataclass(frozen=True) +class _TopologyRelationship: + name: str + type_value: str + label: str + node_name: str + domain_name: str + + +@dataclass +class _TopologyFacts: + issues: list[DomainTopologyIssue] = field(default_factory=list) + controller_edges: defaultdict[str, list[tuple[str, str]]] = field(default_factory=lambda: defaultdict(list)) + join_edges: defaultdict[str, list[tuple[str, str, tuple[str, ...]]]] = field( + default_factory=lambda: defaultdict(list) + ) + controller_pairs: set[tuple[str, str]] = field(default_factory=set) + join_pairs: set[tuple[str, str]] = field(default_factory=set) + + +def _controller_detail_issues( + label: str, + controller_detail: object, + join_detail: object, +) -> list[DomainTopologyIssue]: + issues: list[DomainTopologyIssue] = [] + if controller_detail is None: + issues.append( + _issue( + "domain.relationship.controller-detail-required", + f"{label} type 'domain_controller_for' requires domain_controller detail", + ) + ) + if join_detail is not None: + issues.append( + _issue( + _RELATIONSHIP_DETAIL_MISMATCH, + f"{label} type 'domain_controller_for' must not carry domain_join detail", + ) + ) + return issues + + +def _join_detail_issues( + label: str, + controller_detail: object, + join_detail: object, +) -> list[DomainTopologyIssue]: + issues: list[DomainTopologyIssue] = [] + if join_detail is None: + issues.append( + _issue( + "domain.relationship.join-detail-required", + f"{label} type 'joins_domain' requires domain_join detail", + ) + ) + if controller_detail is not None: + issues.append( + _issue( + _RELATIONSHIP_DETAIL_MISMATCH, + f"{label} type 'joins_domain' must not carry domain_controller detail", + ) + ) + return issues + + +def _untyped_detail_issues( + label: str, + type_value: str, + controller_detail: object, + join_detail: object, +) -> list[DomainTopologyIssue]: + issues: list[DomainTopologyIssue] = [] + if controller_detail is not None or join_detail is not None: + issues.append( + _issue( + _RELATIONSHIP_DETAIL_MISMATCH, + f"{label} carries domain topology detail but has type '{type_value}'", + ) + ) + return issues + + +def _relationship_detail_issues( + relationship_name: str, + relationship: object, + is_unresolved: Callable[[object], bool], +) -> list[DomainTopologyIssue]: + relationship_type = getattr(relationship, "type", "") + issues: list[DomainTopologyIssue] = [] + if not is_unresolved(relationship_type): + type_value = _relationship_type(relationship_type) + controller_detail = getattr(relationship, "domain_controller", None) + join_detail = getattr(relationship, "domain_join", None) + label = f"Relationship '{relationship_name}'" + if type_value == RelationshipType.DOMAIN_CONTROLLER_FOR.value: + issues = _controller_detail_issues(label, controller_detail, join_detail) + elif type_value == RelationshipType.JOINS_DOMAIN.value: + issues = _join_detail_issues(label, controller_detail, join_detail) + else: + issues = _untyped_detail_issues(label, type_value, controller_detail, join_detail) + return issues + + +def _typed_relationship_issues( + relationships: Mapping[str, object], + *, + is_unresolved: Callable[[object], bool], +) -> list[DomainTopologyIssue]: + issues: list[DomainTopologyIssue] = [] + for relationship_name, relationship in relationships.items(): + issues.extend(_relationship_detail_issues(relationship_name, relationship, is_unresolved)) + return issues + + +def _topology_relationship( + relationship_name: str, + relationship: object, + identity_domains: Mapping[str, object], + nodes: Mapping[str, object], + is_unresolved: Callable[[object], bool], +) -> tuple[_TopologyRelationship | None, list[DomainTopologyIssue]]: + relationship_type = getattr(relationship, "type", "") + source_ref = getattr(relationship, "source", "") + target_ref = getattr(relationship, "target", "") + if any(is_unresolved(value) for value in (relationship_type, source_ref, target_ref)): + return None, [] + + type_value = _relationship_type(relationship_type) + topology_types = { + RelationshipType.DOMAIN_CONTROLLER_FOR.value, + RelationshipType.JOINS_DOMAIN.value, + } + if type_value not in topology_types: + return None, [] + + issues: list[DomainTopologyIssue] = [] + label = f"Relationship '{relationship_name}'" + node_name = _resolve_section_ref(source_ref, "nodes", nodes) + domain_name = _resolve_section_ref(target_ref, "identity_domains", identity_domains) + if node_name is None: + issues.append( + _issue( + "domain.relationship.source-unbound", + f"{label} domain topology source '{source_ref}' does not resolve to a node", + ) + ) + elif getattr(nodes[node_name], "type", None) != NodeType.VM: + role = "controller" if type_value == RelationshipType.DOMAIN_CONTROLLER_FOR.value else "join" + issues.append( + _issue( + "domain.relationship.source-not-vm", + f"{label} {role} source '{source_ref}' must be a VM node", + ) + ) + if domain_name is None: + issues.append( + _issue( + "domain.relationship.target-unbound", + f"{label} target '{target_ref}' does not resolve to an identity domain", + ) + ) + + topology_relationship = None + if node_name is not None and domain_name is not None: + topology_relationship = _TopologyRelationship( + name=relationship_name, + type_value=type_value, + label=label, + node_name=node_name, + domain_name=domain_name, + ) + return topology_relationship, issues + + +def _record_controller_relationship( + facts: _TopologyFacts, + topology: _TopologyRelationship, + relationship: object, +) -> None: + if getattr(relationship, "domain_controller", None) is None: + return + pair = (topology.node_name, topology.domain_name) + if pair in facts.controller_pairs: + facts.issues.append( + _issue( + "domain.controller.duplicate", + f"Relationship '{topology.name}' repeats duplicate controller fact for node " + f"'{topology.node_name}' and identity domain '{topology.domain_name}'", + ) + ) + else: + facts.controller_pairs.add(pair) + facts.controller_edges[topology.domain_name].append((topology.name, topology.node_name)) + + +def _resolved_join_controllers( + topology: _TopologyRelationship, + join_detail: object, + nodes: Mapping[str, object], + is_unresolved: Callable[[object], bool], +) -> tuple[tuple[str, ...], list[DomainTopologyIssue]]: + controller_names: list[str] = [] + issues: list[DomainTopologyIssue] = [] + for controller_ref in getattr(join_detail, "controller_refs", ()): + if is_unresolved(controller_ref): + continue + controller_name = _resolve_section_ref(controller_ref, "nodes", nodes) + if controller_name is None: + issues.append( + _issue( + "domain.join.controller-unbound", + f"{topology.label} controller_ref '{controller_ref}' does not resolve to a node", + ) + ) + continue + controller_names.append(controller_name) + return tuple(controller_names), issues + + +def _record_join_relationship( + facts: _TopologyFacts, + topology: _TopologyRelationship, + relationship: object, + nodes: Mapping[str, object], + is_unresolved: Callable[[object], bool], +) -> None: + join_detail = getattr(relationship, "domain_join", None) + if join_detail is None: + return + pair = (topology.node_name, topology.domain_name) + if pair in facts.join_pairs: + facts.issues.append( + _issue( + "domain.join.duplicate", + f"Relationship '{topology.name}' repeats duplicate join fact for node " + f"'{topology.node_name}' and identity domain '{topology.domain_name}'", + ) + ) + return + + facts.join_pairs.add(pair) + controller_names, issues = _resolved_join_controllers(topology, join_detail, nodes, is_unresolved) + facts.issues.extend(issues) + facts.join_edges[topology.domain_name].append((topology.name, topology.node_name, controller_names)) + + +def _collect_topology_facts( + identity_domains: Mapping[str, object], + nodes: Mapping[str, object], + relationships: Mapping[str, object], + is_unresolved: Callable[[object], bool], +) -> _TopologyFacts: + facts = _TopologyFacts(issues=_typed_relationship_issues(relationships, is_unresolved=is_unresolved)) + for relationship_name, relationship in relationships.items(): + topology, issues = _topology_relationship( + relationship_name, + relationship, + identity_domains, + nodes, + is_unresolved, + ) + facts.issues.extend(issues) + if topology is None: + continue + if topology.type_value == RelationshipType.DOMAIN_CONTROLLER_FOR.value: + _record_controller_relationship(facts, topology, relationship) + else: + _record_join_relationship(facts, topology, relationship, nodes, is_unresolved) + return facts + + +def analyze_domain_topology( + *, + identity_domains: Mapping[str, object], + nodes: Mapping[str, object], + accounts: Mapping[str, object], + relationships: Mapping[str, object], + is_unresolved: Callable[[object], bool], +) -> DomainTopologyAnalysis: + """Validate and normalize authored controller, join, and account facts.""" + + facts = _collect_topology_facts(identity_domains, nodes, relationships, is_unresolved) + controllers_by_domain = _controllers_by_domain(identity_domains, facts.controller_edges) + facts.issues.extend(_missing_controller_issues(controllers_by_domain)) + facts.issues.extend(_join_controller_issues(facts.join_edges, controllers_by_domain)) + + memberships = _build_memberships(controllers_by_domain, facts.join_edges) + facts.issues.extend(_membership_issues(memberships, identity_domains)) + node_bindings = {node_name: bindings[0] for node_name, bindings in memberships.items() if bindings} + + account_bindings, account_issues = _account_bindings( + accounts, + identity_domains, + nodes, + memberships, + is_unresolved, + ) + facts.issues.extend(account_issues) + facts.issues.extend( + _apply_authority_bindings( + identity_domains, + accounts, + nodes, + controllers_by_domain, + account_bindings, + is_unresolved, + ) + ) + + return DomainTopologyAnalysis( + node_bindings=node_bindings, + account_bindings=account_bindings, + controllers_by_domain=controllers_by_domain, + issues=tuple(facts.issues), + ) + + +__all__ = [ + "DomainAccountBinding", + "DomainNodeBinding", + "DomainNodeRole", + "DomainTopologyAnalysis", + "DomainTopologyIssue", + "analyze_domain_topology", +] diff --git a/implementations/python/packages/aces_sdl/validator/__init__.py b/implementations/python/packages/aces_sdl/validator/__init__.py index 26613e9e4..8ab8cac48 100644 --- a/implementations/python/packages/aces_sdl/validator/__init__.py +++ b/implementations/python/packages/aces_sdl/validator/__init__.py @@ -4,6 +4,7 @@ from ._content_objectives import _ContentObjectivesMixin from ._core import _ValidatorCore +from ._domain_topology import _DomainTopologyMixin from ._evidence_requirements import _EvidenceRequirementsMixin from ._nodes_infra_network import _NodesInfraNetworkMixin from ._propositions import _PropositionsMixin @@ -28,6 +29,7 @@ class SemanticValidator( _RuntimePlatformMixin, _RuntimeOrchestrationMixin, _RuntimeMailMixin, + _DomainTopologyMixin, _RelationshipsMixin, _RelationshipsProxyMixin, _ContentObjectivesMixin, diff --git a/implementations/python/packages/aces_sdl/validator/_core.py b/implementations/python/packages/aces_sdl/validator/_core.py index 767f8bbc5..d31d81228 100644 --- a/implementations/python/packages/aces_sdl/validator/_core.py +++ b/implementations/python/packages/aces_sdl/validator/_core.py @@ -227,6 +227,7 @@ def validate(self) -> None: self._verify_content() self._verify_accounts() self._verify_relationships() + self._verify_domain_topology() self._verify_relationship_database_access() self._verify_relationship_mail_access() self._verify_relationship_forwarding_edges() diff --git a/implementations/python/packages/aces_sdl/validator/_domain_topology.py b/implementations/python/packages/aces_sdl/validator/_domain_topology.py new file mode 100644 index 000000000..98fbe4069 --- /dev/null +++ b/implementations/python/packages/aces_sdl/validator/_domain_topology.py @@ -0,0 +1,16 @@ +"""Semantic validation adapter for authored identity-domain topology.""" + +from ..semantics.domain_topology import analyze_domain_topology + + +class _DomainTopologyMixin: + def _verify_domain_topology(self) -> None: + analysis = analyze_domain_topology( + identity_domains=self._s.identity_domains, + nodes=self._s.nodes, + accounts=self._s.accounts, + relationships=self._s.relationships, + is_unresolved=self._is_unresolved_var, + ) + for issue in analysis.issues: + self._err(issue.message) diff --git a/implementations/python/packages/aces_sdl/value_parsing.py b/implementations/python/packages/aces_sdl/value_parsing.py index 13627ae01..01504b393 100644 --- a/implementations/python/packages/aces_sdl/value_parsing.py +++ b/implementations/python/packages/aces_sdl/value_parsing.py @@ -1,9 +1,19 @@ """Public value parsing helpers shared with processor support analysis.""" -from ._base import extract_variable_name, parse_enum_or_var, parse_int_or_var +from ._base import ( + VARIABLE_REFERENCE_SCHEMA_MARKER, + WholeFieldVariableReference, + extract_variable_name, + is_variable_ref, + parse_enum_or_var, + parse_int_or_var, +) __all__ = [ + "VARIABLE_REFERENCE_SCHEMA_MARKER", + "WholeFieldVariableReference", "extract_variable_name", + "is_variable_ref", "parse_enum_or_var", "parse_int_or_var", ] diff --git a/implementations/python/pyproject.toml b/implementations/python/pyproject.toml index 217522487..ce62eceeb 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.20.0" +version = "0.21.0" description = "Backend-agnostic cyber range scenario description language and runtime." dynamic = ["readme"] requires-python = ">=3.11" diff --git a/implementations/python/tests/test_authored_domain_topology.py b/implementations/python/tests/test_authored_domain_topology.py new file mode 100644 index 000000000..01c5e33ec --- /dev/null +++ b/implementations/python/tests/test_authored_domain_topology.py @@ -0,0 +1,769 @@ +"""Authored identity-domain topology contracts (issue #763).""" + +from __future__ import annotations + +import textwrap +from copy import deepcopy +from dataclasses import replace +from pathlib import Path + +import pytest +import yaml +from aces_backend_libvirt.capability_envelope import capability_envelope_diagnostics +from aces_backend_libvirt.manifest import LIBVIRT_PROVISIONER_CAPABILITIES +from aces_backend_protocols.backend_manifest import BackendManifest +from aces_backend_protocols.domain_topology import domain_topology_plan_diagnostics +from aces_backend_stubs.stubs import create_stub_manifest, create_stub_target +from aces_contracts.contracts import schema_bundle +from aces_contracts.planning import ProvisioningPlan, ProvisionOp, RuntimeDomain +from aces_contracts.runtime_state import RuntimeSnapshot, SnapshotEntry +from aces_processor.compiler import compile_runtime_model +from aces_processor.models import resource_payload +from aces_processor.planner import plan +from aces_processor.semantics.realization import realization_disclosure +from aces_reference_backend import create_reference_backend_manifest +from aces_runtime.control_plane import RuntimeControlPlane +from aces_sdl import SDLParseError, SDLValidationError, parse_sdl, parse_sdl_file +from aces_sdl.language_service import language_completions +from jsonschema import Draft202012Validator + +_INSTANTIATION_PROVENANCE = { + "authored_digest": { + "profile": "aces-sdl-semantic/v1", + "algorithm": "sha256", + "value": "sha256:" + "a" * 64, + } +} + + +def _scenario(source: str, *, skip_semantic_validation: bool = False): + return parse_sdl( + textwrap.dedent(source), + skip_semantic_validation=skip_semantic_validation, + ) + + +def _valid_payload() -> dict[str, object]: + return { + "name": "domain-lab", + "nodes": { + "dc": {"type": "vm", "os": "windows"}, + "workstation": {"type": "vm", "os": "windows"}, + }, + "accounts": { + "domain-admin": {"username": "Administrator", "node": "dc"}, + "web-service": { + "username": "svc-web", + "node": "workstation", + "spn": "HTTP/workstation.corp.example", + "domain_ref": "corp", + }, + }, + "identity_domains": { + "corp": { + "profile": "active_directory", + "dns_name": "corp.example", + "netbios_name": "CORP", + "authority_account_ref": "domain-admin", + } + }, + "relationships": { + "dc-role": { + "type": "domain_controller_for", + "source": "dc", + "target": "corp", + "domain_controller": {}, + }, + "workstation-join": { + "type": "joins_domain", + "source": "workstation", + "target": "corp", + "domain_join": {"controller_refs": ["dc"]}, + }, + }, + } + + +def _parse_payload(payload: dict[str, object]): + return parse_sdl(yaml.safe_dump(payload, sort_keys=False)) + + +def _manifest_with_domain_profiles(*profiles: str) -> BackendManifest: + base = create_stub_manifest() + capabilities = replace( + base.capabilities, + provisioner=replace( + base.provisioner, + supported_domain_profiles=frozenset(profiles), + ), + ) + return BackendManifest( + identity=base.identity, + supported_contract_versions=base.supported_contract_versions, + compatibility=base.compatibility, + realization_support=base.realization_support, + concept_bindings=base.concept_bindings, + constraints=base.constraints, + capabilities=capabilities, + realization_envelope=base.realization_envelope, + ) + + +def _snapshot_entry_from_operation(operation: ProvisionOp) -> SnapshotEntry: + return SnapshotEntry( + address=operation.address, + domain=RuntimeDomain.PROVISIONING, + resource_type=operation.resource_type, + payload=deepcopy(operation.payload), + ordering_dependencies=operation.ordering_dependencies, + refresh_dependencies=operation.refresh_dependencies, + ) + + +def test_authored_active_directory_topology_has_typed_shape() -> None: + scenario = _scenario( + """ + name: domain-lab + nodes: + dc: + type: vm + os: windows + workstation: + type: vm + os: windows + accounts: + domain-admin: + username: Administrator + node: dc + web-service: + username: svc-web + node: workstation + spn: HTTP/workstation.corp.example + domain_ref: corp + identity_domains: + corp: + profile: active_directory + dns_name: corp.example + netbios_name: CORP + authority_account_ref: domain-admin + relationships: + dc-role: + type: domain_controller_for + source: dc + target: corp + domain_controller: {} + workstation-join: + type: joins_domain + source: workstation + target: corp + domain_join: + controller_refs: [dc] + """ + ) + + domain = scenario.identity_domains["corp"] + assert domain.profile.value == "active_directory" + assert domain.dns_name == "corp.example" + assert domain.netbios_name == "CORP" + assert domain.authority_account_ref == "domain-admin" + assert scenario.accounts["web-service"].domain_ref == "corp" + assert scenario.relationships["dc-role"].domain_controller is not None + assert scenario.relationships["workstation-join"].domain_join.controller_refs == ["dc"] + + +def test_domain_profile_schemas_are_closed_at_each_phase_boundary() -> None: + bundle = schema_bundle() + authoring_payload = _valid_payload() + authored_variable = deepcopy(authoring_payload) + authored_variable["identity_domains"]["corp"]["profile"] = "${profile}" + authored_unknown = deepcopy(authoring_payload) + authored_unknown["identity_domains"]["corp"]["profile"] = "ldap" + + authoring = Draft202012Validator(bundle["sdl-authoring-input-v1"]) + assert authoring.is_valid(authoring_payload) + assert authoring.is_valid(authored_variable) + assert not authoring.is_valid(authored_unknown) + + instantiated_payload = {**authoring_payload, "instantiation_provenance": _INSTANTIATION_PROVENANCE} + instantiated_variable = {**authored_variable, "instantiation_provenance": _INSTANTIATION_PROVENANCE} + instantiated_unknown = {**authored_unknown, "instantiation_provenance": _INSTANTIATION_PROVENANCE} + instantiated = Draft202012Validator(bundle["instantiated-scenario-v1"]) + assert instantiated.is_valid(instantiated_payload) + assert not instantiated.is_valid(instantiated_variable) + assert not instantiated.is_valid(instantiated_unknown) + + snapshot = Draft202012Validator(bundle["instantiated-scenario-snapshot-v1"]) + assert snapshot.is_valid({"profile": "aces-sdl-instantiated-snapshot/v1", "scenario": instantiated_payload}) + assert not snapshot.is_valid({"profile": "aces-sdl-instantiated-snapshot/v1", "scenario": instantiated_variable}) + assert not snapshot.is_valid({"profile": "aces-sdl-instantiated-snapshot/v1", "scenario": instantiated_unknown}) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("dns_name", "-invalid.example"), + ("dns_name", "invalid name.example"), + ("netbios_name", "NAME-THAT-IS-TOO-LONG"), + ("netbios_name", "INVALID/NAME"), + ], +) +def test_active_directory_profile_rejects_invalid_names(field: str, value: str) -> None: + dns_name = value if field == "dns_name" else "corp.example" + netbios_name = value if field == "netbios_name" else "CORP" + with pytest.raises(SDLParseError, match=field): + _scenario( + f""" + name: invalid-domain-name + identity_domains: + corp: + profile: active_directory + dns_name: {dns_name!r} + netbios_name: {netbios_name!r} + authority_account_ref: domain-admin + """ + ) + + +def test_domain_join_rejects_duplicate_controller_candidates() -> None: + with pytest.raises(SDLParseError, match="controller_refs must be unique"): + _scenario( + """ + name: duplicate-controller-candidates + relationships: + join: + type: joins_domain + source: member + target: corp + domain_join: + controller_refs: [dc, dc] + """, + skip_semantic_validation=True, + ) + + +def test_domain_requires_a_controller_edge() -> None: + payload = _valid_payload() + del payload["relationships"]["dc-role"] + + with pytest.raises(SDLValidationError, match="Identity domain 'corp' has no controller"): + _parse_payload(payload) + + +def test_domain_authority_account_must_be_placed_on_its_controller() -> None: + payload = _valid_payload() + payload["accounts"]["domain-admin"]["node"] = "workstation" + + with pytest.raises(SDLValidationError, match="authority account 'domain-admin'.*controller"): + _parse_payload(payload) + + +def test_join_controller_candidate_must_control_the_same_domain() -> None: + payload = _valid_payload() + payload["nodes"]["other-dc"] = {"type": "vm", "os": "windows"} + payload["accounts"]["other-admin"] = {"username": "Administrator", "node": "other-dc"} + payload["identity_domains"]["other"] = { + "profile": "active_directory", + "dns_name": "other.example", + "netbios_name": "OTHER", + "authority_account_ref": "other-admin", + } + payload["relationships"]["other-controller"] = { + "type": "domain_controller_for", + "source": "other-dc", + "target": "other", + "domain_controller": {}, + } + payload["relationships"]["workstation-join"]["domain_join"]["controller_refs"] = ["other-dc"] + + with pytest.raises(SDLValidationError, match="controller_ref 'other-dc'.*same domain 'corp'"): + _parse_payload(payload) + + +def test_spn_requires_an_explicit_domain_binding() -> None: + payload = _valid_payload() + del payload["accounts"]["web-service"]["domain_ref"] + + with pytest.raises(SDLValidationError, match="Account 'web-service'.*SPN.*domain_ref"): + _parse_payload(payload) + + +def test_domain_bound_account_node_must_belong_to_the_domain() -> None: + payload = _valid_payload() + payload["nodes"]["outsider"] = {"type": "vm", "os": "windows"} + payload["accounts"]["web-service"]["node"] = "outsider" + + with pytest.raises(SDLValidationError, match="Account 'web-service'.*node 'outsider'.*domain 'corp'"): + _parse_payload(payload) + + +def test_domain_relationship_type_requires_matching_typed_detail() -> None: + payload = _valid_payload() + del payload["relationships"]["dc-role"]["domain_controller"] + + with pytest.raises(SDLValidationError, match="Relationship 'dc-role'.*requires domain_controller"): + _parse_payload(payload) + + +def test_controller_role_rejects_switch_nodes() -> None: + payload = _valid_payload() + payload["nodes"]["dc"] = {"type": "switch"} + + with pytest.raises(SDLValidationError, match="Relationship 'dc-role'.*controller source 'dc'.*VM"): + _parse_payload(payload) + + +def test_node_cannot_control_multiple_active_directory_domains() -> None: + payload = _valid_payload() + payload["accounts"]["other-admin"] = {"username": "Administrator", "node": "dc"} + payload["identity_domains"]["other"] = { + "profile": "active_directory", + "dns_name": "other.example", + "netbios_name": "OTHER", + "authority_account_ref": "other-admin", + } + payload["relationships"]["other-controller"] = { + "type": "domain_controller_for", + "source": "dc", + "target": "other", + "domain_controller": {}, + } + + with pytest.raises(SDLValidationError, match="Node 'dc'.*multiple active_directory domains"): + _parse_payload(payload) + + +def test_duplicate_controller_edges_are_rejected() -> None: + payload = _valid_payload() + payload["relationships"]["duplicate-controller"] = dict(payload["relationships"]["dc-role"]) + + with pytest.raises(SDLValidationError, match="duplicate controller fact"): + _parse_payload(payload) + + +def test_controller_cannot_also_declare_a_redundant_join() -> None: + payload = _valid_payload() + payload["relationships"]["dc-join"] = { + "type": "joins_domain", + "source": "dc", + "target": "corp", + "domain_join": {"controller_refs": ["dc"]}, + } + + with pytest.raises(SDLValidationError, match="Node 'dc'.*controller.*redundant join"): + _parse_payload(payload) + + +def test_compiler_projects_normalized_domain_topology_and_ordering() -> None: + model = compile_runtime_model(_parse_payload(_valid_payload())) + + controller = model.node_deployments["provision.node.dc"] + member = model.node_deployments["provision.node.workstation"] + controller_binding = controller.domain_topology + member_binding = member.domain_topology + + assert controller_binding.domain_id == "corp" + assert controller_binding.profile == "active_directory" + assert controller_binding.dns_name == "corp.example" + assert controller_binding.netbios_name == "CORP" + assert controller_binding.authority_account_address == "provision.account.domain-admin" + assert controller_binding.role == "controller" + assert controller_binding.controller_addresses == ("provision.node.dc",) + + assert member_binding.domain_id == "corp" + assert member_binding.role == "member" + assert member_binding.controller_addresses == ("provision.node.dc",) + assert member.ordering_dependencies == ("provision.node.dc",) + assert member.refresh_dependencies == ("provision.node.dc",) + + payload = resource_payload(member) + assert payload["domain_topology"]["domain_id"] == "corp" + assert payload["domain_topology"]["controller_addresses"] == ("provision.node.dc",) + + +def test_compiler_projects_domain_binding_to_subject_and_authority_accounts() -> None: + model = compile_runtime_model(_parse_payload(_valid_payload())) + + authority = model.account_placements["provision.account.domain-admin"] + subject = model.account_placements["provision.account.web-service"] + + assert authority.domain_topology.role == "controller" + assert authority.domain_topology.domain_id == "corp" + assert subject.domain_topology.role == "member" + assert subject.domain_topology.domain_id == "corp" + assert authority.ordering_dependencies == ("provision.node.dc",) + assert subject.ordering_dependencies == ("provision.node.workstation",) + + +def test_domain_topology_variables_are_instantiated_before_compilation() -> None: + scenario = _scenario( + """ + name: parameterized-domain + variables: + profile: {type: string, default: active_directory} + dns: {type: string, default: corp.example} + netbios: {type: string, default: CORP} + domain: {type: string, default: corp} + controller: {type: string, default: dc} + nodes: + dc: {type: vm, os: windows} + member: {type: vm, os: windows} + accounts: + admin: {username: Administrator, node: dc} + service: {username: svc, node: member, spn: HTTP/member.corp.example, domain_ref: '${domain}'} + identity_domains: + corp: + profile: '${profile}' + dns_name: '${dns}' + netbios_name: '${netbios}' + authority_account_ref: admin + relationships: + controller: + type: domain_controller_for + source: dc + target: corp + domain_controller: {} + join: + type: joins_domain + source: member + target: corp + domain_join: {controller_refs: ['${controller}']} + """ + ) + + model = compile_runtime_model(scenario) + + binding = model.node_deployments["provision.node.member"].domain_topology + assert binding.profile == "active_directory" + assert binding.dns_name == "corp.example" + assert binding.netbios_name == "CORP" + assert binding.controller_addresses == ("provision.node.dc",) + + +def test_module_composition_namespaces_all_domain_topology_references(tmp_path: Path) -> None: + imported = tmp_path / "domain.yaml" + imported.write_text( + textwrap.dedent( + """ + name: domain-module + version: 1.0.0 + module: + id: aces/domain-module + version: 1.0.0 + exports: + nodes: [dc, member] + accounts: [admin, service] + identity_domains: [corp] + relationships: [controller, join] + nodes: + dc: {type: vm, os: windows} + member: {type: vm, os: windows} + accounts: + admin: {username: Administrator, node: dc} + service: {username: svc, node: member, spn: HTTP/member.corp.example, domain_ref: corp} + identity_domains: + corp: + profile: active_directory + dns_name: corp.example + netbios_name: CORP + authority_account_ref: admin + relationships: + controller: + type: domain_controller_for + source: dc + target: corp + domain_controller: {} + join: + type: joins_domain + source: member + target: corp + domain_join: {controller_refs: [dc]} + """ + ), + encoding="utf-8", + ) + root = tmp_path / "root.yaml" + root.write_text( + textwrap.dedent( + """ + name: root + imports: + - path: domain.yaml + namespace: shared + version: 1.0.0 + """ + ), + encoding="utf-8", + ) + + scenario = parse_sdl_file(root) + + assert scenario.identity_domains["shared.corp"].authority_account_ref == "shared.admin" + assert scenario.accounts["shared.service"].domain_ref == "shared.corp" + assert scenario.relationships["shared.join"].domain_join.controller_refs == ["shared.dc"] + model = compile_runtime_model(scenario) + assert model.node_deployments["provision.node.shared.member"].domain_topology.domain_id == "shared.corp" + + +def test_planner_rejects_domain_profile_outside_provisioner_capabilities() -> None: + model = compile_runtime_model(_parse_payload(_valid_payload())) + + execution_plan = plan(model, _manifest_with_domain_profiles()) + + assert any( + diagnostic.code == "provisioner.unsupported-domain-profile" and diagnostic.address == "provision.node.dc" + for diagnostic in execution_plan.diagnostics + ) + + +def test_planner_accepts_explicitly_supported_domain_profile() -> None: + model = compile_runtime_model(_parse_payload(_valid_payload())) + + execution_plan = plan(model, _manifest_with_domain_profiles("active_directory")) + + assert not any("domain-profile" in diagnostic.code for diagnostic in execution_plan.diagnostics) + + +def test_reference_backend_rejects_unrealized_domain_topology_and_spn() -> None: + model = compile_runtime_model(_parse_payload(_valid_payload())) + + execution_plan = plan(model, create_reference_backend_manifest()) + + codes = {diagnostic.code for diagnostic in execution_plan.diagnostics} + assert "provisioner.unsupported-domain-profile" in codes + assert "provisioner.unsupported-account-feature" in codes + + +def test_libvirt_capability_envelope_rejects_domain_profile_independently() -> None: + model = compile_runtime_model(_parse_payload(_valid_payload())) + provisioning = plan(model, _manifest_with_domain_profiles("active_directory")).provisioning + + diagnostics = capability_envelope_diagnostics(provisioning, LIBVIRT_PROVISIONER_CAPABILITIES) + + assert any( + diagnostic.code == "libvirt-backend.realization.unsupported-domain-profile" + and diagnostic.address == "provision.node.dc" + for diagnostic in diagnostics + ) + + +def test_libvirt_capability_envelope_ignores_resources_without_domain_topology() -> None: + scenario = _scenario( + """ + name: ordinary-workstation + nodes: + workstation: {type: vm, os: windows} + accounts: + local-user: {username: local, node: workstation} + """ + ) + provisioning = plan(compile_runtime_model(scenario), _manifest_with_domain_profiles()).provisioning + + diagnostics = capability_envelope_diagnostics(provisioning, LIBVIRT_PROVISIONER_CAPABILITIES) + + assert not any( + diagnostic.code == "libvirt-backend.realization.unsupported-domain-profile" for diagnostic in diagnostics + ) + + +def test_shared_plan_analysis_accepts_compiler_emitted_topology() -> None: + model = compile_runtime_model(_parse_payload(_valid_payload())) + provisioning = plan(model, _manifest_with_domain_profiles("active_directory")).provisioning + + diagnostics = domain_topology_plan_diagnostics( + provisioning, + supported_domain_profiles=frozenset({"active_directory"}), + ) + + assert diagnostics == [] + + +def test_shared_plan_analysis_resolves_controller_from_snapshot_for_incremental_member() -> None: + model = compile_runtime_model(_parse_payload(_valid_payload())) + provisioning = plan(model, _manifest_with_domain_profiles("active_directory")).provisioning + operations = {operation.address: operation for operation in provisioning.operations} + snapshot_addresses = ( + "provision.node.dc", + "provision.account.domain-admin", + ) + snapshot = RuntimeSnapshot( + entries={address: _snapshot_entry_from_operation(operations[address]) for address in snapshot_addresses} + ) + incremental_plan = ProvisioningPlan( + operations=[operations["provision.node.workstation"]], + ) + + diagnostics = domain_topology_plan_diagnostics( + incremental_plan, + snapshot=snapshot, + supported_domain_profiles=frozenset({"active_directory"}), + ) + + assert diagnostics == [] + + +def test_shared_plan_analysis_operation_overrides_stale_snapshot_entry() -> None: + model = compile_runtime_model(_parse_payload(_valid_payload())) + provisioning = plan(model, _manifest_with_domain_profiles("active_directory")).provisioning + operations = {operation.address: operation for operation in provisioning.operations} + member_address = "provision.node.workstation" + stale_member = _snapshot_entry_from_operation(operations[member_address]) + stale_payload = deepcopy(stale_member.payload) + stale_payload["domain_topology"]["dns_name"] = "legacy.example" + stale_member = replace(stale_member, payload=stale_payload) + snapshot_entries = { + address: _snapshot_entry_from_operation(operations[address]) + for address in ( + "provision.node.dc", + "provision.account.domain-admin", + ) + } + snapshot_entries[member_address] = stale_member + snapshot = RuntimeSnapshot(entries=snapshot_entries) + update_plan = ProvisioningPlan(operations=[operations[member_address]]) + + diagnostics = domain_topology_plan_diagnostics( + update_plan, + snapshot=snapshot, + supported_domain_profiles=frozenset({"active_directory"}), + ) + + assert diagnostics == [] + + +def test_shared_plan_analysis_rejects_member_without_controller_ordering() -> None: + model = compile_runtime_model(_parse_payload(_valid_payload())) + provisioning = plan(model, _manifest_with_domain_profiles("active_directory")).provisioning + operations = [ + replace(operation, ordering_dependencies=(), refresh_dependencies=()) + if operation.address == "provision.node.workstation" + else operation + for operation in provisioning.operations + ] + direct_plan = ProvisioningPlan(operations=operations) + + diagnostics = domain_topology_plan_diagnostics(direct_plan) + + assert any( + diagnostic.code == "provisioning.domain-topology.controller-dependency-missing" + and diagnostic.address == "provision.node.workstation" + for diagnostic in diagnostics + ) + + +def test_shared_plan_analysis_rejects_account_binding_that_disagrees_with_node() -> None: + model = compile_runtime_model(_parse_payload(_valid_payload())) + provisioning = plan(model, _manifest_with_domain_profiles("active_directory")).provisioning + operations = [] + for operation in provisioning.operations: + if operation.address != "provision.account.web-service": + operations.append(operation) + continue + payload = deepcopy(operation.payload) + payload["domain_topology"]["domain_id"] = "other" + operations.append(replace(operation, payload=payload)) + direct_plan = ProvisioningPlan(operations=operations) + + diagnostics = domain_topology_plan_diagnostics(direct_plan) + + assert any( + diagnostic.code == "provisioning.domain-topology.account-node-mismatch" + and diagnostic.address == "provision.account.web-service" + for diagnostic in diagnostics + ) + + +def test_control_plane_rejects_incoherent_domain_topology_before_backend_validation() -> None: + model = compile_runtime_model(_parse_payload(_valid_payload())) + provisioning = plan(model, _manifest_with_domain_profiles("active_directory")).provisioning + operations = [ + replace(operation, ordering_dependencies=(), refresh_dependencies=()) + if operation.address == "provision.node.workstation" + else operation + for operation in provisioning.operations + ] + direct_plan = ProvisioningPlan(operations=operations) + control_plane = RuntimeControlPlane(create_stub_target()) + + receipt = control_plane.submit_provisioning(direct_plan) + + assert receipt.accepted is False + assert [diagnostic.code for diagnostic in receipt.diagnostics] == [ + "provisioning.domain-topology.controller-dependency-missing" + ] + + +def test_domain_topology_is_an_exact_realization_requirement_for_every_carrier() -> None: + model = compile_runtime_model(_parse_payload(_valid_payload())) + + requirements = [ + requirement + for requirement in model.realization_requirements + if requirement.requirement_kind == "domain-topology" + ] + + assert {requirement.address for requirement in requirements} == { + "provision.node.dc", + "provision.node.workstation", + "provision.account.domain-admin", + "provision.account.web-service", + } + assert all(requirement.explicitness.value == "exact" for requirement in requirements) + assert all(requirement.provenance.value == "processor-derived" for requirement in requirements) + + +def test_domain_topology_readback_rejects_silent_approximation() -> None: + model = compile_runtime_model(_parse_payload(_valid_payload())) + provisioning = plan(model, _manifest_with_domain_profiles("active_directory")).provisioning + entries = {} + for operation in provisioning.operations: + payload = deepcopy(operation.payload) + if operation.address == "provision.node.workstation": + payload["domain_topology"]["dns_name"] = "approximated.example" + entries[operation.address] = SnapshotEntry( + address=operation.address, + domain=RuntimeDomain.PROVISIONING, + resource_type=operation.resource_type, + payload=payload, + ordering_dependencies=operation.ordering_dependencies, + refresh_dependencies=operation.refresh_dependencies, + ) + snapshot = RuntimeSnapshot(entries=entries) + + diagnostics, _provenance = realization_disclosure( + model.realization_requirements, + provisioning, + snapshot, + ) + + assert any( + diagnostic.code == "runtime.backend-contract-invalid" and diagnostic.address == "provision.node.workstation" + for diagnostic in diagnostics + ) + + +def test_language_service_completes_domain_fields_and_references() -> None: + source = yaml.safe_dump(_valid_payload(), sort_keys=False) + + domain_fields = language_completions(source, cursor_path="/identity_domains/corp") + account_domains = language_completions(source, cursor_path="/accounts/web-service/domain_ref") + authority_accounts = language_completions( + source, + cursor_path="/identity_domains/corp/authority_account_ref", + ) + join_controllers = language_completions( + source, + cursor_path="/relationships/workstation-join/domain_join/controller_refs", + ) + + assert {item["label"] for item in domain_fields["items"]} >= { + "profile", + "dns_name", + "netbios_name", + "authority_account_ref", + } + assert {item["label"] for item in account_domains["items"]} == {"corp"} + assert {item["label"] for item in authority_accounts["items"]} == { + "domain-admin", + "web-service", + } + assert {item["label"] for item in join_controllers["items"]} == {"dc", "workstation"} diff --git a/implementations/python/tests/test_backend_manifest.py b/implementations/python/tests/test_backend_manifest.py index 8d5038c7f..9b2ff819b 100644 --- a/implementations/python/tests/test_backend_manifest.py +++ b/implementations/python/tests/test_backend_manifest.py @@ -845,6 +845,6 @@ def test_backend_manifest_v2_rejects_duplicate_binding_scopes(): def test_backend_manifest_v2_concept_bindings_roundtrip(): payload = json.loads((V2_VALID_DIR / "stub.json").read_text(encoding="utf-8")) model = BackendManifestV2Model.model_validate(payload) - assert len(model.concept_bindings) == 12 + assert len(model.concept_bindings) == 13 assert model.concept_bindings[0].scope == "capabilities.provisioner.supported_node_types" assert model.concept_bindings[0].family == "assets" diff --git a/implementations/python/tests/test_instantiated_scenario_schema.py b/implementations/python/tests/test_instantiated_scenario_schema.py index 6742c53f4..525d4194a 100644 --- a/implementations/python/tests/test_instantiated_scenario_schema.py +++ b/implementations/python/tests/test_instantiated_scenario_schema.py @@ -92,7 +92,7 @@ def test_instantiated_model_rejects_unresolved_variables(payload: dict) -> None: @pytest.mark.parametrize( ("field", "value"), - (("variables", {}), ("imports", []), ("module", None)), + (("variables", {}), ("imports", []), ("module", None), ("realization", None)), ) def test_instantiated_model_rejects_authoring_fields_even_when_empty(field: str, value: object) -> None: with pytest.raises(ValidationError, match="Extra inputs are not permitted"): @@ -107,8 +107,7 @@ def test_bundle_instantiated_schema_constraints_differ_from_authoring() -> None: bundle = schema_bundle() authoring = json.dumps(bundle["sdl-authoring-input-v1"]) instantiated = json.dumps(bundle["instantiated-scenario-v1"]) - assert _PATTERN_IN_JSON not in authoring - assert instantiated.count(_PATTERN_IN_JSON) > 1 + assert instantiated.count(_PATTERN_IN_JSON) > authoring.count(_PATTERN_IN_JSON) @pytest.mark.parametrize("payload", _VAR_PAYLOADS) @@ -137,6 +136,7 @@ def test_bundle_instantiated_schema_accepts_concrete_scenario() -> None: {**_CONCRETE, "variables": {}}, {**_CONCRETE, "imports": []}, {**_CONCRETE, "module": None}, + {**_CONCRETE, "realization": None}, ), ) def test_bundle_instantiated_schema_enforces_closed_phase_shape(payload: dict) -> None: @@ -155,8 +155,7 @@ def test_published_schemas_differ_in_constraints() -> None: """Acceptance (a) against the published, shipped schema files.""" authoring = _load(SDL_SCHEMA_DIR / "sdl-authoring-input-v1.json") instantiated = _load(SDL_SCHEMA_DIR / "instantiated-scenario-v1.json") - assert _PATTERN_IN_JSON not in json.dumps(authoring) - assert json.dumps(instantiated).count(_PATTERN_IN_JSON) > 1 + assert json.dumps(instantiated).count(_PATTERN_IN_JSON) > json.dumps(authoring).count(_PATTERN_IN_JSON) def test_published_valid_fixture_passes() -> None: diff --git a/implementations/python/tests/test_libvirt_backend_envelopes.py b/implementations/python/tests/test_libvirt_backend_envelopes.py index 5e7ac6747..110185cd6 100644 --- a/implementations/python/tests/test_libvirt_backend_envelopes.py +++ b/implementations/python/tests/test_libvirt_backend_envelopes.py @@ -5,10 +5,12 @@ from dataclasses import replace from textwrap import dedent +import aces_backend_libvirt.manifest as libvirt_manifest_module import pytest +from aces_backend_libvirt.envelopes import LibvirtDriverMode, load_libvirt_realization_envelope from aces_backend_libvirt.manifest import create_libvirt_manifest from aces_backend_libvirt.provisioner import LibvirtProvisioner -from aces_backend_libvirt.target import create_libvirt_components, create_libvirt_target +from aces_backend_libvirt.target import _validate_manifest_mode, create_libvirt_components, create_libvirt_target from aces_backend_libvirt.techvault_native import TechVaultNativeLibvirtDriver from aces_backend_protocols.manifest import backend_manifest_payload from aces_contracts.realization_envelope import BackendRealizationEnvelopeModel, realization_envelope_digest @@ -37,6 +39,26 @@ def test_operational_config_does_not_change_generic_material_identity(): assert default.identity == configured.identity +def test_manifest_projects_domain_profiles_from_selected_envelope(monkeypatch): + envelope = load_libvirt_realization_envelope(LibvirtDriverMode.GENERIC) + configuration = envelope.configuration.model_copy( + update={"supported_domain_profiles": ["active_directory"]}, + ) + widened = envelope.model_copy(update={"configuration": configuration}) + monkeypatch.setattr(libvirt_manifest_module, "load_libvirt_realization_envelope", lambda _mode: widened) + + capabilities = libvirt_manifest_module._provisioner_capabilities(LibvirtDriverMode.GENERIC) + + assert capabilities.supported_domain_profiles == frozenset({"active_directory"}) + + +def test_manifest_binds_domain_profile_capability_to_identities(): + manifest = create_libvirt_manifest() + + bindings = {binding.scope: binding.family for binding in manifest.concept_bindings} + assert bindings["capabilities.provisioner.supported_domain_profiles"] == "identities" + + def test_injected_driver_requires_explicit_mode(): class DriverWithoutMode: pass @@ -119,6 +141,21 @@ def test_manifest_broader_than_selected_envelope_fails_before_driver_io(): assert not driver.recorded_ops +def test_domain_profile_manifest_drift_fails_before_driver_io(): + manifest = create_libvirt_manifest(driver_mode="generic") + broader_provisioner = replace( + manifest.provisioner, + supported_domain_profiles=frozenset({"active_directory"}), + ) + broader_manifest = replace( + manifest, + capabilities=replace(manifest.capabilities, provisioner=broader_provisioner), + ) + + with pytest.raises(ValueError, match="capabilities do not match realization envelope"): + _validate_manifest_mode(broader_manifest, LibvirtDriverMode.GENERIC) + + def _scenario(): return parse_sdl( dedent( diff --git a/implementations/python/tests/test_libvirt_backend_manifest_publication.py b/implementations/python/tests/test_libvirt_backend_manifest_publication.py index 3c6459cad..3aa17ce1e 100644 --- a/implementations/python/tests/test_libvirt_backend_manifest_publication.py +++ b/implementations/python/tests/test_libvirt_backend_manifest_publication.py @@ -73,23 +73,25 @@ def test_libvirt_manifest_validates_against_published_schema(): BackendManifestV2Model.model_validate(payload) -def test_libvirt_target_passes_provisioning_only_conformance(): - """AC1: the target conforms to the published provisioning-only profile, daemon-free. +def test_libvirt_target_manifest_passes_but_realization_envelope_is_non_constructive(): + """AC1: manifest validity does not silently certify realization, daemon-free. - The live provisioning probe (issue #606) is exercised through a daemon-free - recording driver that confirms realization, so conformance proves real - snapshot mutation without a libvirt/QEMU daemon. + The recording driver remains useful hermetic adapter evidence, but ASR-519 + now fails closed because the published expression cannot generate complete + witnesses. Issue #717 owns final native certification. """ report = run_target_conformance(create_libvirt_target(driver=RecordingLibvirtDriver())) assert report.profile == BackendCapabilityProfile.PROVISIONING_ONLY - assert report.passed is True, [diag.message for diag in report.diagnostics] + assert report.passed is False assert not report.unsupported_contract_gaps assert not report.unsupported_capability_gaps live_manifest = next((case for case in report.cases if case.name == "target-manifest"), None) assert live_manifest is not None, "conformance must run the target-manifest validation case" assert live_manifest.passed, [diag.message for diag in live_manifest.diagnostics] + constructive = next(case for case in report.cases if case.name == "realization-envelope-constructive") + assert constructive.outcome == "unsupported" def test_supported_contract_versions_cover_provisioning_only_profile(): diff --git a/implementations/python/tests/test_libvirt_conformance.py b/implementations/python/tests/test_libvirt_conformance.py index dafd55150..82b06efa1 100644 --- a/implementations/python/tests/test_libvirt_conformance.py +++ b/implementations/python/tests/test_libvirt_conformance.py @@ -6,14 +6,14 @@ ``unsupported-capability-claim`` / ``unsupported-contract-declaration`` diagnostics (covered by ``test_backend_conformance_cli.py`` / ``run_fixture_suite`` -- asserted green here for the libvirt-relevant profile). -2. ``run_target_conformance`` against the libvirt target passes a target - *provisioning probe* and asserts *snapshot mutation* -- not manifest / - contract-surface only. This is adapter evidence, not daemon or guest proof. +2. ``run_target_conformance`` refuses realization certification while the + published libvirt envelope is non-constructive. It never promotes the old + daemon-free reference scenario into envelope or native evidence. 3. A conformance report is captured and committed (drift-guarded here). -The target probe runs daemon-free through an injected ``RecordingLibvirtDriver`` -that confirms realization, so the real ``LibvirtProvisioner`` path is exercised -without a libvirt/QEMU daemon. +The direct control-plane tests still exercise the real ``LibvirtProvisioner`` +path through an injected recording driver without a libvirt/QEMU daemon. That +is hermetic adapter evidence only. """ from __future__ import annotations @@ -105,25 +105,24 @@ def test_provisioning_only_fixture_suite_has_no_unsupported_diagnostics(): # --------------------------------------------------------------------------- -# AC2: target provisioning probe + snapshot mutation +# AC2: non-constructive envelope refusal + hermetic adapter mutation # --------------------------------------------------------------------------- -def test_provisioning_only_conformance_runs_live_provisioning_probe(): +def test_provisioning_only_conformance_refuses_non_constructive_envelope(): report = _libvirt_conformance_report() assert report.profile == BackendCapabilityProfile.PROVISIONING_ONLY - assert report.passed is True, [diag.message for diag in report.diagnostics] + assert report.passed is False assert not report.unsupported_contract_gaps assert not report.unsupported_capability_gaps case_names = {case.name for case in report.cases} - # Not manifest/contract-surface only: the probe must actually provision and - # validate a mutated snapshot. - assert {"target-manifest", "target-provisioning", "target-snapshot"} <= case_names - for case in report.cases: - if case.name in {"target-manifest", "target-provisioning", "target-snapshot"}: - assert case.passed, [diag.message for diag in case.diagnostics] + assert "target-manifest" in case_names + assert "target-provisioning" not in case_names + constructive = next(case for case in report.cases if case.name == "realization-envelope-constructive") + assert constructive.outcome == "unsupported" + assert constructive.passed is False def test_libvirt_provisioning_mutates_snapshot(): @@ -149,18 +148,20 @@ def test_libvirt_provisioning_mutates_snapshot(): def test_provisioning_only_conformance_requires_confirmed_realization(): - """A driver that does not confirm realization must fail the target probe. + """A driver that does not confirm realization fails the ordinary adapter boundary. - Guards the backend-neutral anti-pattern: provisioning-only conformance must - not pass on ``target-manifest`` alone, and must not accept an empty snapshot. + This remains a direct hermetic adapter test; the realization-envelope + conformance path deliberately refuses to reuse it as certification evidence. """ - report = run_target_conformance(create_libvirt_target(driver=NullLibvirtDriver())) + target = create_libvirt_target(driver=NullLibvirtDriver()) + control_plane = RuntimeControlPlane(target) + receipt = control_plane.submit_provisioning(_provisioning_plan(target)) + status = control_plane.get_operation(receipt.operation_id) - assert report.passed is False - live_provisioning = next((case for case in report.cases if case.name == "target-provisioning"), None) - assert live_provisioning is not None, "provisioning-only conformance must run a target-provisioning probe" - assert live_provisioning.passed is False + assert status is not None and status.state.value == "failed" + assert not status.changed_addresses + assert not any(entry.domain == RuntimeDomain.PROVISIONING for entry in control_plane.snapshot.entries.values()) # --------------------------------------------------------------------------- @@ -177,4 +178,4 @@ def test_committed_conformance_report_is_current(): "committed libvirt conformance report is stale; regenerate " f"{COMMITTED_REPORT.relative_to(REPO_ROOT)} from run_target_conformance" ) - assert committed["passed"] is True + assert committed["passed"] is False diff --git a/implementations/python/tests/test_libvirt_participant_runtime.py b/implementations/python/tests/test_libvirt_participant_runtime.py index 71bd422eb..9172c1004 100644 --- a/implementations/python/tests/test_libvirt_participant_runtime.py +++ b/implementations/python/tests/test_libvirt_participant_runtime.py @@ -91,29 +91,27 @@ def test_ac1_manifest_default_is_provisioning_only(): # --------------------------------------------------------------------------- -# AC-2: run_target_conformance passes for libvirt target with participant_runtime +# AC-2: participant runtime remains valid while realization certification fails closed # --------------------------------------------------------------------------- -def test_ac2_conformance_passes_with_participant_runtime_manifest(): - # The live provisioning probe (issue #606) now runs for provisioning-only - # targets too, so exercise it through a daemon-free recording driver that - # confirms realization. +def test_ac2_conformance_requires_constructive_envelope_with_participant_runtime_manifest(): + # The participant capability surface remains valid, but the published open + # libvirt envelope cannot produce ASR-519 probes and must not fall back to a + # caller-selected hermetic target-adapter scenario. target = _libvirt_target_with_participant_runtime(driver=RecordingLibvirtDriver()) report = run_target_conformance(target) - assert report.passed is True, f"conformance failed: {report.diagnostics}" + assert report.passed is False assert report.unsupported_contract_gaps == () assert report.unsupported_capability_gaps == () - # report.passed is vacuously True on an empty case set, so assert the live - # pipeline actually ran end-to-end for the participant-runtime manifest: - # the provisioning probe + snapshot-mutation cases must be present and green. - case_names = {case.name for case in report.cases} - assert {"target-manifest", "target-provisioning", "target-snapshot"} <= case_names - for case in report.cases: - if case.name in {"target-manifest", "target-provisioning", "target-snapshot"}: - assert case.passed, [diag.message for diag in case.diagnostics] + cases = {case.name: case for case in report.cases} + assert cases["target-manifest"].passed is True + assert cases["realization-envelope-constructive"].passed is False + assert cases["realization-envelope-constructive"].outcome == "unsupported" + assert "target-provisioning" not in cases + assert "target-snapshot" not in cases # --------------------------------------------------------------------------- diff --git a/implementations/python/tests/test_realization_envelope_contract.py b/implementations/python/tests/test_realization_envelope_contract.py index 320e4d1cd..9c3759ef7 100644 --- a/implementations/python/tests/test_realization_envelope_contract.py +++ b/implementations/python/tests/test_realization_envelope_contract.py @@ -49,6 +49,7 @@ def _payload() -> dict[str, object]: "supported_os_families": ["linux"], "supported_content_types": ["file"], "supported_account_features": ["groups"], + "supported_domain_profiles": [], "supports_acls": True, "memory_mib": {"minimum": 128, "maximum": None}, "vcpus": {"minimum": 1, "maximum": None}, @@ -108,6 +109,7 @@ def test_backend_realization_envelope_validates_its_canonical_digest(): supported_os_families=["linux"], supported_content_types=["file"], supported_account_features=["groups"], + supported_domain_profiles=[], supports_acls=True, memory_mib={"minimum": 128, "maximum": None}, vcpus={"minimum": 1, "maximum": None}, diff --git a/implementations/python/tests/test_realization_envelope_relation.py b/implementations/python/tests/test_realization_envelope_relation.py index e79fa7de9..36efba7d3 100644 --- a/implementations/python/tests/test_realization_envelope_relation.py +++ b/implementations/python/tests/test_realization_envelope_relation.py @@ -38,7 +38,7 @@ ) from aces_sdl._realization_envelope_domains import _MISSING, default_witness_value, out_of_domain_value from aces_sdl._realization_envelope_engine import effective_constraints -from aces_sdl.realization_envelope import generate_negative_probes, member, subsumes, witness +from aces_sdl.realization_envelope import generate_negative_probes, generate_positive_probes, member, subsumes, witness from aces_sdl.scenario import InstantiatedScenario, Scenario from hypothesis import given, settings from hypothesis import strategies as st @@ -342,6 +342,62 @@ def test_witness_rejects_list_indexed_path() -> None: assert any(d.code == "realization-envelope.witness.no-witness" for d in result.diagnostics) +# --------------------------------------------------------------------------- # +# Positive probes (ASR-519) # +# --------------------------------------------------------------------------- # + + +def test_positive_probes_cover_every_finite_member_deterministically() -> None: + env = _web_envelope(os_values=("windows", "linux")) + + first, diagnostics = generate_positive_probes(env) + second, second_diagnostics = generate_positive_probes(env) + + assert diagnostics == second_diagnostics == () + assert [probe.digest for probe in first] == [probe.digest for probe in second] + assert {probe.payload["nodes"]["web"]["os"] for probe in first} == {"linux", "windows"} + for probe in first: + scenario = instantiate_scenario(Scenario.model_validate(probe.payload)) + assert member(scenario, env).holds + + +def test_positive_probes_cover_safe_integer_interval_boundaries() -> None: + env = RealizationEnvelopeModel( + id="bounded-cpu", + scope=EnvelopeScope.SCENARIO, + domains={ + "name": ExactDomain(value="bounded"), + "vm": ExactDomain(value="vm"), + "linux": ExactDomain(value="linux"), + "ram": ExactDomain(value=1024), + "cpu": NumericIntervalDomain(numeric_type=NumericType.INTEGER, lower=1, upper=4), + }, + bindings=[ + EnvelopeBinding(path="name", scope=EnvelopeScope.SCENARIO, posture=Posture.EXACT, domain="name"), + EnvelopeBinding(path="nodes.vm.type", scope=EnvelopeScope.NODE, posture=Posture.EXACT, domain="vm"), + EnvelopeBinding(path="nodes.vm.os", scope=EnvelopeScope.FIELD, posture=Posture.EXACT, domain="linux"), + EnvelopeBinding( + path="nodes.vm.resources.ram", scope=EnvelopeScope.FIELD, posture=Posture.EXACT, domain="ram" + ), + EnvelopeBinding( + path="nodes.vm.resources.cpu", scope=EnvelopeScope.FIELD, posture=Posture.CONSTRAINED, domain="cpu" + ), + ], + ) + + probes, diagnostics = generate_positive_probes(env) + + assert not diagnostics + assert {probe.payload["nodes"]["vm"]["resources"]["cpu"] for probe in probes} == {1, 4} + + +def test_positive_probes_fail_closed_for_non_constructive_envelope() -> None: + probes, diagnostics = generate_positive_probes(RealizationEnvelopeModel(id="open", scope=EnvelopeScope.SCENARIO)) + + assert probes == () + assert any(diag.code == "realization-envelope.positive-probe.no-witness" for diag in diagnostics) + + # --------------------------------------------------------------------------- # # Negative probes (R6) # # --------------------------------------------------------------------------- # @@ -364,9 +420,17 @@ def test_negative_probes_are_all_out_of_envelope() -> None: variations = {p.variation for p in probes} assert "value-outside-domain" in variations assert "extra-dimension" in variations - assert "omitted-required-exact" in variations + assert {"name", "nodes.web.type", "nodes.web.os"} <= {p.path for p in probes} for probe in probes: - assert _probe_is_out_of_envelope(probe.payload, env), probe.path + instance = instantiate_scenario(Scenario.model_validate(probe.payload)) + assert not member(instance, env).holds, probe.path + + +def test_negative_probes_exclude_malformed_exact_omissions() -> None: + probes, diagnostics = generate_negative_probes(_web_envelope()) + + assert not diagnostics + assert all(probe.variation != "omitted-required-exact" for probe in probes) def test_negative_probes_without_witness_report_diagnostic() -> None: @@ -437,6 +501,7 @@ def test_property_negative_probes_out_of_envelope(os_values: set[str]) -> None: env = _web_envelope(os_values=tuple(sorted(os_values)), closed=True) probes, diagnostics = generate_negative_probes(env) assert not diagnostics + assert any(probe.path == "nodes.web.os" for probe in probes) for probe in probes: assert _probe_is_out_of_envelope(probe.payload, env), probe.path diff --git a/implementations/python/tests/test_realization_honesty_conformance.py b/implementations/python/tests/test_realization_honesty_conformance.py new file mode 100644 index 000000000..1e450ea84 --- /dev/null +++ b/implementations/python/tests/test_realization_honesty_conformance.py @@ -0,0 +1,449 @@ +"""ASR-519 falsification tests for realization-honesty conformance.""" + +from __future__ import annotations + +import json +from dataclasses import replace + +import pytest +from aces_backend_libvirt.envelopes import LibvirtDriverMode, load_libvirt_realization_envelope +from aces_backend_libvirt.manifest import create_libvirt_manifest +from aces_backend_libvirt.target import create_libvirt_target +from aces_backend_protocols.capabilities import BackendManifest +from aces_backend_stubs.stubs import StubProvisioner +from aces_conformance.conformance import ( + BackendCapabilityProfile, + backend_conformance_report_payload, + run_target_conformance, +) +from aces_conformance.realization import ( + ExecutionBasis, + ExpectedRealizationObservation, + RealizationProbeEvidence, + RealizationProbeRequest, + RealizationTransformation, +) +from aces_contracts.realization_envelope import ( + BackendRealizationEnvelopeModel, + ConcernDisposition, + EnumDomain, + EnvelopeBinding, + EnvelopeScope, + ExactDomain, + ObservationStrength, + Posture, + RealizationConcern, + RealizationEnvelopeModel, + realization_envelope_digest, +) +from aces_contracts.realization_observation import RealizationObservation +from aces_operations.realization_conformance import write_backend_conformance_report +from aces_runtime.registry import RuntimeTarget + +_SCENARIO = """\ +name: honesty +nodes: + vm: + type: vm + os: linux +""" + + +def _constructive_expression() -> RealizationEnvelopeModel: + return RealizationEnvelopeModel( + id="honesty.expression.v1", + scope=EnvelopeScope.SCENARIO, + domains={ + "name": ExactDomain(value="honesty"), + "type": ExactDomain(value="vm"), + "os": EnumDomain(values=["linux"]), + }, + bindings=[ + EnvelopeBinding(path="name", scope=EnvelopeScope.SCENARIO, posture=Posture.EXACT, domain="name"), + EnvelopeBinding(path="nodes.vm.type", scope=EnvelopeScope.NODE, posture=Posture.EXACT, domain="type"), + EnvelopeBinding(path="nodes.vm.os", scope=EnvelopeScope.FIELD, posture=Posture.CONSTRAINED, domain="os"), + ], + ) + + +def _constructive_envelope( + mode: LibvirtDriverMode = LibvirtDriverMode.GENERIC, + strength: ObservationStrength = ObservationStrength.DRIVER_REPORTED, +) -> BackendRealizationEnvelopeModel: + base = load_libvirt_realization_envelope(mode) + payload = base.model_dump(mode="json") + payload["id"] = f"libvirt-qemu.{mode.value}.honesty-test.v1" + payload["expression"] = _constructive_expression().model_dump(mode="json") + for disclosure in payload["concerns"]: + if disclosure["concern"] == RealizationConcern.TOPOLOGY.value: + disclosure.update( + disposition=ConcernDisposition.REALIZED.value, + observation_strength=strength.value, + mechanism="test-addressed-observer", + transformations=[], + ) + else: + disclosure.update( + disposition=ConcernDisposition.UNSUPPORTED.value, + observation_strength=ObservationStrength.NONE.value, + mechanism=None, + transformations=[], + ) + payload["digest"] = realization_envelope_digest(payload) + return BackendRealizationEnvelopeModel.model_validate(payload) + + +def _manifest_with_envelope(envelope: BackendRealizationEnvelopeModel) -> BackendManifest: + base = create_libvirt_manifest(driver_mode=envelope.configuration.mode) + return BackendManifest( + identity=base.identity, + supported_contract_versions=base.supported_contract_versions, + compatibility=base.compatibility, + realization_support=base.realization_support, + concept_bindings=base.concept_bindings, + constraints=base.constraints, + capabilities=base.capabilities, + realization_envelope=envelope, + ) + + +def _target(envelope: BackendRealizationEnvelopeModel | None = None) -> RuntimeTarget: + selected = envelope or _constructive_envelope() + return RuntimeTarget( + name="libvirt-qemu", + manifest=_manifest_with_envelope(selected), + provisioner=StubProvisioner(), + ) + + +def _mode_target(mode: LibvirtDriverMode) -> RuntimeTarget: + return RuntimeTarget( + name="libvirt-qemu", + manifest=create_libvirt_manifest(driver_mode=mode.value), + provisioner=StubProvisioner(), + ) + + +class _ScriptedHarness: + def __init__( + self, + fault: str | None = None, + observation_strength: ObservationStrength = ObservationStrength.DRIVER_REPORTED, + ) -> None: + self.fault = fault + self.observation_strength = observation_strength + self.calls: list[RealizationProbeRequest] = [] + + def execute(self, request: RealizationProbeRequest) -> RealizationProbeEvidence: + self.calls.append(request) + if request.negative: + return self._negative(request) + return self._positive(request) + + def _positive(self, request: RealizationProbeRequest) -> RealizationProbeEvidence: + operations = tuple(operation.address for operation in request.provisioning_plan.actionable_operations) + expected = tuple( + ExpectedRealizationObservation( + address=address, + field_path="exists", + concern=RealizationConcern.TOPOLOGY, + value=True, + ) + for address in operations + ) + observations = tuple( + RealizationObservation( + address=item.address, + field_path=item.field_path, + concern=item.concern, + source=self.observation_strength, + value=item.value, + operation_id=item.address, + probe_digest=request.probe_digest, + envelope_digest=request.envelope_digest, + configuration_digest=request.configuration_digest, + observer_version=request.observer_version, + sequence=2, + origin="observed", + binding_verified=True, + ) + for item in expected + ) + evidence = RealizationProbeEvidence( + accepted=True, + accounted_operations=operations, + changed_addresses=operations, + expected_observations=expected, + observations=observations, + driver_invoked=True, + native_mutated=True, + portable_state_before="sha256:" + "0" * 64, + portable_state_after="sha256:" + "1" * 64, + native_state_before="sha256:" + "2" * 64, + native_state_after="sha256:" + "3" * 64, + baseline_sequence=1, + cleanup_verified=True, + ) + fault = self.fault + if fault == "missing-operation": + return replace(evidence, accounted_operations=()) + if fault == "schema-valid-noop": + return replace(evidence, portable_state_after=evidence.portable_state_before) + if fault == "missing-disclosure": + return replace(evidence, expected_observations=(), observations=()) + if fault == "missing-observation": + return replace(evidence, observations=()) + if fault == "stale-observation": + return replace(evidence, observations=tuple(replace(item, sequence=1) for item in observations)) + if fault == "planned-as-observed": + return replace(evidence, observations=tuple(replace(item, origin="planned") for item in observations)) + if fault == "fabricated-observation": + return replace( + evidence, + observations=tuple(replace(item, binding_verified=False) for item in observations), + ) + if fault in {"silent-transform", "resource-clamp", "image-substitution"}: + kind = { + "silent-transform": "default-substitution", + "resource-clamp": "bounded-normalization", + "image-substitution": "image-substitution", + }[fault] + concern = ( + RealizationConcern.IMAGE if fault == "image-substitution" else RealizationConcern.RESOURCE_ALLOCATION + ) + return replace( + evidence, + transformations=( + RealizationTransformation( + address=operations[0], concern=concern, kind=kind, disclosed=fault != "silent-transform" + ), + ), + ) + if fault == "cleanup": + return replace(evidence, cleanup_verified=False) + if fault == "residual": + return replace(evidence, residual_state=(operations[0],)) + return evidence + + def _negative(self, request: RealizationProbeRequest) -> RealizationProbeEvidence: + evidence = RealizationProbeEvidence( + accepted=False, + driver_invoked=False, + native_mutated=False, + portable_state_before="sha256:" + "4" * 64, + portable_state_after="sha256:" + "4" * 64, + native_state_before="sha256:" + "5" * 64, + native_state_after="sha256:" + "5" * 64, + cleanup_verified=True, + ) + if self.fault == "negative-driver": + return replace(evidence, driver_invoked=True) + if self.fault == "negative-native": + return replace(evidence, native_mutated=True) + if self.fault == "negative-portable-state": + return replace(evidence, portable_state_after="sha256:" + "6" * 64) + if self.fault == "negative-native-state": + return replace(evidence, native_state_after="sha256:" + "7" * 64) + if self.fault == "negative-cleanup": + return replace(evidence, cleanup_verified=False, residual_state=("node.vm",)) + return evidence + + +def _run( + harness: _ScriptedHarness, + envelope: BackendRealizationEnvelopeModel | None = None, + **kwargs, +): + return run_target_conformance( + _target(envelope), + reference_scenario=_SCENARIO, + realization_harness=harness, + execution_basis=ExecutionBasis.HERMETIC_LIVE, + **kwargs, + ) + + +def test_constructive_envelope_runs_positive_and_negative_honesty_probes() -> None: + harness = _ScriptedHarness() + + report = _run(harness) + + assert report.passed is True, [diag.code for case in report.cases for diag in case.diagnostics] + honesty = [case for case in report.cases if case.probe_kind is not None] + assert {case.probe_kind for case in honesty} == {"positive", "negative"} + assert all(case.execution_basis == "hermetic-live" for case in honesty) + assert all(case.envelope_digest == _constructive_envelope().digest for case in honesty) + assert all(case.probe_set_digest for case in honesty) + assert report.native_conformance is False + assert report.claim.left_carrier_ref != "backend-target:libvirt-qemu" + assert _constructive_envelope().configuration.mode in report.claim.left_carrier_ref + + +@pytest.mark.parametrize( + "required_strength", + [ObservationStrength.DAEMON_OBSERVED, ObservationStrength.GUEST_OBSERVED], +) +def test_positive_probe_enforces_declared_daemon_or_guest_strength( + required_strength: ObservationStrength, +) -> None: + envelope = _constructive_envelope(strength=required_strength) + + weak = _run(_ScriptedHarness(), envelope=envelope) + strong = _run( + _ScriptedHarness(observation_strength=required_strength), + envelope=envelope, + ) + + assert weak.passed is False + assert "conformance.observation-strength-insufficient" in { + diag.code for case in weak.cases for diag in case.diagnostics + } + assert strong.passed is True + + +@pytest.mark.parametrize( + ("fault", "code"), + [ + ("missing-operation", "conformance.operation-accounting-incomplete"), + ("schema-valid-noop", "conformance.positive-portable-state-unchanged"), + ("missing-disclosure", "conformance.observation-inventory-incomplete"), + ("missing-observation", "conformance.observation-missing"), + ("stale-observation", "conformance.observation-stale"), + ("planned-as-observed", "conformance.observation-not-independent"), + ("fabricated-observation", "conformance.observation-binding-invalid"), + ("silent-transform", "conformance.transformation-undisclosed"), + ("resource-clamp", "conformance.transformation-unverified"), + ("image-substitution", "conformance.transformation-unverified"), + ("cleanup", "conformance.cleanup-unverified"), + ("residual", "conformance.residual-state"), + ], +) +def test_dishonest_positive_behaviors_fail_for_stable_reason(fault: str, code: str) -> None: + report = _run(_ScriptedHarness(fault)) + + assert report.passed is False + assert code in {diag.code for case in report.cases for diag in case.diagnostics} + + +@pytest.mark.parametrize( + ("fault", "code"), + [ + ("negative-driver", "conformance.negative-driver-invoked"), + ("negative-native", "conformance.negative-native-mutation"), + ("negative-portable-state", "conformance.negative-portable-state-mutated"), + ("negative-native-state", "conformance.negative-native-state-mutated"), + ("negative-cleanup", "conformance.cleanup-unverified"), + ], +) +def test_negative_probe_requires_rejection_without_mutation(fault: str, code: str) -> None: + report = _run(_ScriptedHarness(fault)) + + assert report.passed is False + assert code in {diag.code for case in report.cases for diag in case.diagnostics} + + +def test_current_open_libvirt_envelope_fails_as_non_constructive_without_fallback() -> None: + harness = _ScriptedHarness() + report = run_target_conformance( + create_libvirt_target(driver_mode="generic"), + reference_scenario=_SCENARIO, + realization_harness=harness, + execution_basis=ExecutionBasis.HERMETIC_LIVE, + ) + + assert report.passed is False + case = next(case for case in report.cases if case.name == "realization-envelope-constructive") + assert case.outcome == "unsupported" + assert not harness.calls + + +@pytest.mark.parametrize( + ("target_mode", "wrong_mode"), + [ + (LibvirtDriverMode.GENERIC, LibvirtDriverMode.TECHVAULT_APPLIANCE), + (LibvirtDriverMode.TECHVAULT_APPLIANCE, LibvirtDriverMode.GENERIC), + ], +) +def test_libvirt_modes_refuse_wrong_configuration_envelope_before_execution( + target_mode: LibvirtDriverMode, + wrong_mode: LibvirtDriverMode, +) -> None: + harness = _ScriptedHarness() + report = run_target_conformance( + _mode_target(target_mode), + realization_harness=harness, + realization_envelope=load_libvirt_realization_envelope(wrong_mode), + execution_basis=ExecutionBasis.HERMETIC_LIVE, + ) + + assert report.passed is False + assert not harness.calls + assert "conformance.realization-envelope-mismatch" in { + diag.code for case in report.cases for diag in case.diagnostics + } + + +def test_only_native_live_can_support_native_conformance() -> None: + hermetic = _run(_ScriptedHarness(), native_conformance=True) + native = run_target_conformance( + _target(), + reference_scenario=_SCENARIO, + realization_harness=_ScriptedHarness(), + execution_basis=ExecutionBasis.NATIVE_LIVE, + native_conformance=True, + ) + + assert hermetic.passed is False + assert hermetic.native_conformance is False + assert native.passed is True + assert native.native_conformance is True + + +def test_failed_report_cannot_claim_native_conformance() -> None: + report = run_target_conformance( + _target(), + profile=BackendCapabilityProfile.FULL_REMOTE_CONTROL_PLANE, + reference_scenario=_SCENARIO, + realization_harness=_ScriptedHarness(), + execution_basis=ExecutionBasis.NATIVE_LIVE, + native_conformance=True, + ) + + assert report.passed is False + assert report.native_conformance is False + + +def test_report_payload_enumerates_probe_evidence_without_raw_values() -> None: + report = _run(_ScriptedHarness()) + + payload = backend_conformance_report_payload(report) + honesty = [case for case in payload["cases"] if case["probe_kind"] is not None] + + assert honesty + assert all(case["outcome"] == "passed" for case in honesty) + assert all(case["probe_digest"].startswith("sha256:") for case in honesty) + assert all("expected_operations" in case for case in honesty) + assert all("cleanup_verified" in case for case in honesty) + assert "honesty" not in json.dumps(honesty) + + +def test_machine_readable_report_write_is_redaction_gated(tmp_path) -> None: + report = _run(_ScriptedHarness()) + + path = write_backend_conformance_report( + backend_conformance_report_payload(report), output_dir=tmp_path, run_id="honesty-native-1" + ) + + assert json.loads(path.read_text(encoding="utf-8")) == backend_conformance_report_payload(report) + + cases = list(report.cases) + index = next(index for index, case in enumerate(cases) if case.probe_kind == "positive") + cases[index] = replace(cases[index], residual_state=("/home/operator/private",)) + leaking = replace(report, cases=tuple(cases)) + leaking_payload = backend_conformance_report_payload(leaking) + with pytest.raises(ValueError, match="redaction"): + write_backend_conformance_report( + leaking_payload, + output_dir=tmp_path, + run_id="honesty-native-2", + ) diff --git a/implementations/python/tests/test_reference_backend_manifest.py b/implementations/python/tests/test_reference_backend_manifest.py index e2370ace2..74711af7e 100644 --- a/implementations/python/tests/test_reference_backend_manifest.py +++ b/implementations/python/tests/test_reference_backend_manifest.py @@ -42,6 +42,13 @@ def test_manifest_does_not_claim_unimplemented_acl_enforcement(): assert manifest.provisioner.supports_acls is False +def test_manifest_does_not_claim_unimplemented_domain_or_spn_realization(): + manifest = create_reference_backend_manifest() + + assert "spn" not in manifest.provisioner.supported_account_features + assert manifest.provisioner.supported_domain_profiles == frozenset() + + def test_manifest_accepts_and_ignores_extra_config_kwargs(): # Config kwargs flow to both factories; the manifest factory must accept # and ignore extras such as ``driver``. diff --git a/implementations/python/tests/test_runtime_planner.py b/implementations/python/tests/test_runtime_planner.py index 8a7d5c7d9..1286ac325 100644 --- a/implementations/python/tests/test_runtime_planner.py +++ b/implementations/python/tests/test_runtime_planner.py @@ -1033,7 +1033,19 @@ def test_semantic_capability_validation_catches_real_requirements(self): - {direction: in, from_net: corp, action: allow} dc: {count: 1, links: [corp]} accounts: - admin: {username: administrator, node: dc, spn: LDAP/dc.example.local} + admin: {username: administrator, node: dc, spn: LDAP/dc.example.local, domain_ref: example} +identity_domains: + example: + profile: active_directory + dns_name: example.local + netbios_name: EXAMPLE + authority_account_ref: admin +relationships: + dc-role: + type: domain_controller_for + source: dc + target: example + domain_controller: {} conditions: health: {command: /bin/true, interval: 15} propositions: diff --git a/implementations/python/tests/test_sdl_catalog_parity.py b/implementations/python/tests/test_sdl_catalog_parity.py index 80ec4b79b..cc4a9bf1d 100644 --- a/implementations/python/tests/test_sdl_catalog_parity.py +++ b/implementations/python/tests/test_sdl_catalog_parity.py @@ -14,6 +14,7 @@ CatalogParseError, evaluate_sdl_catalog_parity, main, + parse_reference_catalog, parse_top_level_catalog, ) @@ -21,6 +22,10 @@ "specs/sdl/sections.md", "specs/sdl/references.md", "specs/sdl/runtime-inventory.md", + "specs/sdl/document-model.md", + "specs/sdl/variables-and-instantiation.md", + "specs/sdl/diagnostics.md", + "specs/formal/sdl-phases/README.md", "contracts/schemas/sdl/sdl-authoring-input-v1.json", ) @@ -62,10 +67,43 @@ def test_top_level_catalog_drift_is_flagged(tmp_path: Path, old: str, new: str, def test_checked_summary_drift_is_flagged(tmp_path: Path) -> None: repo = _seed_repo(tmp_path) - _replace(repo, "specs/sdl/sections.md", "sections=25", "sections=24") + _replace(repo, "specs/sdl/sections.md", "sections=26", "sections=25") assert "sdl-catalog-summary" in _rule_ids(repo) +def test_phase_lifecycle_membership_drift_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path) + _replace( + repo, + "specs/sdl/sections.md", + "| `realization` | composition | mapping | normalized |", + "| `realization` | composition | mapping | normalized, expanded |", + ) + assert "sdl-catalog-lifecycle" in _rule_ids(repo) + + +def test_phase_member_catalog_drift_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path) + _replace( + repo, + "specs/formal/sdl-phases/README.md", + "| `realization` | optional | forbidden | forbidden |", + "| `realization` | optional | optional | forbidden |", + ) + assert "sdl-catalog-phase-membership" in _rule_ids(repo) + + +def test_realization_transfer_prose_drift_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path) + _replace( + repo, + "specs/formal/sdl-phases/README.md", + "`expansion_provenance.realization_designations` and later `instantiation_provenance.realization_designations`", + "`expansion_provenance.realization_designations`", + ) + assert "sdl-catalog-phase-transfer" in _rule_ids(repo) + + def test_identity_classification_drift_is_flagged(tmp_path: Path) -> None: repo = _seed_repo(tmp_path) _replace(repo, "specs/sdl/sections.md", "| `map_key` | catalogued |", "| `node_id` | catalogued |") @@ -88,8 +126,8 @@ def test_non_completion_reference_domain_drift_is_flagged(tmp_path: Path) -> Non _replace( repo, "specs/sdl/references.md", - "| `action_contracts.*.interactions.*.related_action_ref` | `action_contracts` |", - "| `action_contracts.*.interactions.*.related_action_ref` | `any` |", + "| `action_contracts.*.interactions.*.related_actions[]` | `action_contracts` |", + "| `action_contracts.*.interactions.*.related_actions[]` | `any` |", ) assert "sdl-catalog-reference-row" in _rule_ids(repo) @@ -98,7 +136,11 @@ def test_non_completion_reference_domain_drift_is_flagged(tmp_path: Path) -> Non ("old", "new"), [ ("| `features` | semantic validation |", "| `features` | |"), - ("| fatal dangling or ambiguous | [node validator]", "| | [node validator]"), + ( + "| fatal dangling or ambiguous | [reference rules](#5-cross-section-reference-edge-catalog) | " + "[node validator]", + "| | [reference rules](#5-cross-section-reference-edge-catalog) | [node validator]", + ), ("[node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py)", ""), ], ) @@ -119,6 +161,78 @@ def test_missing_behavior_reference_edge_is_flagged(tmp_path: Path) -> None: assert "sdl-catalog-behavior-edge" in _rule_ids(repo) +def test_missing_live_reference_edges_are_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path) + _replace( + repo, + "specs/sdl/references.md", + "| `features.*.vulnerabilities[]` |", + "| `features.*.vulnerability_refs[]` |", + ) + rule_ids = _rule_ids(repo) + assert "sdl-catalog-reference-row" in rule_ids + assert "sdl-catalog-reference-path" in rule_ids + + +def test_reference_catalog_uses_live_nested_model_paths() -> None: + rows = parse_reference_catalog((REPO_ROOT / "specs/sdl/references.md").read_text(encoding="utf-8")) + paths = {row.source_path for row in rows} + assert { + "nodes.*.features.*", + "nodes.*.conditions.*", + "nodes.*.injects.*", + "action_contracts.*.temporal_contracts.*.backend_disclosure_refs[]", + "action_contracts.*.backend_timing_disclosures.*.affected_temporal_ids[]", + "action_contracts.*.interactions.*.related_actions[]", + "outcome_interpretation_rules.*.source_bindings.*.ref", + "outcome_interpretation_rules.*.target_bindings.*.ref", + } <= paths + assert { + "outcome_interpretation_rules.*.source_ref", + "outcome_interpretation_rules.*.target_ref", + "action_contracts.*.interactions.*.related_action_ref", + }.isdisjoint(paths) + + +def test_implementation_evidence_cannot_be_normative_owner(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path) + _replace( + repo, + "specs/sdl/references.md", + "[reference rules](#5-cross-section-reference-edge-catalog)", + "[implementation](../../implementations/python/packages/aces_sdl/scenario.py)", + ) + assert "sdl-catalog-reference-owner" in _rule_ids(repo) + + +def test_normative_owner_resolution_is_independent_of_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + repo = _seed_repo(tmp_path / "repo") + monkeypatch.chdir(tmp_path) + assert "sdl-catalog-reference-owner" not in _rule_ids(repo) + + +def test_missing_internal_markdown_target_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path) + _replace( + repo, + "specs/sdl/sections.md", + "[README](README.md)", + "[README](missing-authority.md)", + ) + assert "sdl-catalog-link-target" in _rule_ids(repo) + + +def test_implementation_specific_diagnostic_term_must_be_marked_nonnormative(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path) + _replace( + repo, + "specs/sdl/diagnostics.md", + "> these as `SDLParseError`, `SDLValidationError`, and `SDLInstantiationError`;", + "these as `SDLParseError`, `SDLValidationError`, and `SDLInstantiationError`;", + ) + assert "sdl-catalog-normative-layer" in _rule_ids(repo) + + def test_runtime_child_tree_drift_is_flagged(tmp_path: Path) -> None: repo = _seed_repo(tmp_path) _replace( @@ -152,7 +266,7 @@ def test_catalog_parser_rejects_oversized_input() -> None: def test_cli_reports_json_failure(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: repo = _seed_repo(tmp_path) - _replace(repo, "specs/sdl/sections.md", "sections=25", "sections=24") + _replace(repo, "specs/sdl/sections.md", "sections=26", "sections=25") assert main(["--repo-root", str(repo), "--json"]) == 1 assert '"rule_id": "sdl-catalog-summary"' in capsys.readouterr().out diff --git a/implementations/python/tests/test_sdl_lineage.py b/implementations/python/tests/test_sdl_lineage.py index 843851ce0..70be30e39 100644 --- a/implementations/python/tests/test_sdl_lineage.py +++ b/implementations/python/tests/test_sdl_lineage.py @@ -36,7 +36,7 @@ def test_real_lineage_ledger_is_valid_and_covers_exact_current_subject_set() -> ledger = SDLLineageLedgerModel.model_validate(_payload()) current = {subject.subject_id for subject in ledger.subjects if subject.disposition.value == "current"} assert current == _canonical_subjects(REPO_ROOT) - assert len(current) == 72 + assert len(current) == 74 assert {subject.subject_id for subject in ledger.subjects if subject.disposition.value == "removed"} == { "sdl-field:evaluations", "sdl-field:goals", diff --git a/implementations/python/tests/test_sdl_phase_contracts.py b/implementations/python/tests/test_sdl_phase_contracts.py index a68cdd37b..6ea43418e 100644 --- a/implementations/python/tests/test_sdl_phase_contracts.py +++ b/implementations/python/tests/test_sdl_phase_contracts.py @@ -65,7 +65,7 @@ def test_phase_models_have_disjoint_authoring_and_instantiated_fields() -> None: @pytest.mark.parametrize( ("field", "value"), - (("variables", {}), ("imports", []), ("module", None)), + (("variables", {}), ("imports", []), ("module", None), ("realization", None)), ) def test_instantiated_model_forbids_authoring_machinery_even_when_empty(field: str, value: object) -> None: with pytest.raises(ValidationError, match="Extra inputs are not permitted"): @@ -109,7 +109,7 @@ def test_instantiation_serializes_binding_origin_without_live_variables() -> Non concrete = instantiate_scenario(scenario) payload = concrete.model_dump(mode="json") - assert {"module", "imports", "variables"}.isdisjoint(payload) + assert {"module", "imports", "realization", "variables"}.isdisjoint(payload) assert payload["nodes"]["host"]["os"] == "linux" assert payload["instantiation_provenance"]["bindings"] == [ {"parameter": ["image"], "origin": "default", "value": "linux"} @@ -121,6 +121,24 @@ def test_instantiation_serializes_binding_origin_without_live_variables() -> Non assert not hasattr(concrete, "module") +def test_instantiation_moves_realization_designations_to_provenance() -> None: + scenario = Scenario.model_validate( + { + "name": "designated", + "realization": {"default": "open"}, + } + ) + scenario._set_semantic_validated(True) + + concrete = instantiate_scenario(scenario) + payload = concrete.model_dump(mode="json") + + assert "realization" not in payload + assert payload["instantiation_provenance"]["realization_designations"] == [ + {"namespace": [], "field_pointer": "", "posture": "open"} + ] + + def test_instantiated_artifact_round_trips_without_private_state() -> None: binding = ParameterBinding(parameter=("region",), origin=BindingOrigin.PROVIDED, value="eu-central") first = InstantiatedScenario( diff --git a/implementations/python/tests/test_sdl_realworld.py b/implementations/python/tests/test_sdl_realworld.py index 231f4e7a7..0ebe050b8 100644 --- a/implementations/python/tests/test_sdl_realworld.py +++ b/implementations/python/tests/test_sdl_realworld.py @@ -521,12 +521,41 @@ def _parse(yaml_str: str, label: str): unconstrained-deleg: {name: Unconstrained Delegation, description: "Machine trusts for any service", technical: true, class: CWE-250} ntlm-relay: {name: NTLM Relay, description: "NTLM auth relay to Exchange for privesc", technical: true, class: CWE-294} +identity_domains: + corp: + profile: active_directory + dns_name: corp.offshore.local + netbios_name: CORP + authority_account_ref: da-corp + dev: + profile: active_directory + dns_name: dev.corp.offshore.local + netbios_name: DEV + authority_account_ref: da-dev + accounts: - svc-sql: {username: svc_mssql, node: dc-corp, password_strength: weak, spn: "MSSQL/mssql.corp.offshore.local", groups: [Domain Users]} - da-corp: {username: Administrator, node: dc-corp, password_strength: strong, groups: [Domain Admins]} - nopreauth-user: {username: svc_legacy, node: dc-corp, password_strength: weak, description: "AS-REP roastable"} + svc-sql: {username: svc_mssql, node: dc-corp, password_strength: weak, spn: "MSSQL/mssql.corp.offshore.local", domain_ref: corp, groups: [Domain Users]} + da-corp: {username: Administrator, node: dc-corp, domain_ref: corp, password_strength: strong, groups: [Domain Admins]} + da-dev: {username: Administrator, node: dc-dev, domain_ref: dev, password_strength: strong, groups: [Domain Admins]} + nopreauth-user: {username: svc_legacy, node: dc-corp, domain_ref: corp, password_strength: weak, description: "AS-REP roastable"} relationships: + dc-corp-controls-corp-domain: + type: domain_controller_for + source: dc-corp + target: corp + domain_controller: {} + dc-dev-controls-dev-domain: + type: domain_controller_for + source: dc-dev + target: dev + domain_controller: {} + exchange-joins-corp-domain: + type: joins_domain + source: exchange + target: corp + domain_join: + controller_refs: [dc-corp] dev-trusts-corp: type: trusts source: ad-dev diff --git a/implementations/python/tests/test_sdl_stress.py b/implementations/python/tests/test_sdl_stress.py index df8cb7ce0..9f7e60a22 100644 --- a/implementations/python/tests/test_sdl_stress.py +++ b/implementations/python/tests/test_sdl_stress.py @@ -1092,6 +1092,13 @@ def _parse(yaml_str: str, label: str): technical: true class: CWE-918 +identity_domains: + techvault: + profile: active_directory + dns_name: techvault.local + netbios_name: TECHVAULT + authority_account_ref: svc-backup + accounts: ceo: username: ceo @@ -1118,8 +1125,16 @@ def _parse(yaml_str: str, label: str): groups: [Backup Operators] password_strength: weak spn: "MSSQL/db.techvault.local" + domain_ref: techvault description: "Kerberoastable service account" +relationships: + dc-controls-techvault-domain: + type: domain_controller_for + source: dc + target: techvault + domain_controller: {} + content: phishing-lures: type: dataset @@ -1431,6 +1446,18 @@ def _parse(yaml_str: str, label: str): dependencies: [ad-forest-root] description: "AD Federation Services for SSO" +identity_domains: + corp: + profile: active_directory + dns_name: ${domain_name} + netbios_name: CORP + authority_account_ref: domain-admin + dev: + profile: active_directory + dns_name: ${child_domain} + netbios_name: DEV + authority_account_ref: child-admin + accounts: domain-admin: username: Administrator @@ -1443,6 +1470,7 @@ def _parse(yaml_str: str, label: str): groups: [Domain Users] password_strength: weak spn: "MSSQL/db.corp.local" + domain_ref: corp description: "Kerberoastable service account" child-admin: username: Administrator @@ -1457,6 +1485,28 @@ def _parse(yaml_str: str, label: str): mail: "jdoe@corp.local" relationships: + dc01-controls-corp-domain: + type: domain_controller_for + source: dc01 + target: corp + domain_controller: {} + dc02-controls-dev-domain: + type: domain_controller_for + source: dc02 + target: dev + domain_controller: {} + adfs-joins-corp-domain: + type: joins_domain + source: adfs + target: corp + domain_join: + controller_refs: [dc01] + ws01-joins-corp-domain: + type: joins_domain + source: ws01 + target: corp + domain_join: + controller_refs: [dc01] child-trusts-parent: type: trusts source: ad-child-domain diff --git a/specs/formal/objectives/README.md b/specs/formal/objectives/README.md index ea5211f0c..313af5b07 100644 --- a/specs/formal/objectives/README.md +++ b/specs/formal/objectives/README.md @@ -46,7 +46,7 @@ target-resolution, success-interpretation, and dependency-ordering semantics (`_verify_objectives`) - compiled runtime objective resource, addresses, diagnostics, and ordering/refresh derivation: - - `implementations/python/packages/aces_processor/compiler.py` + - `implementations/python/packages/aces_processor/compiler/` - `implementations/python/packages/aces_processor/models/` - planner ordering/refresh reconciliation: - `implementations/python/packages/aces_processor/planner.py` diff --git a/specs/formal/objectives/declarative-objective-semantics.md b/specs/formal/objectives/declarative-objective-semantics.md index 1e185e560..dc10513b6 100644 --- a/specs/formal/objectives/declarative-objective-semantics.md +++ b/specs/formal/objectives/declarative-objective-semantics.md @@ -177,7 +177,7 @@ Avoid: rendering the issue codes back onto the authoring-error strings) - compiled runtime objective resource, canonical addresses, diagnostics, and ordering/refresh derivation: - - `implementations/python/packages/aces_processor/compiler.py` + - `implementations/python/packages/aces_processor/compiler/` - `implementations/python/packages/aces_processor/models/` (`ObjectiveRuntime`, `Diagnostic`) - planner ordering/refresh reconciliation over the compiled edges: diff --git a/specs/formal/objectives/window-consistency.md b/specs/formal/objectives/window-consistency.md index 762445fe5..1731c1960 100644 --- a/specs/formal/objectives/window-consistency.md +++ b/specs/formal/objectives/window-consistency.md @@ -71,7 +71,7 @@ shape before compiler/planner semantics run. Each resolved reference carries: - shared semantic source of truth: `implementations/python/packages/aces_sdl/semantics/objectives.py` - validator checks: `implementations/python/packages/aces_sdl/validator/` - compiled runtime references and refresh derivation: - - `implementations/python/packages/aces_processor/compiler.py` + - `implementations/python/packages/aces_processor/compiler/` - `implementations/python/packages/aces_processor/models/` - differential and property tests: - `implementations/python/tests/test_semantics_objectives.py` diff --git a/specs/formal/participant-semantics/README.md b/specs/formal/participant-semantics/README.md index 855c606eb..79bb05f09 100644 --- a/specs/formal/participant-semantics/README.md +++ b/specs/formal/participant-semantics/README.md @@ -9,12 +9,19 @@ This document is the issue #71 formal design artifact for: - `SEM-212` - Participant Causality And Attribution Semantics - `SEM-213` - Temporal Participant Semantics - `SEM-215` - Participant Outcome Interpretation Semantics +- `SEM-219` - Participant Tool And Affordance Semantics +- `SEM-220` - Participant Decision-Surface Semantics +- `SEM-226` - Participant Exposure And Visibility-Boundary Semantics It is a design artifact, not an implementation artifact. It establishes the semantic model that later child implementation issues must realize in SDL models, semantic helpers, compiler/runtime contracts, evidence/provenance contracts, and tests. +Issue #119 and ADR-083 extend the original issue #71 design with the joint +`SEM-219`, `SEM-220`, and `SEM-226` decision-surface model. Their executable +implementation remains owned by issues #294, #295, and #296. + ## Current Sufficiency Finding The existing implementation is not sufficient for `SEM-208` through `SEM-215`. @@ -718,7 +725,7 @@ Current implementation artifacts for the `SEM-208` slice: - `implementations/python/packages/aces_sdl/semantics/participant_behavior.py` and `implementations/python/packages/aces_sdl/validator/` fail closed on unbound action-contract and observation-boundary references; -- `implementations/python/packages/aces_processor/compiler.py` maps authored +- `implementations/python/packages/aces_processor/compiler/` maps authored participants to compiled participant action, observation, and behavior addresses; - `implementations/python/packages/aces_processor/models/` defines @@ -761,7 +768,7 @@ Current implementation artifacts for the `SEM-209` slice: - `implementations/python/packages/aces_sdl/semantics/participant_behavior.py` and `implementations/python/packages/aces_sdl/validator/` fail closed on unbound related actions, interaction targets, and shared-state references; -- `implementations/python/packages/aces_processor/compiler.py` carries declared +- `implementations/python/packages/aces_processor/compiler/` carries declared interaction classes and shared-state references into compiled participant action contracts; - `implementations/python/packages/aces_processor/models/` records @@ -833,7 +840,7 @@ Current implementation artifacts for the `SEM-210` slice: and `implementations/python/packages/aces_sdl/validator/` continue to fail closed on unbound participant observation-boundary references, view-rule references, and view-transition evidence references; -- `implementations/python/packages/aces_processor/compiler.py` carries hidden, +- `implementations/python/packages/aces_processor/compiler/` carries hidden, observable, discovered, inferred, concealed, disclosed, deceptive, evidence-only, and realized-view disclosure metadata into compiled participant observation boundaries, including an ordered @@ -907,7 +914,7 @@ Current implementation artifacts for the `SEM-211` slice: plus typed action-contract declarations and backend failure mappings; - `implementations/python/packages/aces_sdl/participant_behavior.py` embeds those typed declarations in governed participant action contracts; -- `implementations/python/packages/aces_processor/compiler.py` carries the +- `implementations/python/packages/aces_processor/compiler/` carries the typed precondition classes, effect classes, failure classes, and backend failure mappings into compiled participant action contracts; - `implementations/python/packages/aces_processor/models/` defines typed @@ -1004,7 +1011,7 @@ Current implementation artifacts for the first `SEM-213` slice: temporal contracts and backend timing disclosures in governed participant action contracts, requires temporal preconditions to resolve to typed temporal contracts, and fails closed on unknown backend disclosure refs; -- `implementations/python/packages/aces_processor/compiler.py` carries temporal +- `implementations/python/packages/aces_processor/compiler/` carries temporal contract ids, kinds, time domains, clock authorities, and backend timing disclosures into compiled participant action contracts; - `implementations/python/packages/aces_processor/models/` defines runtime @@ -1264,6 +1271,291 @@ Current implementation artifacts for the `SEM-217` slice: implementation-facing guardrails and anti-patterns for external knowledge bindings. +## SEM-219 - Participant Tool And Affordance Semantics + +`SEM-219` requires explicit semantics for participant tool and affordance +availability, visibility, invocation, and constraint handling. + +The semantic unit is a participant-meaningful **affordance binding**, not a tool +label. An affordance binding relates: + +- a stable tool or artifact identity, when one exists; +- one or more governed participant action-contract refs; +- participant or behavior-specification scope; +- authority and operating-scope bases; +- observation-boundary and visibility bases; +- parameter, resource, temporal, interaction, and realization constraints; +- expected observation and side-effect classes; +- implementation-support and realized-exposure disclosures; and +- evidence/provenance refs. + +Tool identity, affordance meaning, and apparatus expectation are different +facts. In particular: + +- `participant-tool-affordance-expectations` is an implementation-manifest + capability vocabulary. It does not grant an authored participant an action. +- `ParticipantExposurePolicyModel.tool_affordance_refs` records selected + run-level references. It is not proof that an affordance was visible, + invocable, or delivered. +- an action contract defines portable action meaning. A shell, browser, binary, + API, prompt, ATT&CK technique, or UI control does not replace that contract. + +For affordance `f`, participant `p`, episode `e`, and order point `o`, +the semantic state is a tuple rather than one availability boolean: + +```text +AffordanceState(p, e, o, f) = ( + authored, + visible, + apparatus_supported, + eligible, + invocable_or_admitted, + realized, + constraint_state, + evidence_and_limitations +) +``` + +Each predicate has its own authority: + +- **authored** follows participant/behavior bindings and action-contract refs; +- **visible** follows `V_p,o`, the observation boundary, audience scope, and + exposure policy; +- **apparatus-supported** follows the selected participant implementation and + backend capability disclosures; +- **eligible** follows SEM-211 authority, capability, target, knowledge, + resource, temporal, interaction, and realization preconditions; +- **invocable/admitted** follows a concrete admission decision for an attempt; +- **realized** follows runtime behavior history, results, observations, and + evidence; and +- **constraint state** reports satisfied, unsatisfied, unknown, exhausted, or + unsupported constraints through the owning typed failure semantics. + +No predicate implies another. A visible affordance may be ineligible. An +eligible affordance may be unsupported by the selected apparatus. A supported +affordance may be hidden from this participant. An admitted invocation may +still fail with a declared SEM-211 failure class. + +Constraint handling reuses SEM-211. Missing, unresolved, stale, exhausted, or +unsupported constraints fail closed; they do not fall back to a backend-local +default. Constraint effects on visibility, telemetry, shared state, or outcome +interpretation are explicit side-effect/observation obligations under I5 and +I13. + +Issue #294 owns the executable authoring, validation, compilation, runtime, and +test bindings for this section. It must reuse action contracts, SEM-211 +admission, participant implementation manifests/selections, exposure policies, +behavior history, and observation/evidence records. + +## SEM-220 - Participant Decision-Surface Semantics + +`SEM-220` requires explicit semantics for open-ended action generation, +constrained action forms, candidate-action sets, and their selection meaning. + +For participant `p`, episode `e`, and observation/order point `o`, define +the decision surface as the participant-local projection: + +```text +D(p, e, o) = Project( + behavior and action-contract refs, + V(p, o) and observation-boundary state, + participant context and audience scope, + participant-implementation selection and decision-control mode, + exposure policy, + SEM-211 eligibility state, + realized affordance/support disclosures, + evidence, provenance, marking, redaction, and limitations +) +``` + +The projection carries stable references and relation state. It does not copy +world truth, raw policy bodies, hidden prompts, evaluator state, credentials, +or backend-native objects into a participant-visible payload. + +Every surface has: + +- participant address, episode id, and observation/order point; +- surface form and its selection interpretation; +- behavior, action-contract, observation-boundary, context-view, + implementation-selection, and exposure-policy refs; +- visible context refs and their source/transformation/disclosure bases; +- action entries with presentation or generation basis, visibility, + eligibility, constraint, and support/realization disclosures; +- affordance refs bound to action contracts and observation effects; +- evidence/provenance, markings, redaction, limitations, and weakening; and +- the event/order/evidence anchor from which the surface was derived. + +The three surface forms have distinct selection meaning: + +- **Open-ended generation:** the participant implementation may propose an + action and arguments, but the proposal must resolve to a governed action + contract, validate its argument shape, and pass SEM-211 admission before it + becomes an attempt. Generation authority is not invocation authority. +- **Constrained form:** a form, grammar, or parameter editor maps to an action + contract. Defaults, normalization, omitted values, validation, and lossy + transformations are part of the mapping and must be disclosed. +- **Candidate-action set:** the surface presents a participant-local set of + action-contract entries. Selection identifies a member and its arguments. + A non-member is invalid unless an explicit open-extension path binds it to a + governed contract and applies the same validation and admission gates. + +These are content/selection forms, not new values for +`participant-decision-surface-modes`. The controlled vocabulary describes how +an implementation makes or relays decisions. It cannot carry action lists, +observations, prompts, instructions, or policy bodies. + +Candidate membership does not imply eligibility, and surface presentation does +not imply selection. Selection does not imply admission, execution, success, or +outcome interpretation. Those transitions remain explicit and evidence-backed. + +`ParticipantContextViewModel` is the reuse-first portable envelope because it +already carries participant/episode scope, observation point, governed source +layers, transformation, `payload_ref`, visibility projection, markings, +redaction policy, evidence, provenance, limitations, and comparability. Issue +#295 may introduce a new closed decision-surface payload contract only when the +independently portable payload cannot be represented through that envelope +without weakening SEM-214/216 invariants. Any new payload composes stable refs; +it does not duplicate action, observation, exposure, or implementation records. + +## SEM-226 - Participant Exposure And Visibility-Boundary Semantics + +`SEM-226` requires explicit semantics for participant-visible versus hidden +context across participant decision surfaces. + +This requirement refines the existing time-indexed `V_p,t`, view-rule, +view-transition, observation-boundary, context-view, and audience-view +semantics. It introduces no parallel visibility taxonomy. + +For item `x`, participant `p`, episode `e`, and order point `o`: + +```text +Exposed(x, p, e, o) only if + x is admitted by V(p, o) + and its source layer and transformation are participant-facing + and its audience/role scope includes p + and its marking, redaction, withholding, and loss rules are satisfied + and the selected exposure policy authorizes the disclosure class + and any visibility change has an event/order/evidence anchor at or before o +``` + +The conjunction is fail closed. Backend reachability, operating scope, +participant authority, control-plane authorization, or the presence of an item +in global/cumulative context cannot substitute for it. + +The source classes remain distinct: + +- participant-visible observations; +- authored control-context artifacts; +- hidden world-truth assets; +- adjudication and evaluator-only assets; +- private references, answer material, canaries, and holdout variants; +- scaffold instructions or guidance; +- archival evidence and derived analysis; and +- augmentation supplied by a human, participant implementation, backend, or + other governed source. + +Augmentation names its source, transformation, audience, visibility basis, +evidence/provenance, marking/redaction, and limitations. A generic metadata or +context map is not an exposure authority. + +Exposure is participant-local and time-indexed. A visibility transition +changes surfaces at or after its effective order; future disclosure cannot +justify an earlier surface. When participants have different boundaries, +roles, or transition histories, they may receive different surfaces for the +same world event without semantic inconsistency. + +Realized exposure is separately evidenced. A manifest capability, selected +mode, or exposure-policy ref can explain intent and apparatus support, but +runtime history/observation evidence records what the participant actually +received. Issue #296 owns executable enforcement and adversarial leakage +fixtures for this section. + +### Joint lifecycle and authority boundaries + +The joint model preserves meaning across stages: + +- **authoring** binds participants/behavior specifications to action contracts, + observation boundaries, authority/scope, and affordance semantics; +- **validation** resolves every ref and fails closed on unknown vocabularies, + ambiguous selection meaning, incomplete constraints, or conflicting + visibility bases; +- **compilation** emits canonical participant/action/observation addresses and + the inputs required to derive `D(p,e,o)`; +- **planning** validates selected implementation/backend support and records + declared weakening before execution; +- **execution** applies existing SEM-211 admission and records behavior history, + results, and visibility transitions; +- **observation/retrieval** derives participant-local surfaces from the + applicable `V_p,o` snapshot rather than global or final state; and +- **conformance** compares authored, compiled, selected, realized, and evidenced + facts and reports disagreement through existing diagnostics. + +Live state remains in `RuntimeSnapshot` and `ControlPlaneStore`. Archival +claims remain in existing evidence/provenance contracts. The joint design adds +no decision-surface side store, metadata bag, audit channel, exception +hierarchy, or backend-specific semantic authority. + +### Source-to-contract-to-test matrix + +The positive and adversarial fixture names below are required implementation +cases, not new artifacts delivered by issue #119. Each implementation issue +must preserve or strengthen its rows. + +| Source / clause | Typed carrier or canonical helper | Lifecycle enforcement point | Positive case | Adversarial negative case | Existing invariant / implementation owner | +| --- | --- | --- | --- | --- | --- | +| SEM-219 A: tool identity is distinct from affordance meaning | governed concept/reference identity plus participant action-contract ref | SDL semantic reference resolution and compiled canonical addresses | one tool identity exposes two separately governed action affordances | tool label or ATT&CK id accepted as the action contract | I14, I16 / #294 | +| SEM-219 B: authored availability is participant-local | `agents.*`, behavior-specification refs, action contracts, authority/scope refs | `SemanticValidator`, behavior analysis, full post-instantiation validation | affordance bound to one participant and its behavior spec | globally declared tool silently becomes available to every participant | I1, I4 / #294 | +| SEM-219 C: visibility is independent of availability | `ParticipantViewRule`, `ParticipantViewTransition`, observation boundary, `V_p,o` | compiler timeline plus observation/retrieval validation at the event order | authored affordance becomes visible after its disclosure transition | globally available affordance appears while hidden from this participant | I2, I3 / #294 | +| SEM-219 D: invocation is independently admitted | action contract plus `ParticipantActionAdmissionRequest` and `participant_action_admission_request_violations()` | runtime `admit_action()` before execution | visible, supported action passes authority and target admission | visible action invokes outside participant authority | I4 / #294 | +| SEM-219 E: constraints fail closed | SEM-211 typed preconditions and portable failure classes | semantic validation, planner applicability, runtime admission, result validation | satisfied resource/temporal constraint admits the attempt | exhausted constraint is ignored or mapped to an untyped backend error | I4, I7 / #294 | +| SEM-219 F: support is apparatus metadata | `ParticipantImplementationManifestModel`, selection model, backend feature-support disclosure | apparatus-context validation before execution | selected implementation explicitly supports the bound affordance | backend-supported tool is treated as semantically available without a grant | I11, I12 / #294 | +| SEM-219 G: side effects and observations are explicit | action-contract effects, behavior history, action result, observation envelope, evidence refs | result/snapshot/conformance validation | invocation records declared visibility and telemetry effects | tool output leaks hidden truth without a view rule or evidence anchor | I5, I13 / #294 | +| SEM-220 A: surface has participant/episode/order identity | `ParticipantContextViewModel` envelope plus typed `D(p,e,o)` payload/ref | retrieval and context-view validation | surface resolves to one participant, episode, and observation point | cumulative/global context substitutes for participant-local state | I1, I3, I15 / #295 | +| SEM-220 B: candidate membership is not eligibility | action-entry contract ref plus explicit SEM-211 eligibility state/reason refs | surface derivation followed by independent admission | visible candidate is marked ineligible with a typed reason | every presented candidate is implicitly executable | I4 / #295 | +| SEM-220 C: open-ended proposals bind before admission | action-contract registry and SEM-211 admission helper | proposal resolution, argument validation, then runtime admission | generated proposal resolves and validates before an attempt | free-form generation bypasses applicability or invents backend-local meaning | I4, I11 / #295 | +| SEM-220 D: constrained forms preserve mapping meaning | governed parameter schema plus explicit default/normalization/loss disclosure | authoring validation, compiler mapping, conformance comparison | form values map deterministically to validated action arguments | omitted/defaulted field changes meaning without disclosure | I12, I14, I16 / #295 | +| SEM-220 E: selection is separate from attempt and outcome | decision record, behavior-history attempt, action result, outcome interpretation | execution history and result/outcome validators | chosen candidate links to one admitted attempt and later result | surface appearance is recorded as selection or success | I10 / #295 | +| SEM-220 F: implementation kind does not change semantics | participant implementation manifest/selection and stable surface refs | apparatus validation and cross-run conformance | human proxy and autonomous implementation realize equivalent refs with disclosed differences | implementation type silently changes action or selection meaning | I1, I11, I12, I15 / #295 | +| SEM-226 A: exposure is scoped by `V_p,o` | compiled view-relation timeline and observation boundary | observation/retrieval validation at or before the event order | disclosed item appears only from its effective order | stale or future-visible state enters an earlier surface | I2, I3 / #296 | +| SEM-226 B: source strata remain distinct | `ParticipantContextViewModel.source_layers`, transformation and payload refs | SEM-214 source binding and SEM-216 audience-boundary validator | archival evidence is mediated through a participant-facing transformation | truth/adjudication/evidence payload aliases the visible context payload | I2, I3, I13 / #296 | +| SEM-226 C: role/audience scope is explicit | context-view audience fields, view rule, participant address, markings | `_validate_sem216_audience_boundary` and retrieval authorization | role-scoped context reaches only the intended participant audience | private or role-specific context appears on another participant's surface | I2, I17 / #296 | +| SEM-226 D: augmentation is governed exposure | source layer, transformation, visibility basis, evidence/provenance, limitations | context-view validation and conformance | augmentation records source, transformation, disclosure basis, and limits | scaffold guidance or augmentation metadata enters a generic context bag | I3, I13, I17 / #296 | +| SEM-226 E: exposure changes are anchored | `ParticipantViewTransition`, behavior-history/episode-close anchor, evidence refs | compiler ordering plus runtime/conformance anchor resolution | disclosure transition changes later surfaces with evidence | exposure changes without a history event, order, or evidence anchor | I2, I8, I9 / #296 | +| SEM-226 F: realized exposure is not inferred from policy | exposure-policy ref plus observation/history/evidence records | apparatus validation followed by runtime and conformance checks | selected policy and realized observation agree, with limitations | policy/manifest claim is treated as proof of delivery | I11, I13, I15 / #296 | + +### Adversarial counterexamples + +The matrix includes the required negative cases; these examples make the +cross-requirement failure shapes explicit: + +1. A shell is globally installed and supported, but participant `p` has no + authored affordance binding. It is not available to `p`. +2. An affordance is authored and visible, but its target lies outside `p`'s + authority. It remains visible and is rejected at admission. +3. An affordance is authored and eligible, but the selected implementation + declares no realization support. Planning fails or records it as unsupported; + it does not silently substitute another tool. +4. Candidate actions are computed from hidden evaluator state. The surface is + invalid even when every action contract would otherwise be well formed. +5. A candidate is derived from a disclosure whose effective order is later than + the surface order. Future visibility does not repair the earlier leak. +6. Open-ended generation emits a backend command without resolving a governed + action contract. The proposal is rejected before admission. +7. A constrained form drops a parameter or supplies a hidden default that + changes action meaning. The mapping is invalid without explicit disclosure. +8. A private answer reference, canary, adjudication record, scaffold hint, or + augmentation payload enters the wrong participant's context view. Audience + and hidden-truth boundaries reject it. +9. An exposure policy selects a tool-affordance ref, but runtime history contains + no corresponding exposure evidence. Selection is not proof of realization. +10. A final aggregate surface is used to claim what the participant saw earlier. + The claim is invalid without the order-indexed surface/history sequence. + +The obligations above refine existing I1-I17 invariants. They add no new +`### I*` heading and therefore do not expand the abstract invariant oracle in +this design issue. Issues #294-#296 own concrete typed bindings and negative +fixtures that specialize the existing oracle. + ## Required Future Verification The complete participant surface is `FM3`. @@ -1329,6 +1621,7 @@ Future implementation PRs should still include: ## References - ADR-022: Participant Behavior and Interaction Semantics +- ADR-083: Participant Tool, Decision-Surface, and Exposure Semantics - ADR-007: Lightweight Formal Methods Policy for Semantic Systems - ADR-013: Participant Episode Lifecycle Boundaries - ADR-016: Semantic Layer Scope and Coverage Model diff --git a/specs/formal/realization/explicitness-and-realization.md b/specs/formal/realization/explicitness-and-realization.md index 4983a5ce2..8bae086e8 100644 --- a/specs/formal/realization/explicitness-and-realization.md +++ b/specs/formal/realization/explicitness-and-realization.md @@ -114,7 +114,7 @@ extend these rather than introduce parallel registries: `implementations/python/packages/aces_sdl/instantiate.py` (`instantiate_scenario`, `SDLInstantiationError`) - runtime compilation and planning: - `implementations/python/packages/aces_processor/compiler.py`, + `implementations/python/packages/aces_processor/compiler/`, `implementations/python/packages/aces_processor/semantics/planner.py` - runtime diagnostics, results, and snapshots: `implementations/python/packages/aces_processor/models/` diff --git a/specs/formal/sdl-phases/README.md b/specs/formal/sdl-phases/README.md index 53564e687..2b616a98d 100644 --- a/specs/formal/sdl-phases/README.md +++ b/specs/formal/sdl-phases/README.md @@ -22,7 +22,7 @@ Let: The phase shapes are: ```text -fields(A) subset-of C union {module, imports, variables} +fields(A) subset-of C union {module, imports, realization, variables} fields(E) subset-of C union {variables, expansion_provenance} fields(I) subset-of C union {instantiation_provenance} fields(S) = {profile, scenario} @@ -33,6 +33,23 @@ outside the relevant set is admitted. `name` is required in `A`, `E`, and `I`; `instantiation_provenance` is additionally required in `I`; both `profile` and `scenario` are required in `S`. +## Phase-specific member catalog + +This table is the complete phase-specific member partition. `C` members are +excluded because they are shared executable content. The table is mechanically +checked against the closed phase models; `optional`, `required`, and `forbidden` +describe member admission, not whether an author chose to write an optional +value. + +| Member | Normalized authoring | Expanded authoring | Instantiated | Transfer disposition | +| --- | --- | --- | --- | --- | +| `module` | optional | forbidden | forbidden | Consumed by expansion; verified module facts are represented by `expansion_provenance.imports` when imports are resolved. | +| `imports` | optional | forbidden | forbidden | Consumed by expansion; resolved imports move to `expansion_provenance.imports` and later `instantiation_provenance.imports`. | +| `realization` | optional | forbidden | forbidden | Normalized designation records move to `expansion_provenance.realization_designations` and later `instantiation_provenance.realization_designations`. | +| `variables` | optional | optional | forbidden | Selected values move to provenance bindings; variable definitions do not survive instantiation. | +| `expansion_provenance` | forbidden | optional | forbidden | Its portable import, constraint, explicitness, and realization records feed instantiation provenance. | +| `instantiation_provenance` | forbidden | forbidden | required | Required portable derivation context for an instantiated artifact. | + ## Transition Relations The supported transitions are partial functions: @@ -53,14 +70,19 @@ transition from `I` back to `A` or `E`, and no parser treats `S` as source. ### P1: Phase exclusion ```text -{module, imports} intersect fields(E) = empty -{module, imports, variables} intersect fields(I) = empty -{module, imports, variables} intersect fields(S) = empty +{module, imports, realization} intersect fields(E) = empty +{module, imports, realization, variables} intersect fields(I) = empty +{module, imports, realization, variables} intersect fields(S) = empty ``` The last line applies to snapshot-envelope members; the nested `scenario` must itself satisfy the instantiated rule. +The authored `realization` block is therefore authoring machinery, not +executable content. Its normalized designation records survive under provenance +so downstream realization can resolve the scoped cascade without admitting the +source block into `E` or `I`. + ### P2: Concreteness ```text diff --git a/specs/formal/workflows/README.md b/specs/formal/workflows/README.md index 4a9f29bb5..eae73f744 100644 --- a/specs/formal/workflows/README.md +++ b/specs/formal/workflows/README.md @@ -27,7 +27,7 @@ This directory holds the repo-native formal artifacts for SDL workflow control s - shared rules: `implementations/python/packages/aces_processor/semantics/workflow.py` - validator enforcement: `implementations/python/packages/aces_sdl/validator/` -- compiled contracts: `implementations/python/packages/aces_processor/compiler.py` +- compiled contracts: `implementations/python/packages/aces_processor/compiler/` - typed runtime results and contract checks: - `implementations/python/packages/aces_processor/models/` - `implementations/python/packages/aces_processor/manager.py` diff --git a/specs/formal/workflows/compensation.md b/specs/formal/workflows/compensation.md index 79460faa3..47690c566 100644 --- a/specs/formal/workflows/compensation.md +++ b/specs/formal/workflows/compensation.md @@ -99,7 +99,7 @@ workflow can be `cancelled` or `timed_out` while compensation is `running` or - SDL authoring models: `implementations/python/packages/aces_sdl/orchestration.py` - semantic validation: `implementations/python/packages/aces_sdl/validator/` -- compiled contracts: `implementations/python/packages/aces_processor/compiler.py` +- compiled contracts: `implementations/python/packages/aces_processor/compiler/` - runtime state/history models: `implementations/python/packages/aces_processor/models/` - manager validation: `implementations/python/packages/aces_processor/manager.py` - control-plane lifecycle handling: `implementations/python/packages/aces_processor/control_plane.py` diff --git a/specs/formal/workflows/state-machine.md b/specs/formal/workflows/state-machine.md index 8b21640be..c5c08cfa4 100644 --- a/specs/formal/workflows/state-machine.md +++ b/specs/formal/workflows/state-machine.md @@ -68,7 +68,7 @@ derived from that envelope after validation. - semantic contract definitions: `implementations/python/packages/aces_processor/semantics/workflow.py` - validation and visibility checks: `implementations/python/packages/aces_sdl/validator/` -- compiled step contracts: `implementations/python/packages/aces_processor/compiler.py` +- compiled step contracts: `implementations/python/packages/aces_processor/compiler/` - typed runtime envelopes and result validation: - `implementations/python/packages/aces_processor/models/` - `implementations/python/packages/aces_processor/manager.py` diff --git a/specs/sdl/README.md b/specs/sdl/README.md index a2fd28020..b91542505 100644 --- a/specs/sdl/README.md +++ b/specs/sdl/README.md @@ -71,6 +71,7 @@ tests, rather than a prose rewrite. The catalogs are: | [`references.md`](references.md) | **2. Reference-resolution catalog** | Reference forms (bare, qualified, nested runtime-family, workflow-step, module-composed), the resolution algorithm, the fail-closed ambiguity rule, and the cross-section reference-edge catalog. | | [`variables-and-instantiation.md`](variables-and-instantiation.md) | **3. Variable / instantiation catalog** | Variable types, defaults, `allowed_values`, `${…}` substitution, the instantiation algorithm, and post-instantiation exclusions. | | [`runtime-inventory.md`](runtime-inventory.md) | **4. Runtime-family index** | The node-scoped runtime-inventory index — family key, collection name, primary `_id`, child-ref collections, owning ADR — and the shared invariants stated once, delegating per-field semantics to the family ADRs. | +| [`authored-domain-topology.md`](authored-domain-topology.md) | — | Authored identity domains, controller/member topology, account domain bindings, compiled ordering, provisioner capability, admission, and readback invariants. | | [`observability-and-evidence.md`](observability-and-evidence.md) | **5. Observability and evidence planes** | Scenario-native observability, authored evidence requirements, processor/backend operational observability, captured evidence, derived analysis, and augmentation classification rules. | | [`scientific-scenario-completeness.md`](scientific-scenario-completeness.md) | — | REV1 intended-use profiles, atomic concern dispositions, separately revisioned delivery assessment, computed completeness, and explicit scientific non-claims. | | [`diagnostics.md`](diagnostics.md) | — | The parse / semantic-validation / instantiation stages, direct-artifact admission, and the normative error-vs-advisory classification criterion. | @@ -94,6 +95,8 @@ An implementer can answer each structural question from the named file alone: [`variables-and-instantiation.md`](variables-and-instantiation.md). - *What is the runtime-inventory surface and which ADR owns each family?* → [`runtime-inventory.md`](runtime-inventory.md). +- *How is domain-backed realization authored without inferring topology from runtime inventory?* → + [`authored-domain-topology.md`](authored-domain-topology.md). - *How are scenario-native observability systems and authored evidence requirements kept distinct?* → [`observability-and-evidence.md`](observability-and-evidence.md). @@ -108,7 +111,7 @@ An implementer can answer each structural question from the named file alone: In scope: the SDL authoring model and portable derived phase contracts — document structure, references, variables, instantiation/provenance, canonical snapshots, the runtime-inventory index, observability/evidence plane -rules, and the diagnostic boundary. +rules, authored identity-domain topology, and the diagnostic boundary. Out of scope: delivery-level concerns (container, infrastructure-as-code, and cloud-API mechanics), processor and backend execution contracts, and the diff --git a/specs/sdl/authored-domain-topology.md b/specs/sdl/authored-domain-topology.md new file mode 100644 index 000000000..5caa9f516 --- /dev/null +++ b/specs/sdl/authored-domain-topology.md @@ -0,0 +1,116 @@ +# Authored Identity-Domain Topology + +Status: **normative**. This specification defines the SDL realization-intent +surface established by +[ADR-082](../../docs/decisions/adrs/adr-082-authored-identity-domain-topology.md). +It is distinct from the observed runtime directory identity inventory governed +by ADR-032. + +## 1. Domain declarations + +`identity_domains` is an optional map keyed by a portable SDL identifier. Each +value is an `IdentityDomain` with these required fields: + +| Field | Meaning | +| --- | --- | +| `profile` | Closed realization profile. The initial standard value is `active_directory`. | +| `dns_name` | Concrete DNS domain name, or a whole-field variable before instantiation. | +| `netbios_name` | Concrete NetBIOS domain name of at most 15 characters, or a whole-field variable before instantiation. | +| `authority_account_ref` | Reference to the account authorized as the domain authority principal. | + +DNS and NetBIOS values MUST satisfy their structural name constraints after +instantiation. Domain declarations contain no password, credential source, +backend endpoint, or provider resolver configuration. + +## 2. Typed topology relationships + +A controller role is a relationship with: + +```yaml +type: domain_controller_for +source: +target: +domain_controller: {} +``` + +A member join is a relationship with: + +```yaml +type: joins_domain +source: +target: +domain_join: + controller_refs: [, ...] +``` + +`controller_refs` is ordered, non-empty, and duplicate-free. Every candidate +MUST be a controller for the relationship's target domain. A topology +relationship's type and typed detail MUST agree; generic `properties` do not +carry controller or membership authority. + +## 3. Account bindings + +`accounts.*.domain_ref` explicitly binds an account to an identity domain. The +account's target node MUST be a controller or member of that domain. An account +with a non-empty `spn` MUST declare `domain_ref`; an implementation MUST NOT +derive the domain from the SPN, username, node operating system, or account +name. + +The `authority_account_ref` on a domain is also an explicit domain binding. It +MUST resolve to an account placed on one of that domain's controller nodes. + +## 4. Active Directory invariants + +For the initial `active_directory` profile, semantic validation MUST reject: + +- a domain with no controller; +- a controller or member edge whose source is not a VM; +- duplicate controller or member facts; +- a node that both controls and joins the same domain; +- a node belonging to more than one Active Directory domain; +- a member whose selected controller does not control the same domain; +- an authority account outside the domain's controllers; +- a domain-bound account outside the domain; and +- an SPN-bearing account without explicit `domain_ref`. + +Unresolved whole-field variables defer the affected cross-reference check until +instantiation. The instantiated scenario MUST satisfy every invariant. + +## 5. Composition and references + +Module composition namespaces domain keys and rewrites all topology-bearing +references: account `domain_ref`, domain `authority_account_ref`, relationship +endpoints, and `domain_join.controller_refs`. Bare and section-qualified +references follow the resolution rules in [references.md](references.md). + +## 6. Compiled realization contract + +Each participating node and domain-bound account compiles to a +`DomainTopologyBinding` containing: + +- the normalized domain identifier and profile; +- DNS and NetBIOS names; +- the canonical authority account address; +- the node role (`controller` or `member`); and +- canonical, ordered controller addresses. + +Member node resources MUST order after all selected controller resources. +Account placements MUST order after their target node. The same normalized +binding MUST appear on an account placement and its target node. + +Provisioners declare supported profiles through +`capabilities.provisioner.supported_domain_profiles`. Generic account or SPN +support does not satisfy this capability. Planning and direct provisioning +admission MUST validate the effective graph across resources, non-delete +operations, and admitted snapshot entries before invoking a backend. + +## 7. Realization and evidence + +Every topology carrier is an exact `domain-topology` realization requirement +under SEM-218. A returned snapshot MUST preserve the whole normalized binding. +Omission or a different binding is silent approximation and MUST be rejected as +a backend-contract error. + +This readback proves carrier fidelity, not successful controller promotion or +guest domain membership. Those claims require explicit backend or guest +evidence from a separately defined realization mechanism. diff --git a/specs/sdl/diagnostics.md b/specs/sdl/diagnostics.md index fdd23e3d8..75d302218 100644 --- a/specs/sdl/diagnostics.md +++ b/specs/sdl/diagnostics.md @@ -152,8 +152,7 @@ together, so the boundary stays single-sourced. Exact duplicate keys, conflicting effective keys introduced by `<<`, and distinct structural field spellings that normalise to one field use the stable diagnostic code `sdl.mapping_key_conflict`. They are fatal at the `parse` stage -and **MUST** be raised before Pydantic or any other SDL model constructor sees -the mapping. +and **MUST** be raised before typed SDL model construction sees the mapping. An explicitly non-string or complex mapping key uses `sdl.mapping_key_type`. A cyclic YAML alias graph uses `sdl.alias_cycle`. These @@ -174,12 +173,15 @@ Each diagnostic **MUST** carry: original key declarations and the canonical path identifies the effective target mapping. -The reference implementation continues to use `SDLParseError` for this failure; -it **MUST NOT** introduce a parallel exception hierarchy. Public structured -adapters (including language-service and MCP responses) preserve the code, -stage, canonical path, and both ranges. Plain-text CLI/library rendering may -format the same fields as prose but must not replace them with raw YAML values or -silently downgrade the error to a generic model-validation failure. +Implementations **MUST** expose this failure through the existing parse-error +channel rather than a parallel diagnostic hierarchy. Public structured adapters +(including language-service and MCP responses) preserve the code, stage, +canonical path, and both ranges. Plain-text CLI/library rendering may format the +same fields as prose but must not replace them with raw YAML values or silently +downgrade the error to a generic model-validation failure. + +> *Implementation evidence (non-normative): the reference implementation's +> parse-error class for this channel is `SDLParseError`.* ## 7. Source-profile and migration diagnostics @@ -217,32 +219,39 @@ migration acceptance requires an explicit caller choice. An identifier diagnostic points to the exact defining key or scalar-id token and carries that token's source range. Its bounded message states the grammar without echoing the invalid spelling, adjacent value, document fragment, -parameter map, or traceback. `SDLMigrationPolicy.ACCEPT` does not demote or +parameter map, or traceback. The accepting migration profile does not demote or rewrite an invalid identity; identifier migration requires an explicit atomic rename of the declaration and all resolved references. -A typed-model diagnostic preserves the validator-owned contract statement so -an author can determine why the field is invalid. The parser excludes -Pydantic's input rendering and documentation URL, removes framework prefixes, -escapes control characters, and bounds each message to 512 characters before -placing it in `sdl.model.invalid`. The JSON Pointer and source range remain the -authoritative locator; a raw `ValidationError`, input object, traceback, or -unbounded validator rendering is never exposed. +A typed-model diagnostic preserves the structural contract statement so an +author can determine why the field is invalid. Framework input rendering, +documentation URLs, input objects, tracebacks, and unbounded diagnostic text +are never exposed. The JSON Pointer and source range remain the authoritative +locator, and the bounded message **MUST NOT** exceed 512 characters. + +> *Implementation evidence (non-normative): the reference parser removes +> Pydantic input rendering, documentation URLs, and framework prefixes; escapes +> control characters; and converts raw `ValidationError` instances into +> `sdl.model.invalid` diagnostics.* ## 8. Instantiation and artifact-admission disclosure Instantiation and instantiated-artifact admission diagnostics identify a bounded variable/field location and failure class. They **MUST NOT** render a supplied parameter value, an `allowed_values` domain, a complete parameter map, -the concrete artifact, trust-policy contents, credentials, a raw Pydantic input +the concrete artifact, trust-policy contents, credentials, a raw framework input dump, documentation URL, or traceback. When structural reconstruction fails, the public diagnostic renders an RFC 6901 -location plus a stable validation category. The reference implementation wraps -this in `SDLInstantiationError`; a raw framework `ValidationError` is not the -public artifact-admission contract. Semantic errors discovered after successful -structural admission remain `SDLValidationError` and retain the collect-all -semantics of the semantic pass. +location plus a stable validation category through the instantiation-error +channel; a raw framework exception is not the public artifact-admission +contract. Semantic errors discovered after successful structural admission +remain on the semantic-error channel and retain the collect-all semantics of +the semantic pass. + +> *Implementation evidence (non-normative): the reference implementation wraps +> structural failures in `SDLInstantiationError`; semantic failures remain +> `SDLValidationError`; and raw `ValidationError` instances are not exposed.* Resolved values necessarily occur in the concrete fields they populate and in the portable replay binding record. Authoring, MCP, compiler, and operation diff --git a/specs/sdl/document-model.md b/specs/sdl/document-model.md index 16c59fda3..be6b42bbd 100644 --- a/specs/sdl/document-model.md +++ b/specs/sdl/document-model.md @@ -220,9 +220,9 @@ forbidden rather than represented by an empty compatibility shell. | Form | Required/phase-specific members | Forbidden authoring machinery | Publication | |------|---------------------------------|-------------------------------|-------------| | Source | YAML presentation governed by `sdl-yaml/v1` | n/a | source profile and YAML fixtures | -| Normalized authoring | executable sections; `name`; optional `module`, `imports`, `variables` | none | `sdl-authoring-input-v1` | -| Expanded authoring | executable sections; `name`; root `variables`; typed `expansion_provenance` | `module`, `imports` | internal trusted representation | -| Instantiated | executable sections; `name`; required `instantiation_provenance` | `module`, `imports`, `variables`, any `${…}` token | `instantiated-scenario-v1` | +| Normalized authoring | executable sections; `name`; optional `module`, `imports`, `realization`, `variables` | none | `sdl-authoring-input-v1` | +| Expanded authoring | executable sections; `name`; root `variables`; typed `expansion_provenance` | `module`, `imports`, `realization` | internal trusted representation | +| Instantiated | executable sections; `name`; required `instantiation_provenance` | `module`, `imports`, `realization`, `variables`, any `${…}` token | `instantiated-scenario-v1` | | Canonical instantiated snapshot | required `profile` and admitted `scenario` | all authoring machinery at the envelope; the nested scenario obeys the instantiated row | `instantiated-scenario-snapshot-v1` | The **normalized authoring object** exists after safe source construction, @@ -236,17 +236,20 @@ namespace rewriting but before final root-variable binding. Public exports have their declared namespace prefix and non-exported declarations have the generated `__private` prefix ([ADR-053](../../docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md)). -Composition consumes `module` and `imports`; their verified resolution facts -move into typed expansion provenance. Only the composition path may create this +Composition consumes `module`, `imports`, and the authored `realization` block. +Verified resolution facts and normalized realization-designation records move +into typed expansion provenance. Only the composition path may create this internal representation or generated qualified declaration keys. Full semantic validation applies to it. The **instantiated scenario** exists after the public binding operation has validated its input, selected and checked every binding, substituted values, rebuilt the closed concrete shape, checked provenance consistency, and rerun -semantic validation. Its provenance is part of the portable artifact, not -Python-private context. Direct/deserialized artifacts must pass the same -structural and semantic admission before compilation. +semantic validation. Its provenance retains the normalized realization +designations without retaining the authoring block. Provenance is part of the +portable artifact, not implementation-private context. Direct/deserialized +artifacts must pass the same structural and semantic admission before +compilation. The **canonical instantiated snapshot** is a sealed identity envelope, not input to source parsing, composition, or substitution. Its profile is @@ -258,8 +261,8 @@ authoring/instantiation model of the runtime-layering boundary of [ADR-004](../../docs/decisions/adrs/adr-004-sdl-runtime-layer.md) and [ADR-036](../../docs/decisions/adrs/adr-036-sdl-processor-runtime-module-boundaries.md): -delivery-level realisation is downstream of, and out of scope for, the authoring -model. +delivery-level realisation remains downstream of the author-facing realization +designation and is out of scope for the authoring model. ## 8. Canonical semantic identity diff --git a/specs/sdl/references.md b/specs/sdl/references.md index ba465b304..66651eea3 100644 --- a/specs/sdl/references.md +++ b/specs/sdl/references.md @@ -136,6 +136,8 @@ probe implementations; propositions and assertions carry portable truth. | `infrastructure` | node / link / dependency | `nodes` / switch-backed `infrastructure` | | `content` | target | `nodes` (VM) | | `accounts` | node | `nodes` (VM) | +| `accounts` | domain | `identity_domains` | +| `identity_domains` | authority account | `accounts` | ### Agents, objectives, participant surfaces @@ -201,6 +203,8 @@ any role-bearing refs | `forwarding_edge` | `forwarder_ref` → exactly one `forwarding_agents` element (node-scoped or scenario-level); protocol/role MUST agree with a declared ship target | | `service_integration` | consumer/engine refs → `platform_applications`; auth-principal ref → a declared app-authorization on the engine's node | | `proxy_upstream` | upstream → a resolved runtime application/endpoint | +| `domain_controller_for` | `source` → a VM node; `target` → an `identity_domains` entry | +| `joins_domain` | `source` → a VM node; `target` → an `identity_domains` entry; controller refs → controller nodes for the same domain | ### Variables @@ -210,74 +214,147 @@ any role-bearing refs ## 6. Machine-checkable reference-edge index -This index gives every editor-visible reference field a stable candidate-domain -token and makes the participant behavior surface explicit. It complements the -semantic detail above: subtype-specific relationship and nested-runtime rules -remain narrower than the broad completion domain recorded here. `targetable` +This index gives every cross-section or cross-declaration authoring reference a +stable candidate-domain token. Registered node-runtime inventories and their +local child edges remain governed by the family index in +[`runtime-inventory.md`](runtime-inventory.md); relationship fields that cross +from a top-level section into those inventories are listed here. `targetable` means the declaration index excluding `variables`, `evidence_requirements`, `objectives`, and `workflows`; it is not a synonym for every named object. -`derived:*`, `vocabulary:*`, `registry:*`, `contract:*`, and `opaque:*` name -deliberately distinct resolution mechanisms and MUST NOT be collapsed into a -generic symbol lookup. - -| Source path | Candidate domain | Resolution phase | Failure | Semantic owner | -| --- | --- | --- | --- | --- | -| `nodes.*.features[]` | `features` | semantic validation | fatal dangling or ambiguous | [node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | -| `nodes.*.conditions[]` | `conditions` | semantic validation | fatal dangling or ambiguous | [node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | -| `conditions.*.proposition` | `propositions` | semantic validation | fatal dangling or ambiguous when present | [proposition validator](../../implementations/python/packages/aces_sdl/validator/_propositions.py) | -| `propositions.*.subjects[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [proposition validator](../../implementations/python/packages/aces_sdl/validator/_propositions.py) | -| `propositions.*.evidence_requirements[]` | `evidence_requirements` | semantic validation | fatal dangling or ambiguous | [proposition validator](../../implementations/python/packages/aces_sdl/validator/_propositions.py) | -| `assertions.*.proposition` | `propositions` | semantic validation | fatal dangling or ambiguous | [proposition validator](../../implementations/python/packages/aces_sdl/validator/_propositions.py) | -| `nodes.*.injects[]` | `injects` | semantic validation | fatal dangling or ambiguous | [node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | -| `nodes.*.vulnerabilities[]` | `vulnerabilities` | semantic validation | fatal dangling or ambiguous | [node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | -| `infrastructure.*.links[]` | `infrastructure` | semantic validation | fatal dangling or ambiguous | [infrastructure validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | -| `infrastructure.*.dependencies[]` | `infrastructure` | semantic validation | fatal dangling or ambiguous | [infrastructure validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | -| `features.*.dependencies[]` | `features` | semantic validation | fatal dangling, ambiguous, or cyclic | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | -| `entities.*.vulnerabilities[]` | `vulnerabilities` | semantic validation | fatal dangling or ambiguous | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | -| `injects.*.from_entity` | `entities` | semantic validation | fatal dangling or ambiguous | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | -| `injects.*.to_entities[]` | `entities` | semantic validation | fatal dangling or ambiguous | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | -| `events.*.assertions[]` | `assertions` | semantic validation | fatal dangling, ambiguous, or non-precondition role | [proposition validator](../../implementations/python/packages/aces_sdl/validator/_propositions.py) | -| `events.*.injects[]` | `injects` | semantic validation | fatal dangling or ambiguous | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | -| `scripts.*.events[]` | `events` | semantic validation | fatal dangling or ambiguous | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | -| `stories.*.scripts[]` | `scripts` | semantic validation | fatal dangling or ambiguous | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | -| `content.*.target` | `nodes` | semantic validation | fatal unless target is a VM node | [content validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | -| `accounts.*.node` | `nodes` | semantic validation | fatal unless target is a VM node | [account validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | -| `relationships.*.source` | `targetable` | semantic validation | fatal dangling or ambiguous; subtype may narrow domain | [relationship validator](../../implementations/python/packages/aces_sdl/validator/_relationships.py) | -| `relationships.*.target` | `targetable` | semantic validation | fatal dangling or ambiguous; subtype may narrow domain | [relationship validator](../../implementations/python/packages/aces_sdl/validator/_relationships.py) | -| `agents.*.entity` | `entities` | semantic validation | fatal dangling or ambiguous | [participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | -| `agents.*.starting_accounts[]` | `accounts` | semantic validation | fatal dangling or ambiguous | [participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | -| `agents.*.starting_assertions[]` | `assertions` | semantic validation | fatal dangling, ambiguous, or non-precondition role | [proposition validator](../../implementations/python/packages/aces_sdl/validator/_propositions.py) | -| `action_contracts.*.interactions.*.related_action_ref` | `action_contracts` | semantic validation | fatal dangling or ambiguous | [participant semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | -| `observation_boundaries.*.view_rules.*.information_refs[]` | `derived:boundary_information` | semantic validation | fatal outside declared boundary information | [participant semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | -| `outcome_interpretation_rules.*.source_ref` | `action_contracts,objectives,workflows` | semantic validation | fatal dangling or ambiguous | [outcome semantics](../../implementations/python/packages/aces_sdl/semantics/participant_outcome.py) | -| `behavior_specifications.*.participant_refs[]` | `agents` | semantic validation | fatal dangling or ambiguous | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | -| `behavior_specifications.*.participant_role_refs[]` | `derived:agent_roles` | semantic validation | fatal unless bound by a referenced participant | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | -| `behavior_specifications.*.action_contract_refs[]` | `action_contracts` | semantic validation | fatal dangling or ambiguous | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | -| `behavior_specifications.*.observation_boundary_refs[]` | `observation_boundaries` | semantic validation | fatal dangling or ambiguous | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | -| `behavior_specifications.*.outcome_interpretation_rule_refs[]` | `outcome_interpretation_rules` | semantic validation | fatal dangling or ambiguous | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | -| `behavior_specifications.*.authority_scope_refs[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [behavior validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | -| `behavior_specifications.*.behavior_mode` | `vocabulary:behavior_mode` | structural validation | fatal invalid vocabulary value | [behavior model](behavior-specifications.md) | -| `behavior_specifications.*.ai_offensive_behavior_refs[]` | `vocabulary:ai_offensive_behavior` | semantic validation | fatal unknown vocabulary identifier | [behavior model](behavior-specifications.md) | -| `behavior_specifications.*.offensive_behavior_refs[]` | `vocabulary:offensive_behavior` | semantic validation | fatal unknown vocabulary identifier | [behavior model](behavior-specifications.md) | -| `behavior_specifications.*.realization_profile_ref` | `opaque:realization_profile` | structural validation | fatal invalid reference shape; resolution belongs to realization | [behavior model](behavior-specifications.md) | -| `behavior_specifications.*.backend_feature_support_refs[]` | `registry:behavior_features` | semantic validation | fatal unsupported feature identifier | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | -| `behavior_specifications.*.evidence_contract_refs[]` | `contract:participant_evidence` | semantic validation | fatal unknown contract identifier | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | -| `evidence_requirements.*.source_refs[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [evidence validator](../../implementations/python/packages/aces_sdl/validator/_evidence_requirements.py) | -| `evidence_requirements.*.scope_refs[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [evidence validator](../../implementations/python/packages/aces_sdl/validator/_evidence_requirements.py) | -| `evidence_requirements.*.channel_refs[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [evidence validator](../../implementations/python/packages/aces_sdl/validator/_evidence_requirements.py) | -| `evidence_requirements.*.trigger_ref` | `targetable` | semantic validation | fatal dangling or ambiguous | [evidence validator](../../implementations/python/packages/aces_sdl/validator/_evidence_requirements.py) | -| `evidence_requirements.*.boundary_ref` | `targetable` | semantic validation | fatal dangling or ambiguous | [evidence validator](../../implementations/python/packages/aces_sdl/validator/_evidence_requirements.py) | -| `objectives.*.agent` | `agents` | semantic validation | fatal dangling or ambiguous | [objective semantics](objective-semantics.md) | -| `objectives.*.entity` | `entities` | semantic validation | fatal dangling or ambiguous | [objective semantics](objective-semantics.md) | -| `objectives.*.targets[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [objective semantics](objective-semantics.md) | -| `objectives.*.success.assertions[]` | `assertions` | semantic validation | fatal dangling, ambiguous, or precondition role | [objective semantics](objective-semantics.md) | -| `objectives.*.depends_on[]` | `objectives` | semantic validation | fatal dangling, ambiguous, or cyclic | [objective semantics](objective-semantics.md) | -| `workflows.*.start` | `workflow_steps` | semantic validation | fatal dangling step | [workflow semantics](workflow-semantics.md) | -| `workflows.*.steps.*.when.assertions[]` | `assertions` | semantic validation | fatal dangling, ambiguous, or non-precondition role | [workflow semantics](workflow-semantics.md) | - -The index is checked against language-service completion metadata and against a -required behavior-edge set. Adding a completion-aware field or behavior -reference without a corresponding row fails the repository contract gate. +`declared`, `derived:*`, `runtime:*`, `vocabulary:*`, `registry:*`, `contract:*`, +and `opaque:*` name deliberately distinct resolution mechanisms and **MUST NOT** +be collapsed into a generic symbol lookup. + +"Normative owner" points only to language-neutral prose or an accepted ADR. +"Implementation evidence" points to the independently maintained reference +implementation check. An implementation link is evidence of conformance, never +the source of the row's normative meaning. + +| Source path | Candidate domain | Resolution phase | Failure | Normative owner | Implementation evidence | +| --- | --- | --- | --- | --- | --- | +| `nodes.*.features[]` | `features` | semantic validation | fatal dangling or ambiguous | [reference rules](#5-cross-section-reference-edge-catalog) | [node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `nodes.*.features.*` | `derived:node_roles` | semantic validation | fatal dangling role when non-empty | [reference rules](#5-cross-section-reference-edge-catalog) | [node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `nodes.*.conditions[]` | `conditions` | semantic validation | fatal dangling or ambiguous | [reference rules](#5-cross-section-reference-edge-catalog) | [node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `nodes.*.conditions.*` | `derived:node_roles` | semantic validation | fatal dangling role when non-empty | [reference rules](#5-cross-section-reference-edge-catalog) | [node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `conditions.*.proposition` | `propositions` | semantic validation | fatal dangling or ambiguous when present | [proposition semantics](../formal/objectives/proposition-and-assertion-semantics.md) | [proposition validator](../../implementations/python/packages/aces_sdl/validator/_propositions.py) | +| `propositions.*.subjects[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [proposition semantics](../formal/objectives/proposition-and-assertion-semantics.md) | [proposition validator](../../implementations/python/packages/aces_sdl/validator/_propositions.py) | +| `propositions.*.evidence_requirements[]` | `evidence_requirements` | semantic validation | fatal dangling or ambiguous | [proposition semantics](../formal/objectives/proposition-and-assertion-semantics.md) | [proposition validator](../../implementations/python/packages/aces_sdl/validator/_propositions.py) | +| `assertions.*.proposition` | `propositions` | semantic validation | fatal dangling or ambiguous | [proposition semantics](../formal/objectives/proposition-and-assertion-semantics.md) | [proposition validator](../../implementations/python/packages/aces_sdl/validator/_propositions.py) | +| `nodes.*.injects[]` | `injects` | semantic validation | fatal dangling or ambiguous | [reference rules](#5-cross-section-reference-edge-catalog) | [node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `nodes.*.injects.*` | `derived:node_roles` | semantic validation | fatal dangling role when non-empty | [reference rules](#5-cross-section-reference-edge-catalog) | [node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `nodes.*.vulnerabilities[]` | `vulnerabilities` | semantic validation | fatal dangling or ambiguous | [reference rules](#5-cross-section-reference-edge-catalog) | [node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `nodes.*.roles.*.entities[]` | `entities` | semantic validation | fatal dangling or ambiguous | [reference rules](#5-cross-section-reference-edge-catalog) | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `infrastructure.*.$key` | `nodes` | semantic validation | fatal when no same-named node exists | [reference rules](#5-cross-section-reference-edge-catalog) | [infrastructure validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `infrastructure.*.links[]` | `infrastructure` | semantic validation | fatal dangling or ambiguous | [reference rules](#5-cross-section-reference-edge-catalog) | [infrastructure validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `infrastructure.*.properties[].*` | `infrastructure` | semantic validation | fatal unless the key names a linked switch-backed entry | [reference rules](#5-cross-section-reference-edge-catalog) | [infrastructure validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `infrastructure.*.acls[].from_net` | `infrastructure` | semantic validation | fatal unless the target is switch-backed | [reference rules](#5-cross-section-reference-edge-catalog) | [infrastructure validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `infrastructure.*.acls[].to_net` | `infrastructure` | semantic validation | fatal unless the target is switch-backed | [reference rules](#5-cross-section-reference-edge-catalog) | [infrastructure validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `infrastructure.*.dependencies[]` | `infrastructure` | semantic validation | fatal dangling or ambiguous | [reference rules](#5-cross-section-reference-edge-catalog) | [infrastructure validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `features.*.dependencies[]` | `features` | semantic validation | fatal dangling, ambiguous, or cyclic | [reference rules](#5-cross-section-reference-edge-catalog) | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `features.*.vulnerabilities[]` | `vulnerabilities` | semantic validation | fatal dangling or ambiguous | [reference rules](#5-cross-section-reference-edge-catalog) | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `entities.*.vulnerabilities[]` | `vulnerabilities` | semantic validation | fatal dangling or ambiguous | [reference rules](#5-cross-section-reference-edge-catalog) | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `entities.*.events[]` | `events` | semantic validation | fatal dangling or ambiguous | [reference rules](#5-cross-section-reference-edge-catalog) | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `injects.*.from_entity` | `entities` | semantic validation | fatal dangling or ambiguous | [reference rules](#5-cross-section-reference-edge-catalog) | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `injects.*.to_entities[]` | `entities` | semantic validation | fatal dangling or ambiguous | [reference rules](#5-cross-section-reference-edge-catalog) | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `events.*.assertions[]` | `assertions` | semantic validation | fatal dangling, ambiguous, or non-precondition role | [proposition semantics](../formal/objectives/proposition-and-assertion-semantics.md) | [proposition validator](../../implementations/python/packages/aces_sdl/validator/_propositions.py) | +| `events.*.injects[]` | `injects` | semantic validation | fatal dangling or ambiguous | [reference rules](#5-cross-section-reference-edge-catalog) | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `scripts.*.events[]` | `events` | semantic validation | fatal dangling or ambiguous | [reference rules](#5-cross-section-reference-edge-catalog) | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `stories.*.scripts[]` | `scripts` | semantic validation | fatal dangling or ambiguous | [reference rules](#5-cross-section-reference-edge-catalog) | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `content.*.target` | `nodes` | semantic validation | fatal unless target is a vm node | [reference rules](#5-cross-section-reference-edge-catalog) | [content validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `accounts.*.domain_ref` | `identity_domains` | semantic validation | fatal dangling, ambiguous, or inconsistent topology | [authored domain topology](authored-domain-topology.md) | [domain topology semantics](../../implementations/python/packages/aces_sdl/semantics/domain_topology.py) | +| `identity_domains.*.authority_account_ref` | `accounts` | semantic validation | fatal dangling, ambiguous, or authority outside domain controllers | [authored domain topology](authored-domain-topology.md) | [domain topology semantics](../../implementations/python/packages/aces_sdl/semantics/domain_topology.py) | +| `accounts.*.node` | `nodes` | semantic validation | fatal unless target is a vm node | [reference rules](#5-cross-section-reference-edge-catalog) | [account validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `relationships.*.source` | `targetable` | semantic validation | fatal dangling or ambiguous; subtype may narrow domain | [ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md) | [relationship validator](../../implementations/python/packages/aces_sdl/validator/_relationships.py) | +| `relationships.*.target` | `targetable` | semantic validation | fatal dangling or ambiguous; subtype may narrow domain | [ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md) | [relationship validator](../../implementations/python/packages/aces_sdl/validator/_relationships.py) | +| `relationships.*.database_access.role_ref` | `derived:database_roles` | semantic validation | fatal outside the target database service | [ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md) | [relationship validator](../../implementations/python/packages/aces_sdl/validator/_relationships.py) | +| `relationships.*.mail_access.listener_ref` | `derived:mail_listeners` | semantic validation | fatal outside the target mail service | [ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md) | [mail validator](../../implementations/python/packages/aces_sdl/validator/_runtime_mail.py) | +| `relationships.*.mail_access.mailbox_ref` | `derived:mailboxes` | semantic validation | fatal outside the target mail service | [ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md) | [mail validator](../../implementations/python/packages/aces_sdl/validator/_runtime_mail.py) | +| `relationships.*.mail_access.domain_ref` | `derived:mail_domains` | semantic validation | fatal outside the target mail service | [ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md) | [mail validator](../../implementations/python/packages/aces_sdl/validator/_runtime_mail.py) | +| `relationships.*.forwarding_edge.forwarder_ref` | `runtime:forwarding_agents` | semantic validation | fatal dangling or ambiguous across scenario and node scopes | [ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md) | [relationship validator](../../implementations/python/packages/aces_sdl/validator/_relationships.py) | +| `relationships.*.service_integration.consumer_ref` | `runtime:platform_applications` | semantic validation | fatal dangling or ambiguous | [ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md) | [relationship validator](../../implementations/python/packages/aces_sdl/validator/_relationships.py) | +| `relationships.*.service_integration.engine_ref` | `runtime:platform_applications` | semantic validation | fatal dangling or ambiguous | [ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md) | [relationship validator](../../implementations/python/packages/aces_sdl/validator/_relationships.py) | +| `relationships.*.service_integration.auth_principal_ref` | `derived:engine_authorization_principals` | semantic validation | fatal outside the engine authorization scope | [ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md) | [relationship validator](../../implementations/python/packages/aces_sdl/validator/_relationships.py) | +| `relationships.*.proxy_upstream.route_ref` | `derived:source_application_routes` | semantic validation | fatal outside the source application | [ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md) | [proxy relationship validator](../../implementations/python/packages/aces_sdl/validator/_relationships_proxy.py) | +| `relationships.*.proxy_upstream.upstream_node_ref` | `nodes` | semantic validation | fatal dangling or ambiguous | [ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md) | [proxy relationship validator](../../implementations/python/packages/aces_sdl/validator/_relationships_proxy.py) | +| `relationships.*.proxy_upstream.upstream_service_ref` | `derived:upstream_node_services` | semantic validation | fatal without a resolvable upstream node and service | [ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md) | [proxy relationship validator](../../implementations/python/packages/aces_sdl/validator/_relationships_proxy.py) | +| `relationships.*.domain_join.controller_refs[]` | `nodes` | semantic validation | fatal dangling, ambiguous, or controller outside target domain | [authored domain topology](authored-domain-topology.md) | [domain topology semantics](../../implementations/python/packages/aces_sdl/semantics/domain_topology.py) | +| `agents.*.entity` | `entities` | semantic validation | fatal dangling or ambiguous | [participant semantics](../formal/participant-semantics/README.md) | [participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `agents.*.actions[]` | `action_contracts` | semantic validation | fatal dangling or ambiguous | [participant semantics](../formal/participant-semantics/README.md) | [participant semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `agents.*.starting_accounts[]` | `accounts` | semantic validation | fatal dangling or ambiguous | [participant semantics](../formal/participant-semantics/README.md) | [participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `agents.*.starting_assertions[]` | `assertions` | semantic validation | fatal dangling, ambiguous, or non-precondition role | [participant semantics](../formal/participant-semantics/README.md) | [proposition validator](../../implementations/python/packages/aces_sdl/validator/_propositions.py) | +| `agents.*.initial_knowledge.hosts[]` | `nodes` | semantic validation | fatal unless the target is a vm node | [participant semantics](../formal/participant-semantics/README.md) | [participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `agents.*.initial_knowledge.subnets[]` | `infrastructure` | semantic validation | fatal unless the target is switch-backed | [participant semantics](../formal/participant-semantics/README.md) | [participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `agents.*.initial_knowledge.services[]` | `derived:node_services` | semantic validation | fatal dangling or ambiguous | [participant semantics](../formal/participant-semantics/README.md) | [participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `agents.*.initial_knowledge.accounts[]` | `accounts` | semantic validation | fatal dangling or ambiguous | [participant semantics](../formal/participant-semantics/README.md) | [participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `agents.*.allowed_subnets[]` | `infrastructure` | semantic validation | fatal unless the target is switch-backed | [participant semantics](../formal/participant-semantics/README.md) | [participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `agents.*.authority_anchors[]` | `declared` | semantic validation | fatal dangling or ambiguous | [participant semantics](../formal/participant-semantics/README.md) | [participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `agents.*.operating_scope[]` | `derived:operating_scope` | semantic validation | fatal dangling or ambiguous outside vm nodes, switch-backed infrastructure, services, and content | [participant semantics](../formal/participant-semantics/README.md) | [participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `agents.*.observation_boundaries[]` | `observation_boundaries` | semantic validation | fatal dangling or ambiguous | [participant semantics](../formal/participant-semantics/README.md) | [participant semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `action_contracts.*.interactions.*.related_actions[]` | `action_contracts` | semantic validation | fatal dangling or ambiguous | [participant semantics](../formal/participant-semantics/README.md) | [participant semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `action_contracts.*.interactions.*.target` | `targetable` | semantic validation | fatal dangling or ambiguous | [participant semantics](../formal/participant-semantics/README.md) | [participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `action_contracts.*.interactions.*.shared_state_refs[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [participant semantics](../formal/participant-semantics/README.md) | [participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `action_contracts.*.temporal_contracts.*.backend_disclosure_refs[]` | `derived:backend_timing_disclosures` | structural validation | fatal dangling local disclosure id | [participant semantics](../formal/participant-semantics/README.md) | [temporal model](../../implementations/python/packages/aces_sdl/participant_temporal_semantics.py) | +| `action_contracts.*.backend_timing_disclosures.*.affected_temporal_ids[]` | `derived:temporal_contracts` | structural validation | fatal dangling local temporal id | [participant semantics](../formal/participant-semantics/README.md) | [temporal model](../../implementations/python/packages/aces_sdl/participant_temporal_semantics.py) | +| `observation_boundaries.*.view_rules.*.information_ref` | `derived:boundary_information` | semantic validation | fatal outside declared boundary information | [participant semantics](../formal/participant-semantics/README.md) | [participant semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `observation_boundaries.*.view_rules.*.evidence_refs[]` | `derived:boundary_evidence` | semantic validation | fatal outside declared boundary evidence | [participant semantics](../formal/participant-semantics/README.md) | [participant semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `observation_boundaries.*.view_transitions.*.information_ref` | `derived:boundary_view_rules` | semantic validation | fatal without a matching view rule | [participant semantics](../formal/participant-semantics/README.md) | [participant semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `observation_boundaries.*.view_transitions.*.evidence_refs[]` | `derived:boundary_evidence` | semantic validation | fatal outside declared boundary evidence | [participant semantics](../formal/participant-semantics/README.md) | [participant semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `outcome_interpretation_rules.*.source_bindings.*.ref` | `action_contracts,objectives,workflows` | semantic validation | fatal dangling for SDL-bound layers | [participant semantics](../formal/participant-semantics/README.md) | [outcome semantics](../../implementations/python/packages/aces_sdl/semantics/participant_outcome.py) | +| `outcome_interpretation_rules.*.target_bindings.*.ref` | `objectives,workflows` | semantic validation | fatal dangling for SDL-bound layers | [participant semantics](../formal/participant-semantics/README.md) | [outcome semantics](../../implementations/python/packages/aces_sdl/semantics/participant_outcome.py) | +| `behavior_specifications.*.participant_refs[]` | `agents` | semantic validation | fatal dangling or ambiguous | [behavior model](../formal/participant-behavior-model/README.md) | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `behavior_specifications.*.participant_role_refs[]` | `derived:agent_roles` | semantic validation | fatal unless bound by a referenced participant | [behavior model](../formal/participant-behavior-model/README.md) | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `behavior_specifications.*.action_contract_refs[]` | `action_contracts` | semantic validation | fatal dangling or ambiguous | [behavior model](../formal/participant-behavior-model/README.md) | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `behavior_specifications.*.observation_boundary_refs[]` | `observation_boundaries` | semantic validation | fatal dangling or ambiguous | [behavior model](../formal/participant-behavior-model/README.md) | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `behavior_specifications.*.outcome_interpretation_rule_refs[]` | `outcome_interpretation_rules` | semantic validation | fatal dangling or ambiguous | [behavior model](../formal/participant-behavior-model/README.md) | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `behavior_specifications.*.authority_scope_refs[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [behavior model](../formal/participant-behavior-model/README.md) | [behavior validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `behavior_specifications.*.behavior_mode` | `vocabulary:behavior_mode` | structural validation | fatal invalid vocabulary value | [behavior model](../formal/participant-behavior-model/README.md) | [behavior model](../../implementations/python/packages/aces_sdl/participant_behavior.py) | +| `behavior_specifications.*.ai_offensive_behavior_refs[]` | `vocabulary:ai_offensive_behavior` | semantic validation | fatal unknown vocabulary identifier | [behavior model](../formal/participant-behavior-model/README.md) | [behavior model](../../implementations/python/packages/aces_sdl/participant_behavior.py) | +| `behavior_specifications.*.offensive_behavior_refs[]` | `vocabulary:offensive_behavior` | semantic validation | fatal unknown vocabulary identifier | [behavior model](../formal/participant-behavior-model/README.md) | [behavior model](../../implementations/python/packages/aces_sdl/participant_behavior.py) | +| `behavior_specifications.*.realization_profile_ref` | `opaque:realization_profile` | structural validation | fatal invalid reference shape; resolution belongs to realization | [behavior model](../formal/participant-behavior-model/README.md) | [behavior model](../../implementations/python/packages/aces_sdl/participant_behavior.py) | +| `behavior_specifications.*.backend_feature_support_refs[]` | `registry:behavior_features` | semantic validation | fatal unsupported feature identifier | [behavior model](../formal/participant-behavior-model/README.md) | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `behavior_specifications.*.evidence_contract_refs[]` | `contract:participant_evidence` | semantic validation | fatal unknown contract identifier | [behavior model](../formal/participant-behavior-model/README.md) | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `evidence_requirements.*.source_refs[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [evidence authoring](observability-and-evidence.md) | [evidence validator](../../implementations/python/packages/aces_sdl/validator/_evidence_requirements.py) | +| `evidence_requirements.*.scope_refs[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [evidence authoring](observability-and-evidence.md) | [evidence validator](../../implementations/python/packages/aces_sdl/validator/_evidence_requirements.py) | +| `evidence_requirements.*.channel_refs[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [evidence authoring](observability-and-evidence.md) | [evidence validator](../../implementations/python/packages/aces_sdl/validator/_evidence_requirements.py) | +| `evidence_requirements.*.trigger_ref` | `targetable` | semantic validation | fatal dangling or ambiguous | [evidence authoring](observability-and-evidence.md) | [evidence validator](../../implementations/python/packages/aces_sdl/validator/_evidence_requirements.py) | +| `evidence_requirements.*.boundary_ref` | `targetable` | semantic validation | fatal dangling or ambiguous | [evidence authoring](observability-and-evidence.md) | [evidence validator](../../implementations/python/packages/aces_sdl/validator/_evidence_requirements.py) | +| `objectives.*.agent` | `agents` | semantic validation | fatal dangling or ambiguous | [objective semantics](../formal/objectives/declarative-objective-semantics.md) | [objective semantics](../../implementations/python/packages/aces_sdl/semantics/objective_semantics.py) | +| `objectives.*.entity` | `entities` | semantic validation | fatal dangling or ambiguous | [objective semantics](../formal/objectives/declarative-objective-semantics.md) | [objective semantics](../../implementations/python/packages/aces_sdl/semantics/objective_semantics.py) | +| `objectives.*.actions[]` | `derived:agent_actions` | semantic validation | fatal outside the bound agent action contracts | [objective semantics](../formal/objectives/declarative-objective-semantics.md) | [objective semantics](../../implementations/python/packages/aces_sdl/semantics/objective_semantics.py) | +| `objectives.*.targets[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [objective semantics](../formal/objectives/declarative-objective-semantics.md) | [objective semantics](../../implementations/python/packages/aces_sdl/semantics/objective_semantics.py) | +| `objectives.*.success.assertions[]` | `assertions` | semantic validation | fatal dangling, ambiguous, or precondition role | [proposition semantics](../formal/objectives/proposition-and-assertion-semantics.md) | [objective semantics](../../implementations/python/packages/aces_sdl/semantics/objective_semantics.py) | +| `objectives.*.depends_on[]` | `objectives` | semantic validation | fatal dangling, ambiguous, or cyclic | [objective semantics](../formal/objectives/declarative-objective-semantics.md) | [objective semantics](../../implementations/python/packages/aces_sdl/semantics/objective_semantics.py) | +| `objectives.*.window.stories[]` | `stories` | semantic validation | fatal dangling or ambiguous | [objective semantics](../formal/objectives/declarative-objective-semantics.md) | [objective semantics](../../implementations/python/packages/aces_sdl/semantics/objective_semantics.py) | +| `objectives.*.window.scripts[]` | `scripts` | semantic validation | fatal dangling or outside referenced stories | [objective semantics](../formal/objectives/declarative-objective-semantics.md) | [objective semantics](../../implementations/python/packages/aces_sdl/semantics/objective_semantics.py) | +| `objectives.*.window.events[]` | `events` | semantic validation | fatal dangling or outside referenced scripts | [objective semantics](../formal/objectives/declarative-objective-semantics.md) | [objective semantics](../../implementations/python/packages/aces_sdl/semantics/objective_semantics.py) | +| `objectives.*.window.workflows[]` | `workflows` | semantic validation | fatal dangling or ambiguous | [objective semantics](../formal/objectives/declarative-objective-semantics.md) | [objective semantics](../../implementations/python/packages/aces_sdl/semantics/objective_semantics.py) | +| `objectives.*.window.steps[]` | `workflow_steps` | semantic validation | fatal malformed, dangling, or outside referenced workflows | [objective semantics](../formal/objectives/declarative-objective-semantics.md) | [objective semantics](../../implementations/python/packages/aces_sdl/semantics/objective_semantics.py) | +| `workflows.*.start` | `workflow_steps` | semantic validation | fatal dangling step | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.when.assertions[]` | `assertions` | semantic validation | fatal dangling, ambiguous, or non-precondition role | [proposition semantics](../formal/objectives/proposition-and-assertion-semantics.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.when.objectives[]` | `objectives` | semantic validation | fatal dangling or ambiguous | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.when.steps.*.step` | `workflow_steps` | semantic validation | fatal dangling, self-referential, non-executable, or unavailable before evaluation | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.cases.*.when.assertions[]` | `assertions` | semantic validation | fatal dangling, ambiguous, or non-precondition role | [proposition semantics](../formal/objectives/proposition-and-assertion-semantics.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.cases.*.when.objectives[]` | `objectives` | semantic validation | fatal dangling or ambiguous | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.cases.*.when.steps.*.step` | `workflow_steps` | semantic validation | fatal dangling, self-referential, non-executable, or unavailable before evaluation | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.objective` | `objectives` | semantic validation | fatal dangling or ambiguous | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.next` | `workflow_steps` | semantic validation | fatal dangling, cyclic, or unreachable | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.on_success` | `workflow_steps` | semantic validation | fatal dangling, cyclic, or unreachable | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.on_failure` | `workflow_steps` | semantic validation | fatal dangling, cyclic, or unreachable | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.on_exhausted` | `workflow_steps` | semantic validation | fatal dangling, cyclic, or unreachable | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.then` | `workflow_steps` | semantic validation | fatal dangling, cyclic, or unreachable | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.else` | `workflow_steps` | semantic validation | fatal dangling, cyclic, or unreachable | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.cases.*.next` | `workflow_steps` | semantic validation | fatal dangling, cyclic, or unreachable | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.default` | `workflow_steps` | semantic validation | fatal dangling, cyclic, or unreachable | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.branches[]` | `workflow_steps` | semantic validation | fatal dangling or outside a closed parallel branch | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.join` | `workflow_steps` | semantic validation | fatal dangling, non-join, multiply owned, or outside branch closure | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.workflow` | `workflows` | semantic validation | fatal dangling or cyclic | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | +| `workflows.*.steps.*.compensate_with` | `workflows` | semantic validation | fatal dangling, cyclic, or invalid as a compensation target | [workflow semantics](../formal/workflows/state-machine.md) | [workflow validator](../../implementations/python/packages/aces_sdl/validator/_workflows_verify.py) | + +The index is compared by exact source path, domain, phase, failure semantics, +and implementation evidence against the checked reference contract, and its +completion-aware subset is compared with language-service metadata. Adding, +removing, or renaming an edge on only one surface fails the repository contract +gate; a matching row count cannot hide a different edge. ## Extending the reference catalog diff --git a/specs/sdl/sections.md b/specs/sdl/sections.md index 497350d23..8d7b2d584 100644 --- a/specs/sdl/sections.md +++ b/specs/sdl/sections.md @@ -25,10 +25,9 @@ referenced by others but does not itself reference another section. ## Complete top-level field catalog This table is the complete, mechanically checked top-level language surface. -"Lifecycle" names the document forms in which the field is carried. A -composition field marked `expanded-empty` or `instantiated-empty` remains in the -model with its empty default after module expansion; its authored composition -instructions do not survive as executable scenario meaning. "References" is +"Lifecycle" names the document forms in which the field is carried. A field +absent from a lifecycle is forbidden by that phase's closed model; authoring +machinery is not retained as an empty compatibility field. "References" is `catalogued` when the field owns at least one row in the exact edge index in [`references.md`](references.md). @@ -37,8 +36,8 @@ instructions do not survive as executable scenario meaning. "References" is | `name` | metadata | scalar | normalized, expanded, instantiated | required | `scenario_name` | none | [document model](document-model.md) | | `version` | metadata | scalar | normalized, expanded, instantiated | optional; default `*` | none | none | [document model](document-model.md) | | `description` | metadata | scalar | normalized, expanded, instantiated | optional; default empty string | none | none | [document model](document-model.md) | -| `module` | composition | mapping | normalized, expanded-empty, instantiated-empty | optional; default null | `module.id` | none | [ADR-053](../../docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md) | -| `imports` | composition | list | normalized, expanded-empty, instantiated-empty | optional; default empty list | `namespace` | none | [ADR-053](../../docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md) | +| `module` | composition | mapping | normalized | optional; default null | `module.id` | none | [ADR-053](../../docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md) | +| `imports` | composition | list | normalized | optional; default empty list | `namespace` | none | [ADR-053](../../docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md) | | `realization` | composition | mapping | normalized | optional; default null | none | none | [explicitness and realization](../formal/realization/explicitness-and-realization.md) | | `nodes` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [nodes and runtime inventory](runtime-inventory.md) | | `infrastructure` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [document model](document-model.md) | @@ -54,19 +53,20 @@ instructions do not survive as executable scenario meaning. "References" is | `stories` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [reference catalog](references.md) | | `content` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [document model](document-model.md) | | `accounts` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [document model](document-model.md) | +| `identity_domains` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [authored domain topology](authored-domain-topology.md) | | `relationships` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md) | | `forwarding_agents` | section | list | normalized, expanded, instantiated | optional; default empty list | `forwarding_agent_id` | none | [ADR-050](../../docs/decisions/adrs/adr-050-forwarding-agent-runtime-inventory.md) | -| `agents` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [participant model](participant-model.md) | -| `action_contracts` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [participant model](participant-model.md) | -| `observation_boundaries` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [participant model](participant-model.md) | -| `outcome_interpretation_rules` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [participant model](participant-model.md) | -| `behavior_specifications` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [behavior specifications](behavior-specifications.md) | +| `agents` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [participant model](../formal/participant-semantics/README.md) | +| `action_contracts` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [participant model](../formal/participant-semantics/README.md) | +| `observation_boundaries` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [participant model](../formal/participant-semantics/README.md) | +| `outcome_interpretation_rules` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [participant model](../formal/participant-semantics/README.md) | +| `behavior_specifications` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [behavior specifications](../formal/participant-behavior-model/README.md) | | `evidence_requirements` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [observability and evidence](observability-and-evidence.md) | -| `objectives` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [objective semantics](objective-semantics.md) | -| `workflows` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [workflow semantics](workflow-semantics.md) | -| `variables` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | none | [variables and instantiation](variables-and-instantiation.md) | +| `objectives` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [objective semantics](../formal/objectives/declarative-objective-semantics.md) | +| `workflows` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [workflow semantics](../formal/workflows/state-machine.md) | +| `variables` | section | map | normalized, expanded | optional; default empty map | `map_key` | none | [variables and instantiation](variables-and-instantiation.md) | - + The section set therefore has two authoring shapes: maps keyed by stable user-defined identifiers and the scenario-level `forwarding_agents` list, whose diff --git a/specs/sdl/variables-and-instantiation.md b/specs/sdl/variables-and-instantiation.md index a30bbabf5..455de1dbf 100644 --- a/specs/sdl/variables-and-instantiation.md +++ b/specs/sdl/variables-and-instantiation.md @@ -105,13 +105,15 @@ An instantiated document is concrete. It **MUST NOT** contain: 1. unresolved `${…}` placeholders (§3 step 8); and 2. a `variables` member, even an empty one; 3. an `imports` member, even an empty one; or -4. a `module` member, even a null one. +4. a `module` member, even a null one; or +5. a `realization` member, even a null one. This is the authoring → instantiated distinction: a value that exists only to be substituted (a `${…}` reference) and the machinery that substitutes it (the `variables` definitions) do not survive into the instantiated form. `module` -is packaging metadata and `imports` are composition instructions; verified -resolution evidence survives under provenance instead. +is packaging metadata, `imports` are composition instructions, and `realization` +is an authoring designation block. Verified resolution facts and normalized +realization-designation records survive under provenance instead. Every `instantiated-scenario-v1` payload **MUST** carry a closed `instantiation_provenance` object. Its members are: @@ -124,6 +126,7 @@ Every `instantiated-scenario-v1` payload **MUST** carry a closed | `imports` | Verified resolved imports in declared preorder. Each carries namespace segments, requested and resolved identities, available digests, signer id, and module-local bindings. | | `capability_constraints` | Finite domains retained only for concrete `nodes..os` and `infrastructure..count` fields, addressed by RFC 6901 pointer and qualified parameter identity. | | `explicitness` | Portable SEM-218 model-path classifications whose parameter identities remain resolvable after variable definitions are removed. | +| `realization_designations` | Portable SEM-218 root/scoped posture records. Each carries a namespace, RFC 6901 field pointer, and `closed`, `open`, or `unspecified` posture after the authoring-only `realization` block is removed. | A qualified imported binding identity is the import's `namespace` tuple concatenated with its one-segment local parameter identity. Root and qualified @@ -142,6 +145,11 @@ locations, raw signatures, and source documents are excluded. A signer id and digest are resolution evidence, not a replacement for a signature or an independently chosen trust policy. +Realization designation identities are unique by namespace and field pointer. +They preserve the authored cascade across expansion and instantiation but do not +claim that downstream realization occurred or turn the authoring block into +executable scenario content. + The provenance supplies selected inputs and verification anchors for replay. It does not make replay self-contained or prove that the described transformation ran: repeated resolution still depends on source availability, source bytes, diff --git a/tools/check_sdl_catalog_parity.py b/tools/check_sdl_catalog_parity.py index 2f895584c..19a2b6f08 100644 --- a/tools/check_sdl_catalog_parity.py +++ b/tools/check_sdl_catalog_parity.py @@ -14,9 +14,13 @@ import json import re import sys +import types +from collections.abc import Mapping, Sequence from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Any, Union, get_args, get_origin + +from pydantic import BaseModel REPO_ROOT = Path(__file__).resolve().parents[1] PYTHON_PACKAGES = REPO_ROOT / "implementations" / "python" / "packages" @@ -31,7 +35,13 @@ RUNTIME_SERVICE_FAMILIES, RuntimeReferenceChild, ) -from aces_sdl.scenario import Scenario +from aces_sdl.phase_contracts import ExpansionProvenance, InstantiationProvenance +from aces_sdl.scenario import ( + ExpandedScenario, + InstantiatedScenario, + Scenario, + ScenarioContent, +) from tools.policy.common import ( PolicyFailure, apply_exceptions, @@ -42,11 +52,16 @@ SECTIONS_PATH = "specs/sdl/sections.md" REFERENCES_PATH = "specs/sdl/references.md" RUNTIME_PATH = "specs/sdl/runtime-inventory.md" +DOCUMENT_MODEL_PATH = "specs/sdl/document-model.md" +VARIABLES_PATH = "specs/sdl/variables-and-instantiation.md" +DIAGNOSTICS_PATH = "specs/sdl/diagnostics.md" +PHASES_PATH = "specs/formal/sdl-phases/README.md" SCHEMA_PATH = "contracts/schemas/sdl/sdl-authoring-input-v1.json" _TOP_LEVEL_HEADING = "## Complete top-level field catalog" _REFERENCE_HEADING = "## 6. Machine-checkable reference-edge index" _RUNTIME_HEADING = "## 2. Family index" +_PHASE_HEADING = "## Phase-specific member catalog" _SUMMARY_RE = re.compile( r"