From 78da8fd2c970714232732767398f3b667cd832eb Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 13 Jul 2026 18:45:12 -0700 Subject: [PATCH 1/7] feat(sdl): add authored identity domain topology (#768) * Add authored identity domain topology * Fix SonarCloud findings (cycle 1) * Fix SonarCloud findings (cycle 1) --- .../controlled-vocabularies-v1.json | 16 + .../reference-models-v1.json | 28 +- .../backend-manifest-v2/valid/stub.json | 7 + .../valid/reference.json | 16 + .../reference-models-v1/valid/reference.json | 28 +- .../valid/generic.json | 5 +- .../provenance/sdl-lineage-ledger-v1.json | 62 ++ .../libvirt-qemu/generic-v1.json | 5 +- .../guest-certified-appliance-v1.json | 5 +- .../libvirt-qemu/techvault-appliance-v1.json | 5 +- contracts/schema-publication-manifest.json | 30 +- .../backend-manifest/backend-manifest-v2.json | 8 + .../realization-envelope-v1.json | 9 + .../instantiated-scenario-snapshot-v1.json | 129 ++- .../schemas/sdl/instantiated-scenario-v1.json | 129 ++- .../schemas/sdl/sdl-authoring-input-v1.json | 126 ++- docs/decisions/adrs/README.md | 3 + ...r-082-authored-identity-domain-topology.md | 129 +++ docs/decisions/adrs/adr-index.yaml | 3 + ...-763-authored-domain-topology-preflight.md | 356 ++++++++ .../explain/reference/shared-concept-model.md | 1 + docs/explain/sdl/sections.md | 50 +- .../hospital-ransomware-surgery-day.sdl.yaml | 33 + .../capability_envelope.py | 14 + .../aces_backend_protocols/__init__.py | 4 + .../aces_backend_protocols/capabilities.py | 8 + .../aces_backend_protocols/domain_topology.py | 424 ++++++++++ .../aces_backend_protocols/manifest.py | 1 + .../packages/aces_backend_stubs/stubs.py | 2 + .../packages/aces_contracts/contracts.py | 33 +- .../realization_envelope_carrier.py | 3 + .../packages/aces_processor/compiler.py | 114 ++- .../aces_processor/models/resources.py | 3 + .../python/packages/aces_processor/planner.py | 8 + .../aces_processor/semantics/realization.py | 1 + .../aces_reference_backend/manifest.py | 2 + .../packages/aces_runtime/control_plane.py | 20 +- .../python/packages/aces_sdl/_base.py | 25 +- .../python/packages/aces_sdl/_declarations.py | 1 + .../packages/aces_sdl/_language_metadata.py | 15 +- .../packages/aces_sdl/_mapping_scopes.py | 1 + .../packages/aces_sdl/_module_symbols.py | 2 + .../python/packages/aces_sdl/accounts.py | 1 + .../python/packages/aces_sdl/composition.py | 21 +- .../packages/aces_sdl/identity_domains.py | 83 ++ .../python/packages/aces_sdl/relationships.py | 5 + .../python/packages/aces_sdl/scenario.py | 2 + .../semantics/_domain_topology_bindings.py | 351 ++++++++ .../semantics/_domain_topology_types.py | 84 ++ .../aces_sdl/semantics/domain_topology.py | 384 +++++++++ .../packages/aces_sdl/validator/__init__.py | 2 + .../packages/aces_sdl/validator/_core.py | 1 + .../aces_sdl/validator/_domain_topology.py | 16 + .../python/packages/aces_sdl/value_parsing.py | 12 +- .../tests/test_authored_domain_topology.py | 758 ++++++++++++++++++ .../python/tests/test_backend_manifest.py | 2 +- .../test_instantiated_scenario_schema.py | 6 +- .../test_realization_envelope_contract.py | 2 + .../python/tests/test_runtime_planner.py | 14 +- .../python/tests/test_sdl_catalog_parity.py | 4 +- .../python/tests/test_sdl_lineage.py | 2 +- .../python/tests/test_sdl_realworld.py | 35 +- .../python/tests/test_sdl_stress.py | 50 ++ specs/sdl/README.md | 5 +- specs/sdl/authored-domain-topology.md | 116 +++ specs/sdl/references.md | 7 + specs/sdl/sections.md | 3 +- tools/check_sdl_catalog_parity.py | 21 + 68 files changed, 3789 insertions(+), 62 deletions(-) create mode 100644 docs/decisions/adrs/adr-082-authored-identity-domain-topology.md create mode 100644 docs/decisions/issue-763-authored-domain-topology-preflight.md create mode 100644 implementations/python/packages/aces_backend_protocols/domain_topology.py create mode 100644 implementations/python/packages/aces_sdl/identity_domains.py create mode 100644 implementations/python/packages/aces_sdl/semantics/_domain_topology_bindings.py create mode 100644 implementations/python/packages/aces_sdl/semantics/_domain_topology_types.py create mode 100644 implementations/python/packages/aces_sdl/semantics/domain_topology.py create mode 100644 implementations/python/packages/aces_sdl/validator/_domain_topology.py create mode 100644 implementations/python/tests/test_authored_domain_topology.py create mode 100644 specs/sdl/authored-domain-topology.md 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/decisions/adrs/README.md b/docs/decisions/adrs/README.md index 9696703c6..e4f456b8e 100644 --- a/docs/decisions/adrs/README.md +++ b/docs/decisions/adrs/README.md @@ -125,6 +125,8 @@ 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 | Title | Status | Date | @@ -211,3 +213,4 @@ 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 | 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-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-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/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/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_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_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_processor/compiler.py b/implementations/python/packages/aces_processor/compiler.py index f04fc504c..418b5222b 100644 --- a/implementations/python/packages/aces_processor/compiler.py +++ b/implementations/python/packages/aces_processor/compiler.py @@ -8,6 +8,7 @@ WorkflowFeature, WorkflowStatePredicateFeature, ) +from aces_backend_protocols.domain_topology import DomainTopologyBinding from aces_contracts.addressing import render_compiled_address from aces_contracts.versions import WORKFLOW_STATE_SCHEMA_VERSION from aces_sdl import build_declaration_index @@ -23,6 +24,12 @@ ) from aces_sdl.realization_designation import resolve_realization_designation from aces_sdl.scenario import ExpandedScenario, InstantiatedScenario, Scenario +from aces_sdl.semantics.domain_topology import ( + DomainNodeBinding, + DomainNodeRole, + DomainTopologyAnalysis, + analyze_domain_topology, +) from aces_sdl.semantics.objective_semantics import ( OBJECTIVE_WINDOW_DEPENDENCY_ROLES, partition_objective_dependencies, @@ -31,6 +38,7 @@ from aces_sdl.semantics.workflow import ( workflow_step_semantic_contract, ) +from aces_sdl.value_parsing import is_variable_ref from .models import ( AccountPlacement, @@ -227,6 +235,40 @@ 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) @@ -907,12 +949,20 @@ def _node_dependency_addresses( return addresses +@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) @@ -940,14 +990,20 @@ def _compile_node_runtimes( 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, - networks=networks, - node_deployments=node_deployments, + domain_topology=compiled_domain_binding, + targets=targets, ) return networks, node_deployments @@ -959,12 +1015,12 @@ def _record_node_runtime( node_spec: dict[str, Any], infra_spec: dict[str, Any], dependency_addresses: list[str], - networks: dict[str, NetworkRuntime], - node_deployments: dict[str, NodeRuntime], + domain_topology: DomainTopologyBinding | None, + targets: _NodeRuntimeTargets, ) -> None: spec = {"node": node_spec, "infrastructure": infra_spec} if node_type == NodeType.SWITCH: - networks[_network_address(node_name)] = NetworkRuntime( + targets.networks[_network_address(node_name)] = NetworkRuntime( address=_network_address(node_name), name=node_name, node_name=node_name, @@ -973,13 +1029,14 @@ def _record_node_runtime( refresh_dependencies=_dedupe(dependency_addresses), ) return - node_deployments[_node_address(node_name)] = NodeRuntime( + 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), @@ -1237,6 +1294,7 @@ def _compile_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(): @@ -1253,12 +1311,21 @@ def _compile_account_placements( 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), @@ -2422,6 +2489,7 @@ def _realization_requirement_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. @@ -2482,6 +2550,27 @@ def _compile_realization_requirements( 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) @@ -2511,6 +2600,13 @@ def compile_runtime_model(scenario: Scenario | ExpandedScenario | InstantiatedSc ) 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, @@ -2520,7 +2616,7 @@ def compile_runtime_model(scenario: Scenario | ExpandedScenario | InstantiatedSc ) = _compile_templates(scenario) entity_specs, agent_specs, relationship_specs = _metadata_specs(scenario) - networks, node_deployments = _compile_node_runtimes(scenario, diagnostics) + 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) @@ -2533,7 +2629,7 @@ def compile_runtime_model(scenario: Scenario | ExpandedScenario | InstantiatedSc 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) + 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) @@ -2576,6 +2672,6 @@ def compile_runtime_model(scenario: Scenario | ExpandedScenario | InstantiatedSc workflows=workflows, objectives=objectives, diagnostics=diagnostics, - realization_requirements=_compile_realization_requirements(scenario), + realization_requirements=_compile_realization_requirements(scenario, domain_analysis), realization_instance=scenario, ) 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..8b6756a8e 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( @@ -124,6 +125,7 @@ def _capabilities() -> BackendCapabilitySet: 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_domain_profiles=frozenset({"active_directory"}), 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/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/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/tests/test_authored_domain_topology.py b/implementations/python/tests/test_authored_domain_topology.py new file mode 100644 index 000000000..12f09f418 --- /dev/null +++ b/implementations/python/tests/test_authored_domain_topology.py @@ -0,0 +1,758 @@ +"""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_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_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..71cbc5218 100644 --- a/implementations/python/tests/test_instantiated_scenario_schema.py +++ b/implementations/python/tests/test_instantiated_scenario_schema.py @@ -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) @@ -155,8 +154,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_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_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..e356ae469 100644 --- a/implementations/python/tests/test_sdl_catalog_parity.py +++ b/implementations/python/tests/test_sdl_catalog_parity.py @@ -62,7 +62,7 @@ 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) @@ -152,7 +152,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_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/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/references.md b/specs/sdl/references.md index ba465b304..11f2fc9c8 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 @@ -242,8 +246,11 @@ generic symbol lookup. | `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) | +| `accounts.*.domain_ref` | `identity_domains` | semantic validation | fatal dangling, ambiguous, or inconsistent topology | [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 | [domain topology semantics](../../implementations/python/packages/aces_sdl/semantics/domain_topology.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) | +| `relationships.*.domain_join.controller_refs[]` | `nodes` | semantic validation | fatal dangling, ambiguous, or controller outside target domain | [domain topology semantics](../../implementations/python/packages/aces_sdl/semantics/domain_topology.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) | diff --git a/specs/sdl/sections.md b/specs/sdl/sections.md index 497350d23..9bf262267 100644 --- a/specs/sdl/sections.md +++ b/specs/sdl/sections.md @@ -54,6 +54,7 @@ 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) | @@ -66,7 +67,7 @@ instructions do not survive as executable scenario meaning. "References" is | `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) | - + 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/tools/check_sdl_catalog_parity.py b/tools/check_sdl_catalog_parity.py index 2f895584c..3f03f4f93 100644 --- a/tools/check_sdl_catalog_parity.py +++ b/tools/check_sdl_catalog_parity.py @@ -76,6 +76,9 @@ _RELATIONSHIP_VALIDATOR = ( "[relationship validator](../../implementations/python/packages/aces_sdl/validator/_relationships.py)" ) +_DOMAIN_TOPOLOGY_SEMANTICS = ( + "[domain topology semantics](../../implementations/python/packages/aces_sdl/semantics/domain_topology.py)" +) _PARTICIPANT_VALIDATOR = ( "[participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py)" ) @@ -182,6 +185,18 @@ "fatal unless target is a vm node", _CONTENT_VALIDATOR, ), + "accounts.*.domain_ref": ( + "identity_domains", + _SEMANTIC, + "fatal dangling, ambiguous, or inconsistent topology", + _DOMAIN_TOPOLOGY_SEMANTICS, + ), + "identity_domains.*.authority_account_ref": ( + "accounts", + _SEMANTIC, + "fatal dangling, ambiguous, or authority outside domain controllers", + _DOMAIN_TOPOLOGY_SEMANTICS, + ), "accounts.*.node": ( "nodes", _SEMANTIC, @@ -200,6 +215,12 @@ "fatal dangling or ambiguous; subtype may narrow domain", _RELATIONSHIP_VALIDATOR, ), + "relationships.*.domain_join.controller_refs[]": ( + "nodes", + _SEMANTIC, + "fatal dangling, ambiguous, or controller outside target domain", + _DOMAIN_TOPOLOGY_SEMANTICS, + ), "agents.*.entity": ("entities", _SEMANTIC, _DANGLING, _PARTICIPANT_VALIDATOR), "agents.*.starting_accounts[]": ( "accounts", From 5763584e3f1d6ff5731acf0624839c482b9ed018 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:45:49 -0700 Subject: [PATCH 2/7] chore: back-merge v0.21.0 into dev (#773) * chore(main): release 0.19.0 * chore(main): release 0.19.1 * chore(main): release 0.20.0 * chore(main): release 0.21.0 --------- Co-authored-by: Brad Edwards Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 20 ++++++++++++++++++++ implementations/python/pyproject.toml | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) 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/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" From 4667c901c404860e2bda334557bd452ef27cd1c9 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 13 Jul 2026 22:08:06 -0700 Subject: [PATCH 3/7] docs(sdl): align prose specification with live contracts (#775) Validate SDL prose and reference contracts --- ...ue-541-sdl-spec-second-review-preflight.md | 103 +++ .../test_instantiated_scenario_schema.py | 3 +- .../python/tests/test_sdl_catalog_parity.py | 120 ++- .../python/tests/test_sdl_phase_contracts.py | 22 +- specs/formal/sdl-phases/README.md | 30 +- specs/sdl/diagnostics.md | 53 +- specs/sdl/document-model.md | 23 +- specs/sdl/references.md | 208 +++-- specs/sdl/sections.md | 27 +- specs/sdl/variables-and-instantiation.md | 14 +- tools/check_sdl_catalog_parity.py | 726 +++++++++++++++++- tools/policy/requirement_order.yaml | 5 + 12 files changed, 1178 insertions(+), 156 deletions(-) create mode 100644 docs/decisions/issue-541-sdl-spec-second-review-preflight.md 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/implementations/python/tests/test_instantiated_scenario_schema.py b/implementations/python/tests/test_instantiated_scenario_schema.py index 71cbc5218..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"): @@ -136,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: diff --git a/implementations/python/tests/test_sdl_catalog_parity.py b/implementations/python/tests/test_sdl_catalog_parity.py index e356ae469..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", ) @@ -66,6 +71,39 @@ def test_checked_summary_drift_is_flagged(tmp_path: Path) -> None: 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( 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/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/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 11f2fc9c8..66651eea3 100644 --- a/specs/sdl/references.md +++ b/specs/sdl/references.md @@ -214,77 +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) | -| `accounts.*.domain_ref` | `identity_domains` | semantic validation | fatal dangling, ambiguous, or inconsistent topology | [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 | [domain topology semantics](../../implementations/python/packages/aces_sdl/semantics/domain_topology.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) | -| `relationships.*.domain_join.controller_refs[]` | `nodes` | semantic validation | fatal dangling, ambiguous, or controller outside target domain | [domain topology semantics](../../implementations/python/packages/aces_sdl/semantics/domain_topology.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 9bf262267..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) | @@ -57,15 +56,15 @@ instructions do not survive as executable scenario meaning. "References" is | `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) | 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 3f03f4f93..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"