From 6958fed460067cd5a3b9c5dd6a4a2ca6cfd75aae Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 6 Jun 2026 09:36:23 +0200 Subject: [PATCH 01/70] fix: consolidate runtime validation helpers --- changelog.d/442.fixed.md | 1 + ...-038-runtime-mail-service-logical-state.md | 59 +++++++++++++++++ ...-implementation-manifest-and-provenance.md | 41 ++++++++++++ docs/explain/sdl/validation.md | 32 ++++++++++ .../python/packages/aces_sdl/_base.py | 8 ++- .../aces_sdl/runtime_app_authorization.py | 24 +++---- .../packages/aces_sdl/runtime_database.py | 26 ++------ .../packages/aces_sdl/runtime_datastore.py | 16 +---- .../aces_sdl/runtime_datastore_partitions.py | 30 +++------ .../aces_sdl/runtime_directory_identity.py | 63 ++++++++++--------- .../python/packages/aces_sdl/runtime_dns.py | 30 ++++----- .../packages/aces_sdl/runtime_dns_records.py | 10 +-- .../packages/aces_sdl/runtime_file_service.py | 15 ++--- .../aces_sdl/runtime_forwarding_agent.py | 34 +++++----- .../packages/aces_sdl/runtime_mail_service.py | 25 +++----- .../aces_sdl/runtime_network_detection.py | 50 ++++++--------- .../aces_sdl/runtime_network_sensor.py | 28 +++------ .../runtime_platform_application_content.py | 27 +++----- .../aces_sdl/runtime_security_monitoring.py | 60 +++++++----------- ...runtime_security_monitoring_definitions.py | 34 +++------- .../packages/aces_sdl/runtime_values.py | 43 +++++++++---- .../tests/test_runtime_family_invariants.py | 49 +++++++++++++++ 22 files changed, 389 insertions(+), 316 deletions(-) create mode 100644 changelog.d/442.fixed.md diff --git a/changelog.d/442.fixed.md b/changelog.d/442.fixed.md new file mode 100644 index 000000000..187dffa87 --- /dev/null +++ b/changelog.d/442.fixed.md @@ -0,0 +1 @@ +Consolidated runtime validation helper policy and backfilled mail validation documentation plus ADR gate coverage for the runtime SDL consistency work. diff --git a/docs/decisions/adrs/adr-038-runtime-mail-service-logical-state.md b/docs/decisions/adrs/adr-038-runtime-mail-service-logical-state.md index 2d86c26ca..a2868dfe9 100644 --- a/docs/decisions/adrs/adr-038-runtime-mail-service-logical-state.md +++ b/docs/decisions/adrs/adr-038-runtime-mail-service-logical-state.md @@ -78,6 +78,56 @@ The implementation must reuse existing SDL gates: - generated-schema publication from Python model sources only; - repo policy, requirement governance, and full verification gates. +## Security and Validation Gates + +- Parser/model gate: stable `mail_service_id` and child ids are concrete + symbols, not mapping keys or `${var}` placeholders. +- SDL model gate: duplicate mail-service child ids are rejected per collection + and across the service-local reference namespace. +- Enum normalization gate: mail protocol, listener role, AUTH mechanism, TLS + mode, mailbox/domain/store/queue kinds, mailbox status, setting provenance, + and setting classifications use the shared enum-or-var parser so hyphen and + underscore authoring spellings cannot drift by module. +- Secret-handling gate: mailbox credential posture and setting sensitivity are + classifications only; raw passwords, hashes, API tokens, keytabs, private + keys, and other secret values are not portable mail-service data. +- Semantic validation gate: mail services and listeners resolve optional + same-node `Node.services` refs; component, domain, mailbox-store, mailbox, + alias, routing, and setting refs resolve inside the owning mail service. +- Relationship gate: top-level relationships carrying `mail_access` target a + runtime mail service and resolve concrete listener, mailbox, and domain refs + inside that service. +- Evidence gate: setting source paths resolve to observed filesystem inventory + when the node has file inventory. +- Contract/schema gate: generated JSON Schemas come from Python model sources; + schema files are not edited by hand. + +## Guardrails + +- Do not encode SMTP, IMAP, mailbox, alias, routing, or queue state as HTTP + application routes. +- Do not overload `Node.services`; it remains a transport binding that a + mail-service record may reference. +- Do not treat top-level `accounts` as mailbox inventory. Mailboxes may refer to + accounts or local users, but the mailbox record owns observed mailbox state. +- Do not store raw Postfix, Dovecot, queue, mailbox, or credential payloads in + the portable SDL model; keep vendor-specific files as evidence. +- Do not keep mail validation on a separate free-function path. It must remain + an in-class `SemanticValidator` pass that uses the same error collection and + unresolved-variable behavior as other runtime families. +- Do not duplicate generic runtime helper policy in mail-specific modules; use + `runtime_values.py` for enum parsing, string-list coercion, non-empty strings, + absolute-path lists, duplicate detection, and observed-value redaction. + +## Non-Goals + +- Building a Postfix, Dovecot, POP3, LMTP, Sieve, DKIM, SPF, or DMARC parser. +- Modeling complete mailbox contents, per-message queue contents, or mail logs + as first-class SDL records. +- Replacing filesystem inventory, content evidence, top-level accounts, + transport services, runtime applications, or generic relationships. +- Designing backend mail provisioning behavior or mail-delivery simulation. + ## Consequences Positive: @@ -110,3 +160,12 @@ Rejected alternatives: surfaces are evidence/data placement, not mail-service logical state. - Store raw Postfix/Dovecot config dumps. Raw dumps are provider-coupled, untyped, and unsafe for secret-bearing settings. + +## References + +- [Runtime File-Service and Filesystem Presence Semantics](adr-037-runtime-file-service-and-filesystem-presence-semantics.md), + [Database Runtime Inventory](adr-029-database-logical-state-runtime-surface.md), and + [Participant-Interactable Runtime Node State](adr-033-scenario-delivery-boundary-for-runtime-node-state.md). +- [SDL Semantic Validation](../../explain/sdl/validation.md), + [Runtime Architecture](../../explain/sdl/runtime-architecture.md), and + [SDL Limitations](../../explain/sdl/limitations.md). diff --git a/docs/decisions/adrs/adr-041-participant-implementation-manifest-and-provenance.md b/docs/decisions/adrs/adr-041-participant-implementation-manifest-and-provenance.md index df1cecbc1..623fe0819 100644 --- a/docs/decisions/adrs/adr-041-participant-implementation-manifest-and-provenance.md +++ b/docs/decisions/adrs/adr-041-participant-implementation-manifest-and-provenance.md @@ -84,6 +84,39 @@ authored SDL `agents`, participant episode lifecycle state, control-plane identity, backend provenance, processor provenance, and derived evaluation results. +## Security and Validation Gates + +- Contract/model gate: both contracts are closed `ContractModel` payloads with + generated JSON Schemas, conformance fixtures, and publication-manifest entries. +- Vocabulary gate: implementation kinds, participant implementation contracts, + decision-surface modes, tool-affordance expectations, and exposure-policy + kinds resolve through `controlled-vocabularies-v1`. +- Provenance gate: run-level records preserve selected manifest/config refs and + digests instead of inferring implementation identity from authored SDL agents, + backend state, or logs. +- Exposure gate: decision-surface mode and exposure-policy kind are explicit + data so reviewers can distinguish what the participant implementation was + allowed to see from what the authored scenario intended. +- Secret-handling gate: hidden context, credentials, prompts, and raw + participant implementation configuration remain outside the portable + manifest/provenance records; references and digests are the portable surface. +- Schema gate: generated schemas are derived from contract model sources and are + not edited by hand. +- Conformance gate: valid and invalid fixtures exercise both the manifest and + provenance contracts through the existing conformance runner. + +## Guardrails + +- Do not use SDL `agents` as evidence of which participant implementation ran. +- Do not treat backend `participant_runtime` capability or processor capability + as participant implementation identity. +- Do not place participant implementation manifests under SDL runtime node + inventory; this is apparatus metadata, not observed node state. +- Do not include hidden prompts, secret credentials, raw configuration payloads, + or decision-surface contents in the portable manifest/provenance artifacts. +- Do not fork a participant-specific schema publication path; use the existing + contract/schema/conformance machinery. + ## Consequences ### Positive @@ -121,3 +154,11 @@ results. - Redesign participant episode lifecycle contracts. - Change control-plane authentication or authorization. - Implement a live external participant runtime protocol. + +## References + +- [SDL Runtime Architecture](../../explain/sdl/runtime-architecture.md) and + [SDL Semantic Validation](../../explain/sdl/validation.md). +- [Contract publication manifest](../../../contracts/schema-publication-manifest.json) + and + [Controlled vocabularies](../../../contracts/concept-authority/controlled-vocabularies-v1.json). diff --git a/docs/explain/sdl/validation.md b/docs/explain/sdl/validation.md index d847f7f4f..d634df5d5 100644 --- a/docs/explain/sdl/validation.md +++ b/docs/explain/sdl/validation.md @@ -54,6 +54,8 @@ becoming a validator-only interpretation of the SDL. | `verify_runtime_platform_applications` | Runtime platform applications resolve their owning transport `service` to a same-node service binding, a non-empty, non-variable `authorization_ref` to a same-node `app_authorization`, content-object `references` to sibling `content_object_id` values, and `marking_refs` to sibling `marking_id` values. The model-local `require_profile_for_platform_kind` guard fails an under-populated `threat_intel`/`soar`/`analyzer_engine`/`case_management`/`analytics_dashboard` instance. | | `verify_runtime_forwarding_agents` | Runtime forwarding agents resolve each `ship_target`'s `target_node_ref`, when concrete, to a defined node, and a concrete `target_service_ref` to a service on the referenced node (or, for node-hosted agents only, on the owning node). Scenario-level forwarding agents require `target_node_ref` when `target_service_ref` is concrete, and `forwarding_agent_id` values are unique across node-hosted and scenario-level registries. The model-local `require_profile_for_agent_kind` guard fails an under-populated `log_forwarder` (requires a `buffer_policy` plus an ingestion `ship_target`, rejects `ioc_to_rule` transforms) or `content_sync` (requires an `api_pull` source, an `ioc_to_rule` transform, and a `reload_channel`, rejects a `buffer_policy` and `ship_target` enrollment endpoints) instance. | | `verify_runtime_orchestration_authorities` | Runtime orchestration authorities resolve a non-empty, non-variable `control_interface_ref` to a `RuntimeControlInterface` declared in the same node's `runtime.local_control_interfaces` (by `control_interface_id`); for a `host_root_equivalent` privilege class the referenced interface must additionally be a read-write docker socket (access `read_write`, kind `unix_socket`, path ending in `docker.sock`), with `${var}` interface access/kind/path permissive. The model-local `require_profile_for_privilege_class` guard fails a `host_root_equivalent` authority that carries no concrete `control_interface_ref`. | +| `verify_runtime_mail_services` | Runtime mail services and listeners resolve optional same-node `Node.services` refs. Listener component refs, mailbox domain/store refs, alias target refs, routing source/target refs, and setting component refs resolve inside the owning mail service. Mailbox account refs resolve to top-level accounts, local-user refs resolve to `runtime.local_identity` when present, and setting source paths resolve to observed runtime filesystem entries when the node has file inventory. | +| `verify_relationship_mail_access` | A relationship with `mail_access` must target a runtime mail service. Concrete `listener_ref`, `mailbox_ref`, and `domain_ref` values resolve within that target service, while protocol, auth-mechanism, and TLS-mode fields are structurally normalized by the `RelationshipMailAccess` model. | | `verify_agents` | Entity references resolve. Starting accounts and initial-knowledge accounts exist in accounts section. Allowed subnets and initial-knowledge subnets must resolve to switch-backed infrastructure entries. Initial-knowledge hosts must resolve to VM nodes. Initial-knowledge services exist in `nodes.*.services[].name`. | | `verify_participant_behavior` | Agent action refs resolve to declared action contracts, observation-boundary refs resolve to declared boundaries, interaction refs resolve to declared actions or targetable state, and boundary view rules/transitions resolve to declared observable, hidden, or evidence refs. | | `verify_objectives` | Objective actors resolve (`agent` or `entity`). Objective actions must be declared by the referenced agent. Targets resolve to named scenario elements, including qualified service/ACL refs and section-qualified top-level refs. Ambiguous bare refs are rejected with qualified alternatives. Success criteria resolve to declared conditions/metrics/evaluations/TLOs/goals. Optional windows resolve through one shared normalized analysis over stories/scripts/events/workflows/workflow-steps, must remain internally consistent, and fail closed on dangling or out-of-window refs. Objective dependencies must resolve and stay acyclic. | @@ -148,6 +150,24 @@ process name or PID. Optional `published_port_refs` entries resolve to `runtime.network.published_ports` by host IP, host port, container port, and protocol and must match the listener's container-side port/protocol. +The optional `runtime.mail_services` inventory has model-local and semantic +rules. Mail-service ids are stable concrete symbols and unique within a node +runtime block; service-local component, listener, domain, mailbox-store, +mailbox, alias, routing-rule, queue, and setting ids are unique within their +collections and across the service-local reference namespace. Mail protocols, +listener roles, AUTH mechanisms, TLS modes, domain/mailbox/store/queue kinds, +mailbox status, setting provenance, and value classifications are normalized +from bounded enums while allowing full-value variables. Secret-bearing settings +must omit raw values and use redacted/operator-secret classifications. Service +and listener `service` refs resolve to same-node transport bindings; component, +domain, mailbox-store, mailbox, alias, routing, and setting refs resolve inside +the owning mail service. Mailbox `account_ref` values resolve to top-level +accounts, mailbox `local_user_ref` values resolve to `runtime.local_identity` +when local users are declared, and setting source paths are checked against +`runtime.filesystem_inventory` when that inventory is non-empty. A top-level +relationship with `mail_access` targets a runtime mail service and resolves +concrete listener, mailbox, and domain refs inside that target service. + The optional `runtime.network_detection_engines` inventory has model-local and semantic rules. Engine ids are stable concrete symbols and are unique within one node runtime block; engine-local rule-source, network-set, output-stream, @@ -353,6 +373,18 @@ evidence-capture contract surfaces are separate validation domains. They should not be retrofitted into validator-only behavior before the authored surface or external contracts exist. +## Enum normalization convention + +All SDL enum-or-var parsers share one author-facing normalization rule: +concrete strings are lowercased and hyphen aliases are mapped to underscore +enum values before matching, while full-value `${var}` placeholders and `None` +remain deferred. Runtime fields continue to call +`parse_runtime_enum_or_var`, but that helper delegates to the canonical +`parse_enum_or_var` implementation in `_base.py`, so runtime and non-runtime +enum fields cannot drift on accepted spellings. The shared behavior is covered by +`test_enum_or_var_helpers_share_hyphen_alias_normalization` in +`tests/test_runtime_family_invariants.py`. + ## Runtime enum sentinel convention Runtime service-family enums (every `Enum` defined in an `aces_sdl` module whose diff --git a/implementations/python/packages/aces_sdl/_base.py b/implementations/python/packages/aces_sdl/_base.py index de2e81c3f..2eecea7ad 100644 --- a/implementations/python/packages/aces_sdl/_base.py +++ b/implementations/python/packages/aces_sdl/_base.py @@ -31,10 +31,14 @@ def extract_variable_name(v: str) -> str | None: def normalize_enum_value(v: str) -> str: - """Normalize a string for case-insensitive enum matching.""" + """Normalize a string for case-insensitive enum matching. + + SDL enum authoring accepts hyphen aliases for underscore-valued members so + YAML-facing values can use either common spelling without per-family drift. + """ if is_variable_ref(v): return v - return v.lower() if isinstance(v, str) else v + return v.lower().replace("-", "_") if isinstance(v, str) else v def parse_enum_or_var( diff --git a/implementations/python/packages/aces_sdl/runtime_app_authorization.py b/implementations/python/packages/aces_sdl/runtime_app_authorization.py index 820030b21..3dc5c8cf4 100644 --- a/implementations/python/packages/aces_sdl/runtime_app_authorization.py +++ b/implementations/python/packages/aces_sdl/runtime_app_authorization.py @@ -104,14 +104,6 @@ class RuntimeAppAuthorizationGrantEffect(str, Enum): ) -def _normalize_enum(value: object, enum_cls: type[Enum], *, field_name: str) -> object: - return parse_runtime_enum_or_var(value, enum_cls, field_name=field_name) - - -def _coerce_refs(value: object) -> object: - return coerce_string_list(value) - - class RuntimeAppAuthorizationPrincipal(SDLModel): """A user, service account, API key, or backend role known to the store. @@ -138,7 +130,7 @@ def validate_principal_id(cls, v: str) -> str: @field_validator("kind", mode="before") @classmethod def normalize_kind(cls, v: RuntimeAppAuthorizationPrincipalKind | str) -> object: - return _normalize_enum(v, RuntimeAppAuthorizationPrincipalKind, field_name="kind") + return parse_runtime_enum_or_var(v, RuntimeAppAuthorizationPrincipalKind, field_name="kind") @field_validator("credential_classification", mode="before") @classmethod @@ -146,7 +138,7 @@ def normalize_credential_classification( cls, v: RuntimeAppAuthorizationCredentialClassification | str, ) -> object: - return _normalize_enum( + return parse_runtime_enum_or_var( v, RuntimeAppAuthorizationCredentialClassification, field_name="credential_classification", @@ -160,7 +152,7 @@ def parse_optional_bools(cls, v: object) -> bool | str | None: @field_validator("backend_roles", mode="before") @classmethod def coerce_backend_roles(cls, v: object) -> object: - return _coerce_refs(v) + return coerce_string_list(v) @model_validator(mode="after") def validate_principal(self) -> "RuntimeAppAuthorizationPrincipal": @@ -224,17 +216,17 @@ def validate_grant_id(cls, v: str) -> str: @field_validator("resource_kind", mode="before") @classmethod def normalize_resource_kind(cls, v: RuntimeAppAuthorizationResourceVocabulary | str) -> object: - return _normalize_enum(v, RuntimeAppAuthorizationResourceVocabulary, field_name="resource_kind") + return parse_runtime_enum_or_var(v, RuntimeAppAuthorizationResourceVocabulary, field_name="resource_kind") @field_validator("effect", mode="before") @classmethod def normalize_effect(cls, v: RuntimeAppAuthorizationGrantEffect | str) -> object: - return _normalize_enum(v, RuntimeAppAuthorizationGrantEffect, field_name="effect") + return parse_runtime_enum_or_var(v, RuntimeAppAuthorizationGrantEffect, field_name="effect") @field_validator("actions", "resource_patterns", mode="before") @classmethod def coerce_lists(cls, v: object) -> object: - return _coerce_refs(v) + return coerce_string_list(v) @model_validator(mode="after") def validate_grant(self) -> "RuntimeAppAuthorizationGrant": @@ -261,7 +253,7 @@ def validate_mapping_id(cls, v: str) -> str: @field_validator("backend_roles", "users", "hosts", mode="before") @classmethod def coerce_lists(cls, v: object) -> object: - return _coerce_refs(v) + return coerce_string_list(v) @model_validator(mode="after") def validate_role_mapping(self) -> "RuntimeAppAuthorizationRoleMapping": @@ -308,7 +300,7 @@ def validate_app_authorization_id(cls, v: str) -> str: @field_validator("resource_vocabulary", mode="before") @classmethod def normalize_resource_vocabulary(cls, v: RuntimeAppAuthorizationResourceVocabulary | str) -> object: - return _normalize_enum(v, RuntimeAppAuthorizationResourceVocabulary, field_name="resource_vocabulary") + return parse_runtime_enum_or_var(v, RuntimeAppAuthorizationResourceVocabulary, field_name="resource_vocabulary") @field_validator("auth_enabled", mode="before") @classmethod diff --git a/implementations/python/packages/aces_sdl/runtime_database.py b/implementations/python/packages/aces_sdl/runtime_database.py index 47955c9f1..52bce8603 100644 --- a/implementations/python/packages/aces_sdl/runtime_database.py +++ b/implementations/python/packages/aces_sdl/runtime_database.py @@ -50,6 +50,7 @@ enforce_observed_value_redaction, parse_optional_bool_or_var, parse_runtime_enum_or_var, + reject_duplicates, require_symbol, ) @@ -85,23 +86,6 @@ ) -def _reject_duplicates( - values: Any, - *, - label: str, - container_label: str, - duplicate_template: str = "Duplicate {label} '{value}' in {container_label}", -) -> None: - """Raise on the first repeated value in ``values``; skip empty / None entries.""" - seen: set[object] = set() - for value in values: - if value is None or value == "": - continue - if value in seen: - raise ValueError(duplicate_template.format(label=label, value=value, container_label=container_label)) - seen.add(value) - - def _db_listen_address_or_var(value: str, *, field_name: str) -> str: """Validate a database listener address, allowing ``${var}`` placeholders. @@ -441,7 +425,7 @@ def validate_service(self) -> "RuntimeDatabaseService": def _reject_duplicate_top_level_ids(self) -> None: for field_name, attr in (("database", "database_id"), ("role", "role_id")): - _reject_duplicates( + reject_duplicates( (getattr(item, attr) for item in getattr(self, f"{field_name}s")), label=f"{field_name} {attr}", container_label=f"database service '{self.database_service_id}'", @@ -456,11 +440,11 @@ def _reject_duplicate_nested_object_ids(self) -> None: schema_ids = [schema.schema_id for db in self.databases for schema in db.schemas] table_ids = [table.table_id for db in self.databases for schema in db.schemas for table in schema.tables] suffix = f"database service '{self.database_service_id}'; grant object_ref needs unambiguous resolution" - _reject_duplicates(schema_ids, label="schema schema_id", container_label=suffix) - _reject_duplicates(table_ids, label="table table_id", container_label=suffix) + reject_duplicates(schema_ids, label="schema schema_id", container_label=suffix) + reject_duplicates(table_ids, label="table table_id", container_label=suffix) def _reject_duplicate_setting_names(self) -> None: - _reject_duplicates( + reject_duplicates( (setting.name for setting in self.settings), label="database setting", container_label=f"database service '{self.database_service_id}'", diff --git a/implementations/python/packages/aces_sdl/runtime_datastore.py b/implementations/python/packages/aces_sdl/runtime_datastore.py index 99f0cd5cc..e8390b19a 100644 --- a/implementations/python/packages/aces_sdl/runtime_datastore.py +++ b/implementations/python/packages/aces_sdl/runtime_datastore.py @@ -22,8 +22,6 @@ semantic validator) — this surface carries no embedded principal/role/grant. """ -from enum import Enum - from pydantic import Field, field_validator, model_validator from ._base import SDLModel, is_variable_ref @@ -72,14 +70,6 @@ ] -def _normalize_enum(value: object, enum_cls: type[Enum], *, field_name: str) -> object: - return parse_runtime_enum_or_var(value, enum_cls, field_name=field_name) - - -def _coerce_refs(value: object) -> object: - return coerce_string_list(value) - - class RuntimeDatastoreService(SDLModel): """An observed datastore service hosted by a transport service on a node. @@ -123,12 +113,12 @@ def validate_datastore_service_id(cls, v: str) -> str: @field_validator("engine", mode="before") @classmethod def normalize_engine(cls, v: RuntimeDatastoreEngine | str) -> object: - return _normalize_enum(v, RuntimeDatastoreEngine, field_name="engine") + return parse_runtime_enum_or_var(v, RuntimeDatastoreEngine, field_name="engine") @field_validator("data_model", mode="before") @classmethod def normalize_data_model(cls, v: RuntimeDatastoreDataModel | str) -> object: - return _normalize_enum(v, RuntimeDatastoreDataModel, field_name="data_model") + return parse_runtime_enum_or_var(v, RuntimeDatastoreDataModel, field_name="data_model") @field_validator( "templates", @@ -144,7 +134,7 @@ def normalize_data_model(cls, v: RuntimeDatastoreDataModel | str) -> object: ) @classmethod def coerce_string_lists(cls, v: object) -> object: - return _coerce_refs(v) + return coerce_string_list(v) @model_validator(mode="after") def validate_datastore_service(self) -> "RuntimeDatastoreService": diff --git a/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py b/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py index 553d55551..96db30d99 100644 --- a/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py +++ b/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py @@ -12,8 +12,6 @@ helper, exactly as the relational ``DatabaseSetting`` does. """ -from enum import Enum - from pydantic import Field, ValidationInfo, field_validator, model_validator from ._base import SDLModel, parse_int_or_var @@ -51,14 +49,6 @@ ) -def _normalize_enum(value: object, enum_cls: type[Enum], *, field_name: str) -> object: - return parse_runtime_enum_or_var(value, enum_cls, field_name=field_name) - - -def _coerce_refs(value: object) -> object: - return coerce_string_list(value) - - def _reject_duplicate_values(values: list[object], *, field_name: str, owner: str) -> None: seen: set[object] = set() for value in values: @@ -92,9 +82,9 @@ def validate_node_id(cls, v: str) -> str: @field_validator("roles", mode="before") @classmethod def normalize_roles(cls, v: object) -> object: - values = _coerce_refs(v) + values = coerce_string_list(v) if isinstance(values, list): - return [_normalize_enum(item, RuntimeDatastoreNodeRole, field_name="roles") for item in values] + return [parse_runtime_enum_or_var(item, RuntimeDatastoreNodeRole, field_name="roles") for item in values] return values @field_validator("is_coordinator", mode="before") @@ -159,12 +149,12 @@ def validate_partition_id(cls, v: str) -> str: @field_validator("kind", mode="before") @classmethod def normalize_kind(cls, v: RuntimeDatastorePartitionKind | str) -> object: - return _normalize_enum(v, RuntimeDatastorePartitionKind, field_name="kind") + return parse_runtime_enum_or_var(v, RuntimeDatastorePartitionKind, field_name="kind") @field_validator("replication_strategy", mode="before") @classmethod def normalize_replication_strategy(cls, v: RuntimeDatastoreReplicationStrategy | str) -> object: - return _normalize_enum(v, RuntimeDatastoreReplicationStrategy, field_name="replication_strategy") + return parse_runtime_enum_or_var(v, RuntimeDatastoreReplicationStrategy, field_name="replication_strategy") @field_validator("shard_count", "replica_count", "replication_factor", mode="before") @classmethod @@ -207,7 +197,7 @@ def validate_persistence_id(cls, v: str) -> str: @field_validator("rdb_save_points", mode="before") @classmethod def coerce_save_points(cls, v: object) -> object: - return _coerce_refs(v) + return coerce_string_list(v) @field_validator("aof", mode="before") @classmethod @@ -217,7 +207,7 @@ def parse_aof(cls, v: object) -> bool | str | None: @field_validator("eviction", mode="before") @classmethod def normalize_eviction(cls, v: RuntimeDatastoreEvictionPolicy | str) -> object: - return _normalize_enum(v, RuntimeDatastoreEvictionPolicy, field_name="eviction") + return parse_runtime_enum_or_var(v, RuntimeDatastoreEvictionPolicy, field_name="eviction") class RuntimeDatastoreTransportSecurity(SDLModel): @@ -242,7 +232,7 @@ def validate_transport_security_id(cls, v: str) -> str: @field_validator("mode", mode="before") @classmethod def normalize_mode(cls, v: RuntimeDatastoreTransportSecurityMode | str) -> object: - return _normalize_enum(v, RuntimeDatastoreTransportSecurityMode, field_name="mode") + return parse_runtime_enum_or_var(v, RuntimeDatastoreTransportSecurityMode, field_name="mode") @field_validator("client_verification", "node_verification", mode="before") @classmethod @@ -280,17 +270,17 @@ def validate_name(cls, v: str) -> str: @field_validator("scope", mode="before") @classmethod def normalize_scope(cls, v: RuntimeDatastoreSettingScope | str) -> object: - return _normalize_enum(v, RuntimeDatastoreSettingScope, field_name="scope") + return parse_runtime_enum_or_var(v, RuntimeDatastoreSettingScope, field_name="scope") @field_validator("provenance", mode="before") @classmethod def normalize_provenance(cls, v: RuntimeDatastoreSettingProvenance | str) -> object: - return _normalize_enum(v, RuntimeDatastoreSettingProvenance, field_name="provenance") + return parse_runtime_enum_or_var(v, RuntimeDatastoreSettingProvenance, field_name="provenance") @field_validator("classification", mode="before") @classmethod def normalize_classification(cls, v: RuntimeSensitivityClassification | str) -> object: - return _normalize_enum(v, RuntimeSensitivityClassification, field_name="classification") + return parse_runtime_enum_or_var(v, RuntimeSensitivityClassification, field_name="classification") @model_validator(mode="after") def validate_setting(self) -> "RuntimeDatastoreSetting": diff --git a/implementations/python/packages/aces_sdl/runtime_directory_identity.py b/implementations/python/packages/aces_sdl/runtime_directory_identity.py index 16d170943..d20ce8efc 100644 --- a/implementations/python/packages/aces_sdl/runtime_directory_identity.py +++ b/implementations/python/packages/aces_sdl/runtime_directory_identity.py @@ -18,6 +18,8 @@ enforce_observed_value_redaction, parse_optional_bool_or_var, parse_runtime_enum_or_var, + reject_duplicates, + require_non_empty, require_symbol, ) @@ -40,6 +42,7 @@ RuntimeSensitivityClassification.REDACTED, RuntimeSensitivityClassification.OPERATOR_SECRET, ) +_DUPLICATE_IDENTITY_TEMPLATE = "Duplicate runtime identity {label} '{value}' in {container_label}" class RuntimeIdentityAuthorityKind(str, Enum): @@ -132,22 +135,6 @@ class RuntimeIdentityRecordOrigin(str, Enum): OTHER = "other" -def _require_non_empty(value: str, *, field_name: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{field_name} must be a non-empty string") - return value - - -def _reject_duplicates(values: Any, *, label: str, container_label: str) -> None: - seen: set[object] = set() - for value in values: - if value is None or value == "": - continue - if value in seen: - raise ValueError(f"Duplicate runtime identity {label} '{value}' in {container_label}") - seen.add(value) - - def _reject_duplicate_local_ref_ids(authority: "RuntimeIdentityAuthority") -> None: seen: dict[str, str] = {} entries: list[tuple[str, str]] = [("identity_authority_id", authority.identity_authority_id)] @@ -184,7 +171,7 @@ class RuntimeIdentityAttribute(SDLModel): @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: - return _require_non_empty(v, field_name="identity attribute name") + return require_non_empty(v, field_name="identity attribute name") @field_validator("values", mode="before") @classmethod @@ -306,7 +293,7 @@ def normalize_kind(cls, v: RuntimeIdentitySubjectKind | str) -> RuntimeIdentityS @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: - return _require_non_empty(v, field_name="identity subject name") + return require_non_empty(v, field_name="identity subject name") @field_validator("enabled", mode="before") @classmethod @@ -325,15 +312,17 @@ def coerce_service_principal_names(cls, v: Any) -> list[str]: @model_validator(mode="after") def validate_subject(self) -> "RuntimeIdentitySubject": - _reject_duplicates( + reject_duplicates( self.service_principal_names, label="service_principal_name", container_label=f"subject '{self.subject_id}'", + duplicate_template=_DUPLICATE_IDENTITY_TEMPLATE, ) - _reject_duplicates( + reject_duplicates( (attribute.name for attribute in self.attributes), label="attribute", container_label=f"subject '{self.subject_id}'", + duplicate_template=_DUPLICATE_IDENTITY_TEMPLATE, ) return self @@ -365,15 +354,17 @@ def coerce_applies_to_refs(cls, v: Any) -> list[str]: @model_validator(mode="after") def validate_policy(self) -> "RuntimeIdentityPolicy": - _reject_duplicates( + reject_duplicates( self.applies_to_refs, label="policy applies_to_ref", container_label=f"policy '{self.policy_id}'", + duplicate_template=_DUPLICATE_IDENTITY_TEMPLATE, ) - _reject_duplicates( + reject_duplicates( (setting.name for setting in self.settings), label="policy setting", container_label=f"policy '{self.policy_id}'", + duplicate_template=_DUPLICATE_IDENTITY_TEMPLATE, ) return self @@ -404,12 +395,12 @@ def normalize_relationship_type( @field_validator("source_ref") @classmethod def validate_source_ref(cls, v: str) -> str: - return _require_non_empty(v, field_name="source_ref") + return require_non_empty(v, field_name="source_ref") @field_validator("target_ref", "external_target") @classmethod def validate_optional_targets(cls, v: str, info: ValidationInfo) -> str: - return _require_non_empty(v, field_name=info.field_name) if v else v + return require_non_empty(v, field_name=info.field_name) if v else v @model_validator(mode="after") def validate_relationship_target(self) -> "RuntimeIdentityRelationship": @@ -449,17 +440,29 @@ def normalize_kind(cls, v: RuntimeIdentityAuthorityKind | str) -> RuntimeIdentit @model_validator(mode="after") def validate_authority(self) -> "RuntimeIdentityAuthority": container = f"identity authority '{self.identity_authority_id}'" - _reject_duplicates( - (service.service_id for service in self.services), label="service_id", container_label=container + reject_duplicates( + (service.service_id for service in self.services), + label="service_id", + container_label=container, + duplicate_template=_DUPLICATE_IDENTITY_TEMPLATE, + ) + reject_duplicates( + (subject.subject_id for subject in self.subjects), + label="subject_id", + container_label=container, + duplicate_template=_DUPLICATE_IDENTITY_TEMPLATE, ) - _reject_duplicates( - (subject.subject_id for subject in self.subjects), label="subject_id", container_label=container + reject_duplicates( + (policy.policy_id for policy in self.policies), + label="policy_id", + container_label=container, + duplicate_template=_DUPLICATE_IDENTITY_TEMPLATE, ) - _reject_duplicates((policy.policy_id for policy in self.policies), label="policy_id", container_label=container) - _reject_duplicates( + reject_duplicates( (relationship.relationship_id for relationship in self.relationships), label="relationship_id", container_label=container, + duplicate_template=_DUPLICATE_IDENTITY_TEMPLATE, ) _reject_duplicate_local_ref_ids(self) return self diff --git a/implementations/python/packages/aces_sdl/runtime_dns.py b/implementations/python/packages/aces_sdl/runtime_dns.py index 80f6aeb8f..d59438e26 100644 --- a/implementations/python/packages/aces_sdl/runtime_dns.py +++ b/implementations/python/packages/aces_sdl/runtime_dns.py @@ -13,7 +13,6 @@ """ import ipaddress -from collections.abc import Iterable from pydantic import Field, ValidationInfo, field_validator, model_validator @@ -26,7 +25,6 @@ DnsSrvRdata, _dns_name_or_var, _parse_port_or_var, - _require_non_empty, ) from .runtime_dns_vocab import ( DnsForwarderTransport, @@ -48,6 +46,8 @@ enforce_observed_value_redaction, parse_optional_bool_or_var, parse_runtime_enum_or_var, + reject_duplicates, + require_non_empty, require_symbol, ) @@ -81,12 +81,13 @@ RuntimeSensitivityClassification.REDACTED, RuntimeSensitivityClassification.OPERATOR_SECRET, ) +_DUPLICATE_DNS_TEMPLATE = "Duplicate DNS {label} '{value}' in {container_label}" def _policy_selector_or_var(value: str, *, field_name: str) -> str: if is_variable_ref(value): return value - _require_non_empty(value, field_name=field_name) + require_non_empty(value, field_name=field_name) if any(ch.isspace() for ch in value): raise ValueError(f"{field_name} must not contain whitespace") try: @@ -100,16 +101,6 @@ def _policy_selector_or_var(value: str, *, field_name: str) -> str: return value -def _reject_duplicates(values: Iterable[object], *, label: str, container_label: str) -> None: - seen: set[object] = set() - for value in values: - if value is None or value == "": - continue - if value in seen: - raise ValueError(f"Duplicate DNS {label} '{value}' in {container_label}") - seen.add(value) - - class DnsForwarder(SDLModel): """Recursive upstream resolver endpoint.""" @@ -227,7 +218,7 @@ def validate_allowed_clients(cls, v: list[str]) -> list[str]: @classmethod def validate_key_names(cls, v: list[str]) -> list[str]: for item in v: - _require_non_empty(item, field_name="dynamic update key_names") + require_non_empty(item, field_name="dynamic update key_names") if len(v) != len(set(v)): raise ValueError("Duplicate DNS dynamic update key_names entry") return v @@ -245,7 +236,7 @@ class DnsRuntimeSetting(SDLModel): @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: - return _require_non_empty(v, field_name="DNS setting name") + return require_non_empty(v, field_name="DNS setting name") @field_validator("value_classification", mode="before") @classmethod @@ -329,10 +320,11 @@ def validate_zone_file_refs(cls, v: list[str]) -> list[str]: @model_validator(mode="after") def validate_zone(self) -> "DnsZone": - _reject_duplicates( + reject_duplicates( (rrset.rrset_id for rrset in self.rrsets), label="rrset_id", container_label=f"zone '{self.zone_id}'", + duplicate_template=_DUPLICATE_DNS_TEMPLATE, ) seen_bindings: set[tuple[str, str, str]] = set() for rrset in self.rrsets: @@ -400,14 +392,16 @@ def validate_file_refs(cls, v: list[str], info: ValidationInfo) -> list[str]: @model_validator(mode="after") def validate_service(self) -> "RuntimeDnsService": - _reject_duplicates( + reject_duplicates( (zone.zone_id for zone in self.zones), label="zone_id", container_label=f"DNS service '{self.dns_service_id}'", + duplicate_template=_DUPLICATE_DNS_TEMPLATE, ) - _reject_duplicates( + reject_duplicates( (setting.name for setting in self.settings), label="setting", container_label=f"DNS service '{self.dns_service_id}'", + duplicate_template=_DUPLICATE_DNS_TEMPLATE, ) return self diff --git a/implementations/python/packages/aces_sdl/runtime_dns_records.py b/implementations/python/packages/aces_sdl/runtime_dns_records.py index c821a4c17..c2bb37cd5 100644 --- a/implementations/python/packages/aces_sdl/runtime_dns_records.py +++ b/implementations/python/packages/aces_sdl/runtime_dns_records.py @@ -6,7 +6,7 @@ from ._base import SDLModel, is_variable_ref, parse_int_or_var from .runtime_dns_vocab import DnsRecordClass, DnsRecordProvenance, DnsRecordType -from .runtime_values import coerce_string_list, parse_runtime_enum_or_var, require_symbol +from .runtime_values import coerce_string_list, parse_runtime_enum_or_var, require_non_empty, require_symbol __all__ = [ "DnsMxRdata", @@ -24,16 +24,10 @@ _TARGET_RECORD_TYPES = frozenset({DnsRecordType.CNAME, DnsRecordType.NS, DnsRecordType.PTR}) -def _require_non_empty(value: str, *, field_name: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{field_name} must be a non-empty string") - return value - - def _dns_name_or_var(value: str, *, field_name: str) -> str: if is_variable_ref(value): return value - _require_non_empty(value, field_name=field_name) + require_non_empty(value, field_name=field_name) if any(ch.isspace() for ch in value): raise ValueError(f"{field_name} must not contain whitespace") return value diff --git a/implementations/python/packages/aces_sdl/runtime_file_service.py b/implementations/python/packages/aces_sdl/runtime_file_service.py index 29ee856d1..e19086391 100644 --- a/implementations/python/packages/aces_sdl/runtime_file_service.py +++ b/implementations/python/packages/aces_sdl/runtime_file_service.py @@ -30,6 +30,7 @@ coerce_string_list, parse_optional_bool_or_var, parse_runtime_enum_or_var, + require_non_empty, require_symbol, ) @@ -183,12 +184,6 @@ class RuntimeFileServiceAccessOutcome(str, Enum): OTHER = "other" -def _require_non_empty(value: str, *, field_name: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{field_name} must be a non-empty string") - return value - - def _reject_duplicate_local_ref_ids(service: "RuntimeFileService") -> None: seen: dict[str, str] = {} entries: list[tuple[str, str]] = [("file_service_id", service.file_service_id)] @@ -232,7 +227,7 @@ def validate_share_id(cls, v: str) -> str: @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: - return _require_non_empty(v, field_name="share name") + return require_non_empty(v, field_name="share name") @field_validator("kind", mode="before") @classmethod @@ -300,7 +295,7 @@ def validate_principal_id(cls, v: str) -> str: @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: - return _require_non_empty(v, field_name="principal name") + return require_non_empty(v, field_name="principal name") @field_validator("kind", mode="before") @classmethod @@ -359,7 +354,7 @@ def validate_rule_id(cls, v: str) -> str: @field_validator("subject_ref", "resource_ref") @classmethod def validate_refs(cls, v: str, info: ValidationInfo) -> str: - return _require_non_empty(v, field_name=info.field_name) + return require_non_empty(v, field_name=info.field_name) @field_validator("action", mode="before") @classmethod @@ -401,7 +396,7 @@ def validate_observation_id(cls, v: str) -> str: @field_validator("subject_ref", "resource_ref") @classmethod def validate_refs(cls, v: str, info: ValidationInfo) -> str: - return _require_non_empty(v, field_name=info.field_name) + return require_non_empty(v, field_name=info.field_name) @field_validator("action", mode="before") @classmethod diff --git a/implementations/python/packages/aces_sdl/runtime_forwarding_agent.py b/implementations/python/packages/aces_sdl/runtime_forwarding_agent.py index c28a98a68..bb9c8dfbf 100644 --- a/implementations/python/packages/aces_sdl/runtime_forwarding_agent.py +++ b/implementations/python/packages/aces_sdl/runtime_forwarding_agent.py @@ -23,8 +23,6 @@ ``name_indicates_secret`` helper and the closed enrollment lattice. """ -from enum import Enum - from pydantic import Field, ValidationInfo, field_validator, model_validator from ._base import SDLModel, is_variable_ref, parse_int_or_var @@ -84,10 +82,6 @@ ) -def _normalize_enum(value: object, enum_cls: type[Enum], *, field_name: str) -> object: - return parse_runtime_enum_or_var(value, enum_cls, field_name=field_name) - - class RuntimeForwardingSource(SDLModel): """An observed forwarder input source (tailed path, API pull, or queue).""" @@ -106,12 +100,12 @@ def validate_source_id(cls, v: str) -> str: @field_validator("kind", mode="before") @classmethod def normalize_kind(cls, v: RuntimeForwardingSourceKind | str) -> object: - return _normalize_enum(v, RuntimeForwardingSourceKind, field_name="kind") + return parse_runtime_enum_or_var(v, RuntimeForwardingSourceKind, field_name="kind") @field_validator("parse_format", mode="before") @classmethod def normalize_parse_format(cls, v: RuntimeForwardingParseFormat | str) -> object: - return _normalize_enum(v, RuntimeForwardingParseFormat, field_name="parse_format") + return parse_runtime_enum_or_var(v, RuntimeForwardingParseFormat, field_name="parse_format") class RuntimeForwardingTransform(SDLModel): @@ -130,7 +124,7 @@ def validate_transform_id(cls, v: str) -> str: @field_validator("kind", mode="before") @classmethod def normalize_kind(cls, v: RuntimeForwardingTransformKind | str) -> object: - return _normalize_enum(v, RuntimeForwardingTransformKind, field_name="kind") + return parse_runtime_enum_or_var(v, RuntimeForwardingTransformKind, field_name="kind") class RuntimeForwardingShipTarget(SDLModel): @@ -167,12 +161,12 @@ def parse_ports(cls, v: object, info: ValidationInfo) -> int | str | None: @field_validator("protocol", mode="before") @classmethod def normalize_protocol(cls, v: RuntimeForwardingProtocol | str) -> object: - return _normalize_enum(v, RuntimeForwardingProtocol, field_name="protocol") + return parse_runtime_enum_or_var(v, RuntimeForwardingProtocol, field_name="protocol") @field_validator("enrollment_identity_classification", mode="before") @classmethod def normalize_enrollment_classification(cls, v: RuntimeForwardingEnrollmentClassification | str) -> object: - return _normalize_enum( + return parse_runtime_enum_or_var( v, RuntimeForwardingEnrollmentClassification, field_name="enrollment_identity_classification" ) @@ -219,7 +213,7 @@ def parse_counts(cls, v: object, info: ValidationInfo) -> int | str | None: @field_validator("crypto", mode="before") @classmethod def normalize_crypto(cls, v: RuntimeForwardingBufferCrypto | str) -> object: - return _normalize_enum(v, RuntimeForwardingBufferCrypto, field_name="crypto") + return parse_runtime_enum_or_var(v, RuntimeForwardingBufferCrypto, field_name="crypto") class RuntimeForwardingReloadChannel(SDLModel): @@ -243,7 +237,7 @@ def validate_reload_channel_id(cls, v: str) -> str: @field_validator("kind", mode="before") @classmethod def normalize_kind(cls, v: RuntimeForwardingReloadChannelKind | str) -> object: - return _normalize_enum(v, RuntimeForwardingReloadChannelKind, field_name="kind") + return parse_runtime_enum_or_var(v, RuntimeForwardingReloadChannelKind, field_name="kind") class RuntimeForwardingSetting(SDLModel): @@ -270,12 +264,12 @@ def validate_setting_id(cls, v: str) -> str: @field_validator("provenance", mode="before") @classmethod def normalize_provenance(cls, v: RuntimeForwardingSettingProvenance | str) -> object: - return _normalize_enum(v, RuntimeForwardingSettingProvenance, field_name="provenance") + return parse_runtime_enum_or_var(v, RuntimeForwardingSettingProvenance, field_name="provenance") @field_validator("classification", mode="before") @classmethod def normalize_classification(cls, v: RuntimeForwardingSettingClassification | str) -> object: - return _normalize_enum(v, RuntimeForwardingSettingClassification, field_name="classification") + return parse_runtime_enum_or_var(v, RuntimeForwardingSettingClassification, field_name="classification") @model_validator(mode="after") def validate_setting(self) -> "RuntimeForwardingSetting": @@ -320,12 +314,12 @@ def validate_forwarding_agent_id(cls, v: str) -> str: @field_validator("implementation", mode="before") @classmethod def normalize_implementation(cls, v: RuntimeForwardingAgentImplementation | str) -> object: - return _normalize_enum(v, RuntimeForwardingAgentImplementation, field_name="implementation") + return parse_runtime_enum_or_var(v, RuntimeForwardingAgentImplementation, field_name="implementation") @field_validator("agent_kind", mode="before") @classmethod def normalize_agent_kind(cls, v: RuntimeForwardingAgentKind | str) -> object: - return _normalize_enum(v, RuntimeForwardingAgentKind, field_name="agent_kind") + return parse_runtime_enum_or_var(v, RuntimeForwardingAgentKind, field_name="agent_kind") @model_validator(mode="after") def validate_forwarding_agent(self) -> "RuntimeForwardingAgent": @@ -466,19 +460,19 @@ def validate_forwarder_ref(cls, v: str) -> str: @field_validator("target_listener_role", mode="before") @classmethod def normalize_target_listener_role(cls, v: RuntimeSecurityMonitoringListenerRole | str) -> object: - return _normalize_enum(v, RuntimeSecurityMonitoringListenerRole, field_name="target_listener_role") + return parse_runtime_enum_or_var(v, RuntimeSecurityMonitoringListenerRole, field_name="target_listener_role") @field_validator("enrollment_identity_classification", mode="before") @classmethod def normalize_enrollment_classification(cls, v: RuntimeForwardingEnrollmentClassification | str) -> object: - return _normalize_enum( + return parse_runtime_enum_or_var( v, RuntimeForwardingEnrollmentClassification, field_name="enrollment_identity_classification" ) @field_validator("parse_format", mode="before") @classmethod def normalize_parse_format(cls, v: RuntimeForwardingParseFormat | str) -> object: - return _normalize_enum(v, RuntimeForwardingParseFormat, field_name="parse_format") + return parse_runtime_enum_or_var(v, RuntimeForwardingParseFormat, field_name="parse_format") @model_validator(mode="after") def validate_enrollment_identity(self) -> "RelationshipForwardingEdge": diff --git a/implementations/python/packages/aces_sdl/runtime_mail_service.py b/implementations/python/packages/aces_sdl/runtime_mail_service.py index 6aec635dc..e9334141e 100644 --- a/implementations/python/packages/aces_sdl/runtime_mail_service.py +++ b/implementations/python/packages/aces_sdl/runtime_mail_service.py @@ -35,6 +35,8 @@ coerce_string_list, enforce_observed_value_redaction, parse_runtime_enum_or_var, + reject_duplicates, + require_non_empty, require_symbol, ) @@ -71,12 +73,7 @@ RuntimeSensitivityClassification.REDACTED, RuntimeSensitivityClassification.OPERATOR_SECRET, ) - - -def _require_non_empty(value: str, *, field_name: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{field_name} must be a non-empty string") - return value +_DUPLICATE_MAIL_TEMPLATE = "Duplicate runtime mail-service {label} '{value}' in {container_label}" def _mail_address_or_var(value: str, *, field_name: str) -> str: @@ -97,14 +94,6 @@ def _domain_name_or_var(value: str, *, field_name: str) -> str: return value -def _reject_duplicates(values: list[str], *, label: str, container_label: str) -> None: - seen: set[str] = set() - for value in values: - if value in seen: - raise ValueError(f"Duplicate runtime mail-service {label} '{value}' in {container_label}") - seen.add(value) - - class RuntimeMailComponent(SDLModel): """A mail-service engine/component such as Postfix, Dovecot, or a filter.""" @@ -127,7 +116,7 @@ def normalize_kind(cls, v: RuntimeMailComponentKind | str) -> RuntimeMailCompone @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: - return _require_non_empty(v, field_name="component name") + return require_non_empty(v, field_name="component name") class RuntimeMailListener(SDLModel): @@ -400,7 +389,7 @@ def validate_setting_id(cls, v: str) -> str: @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: - return _require_non_empty(v, field_name="setting name") + return require_non_empty(v, field_name="setting name") @field_validator("value_classification", mode="before") @classmethod @@ -477,10 +466,12 @@ def _reject_duplicate_collection_ids(self) -> None: ("queue_id", "queues"), ("setting_id", "settings"), ): - _reject_duplicates( + reject_duplicates( [getattr(item, label) for item in getattr(self, attr)], label=label, container_label=f"mail service '{self.mail_service_id}'", + duplicate_template=_DUPLICATE_MAIL_TEMPLATE, + skip_empty=False, ) def _reject_duplicate_local_ref_ids(self) -> None: diff --git a/implementations/python/packages/aces_sdl/runtime_network_detection.py b/implementations/python/packages/aces_sdl/runtime_network_detection.py index 6fcfad954..a5787e80a 100644 --- a/implementations/python/packages/aces_sdl/runtime_network_detection.py +++ b/implementations/python/packages/aces_sdl/runtime_network_detection.py @@ -12,6 +12,7 @@ parse_optional_bool_or_var, parse_runtime_enum_or_var, require_symbol, + validate_absolute_paths, ) __all__ = [ @@ -183,18 +184,6 @@ class RuntimeNetworkDetectionControlCapability(str, Enum): OTHER = "other" -def _normalize_enum(value: object, enum_cls: type[Enum], *, field_name: str) -> object: - return parse_runtime_enum_or_var(value, enum_cls, field_name=field_name) - - -def _coerce_refs(value: object) -> object: - return coerce_string_list(value) - - -def _absolute_refs(values: list[str], *, field_name: str) -> list[str]: - return [absolute_path_or_var(item, field_name=field_name) for item in values] - - def _reject_duplicate_values(values: list[object], *, field_name: str, owner: str) -> None: seen: set[object] = set() for value in values: @@ -224,12 +213,12 @@ def validate_source_id(cls, v: str) -> str: @field_validator("kind", mode="before") @classmethod def normalize_kind(cls, v: RuntimeNetworkDetectionRuleSourceKind | str) -> object: - return _normalize_enum(v, RuntimeNetworkDetectionRuleSourceKind, field_name="kind") + return parse_runtime_enum_or_var(v, RuntimeNetworkDetectionRuleSourceKind, field_name="kind") @field_validator("format", mode="before") @classmethod def normalize_format(cls, v: RuntimeNetworkDetectionRuleFormat | str) -> object: - return _normalize_enum(v, RuntimeNetworkDetectionRuleFormat, field_name="format") + return parse_runtime_enum_or_var(v, RuntimeNetworkDetectionRuleFormat, field_name="format") @field_validator("rule_count", mode="before") @classmethod @@ -239,12 +228,12 @@ def parse_rule_count(cls, v: object) -> int | str | None: @field_validator("file_refs", mode="before") @classmethod def coerce_file_refs(cls, v: object) -> object: - return _coerce_refs(v) + return coerce_string_list(v) @field_validator("file_refs") @classmethod def validate_file_refs(cls, v: list[str]) -> list[str]: - return _absolute_refs(v, field_name="file_refs") + return validate_absolute_paths(v, field_name="file_refs") @field_validator("loaded", mode="before") @classmethod @@ -270,12 +259,12 @@ def validate_set_id(cls, v: str) -> str: @field_validator("kind", mode="before") @classmethod def normalize_kind(cls, v: RuntimeNetworkDetectionNetworkSetKind | str) -> object: - return _normalize_enum(v, RuntimeNetworkDetectionNetworkSetKind, field_name="kind") + return parse_runtime_enum_or_var(v, RuntimeNetworkDetectionNetworkSetKind, field_name="kind") @field_validator("selector_values", "network_refs", mode="before") @classmethod def coerce_lists(cls, v: object) -> object: - return _coerce_refs(v) + return coerce_string_list(v) @model_validator(mode="after") def validate_unique_values(self) -> "RuntimeNetworkDetectionNetworkSet": @@ -302,7 +291,7 @@ def validate_stream_id(cls, v: str) -> str: @field_validator("format", mode="before") @classmethod def normalize_format(cls, v: RuntimeNetworkDetectionOutputFormat | str) -> object: - return _normalize_enum(v, RuntimeNetworkDetectionOutputFormat, field_name="format") + return parse_runtime_enum_or_var(v, RuntimeNetworkDetectionOutputFormat, field_name="format") @field_validator("path") @classmethod @@ -312,10 +301,11 @@ def validate_path(cls, v: str) -> str: @field_validator("event_types", mode="before") @classmethod def normalize_event_types(cls, v: object) -> object: - values = _coerce_refs(v) + values = coerce_string_list(v) if isinstance(values, list): return [ - _normalize_enum(item, RuntimeNetworkDetectionEventType, field_name="event_types") for item in values + parse_runtime_enum_or_var(item, RuntimeNetworkDetectionEventType, field_name="event_types") + for item in values ] return values @@ -349,7 +339,7 @@ def validate_channel_id(cls, v: str) -> str: @field_validator("kind", mode="before") @classmethod def normalize_kind(cls, v: RuntimeNetworkDetectionControlChannelKind | str) -> object: - return _normalize_enum(v, RuntimeNetworkDetectionControlChannelKind, field_name="kind") + return parse_runtime_enum_or_var(v, RuntimeNetworkDetectionControlChannelKind, field_name="kind") @field_validator("path") @classmethod @@ -359,10 +349,10 @@ def validate_path(cls, v: str) -> str: @field_validator("capabilities", mode="before") @classmethod def normalize_capabilities(cls, v: object) -> object: - values = _coerce_refs(v) + values = coerce_string_list(v) if isinstance(values, list): return [ - _normalize_enum(item, RuntimeNetworkDetectionControlCapability, field_name="capabilities") + parse_runtime_enum_or_var(item, RuntimeNetworkDetectionControlCapability, field_name="capabilities") for item in values ] return values @@ -409,30 +399,30 @@ def validate_network_detection_engine_id(cls, v: str) -> str: @field_validator("implementation", mode="before") @classmethod def normalize_implementation(cls, v: RuntimeNetworkDetectionEngineImplementation | str) -> object: - return _normalize_enum(v, RuntimeNetworkDetectionEngineImplementation, field_name="implementation") + return parse_runtime_enum_or_var(v, RuntimeNetworkDetectionEngineImplementation, field_name="implementation") @field_validator("engine_kind", mode="before") @classmethod def normalize_engine_kind(cls, v: RuntimeNetworkDetectionEngineKind | str) -> object: - return _normalize_enum(v, RuntimeNetworkDetectionEngineKind, field_name="engine_kind") + return parse_runtime_enum_or_var(v, RuntimeNetworkDetectionEngineKind, field_name="engine_kind") @field_validator("configuration_file_refs", "log_file_refs", "evidence_refs", mode="before") @classmethod def coerce_file_refs(cls, v: object) -> object: - return _coerce_refs(v) + return coerce_string_list(v) @field_validator("configuration_file_refs", "log_file_refs", "evidence_refs") @classmethod def validate_file_refs(cls, v: list[str], info: ValidationInfo) -> list[str]: - return _absolute_refs(v, field_name=info.field_name) + return validate_absolute_paths(v, field_name=info.field_name) @field_validator("app_layer_protocols", mode="before") @classmethod def normalize_app_layer_protocols(cls, v: object) -> object: - values = _coerce_refs(v) + values = coerce_string_list(v) if isinstance(values, list): return [ - _normalize_enum(item, RuntimeNetworkDetectionAppProtocol, field_name="app_layer_protocols") + parse_runtime_enum_or_var(item, RuntimeNetworkDetectionAppProtocol, field_name="app_layer_protocols") for item in values ] return values diff --git a/implementations/python/packages/aces_sdl/runtime_network_sensor.py b/implementations/python/packages/aces_sdl/runtime_network_sensor.py index 80c91a02d..9876f4656 100644 --- a/implementations/python/packages/aces_sdl/runtime_network_sensor.py +++ b/implementations/python/packages/aces_sdl/runtime_network_sensor.py @@ -6,10 +6,10 @@ from ._base import SDLModel from .runtime_values import ( - absolute_path_or_var, coerce_string_list, parse_runtime_enum_or_var, require_symbol, + validate_absolute_paths, ) __all__ = [ @@ -69,18 +69,6 @@ class RuntimeNetworkSensorCaptureMode(str, Enum): OTHER = "other" -def _normalize_enum(value: object, enum_cls: type[Enum], *, field_name: str) -> object: - return parse_runtime_enum_or_var(value, enum_cls, field_name=field_name) - - -def _coerce_refs(value: object) -> object: - return coerce_string_list(value) - - -def _absolute_refs(values: list[str], *, field_name: str) -> list[str]: - return [absolute_path_or_var(item, field_name=field_name) for item in values] - - def _reject_duplicate_values(values: list[str], *, field_name: str, sensor_id: str) -> None: seen: set[str] = set() for value in values: @@ -119,12 +107,12 @@ def normalize_implementation( cls, v: RuntimeNetworkSensorImplementation | str, ) -> RuntimeNetworkSensorImplementation | str: - return _normalize_enum(v, RuntimeNetworkSensorImplementation, field_name="implementation") + return parse_runtime_enum_or_var(v, RuntimeNetworkSensorImplementation, field_name="implementation") @field_validator("sensor_kind", mode="before") @classmethod def normalize_sensor_kind(cls, v: RuntimeNetworkSensorKind | str) -> RuntimeNetworkSensorKind | str: - return _normalize_enum(v, RuntimeNetworkSensorKind, field_name="sensor_kind") + return parse_runtime_enum_or_var(v, RuntimeNetworkSensorKind, field_name="sensor_kind") @field_validator("monitoring_posture", mode="before") @classmethod @@ -132,7 +120,7 @@ def normalize_monitoring_posture( cls, v: RuntimeNetworkSensorMonitoringPosture | str, ) -> RuntimeNetworkSensorMonitoringPosture | str: - return _normalize_enum(v, RuntimeNetworkSensorMonitoringPosture, field_name="monitoring_posture") + return parse_runtime_enum_or_var(v, RuntimeNetworkSensorMonitoringPosture, field_name="monitoring_posture") @field_validator("capture_mode", mode="before") @classmethod @@ -140,22 +128,22 @@ def normalize_capture_mode( cls, v: RuntimeNetworkSensorCaptureMode | str, ) -> RuntimeNetworkSensorCaptureMode | str: - return _normalize_enum(v, RuntimeNetworkSensorCaptureMode, field_name="capture_mode") + return parse_runtime_enum_or_var(v, RuntimeNetworkSensorCaptureMode, field_name="capture_mode") @field_validator("capture_interfaces", "monitored_network_refs", mode="before") @classmethod def coerce_ref_lists(cls, v: object) -> object: - return _coerce_refs(v) + return coerce_string_list(v) @field_validator("configuration_file_refs", "log_file_refs", "evidence_refs", mode="before") @classmethod def coerce_file_refs(cls, v: object) -> object: - return _coerce_refs(v) + return coerce_string_list(v) @field_validator("configuration_file_refs", "log_file_refs", "evidence_refs") @classmethod def validate_file_refs(cls, v: list[str], info: ValidationInfo) -> list[str]: - return _absolute_refs(v, field_name=info.field_name) + return validate_absolute_paths(v, field_name=info.field_name) @model_validator(mode="after") def validate_unique_refs(self) -> "RuntimeNetworkSensor": diff --git a/implementations/python/packages/aces_sdl/runtime_platform_application_content.py b/implementations/python/packages/aces_sdl/runtime_platform_application_content.py index 5dc289815..4f1b12f12 100644 --- a/implementations/python/packages/aces_sdl/runtime_platform_application_content.py +++ b/implementations/python/packages/aces_sdl/runtime_platform_application_content.py @@ -7,7 +7,6 @@ optional execution policy, and provenance-bearing settings. """ -from enum import Enum from typing import Any from pydantic import Field, field_validator, model_validator @@ -47,14 +46,6 @@ ) -def _normalize_enum(value: object, enum_cls: type[Enum], *, field_name: str) -> object: - return parse_runtime_enum_or_var(value, enum_cls, field_name=field_name) - - -def _coerce_refs(value: object) -> object: - return coerce_string_list(value) - - def _reject_duplicate_values(values: list[object], *, field_name: str, owner: str) -> None: seen: set[object] = set() for value in values: @@ -114,12 +105,12 @@ def validate_content_object_id(cls, v: str) -> str: @field_validator("kind", mode="before") @classmethod def normalize_kind(cls, v: RuntimePlatformApplicationContentObjectKind | str) -> object: - return _normalize_enum(v, RuntimePlatformApplicationContentObjectKind, field_name="kind") + return parse_runtime_enum_or_var(v, RuntimePlatformApplicationContentObjectKind, field_name="kind") @field_validator("references", "marking_refs", "evidence_refs", mode="before") @classmethod def coerce_ref_lists(cls, v: object) -> object: - return _coerce_refs(v) + return coerce_string_list(v) @model_validator(mode="after") def validate_content_object(self) -> "RuntimePlatformApplicationContentObject": @@ -146,7 +137,7 @@ def validate_marking_id(cls, v: str) -> str: @field_validator("scheme", mode="before") @classmethod def normalize_scheme(cls, v: RuntimePlatformApplicationMarkingScheme | str) -> object: - return _normalize_enum(v, RuntimePlatformApplicationMarkingScheme, field_name="scheme") + return parse_runtime_enum_or_var(v, RuntimePlatformApplicationMarkingScheme, field_name="scheme") class RuntimePlatformApplicationUpstreamBinding(SDLModel): @@ -166,7 +157,7 @@ def validate_binding_id(cls, v: str) -> str: @field_validator("role", mode="before") @classmethod def normalize_role(cls, v: RuntimePlatformApplicationUpstreamBindingRole | str) -> object: - return _normalize_enum(v, RuntimePlatformApplicationUpstreamBindingRole, field_name="role") + return parse_runtime_enum_or_var(v, RuntimePlatformApplicationUpstreamBindingRole, field_name="role") class RuntimePlatformApplicationConnector(SDLModel): @@ -193,7 +184,7 @@ def validate_connector_id(cls, v: str) -> str: @field_validator("kind", mode="before") @classmethod def normalize_kind(cls, v: RuntimePlatformApplicationConnectorKind | str) -> object: - return _normalize_enum(v, RuntimePlatformApplicationConnectorKind, field_name="kind") + return parse_runtime_enum_or_var(v, RuntimePlatformApplicationConnectorKind, field_name="kind") @field_validator("credential_classification", mode="before") @classmethod @@ -201,7 +192,7 @@ def normalize_credential_classification( cls, v: RuntimePlatformApplicationSettingClassification | str, ) -> object: - return _normalize_enum( + return parse_runtime_enum_or_var( v, RuntimePlatformApplicationSettingClassification, field_name="credential_classification", @@ -276,12 +267,14 @@ def validate_setting_id(cls, v: str) -> str: @field_validator("provenance", mode="before") @classmethod def normalize_provenance(cls, v: RuntimePlatformApplicationSettingProvenance | str) -> object: - return _normalize_enum(v, RuntimePlatformApplicationSettingProvenance, field_name="provenance") + return parse_runtime_enum_or_var(v, RuntimePlatformApplicationSettingProvenance, field_name="provenance") @field_validator("classification", mode="before") @classmethod def normalize_classification(cls, v: RuntimePlatformApplicationSettingClassification | str) -> object: - return _normalize_enum(v, RuntimePlatformApplicationSettingClassification, field_name="classification") + return parse_runtime_enum_or_var( + v, RuntimePlatformApplicationSettingClassification, field_name="classification" + ) @model_validator(mode="after") def validate_setting(self) -> "RuntimePlatformApplicationSetting": diff --git a/implementations/python/packages/aces_sdl/runtime_security_monitoring.py b/implementations/python/packages/aces_sdl/runtime_security_monitoring.py index ad257c89a..ba49b51ee 100644 --- a/implementations/python/packages/aces_sdl/runtime_security_monitoring.py +++ b/implementations/python/packages/aces_sdl/runtime_security_monitoring.py @@ -20,7 +20,9 @@ enforce_observed_value_redaction, parse_optional_bool_or_var, parse_runtime_enum_or_var, + require_non_empty, require_symbol, + validate_absolute_paths, ) __all__ = [ @@ -187,24 +189,6 @@ class RuntimeSecurityMonitoringSettingProvenance(str, Enum): OTHER = "other" -def _require_non_empty(value: str, *, field_name: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{field_name} must be a non-empty string") - return value - - -def _normalize_enum(value: Any, enum_cls: type[Enum], *, field_name: str) -> Any: - return parse_runtime_enum_or_var(value, enum_cls, field_name=field_name) - - -def _coerce_refs(value: Any) -> list[str]: - return coerce_string_list(value) - - -def _absolute_refs(values: list[str], *, field_name: str) -> list[str]: - return [absolute_path_or_var(item, field_name=field_name) for item in values] - - class RuntimeSecurityMonitoringListener(SDLModel): """A manager listener bound to a same-node transport service.""" @@ -227,7 +211,7 @@ def normalize_role( cls, v: RuntimeSecurityMonitoringListenerRole | str, ) -> RuntimeSecurityMonitoringListenerRole | str: - return _normalize_enum(v, RuntimeSecurityMonitoringListenerRole, field_name="role") + return parse_runtime_enum_or_var(v, RuntimeSecurityMonitoringListenerRole, field_name="role") @field_validator("auth_required", "tls_enabled", mode="before") @classmethod @@ -257,7 +241,7 @@ def normalize_kind( cls, v: RuntimeSecurityMonitoringComponentKind | str, ) -> RuntimeSecurityMonitoringComponentKind | str: - return _normalize_enum(v, RuntimeSecurityMonitoringComponentKind, field_name="kind") + return parse_runtime_enum_or_var(v, RuntimeSecurityMonitoringComponentKind, field_name="kind") @field_validator("status", mode="before") @classmethod @@ -265,7 +249,7 @@ def normalize_status( cls, v: RuntimeSecurityMonitoringComponentStatus | str, ) -> RuntimeSecurityMonitoringComponentStatus | str: - return _normalize_enum(v, RuntimeSecurityMonitoringComponentStatus, field_name="status") + return parse_runtime_enum_or_var(v, RuntimeSecurityMonitoringComponentStatus, field_name="status") @field_validator("enabled", mode="before") @classmethod @@ -275,7 +259,7 @@ def parse_enabled(cls, v: Any) -> bool | str | None: @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: - return _require_non_empty(v, field_name="component name") + return require_non_empty(v, field_name="component name") class RuntimeSecurityMonitoringAgent(SDLModel): @@ -302,17 +286,17 @@ def normalize_status( cls, v: RuntimeSecurityMonitoringAgentStatus | str, ) -> RuntimeSecurityMonitoringAgentStatus | str: - return _normalize_enum(v, RuntimeSecurityMonitoringAgentStatus, field_name="status") + return parse_runtime_enum_or_var(v, RuntimeSecurityMonitoringAgentStatus, field_name="status") @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: - return _require_non_empty(v, field_name="agent name") + return require_non_empty(v, field_name="agent name") @field_validator("group_refs", mode="before") @classmethod def coerce_group_refs(cls, v: Any) -> list[str]: - return _coerce_refs(v) + return coerce_string_list(v) class RuntimeSecurityMonitoringAgentGroup(SDLModel): @@ -332,12 +316,12 @@ def validate_group_id(cls, v: str) -> str: @field_validator("member_refs", "configuration_file_refs", mode="before") @classmethod def coerce_lists(cls, v: Any) -> list[str]: - return _coerce_refs(v) + return coerce_string_list(v) @field_validator("configuration_file_refs") @classmethod def validate_configuration_file_refs(cls, v: list[str]) -> list[str]: - return _absolute_refs(v, field_name="configuration_file_refs") + return validate_absolute_paths(v, field_name="configuration_file_refs") class RuntimeSecurityMonitoringContentSet(SDLModel): @@ -363,7 +347,7 @@ def normalize_kind( cls, v: RuntimeSecurityMonitoringContentKind | str, ) -> RuntimeSecurityMonitoringContentKind | str: - return _normalize_enum(v, RuntimeSecurityMonitoringContentKind, field_name="kind") + return parse_runtime_enum_or_var(v, RuntimeSecurityMonitoringContentKind, field_name="kind") @field_validator("format", mode="before") @classmethod @@ -371,7 +355,7 @@ def normalize_format( cls, v: RuntimeSecurityMonitoringContentFormat | str, ) -> RuntimeSecurityMonitoringContentFormat | str: - return _normalize_enum(v, RuntimeSecurityMonitoringContentFormat, field_name="format") + return parse_runtime_enum_or_var(v, RuntimeSecurityMonitoringContentFormat, field_name="format") @field_validator("file_count", mode="before") @classmethod @@ -381,12 +365,12 @@ def parse_file_count(cls, v: Any) -> int | str | None: @field_validator("file_refs", mode="before") @classmethod def coerce_file_refs(cls, v: Any) -> list[str]: - return _coerce_refs(v) + return coerce_string_list(v) @field_validator("file_refs") @classmethod def validate_file_refs(cls, v: list[str]) -> list[str]: - return _absolute_refs(v, field_name="file_refs") + return validate_absolute_paths(v, field_name="file_refs") @field_validator("loaded", mode="before") @classmethod @@ -414,7 +398,7 @@ def validate_setting_id(cls, v: str) -> str: @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: - return _require_non_empty(v, field_name="setting name") + return require_non_empty(v, field_name="setting name") @field_validator("value_classification", mode="before") @classmethod @@ -422,7 +406,7 @@ def normalize_value_classification( cls, v: RuntimeSensitivityClassification | str, ) -> RuntimeSensitivityClassification | str: - return _normalize_enum(v, RuntimeSensitivityClassification, field_name="value_classification") + return parse_runtime_enum_or_var(v, RuntimeSensitivityClassification, field_name="value_classification") @field_validator("provenance", mode="before") @classmethod @@ -430,7 +414,7 @@ def normalize_provenance( cls, v: RuntimeSecurityMonitoringSettingProvenance | str, ) -> RuntimeSecurityMonitoringSettingProvenance | str: - return _normalize_enum(v, RuntimeSecurityMonitoringSettingProvenance, field_name="provenance") + return parse_runtime_enum_or_var(v, RuntimeSecurityMonitoringSettingProvenance, field_name="provenance") @field_validator("source_path") @classmethod @@ -483,7 +467,7 @@ def normalize_implementation( cls, v: RuntimeSecurityMonitoringImplementation | str, ) -> RuntimeSecurityMonitoringImplementation | str: - return _normalize_enum(v, RuntimeSecurityMonitoringImplementation, field_name="implementation") + return parse_runtime_enum_or_var(v, RuntimeSecurityMonitoringImplementation, field_name="implementation") @field_validator("manager_kind", mode="before") @classmethod @@ -491,17 +475,17 @@ def normalize_manager_kind( cls, v: RuntimeSecurityMonitoringManagerKind | str, ) -> RuntimeSecurityMonitoringManagerKind | str: - return _normalize_enum(v, RuntimeSecurityMonitoringManagerKind, field_name="manager_kind") + return parse_runtime_enum_or_var(v, RuntimeSecurityMonitoringManagerKind, field_name="manager_kind") @field_validator("configuration_file_refs", "log_file_refs", "evidence_refs", mode="before") @classmethod def coerce_file_refs(cls, v: Any) -> list[str]: - return _coerce_refs(v) + return coerce_string_list(v) @field_validator("configuration_file_refs", "log_file_refs", "evidence_refs") @classmethod def validate_file_refs(cls, v: list[str], info: ValidationInfo) -> list[str]: - return _absolute_refs(v, field_name=info.field_name) + return validate_absolute_paths(v, field_name=info.field_name) @model_validator(mode="after") def validate_manager(self) -> "RuntimeSecurityMonitoringManager": diff --git a/implementations/python/packages/aces_sdl/runtime_security_monitoring_definitions.py b/implementations/python/packages/aces_sdl/runtime_security_monitoring_definitions.py index 7a2ae14de..c2e8272e9 100644 --- a/implementations/python/packages/aces_sdl/runtime_security_monitoring_definitions.py +++ b/implementations/python/packages/aces_sdl/runtime_security_monitoring_definitions.py @@ -11,7 +11,9 @@ coerce_string_list, parse_optional_bool_or_var, parse_runtime_enum_or_var, + require_non_empty, require_symbol, + validate_absolute_paths, ) __all__ = [ @@ -70,24 +72,6 @@ class RuntimeSecurityMonitoringFieldPredicateOperator(str, Enum): UNKNOWN = "unknown" -def _require_non_empty(value: str, *, field_name: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{field_name} must be a non-empty string") - return value - - -def _normalize_enum(value: Any, enum_cls: type[Enum], *, field_name: str) -> Any: - return parse_runtime_enum_or_var(value, enum_cls, field_name=field_name) - - -def _coerce_refs(value: Any) -> list[str]: - return coerce_string_list(value) - - -def _absolute_refs(values: list[str], *, field_name: str) -> list[str]: - return [absolute_path_or_var(item, field_name=field_name) for item in values] - - class RuntimeSecurityMonitoringFieldPredicate(SDLModel): """A normalized field predicate extracted from a detection definition.""" @@ -101,7 +85,7 @@ class RuntimeSecurityMonitoringFieldPredicate(SDLModel): @field_validator("field") @classmethod def validate_field(cls, v: str) -> str: - return _require_non_empty(v, field_name="field predicate field") + return require_non_empty(v, field_name="field predicate field") @field_validator("operator", mode="before") @classmethod @@ -109,7 +93,7 @@ def normalize_operator( cls, v: RuntimeSecurityMonitoringFieldPredicateOperator | str, ) -> RuntimeSecurityMonitoringFieldPredicateOperator | str: - return _normalize_enum(v, RuntimeSecurityMonitoringFieldPredicateOperator, field_name="operator") + return parse_runtime_enum_or_var(v, RuntimeSecurityMonitoringFieldPredicateOperator, field_name="operator") class RuntimeSecurityMonitoringDetectionDefinition(SDLModel): @@ -167,7 +151,7 @@ def normalize_engine( cls, v: RuntimeSecurityMonitoringDetectionEngine | str, ) -> RuntimeSecurityMonitoringDetectionEngine | str: - return _normalize_enum(v, RuntimeSecurityMonitoringDetectionEngine, field_name="engine") + return parse_runtime_enum_or_var(v, RuntimeSecurityMonitoringDetectionEngine, field_name="engine") @field_validator("definition_kind", mode="before") @classmethod @@ -175,7 +159,9 @@ def normalize_definition_kind( cls, v: RuntimeSecurityMonitoringDetectionDefinitionKind | str, ) -> RuntimeSecurityMonitoringDetectionDefinitionKind | str: - return _normalize_enum(v, RuntimeSecurityMonitoringDetectionDefinitionKind, field_name="definition_kind") + return parse_runtime_enum_or_var( + v, RuntimeSecurityMonitoringDetectionDefinitionKind, field_name="definition_kind" + ) @field_validator("source_file_ref") @classmethod @@ -224,12 +210,12 @@ def parse_optional_state_bool(cls, v: Any, info: ValidationInfo) -> bool | str | ) @classmethod def coerce_lists(cls, v: Any) -> list[str]: - return _coerce_refs(v) + return coerce_string_list(v) @field_validator("evidence_refs") @classmethod def validate_evidence_refs(cls, v: list[str]) -> list[str]: - return _absolute_refs(v, field_name="evidence_refs") + return validate_absolute_paths(v, field_name="evidence_refs") @model_validator(mode="after") def validate_definition(self) -> "RuntimeSecurityMonitoringDetectionDefinition": diff --git a/implementations/python/packages/aces_sdl/runtime_values.py b/implementations/python/packages/aces_sdl/runtime_values.py index 02c97dc10..2a5254f18 100644 --- a/implementations/python/packages/aces_sdl/runtime_values.py +++ b/implementations/python/packages/aces_sdl/runtime_values.py @@ -9,6 +9,7 @@ from ._base import ( is_variable_ref, parse_bool_or_var, + parse_enum_or_var, ) _BYTE_UNITS = { @@ -212,18 +213,7 @@ def control_interface_path_or_var(value: str, *, field_name: str) -> str: def parse_runtime_enum_or_var(value: Any, enum_cls: type[Enum], *, field_name: str): - if value is None or is_variable_ref(value): - return value - if isinstance(value, enum_cls): - return value - if isinstance(value, str): - normalized = value.lower().replace("-", "_") - try: - return enum_cls(normalized) - except ValueError as e: - allowed = ", ".join(member.value for member in enum_cls) - raise ValueError(f"{field_name} must be one of: {allowed}") from e - raise ValueError(f"{field_name} must be a string") + return parse_enum_or_var(value, enum_cls, field_name=field_name) def parse_optional_bool_or_var(value: Any, *, field_name: str) -> bool | str | None: @@ -238,10 +228,39 @@ def coerce_string_list(value: Any): return value +def require_non_empty(value: str, *, field_name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + return value + + def validate_absolute_paths(values: list[str], *, field_name: str) -> list[str]: return [absolute_path_or_var(value, field_name=field_name) for value in values] +def reject_duplicates( + values: Iterable[object], + *, + label: str, + container_label: str, + duplicate_template: str = "Duplicate {label} '{value}' in {container_label}", + skip_empty: bool = True, +) -> None: + """Raise on the first repeated value in ``values``. + + Most runtime child-id checks ignore optional empty refs, while a few service + namespaces validate all values. ``skip_empty`` keeps that policy explicit at + each call site. + """ + seen: set[object] = set() + for value in values: + if skip_empty and (value is None or value == ""): + continue + if value in seen: + raise ValueError(duplicate_template.format(label=label, value=value, container_label=container_label)) + seen.add(value) + + def parse_ram(value: str | int) -> int | str: """Parse a human-readable RAM string to bytes. diff --git a/implementations/python/tests/test_runtime_family_invariants.py b/implementations/python/tests/test_runtime_family_invariants.py index 2e1afb8f8..5461cf71f 100644 --- a/implementations/python/tests/test_runtime_family_invariants.py +++ b/implementations/python/tests/test_runtime_family_invariants.py @@ -17,14 +17,19 @@ from __future__ import annotations +import ast import enum import importlib import pkgutil import typing +from pathlib import Path import aces_sdl from aces_sdl import _runtime_service_families as rsf +from aces_sdl._base import parse_enum_or_var from aces_sdl.runtime_configuration import RuntimeConfiguration +from aces_sdl.runtime_directory_identity import RuntimeIdentityRelationshipKind +from aces_sdl.runtime_values import parse_runtime_enum_or_var def _singularize(plural: str) -> str: @@ -69,6 +74,21 @@ def _current_violations() -> set[str]: # whole surface satisfies the invariant set. KNOWN_VIOLATIONS: set[str] = set() +_SHARED_HELPER_DEFINITION_NAMES = frozenset( + { + "_absolute_refs", + "_coerce_refs", + "_normalize_enum", + "_reject_duplicates", + "_require_non_empty", + "coerce_string_list", + "parse_runtime_enum_or_var", + "reject_duplicates", + "require_non_empty", + "validate_absolute_paths", + } +) + def test_runtime_family_invariants_no_new_drift() -> None: """Live violations must exactly match the tracked allowlist (no drift).""" @@ -83,6 +103,35 @@ def test_runtime_family_invariants_no_new_drift() -> None: ) +def test_runtime_modules_do_not_redeclare_shared_validation_helpers() -> None: + """Runtime families import shared helper policy instead of shadowing it.""" + + package_dir = Path(aces_sdl.__file__).resolve().parent + offenders: list[str] = [] + for path in sorted(package_dir.glob("runtime_*.py")): + if path.name == "runtime_values.py": + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name in _SHARED_HELPER_DEFINITION_NAMES: + offenders.append(f"{path.name}:{node.lineno}:{node.name}") + + assert not offenders, "Runtime modules must not redeclare shared validation helpers:\n " + "\n ".join(offenders) + + +def test_enum_or_var_helpers_share_hyphen_alias_normalization() -> None: + """Runtime and base enum parsing share one author-facing normalization rule.""" + + assert ( + parse_runtime_enum_or_var("member-of", RuntimeIdentityRelationshipKind, field_name="relationship_type") + is RuntimeIdentityRelationshipKind.MEMBER_OF + ) + assert ( + parse_enum_or_var("member-of", RuntimeIdentityRelationshipKind, field_name="relationship_type") + is RuntimeIdentityRelationshipKind.MEMBER_OF + ) + + def test_primary_id_field_exists_on_model() -> None: """Every registry primary id field must be a real field on its model.""" From b462808314c10f1ad147cb3b4a0f75fb3b7463e0 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 6 Jun 2026 18:35:50 +0200 Subject: [PATCH 02/70] Add participant runtime lifecycle contracts --- changelog.d/193.added.md | 1 + ...pant-behavior-history-event-stream-v1.json | 113 ++++++++ .../snapshots/runtime-snapshot-v1.json | 113 ++++++++ .../packages/aces_contracts/contracts.py | 72 ++++++ .../aces_contracts/participant_behavior.py | 134 ++++++++++ .../python/packages/aces_processor/models.py | 141 ++++++++++ ..._run_306_participant_decision_lifecycle.py | 243 ++++++++++++++++++ 7 files changed, 817 insertions(+) create mode 100644 changelog.d/193.added.md create mode 100644 implementations/python/tests/test_run_306_participant_decision_lifecycle.py diff --git a/changelog.d/193.added.md b/changelog.d/193.added.md new file mode 100644 index 000000000..4ddf77ca8 --- /dev/null +++ b/changelog.d/193.added.md @@ -0,0 +1 @@ +Added RUN-306 participant runtime lifecycle fields to behavior-history event contracts, schemas, and validators. diff --git a/contracts/schemas/control-plane/participant-behavior-history-event-stream-v1.json b/contracts/schemas/control-plane/participant-behavior-history-event-stream-v1.json index 394f4c20f..5e3808aa0 100644 --- a/contracts/schemas/control-plane/participant-behavior-history-event-stream-v1.json +++ b/contracts/schemas/control-plane/participant-behavior-history-event-stream-v1.json @@ -264,6 +264,18 @@ "title": "ParticipantActionResultModel", "type": "object" }, + "ParticipantAdmissionDisposition": { + "description": "RUN-306 selection/admission disposition values.", + "enum": [ + "admitted", + "rejected", + "withheld", + "unknown", + "not_applicable" + ], + "title": "ParticipantAdmissionDisposition", + "type": "string" + }, "ParticipantAttributionCandidateKind": { "description": "Portable candidate classes for participant attribution edges.", "enum": [ @@ -565,6 +577,24 @@ "title": "ParticipantInteractionClass", "type": "string" }, + "ParticipantLifecycleOperationState": { + "description": "RUN-306 operation states for execution-attempt records.", + "enum": [ + "submitted", + "acknowledged", + "running", + "blocked", + "completed", + "partial", + "failed", + "timed_out", + "cancelled", + "unknown", + "unsupported" + ], + "title": "ParticipantLifecycleOperationState", + "type": "string" + }, "ParticipantObservationDetailsModel": { "additionalProperties": false, "properties": { @@ -817,6 +847,20 @@ "title": "ParticipantOutcomeTargetRecordModel", "type": "object" }, + "ParticipantPhaseRealization": { + "description": "RUN-306 realization modes for an observable lifecycle phase.", + "enum": [ + "observed", + "runtime_mediated", + "externally_supplied", + "opaque", + "unknown", + "not_applicable", + "unsupported" + ], + "title": "ParticipantPhaseRealization", + "type": "string" + }, "ParticipantPreconditionClass": { "description": "SEM-211 precondition classes for participant action applicability.", "enum": [ @@ -832,6 +876,18 @@ "title": "ParticipantPreconditionClass", "type": "string" }, + "ParticipantRuntimeLifecyclePhase": { + "description": "RUN-306 observable participant runtime lifecycle phases.", + "enum": [ + "intent_or_proposal", + "selection_or_admission", + "execution_attempt", + "observation_emission", + "state_update_commit" + ], + "title": "ParticipantRuntimeLifecyclePhase", + "type": "string" + }, "ParticipantTemporalEventPoint": { "description": "Named participant event points used by temporal contracts.", "enum": [ @@ -983,6 +1039,17 @@ "default": null, "title": "Actor Provenance" }, + "admission_disposition": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantAdmissionDisposition" + }, + { + "type": "null" + } + ], + "default": null + }, "attribution_edges": { "items": { "$ref": "#/$defs/ParticipantAttributionEdgeModel" @@ -1038,6 +1105,17 @@ "default": null, "title": "Joint Action Set Id" }, + "lifecycle_phase": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantRuntimeLifecyclePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "observation_boundary_address": { "anyOf": [ { @@ -1062,6 +1140,30 @@ ], "default": null }, + "operation_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Operation Ref" + }, + "operation_state": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantLifecycleOperationState" + }, + { + "type": "null" + } + ], + "default": null + }, "outcome_interpretations": { "items": { "$ref": "#/$defs/ParticipantOutcomeInterpretationRecordModel" @@ -1074,6 +1176,17 @@ "title": "Participant Address", "type": "string" }, + "phase_realization": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantPhaseRealization" + }, + { + "type": "null" + } + ], + "default": null + }, "post_state_digest": { "anyOf": [ { diff --git a/contracts/schemas/snapshots/runtime-snapshot-v1.json b/contracts/schemas/snapshots/runtime-snapshot-v1.json index 1a7e1984b..b845d66f6 100644 --- a/contracts/schemas/snapshots/runtime-snapshot-v1.json +++ b/contracts/schemas/snapshots/runtime-snapshot-v1.json @@ -449,6 +449,18 @@ "title": "ParticipantActionResultModel", "type": "object" }, + "ParticipantAdmissionDisposition": { + "description": "RUN-306 selection/admission disposition values.", + "enum": [ + "admitted", + "rejected", + "withheld", + "unknown", + "not_applicable" + ], + "title": "ParticipantAdmissionDisposition", + "type": "string" + }, "ParticipantAttributionCandidateKind": { "description": "Portable candidate classes for participant attribution edges.", "enum": [ @@ -740,6 +752,17 @@ "default": null, "title": "Actor Provenance" }, + "admission_disposition": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantAdmissionDisposition" + }, + { + "type": "null" + } + ], + "default": null + }, "attribution_edges": { "items": { "$ref": "#/$defs/ParticipantAttributionEdgeModel" @@ -795,6 +818,17 @@ "default": null, "title": "Joint Action Set Id" }, + "lifecycle_phase": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantRuntimeLifecyclePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "observation_boundary_address": { "anyOf": [ { @@ -819,6 +853,30 @@ ], "default": null }, + "operation_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Operation Ref" + }, + "operation_state": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantLifecycleOperationState" + }, + { + "type": "null" + } + ], + "default": null + }, "outcome_interpretations": { "items": { "$ref": "#/$defs/ParticipantOutcomeInterpretationRecordModel" @@ -831,6 +889,17 @@ "title": "Participant Address", "type": "string" }, + "phase_realization": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantPhaseRealization" + }, + { + "type": "null" + } + ], + "default": null + }, "post_state_digest": { "anyOf": [ { @@ -1105,6 +1174,24 @@ "title": "ParticipantInteractionClass", "type": "string" }, + "ParticipantLifecycleOperationState": { + "description": "RUN-306 operation states for execution-attempt records.", + "enum": [ + "submitted", + "acknowledged", + "running", + "blocked", + "completed", + "partial", + "failed", + "timed_out", + "cancelled", + "unknown", + "unsupported" + ], + "title": "ParticipantLifecycleOperationState", + "type": "string" + }, "ParticipantObservationDetailsModel": { "additionalProperties": false, "properties": { @@ -1357,6 +1444,20 @@ "title": "ParticipantOutcomeTargetRecordModel", "type": "object" }, + "ParticipantPhaseRealization": { + "description": "RUN-306 realization modes for an observable lifecycle phase.", + "enum": [ + "observed", + "runtime_mediated", + "externally_supplied", + "opaque", + "unknown", + "not_applicable", + "unsupported" + ], + "title": "ParticipantPhaseRealization", + "type": "string" + }, "ParticipantPreconditionClass": { "description": "SEM-211 precondition classes for participant action applicability.", "enum": [ @@ -1372,6 +1473,18 @@ "title": "ParticipantPreconditionClass", "type": "string" }, + "ParticipantRuntimeLifecyclePhase": { + "description": "RUN-306 observable participant runtime lifecycle phases.", + "enum": [ + "intent_or_proposal", + "selection_or_admission", + "execution_attempt", + "observation_emission", + "state_update_commit" + ], + "title": "ParticipantRuntimeLifecyclePhase", + "type": "string" + }, "ParticipantTemporalEventPoint": { "description": "Named participant event points used by temporal contracts.", "enum": [ diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index 09d70db09..0e73c6ea6 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -47,8 +47,12 @@ validate_processor_supported_sdl_versions, ) from .participant_behavior import ( + ParticipantAdmissionDisposition, ParticipantBehaviorHistoryEventType, + ParticipantLifecycleOperationState, ParticipantObservationStatus, + ParticipantPhaseRealization, + ParticipantRuntimeLifecyclePhase, ) from .versions import ( BACKEND_MANIFEST_V2_SCHEMA_VERSION, @@ -744,6 +748,11 @@ class ParticipantBehaviorHistoryEventModel(ContractModel): observation_boundary_address: NonEmptyString | None = None observation_status: ParticipantObservationStatus | None = None actor_provenance: NonEmptyString | None = None + lifecycle_phase: ParticipantRuntimeLifecyclePhase | None = None + phase_realization: ParticipantPhaseRealization | None = None + admission_disposition: ParticipantAdmissionDisposition | None = None + operation_ref: NonEmptyString | None = None + operation_state: ParticipantLifecycleOperationState | None = None state_transition_kind: NonEmptyString | None = None post_state_digest: NonEmptyString | None = None joint_action_set_id: NonEmptyString | None = None @@ -757,6 +766,69 @@ class ParticipantBehaviorHistoryEventModel(ContractModel): temporal_contexts: list[ParticipantTemporalRuntimeContextModel] = Field(default_factory=list) details: ParticipantObservationDetailsModel = Field(default_factory=ParticipantObservationDetailsModel) + @model_validator(mode="after") + def _validate_lifecycle_fields(self) -> ParticipantBehaviorHistoryEventModel: + lifecycle_fields = ( + self.phase_realization, + self.admission_disposition, + self.operation_ref, + self.operation_state, + ) + if self.lifecycle_phase is None: + if any(value is not None for value in lifecycle_fields): + raise ValueError("participant behavior lifecycle fields require lifecycle_phase") + return self + if self.phase_realization is None: + raise ValueError("lifecycle_phase requires phase_realization") + self._validate_admission_lifecycle_scope() + self._validate_operation_lifecycle_scope() + self._validate_lifecycle_phase_matches_event_type() + return self + + def _validate_admission_lifecycle_scope(self) -> None: + if self.lifecycle_phase == ParticipantRuntimeLifecyclePhase.SELECTION_OR_ADMISSION: + if self.admission_disposition is None: + raise ValueError("selection_or_admission lifecycle_phase requires admission_disposition") + return + if self.admission_disposition is not None: + raise ValueError("admission_disposition requires lifecycle_phase selection_or_admission") + + def _validate_operation_lifecycle_scope(self) -> None: + if ( + self.operation_state is not None + and self.lifecycle_phase != ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT + ): + raise ValueError("operation_state requires lifecycle_phase execution_attempt") + if ( + self.operation_ref is not None + and self.lifecycle_phase != ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT + ): + raise ValueError("operation_ref requires lifecycle_phase execution_attempt") + + def _validate_lifecycle_phase_matches_event_type(self) -> None: + if self.event_type == ParticipantBehaviorHistoryEventType.ACTION_ATTEMPTED: + allowed = { + ParticipantRuntimeLifecyclePhase.INTENT_OR_PROPOSAL, + ParticipantRuntimeLifecyclePhase.SELECTION_OR_ADMISSION, + ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT, + } + if self.lifecycle_phase not in allowed: + raise ValueError( + "action_attempted lifecycle_phase must be one of intent_or_proposal, " + "selection_or_admission, execution_attempt" + ) + return + if ( + self.event_type == ParticipantBehaviorHistoryEventType.STATE_TRANSITION_RECORDED + and self.lifecycle_phase != ParticipantRuntimeLifecyclePhase.STATE_UPDATE_COMMIT + ): + raise ValueError("state_transition_recorded lifecycle_phase must be state_update_commit") + if ( + self.event_type == ParticipantBehaviorHistoryEventType.OBSERVATION_EMITTED + and self.lifecycle_phase != ParticipantRuntimeLifecyclePhase.OBSERVATION_EMISSION + ): + raise ValueError("observation_emitted lifecycle_phase must be observation_emission") + class PlanOperationModel(ContractModel): action: str diff --git a/implementations/python/packages/aces_contracts/participant_behavior.py b/implementations/python/packages/aces_contracts/participant_behavior.py index b90a5d16e..9572ba255 100644 --- a/implementations/python/packages/aces_contracts/participant_behavior.py +++ b/implementations/python/packages/aces_contracts/participant_behavior.py @@ -41,6 +41,54 @@ class ParticipantActionResultStatus(str, Enum): UNKNOWN = "unknown" +class ParticipantRuntimeLifecyclePhase(str, Enum): + """RUN-306 observable participant runtime lifecycle phases.""" + + INTENT_OR_PROPOSAL = "intent_or_proposal" + SELECTION_OR_ADMISSION = "selection_or_admission" + EXECUTION_ATTEMPT = "execution_attempt" + OBSERVATION_EMISSION = "observation_emission" + STATE_UPDATE_COMMIT = "state_update_commit" + + +class ParticipantPhaseRealization(str, Enum): + """RUN-306 realization modes for an observable lifecycle phase.""" + + OBSERVED = "observed" + RUNTIME_MEDIATED = "runtime_mediated" + EXTERNALLY_SUPPLIED = "externally_supplied" + OPAQUE = "opaque" + UNKNOWN = "unknown" + NOT_APPLICABLE = "not_applicable" + UNSUPPORTED = "unsupported" + + +class ParticipantAdmissionDisposition(str, Enum): + """RUN-306 selection/admission disposition values.""" + + ADMITTED = "admitted" + REJECTED = "rejected" + WITHHELD = "withheld" + UNKNOWN = "unknown" + NOT_APPLICABLE = "not_applicable" + + +class ParticipantLifecycleOperationState(str, Enum): + """RUN-306 operation states for execution-attempt records.""" + + SUBMITTED = "submitted" + ACKNOWLEDGED = "acknowledged" + RUNNING = "running" + BLOCKED = "blocked" + COMPLETED = "completed" + PARTIAL = "partial" + FAILED = "failed" + TIMED_OUT = "timed_out" + CANCELLED = "cancelled" + UNKNOWN = "unknown" + UNSUPPORTED = "unsupported" + + _PARTICIPANT_BEHAVIOR_HISTORY_KEY = "runtime.snapshot.participant-behavior-history" _PARTICIPANT_RUNTIME_METADATA_KEY = "runtime.snapshot.metadata" _RESERVED_RUNTIME_STATE_KEYS = frozenset( @@ -65,6 +113,7 @@ class ParticipantActionResultStatus(str, Enum): "post_state_digest", "joint_action_set_id", "interaction_ref", + "operation_ref", ) @@ -245,6 +294,87 @@ def _iter_behavior_event_shape_violations( if not isinstance(details, Mapping): yield (locator, "participant behavior details must be a mapping") + yield from _iter_behavior_event_lifecycle_violations(locator, event_type, event) + + +def _enum_values(enum_type: type[Enum]) -> set[str]: + return {item.value for item in enum_type} + + +def _iter_optional_enum_violations( + locator: str, + event: Mapping[object, object], + field: str, + enum_type: type[Enum], +) -> Iterator[tuple[str, str]]: + value = event.get(field) + if value is not None and value not in _enum_values(enum_type): + yield (locator, f"participant behavior {field} {value!r} is not supported") + + +def _iter_behavior_event_lifecycle_violations( + locator: str, + event_type: object, + event: Mapping[object, object], +) -> Iterator[tuple[str, str]]: + lifecycle_phase = event.get("lifecycle_phase") + phase_realization = event.get("phase_realization") + admission_disposition = event.get("admission_disposition") + operation_ref = event.get("operation_ref") + operation_state = event.get("operation_state") + + for field, enum_type in ( + ("lifecycle_phase", ParticipantRuntimeLifecyclePhase), + ("phase_realization", ParticipantPhaseRealization), + ("admission_disposition", ParticipantAdmissionDisposition), + ("operation_state", ParticipantLifecycleOperationState), + ): + yield from _iter_optional_enum_violations(locator, event, field, enum_type) + + if lifecycle_phase is None: + lifecycle_fields = (phase_realization, admission_disposition, operation_ref, operation_state) + if any(value is not None for value in lifecycle_fields): + yield (locator, "participant behavior lifecycle fields require lifecycle_phase") + return + if lifecycle_phase not in _enum_values(ParticipantRuntimeLifecyclePhase): + return + if phase_realization is None: + yield (locator, "lifecycle_phase requires phase_realization") + elif phase_realization not in _enum_values(ParticipantPhaseRealization): + return + + if lifecycle_phase == ParticipantRuntimeLifecyclePhase.SELECTION_OR_ADMISSION.value: + if admission_disposition is None: + yield (locator, "selection_or_admission lifecycle_phase requires admission_disposition") + elif admission_disposition is not None: + yield (locator, "admission_disposition requires lifecycle_phase selection_or_admission") + + if operation_state is not None and lifecycle_phase != ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT.value: + yield (locator, "operation_state requires lifecycle_phase execution_attempt") + if operation_ref is not None and lifecycle_phase != ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT.value: + yield (locator, "operation_ref requires lifecycle_phase execution_attempt") + + if event_type == ParticipantBehaviorHistoryEventType.ACTION_ATTEMPTED.value: + allowed = { + ParticipantRuntimeLifecyclePhase.INTENT_OR_PROPOSAL.value, + ParticipantRuntimeLifecyclePhase.SELECTION_OR_ADMISSION.value, + ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT.value, + } + if lifecycle_phase not in allowed: + yield ( + locator, + ( + "action_attempted lifecycle_phase must be one of intent_or_proposal, " + "selection_or_admission, execution_attempt" + ), + ) + elif event_type == ParticipantBehaviorHistoryEventType.STATE_TRANSITION_RECORDED.value: + if lifecycle_phase != ParticipantRuntimeLifecyclePhase.STATE_UPDATE_COMMIT.value: + yield (locator, "state_transition_recorded lifecycle_phase must be state_update_commit") + elif event_type == ParticipantBehaviorHistoryEventType.OBSERVATION_EMITTED.value: + if lifecycle_phase != ParticipantRuntimeLifecyclePhase.OBSERVATION_EMISSION.value: + yield (locator, "observation_emitted lifecycle_phase must be observation_emission") + def _iter_behavior_event_episode_reference_violations( locator: str, @@ -330,10 +460,14 @@ def _collect_episode_history_ids( __all__ = ( + "ParticipantAdmissionDisposition", "ParticipantActionPreconditionStatus", "ParticipantActionResultStatus", "ParticipantBehaviorHistoryEventType", + "ParticipantLifecycleOperationState", "ParticipantObservationStatus", + "ParticipantPhaseRealization", + "ParticipantRuntimeLifecyclePhase", "iter_participant_behavior_snapshot_violations", "iter_participant_runtime_history_transition_violations", ) diff --git a/implementations/python/packages/aces_processor/models.py b/implementations/python/packages/aces_processor/models.py index 191da152f..103fbe47f 100644 --- a/implementations/python/packages/aces_processor/models.py +++ b/implementations/python/packages/aces_processor/models.py @@ -49,12 +49,24 @@ from aces_contracts.participant_behavior import ( ParticipantActionResultStatus as ParticipantActionResultStatus, ) +from aces_contracts.participant_behavior import ( + ParticipantAdmissionDisposition as ParticipantAdmissionDisposition, +) from aces_contracts.participant_behavior import ( ParticipantBehaviorHistoryEventType as ParticipantBehaviorHistoryEventType, ) +from aces_contracts.participant_behavior import ( + ParticipantLifecycleOperationState as ParticipantLifecycleOperationState, +) from aces_contracts.participant_behavior import ( ParticipantObservationStatus as ParticipantObservationStatus, ) +from aces_contracts.participant_behavior import ( + ParticipantPhaseRealization as ParticipantPhaseRealization, +) +from aces_contracts.participant_behavior import ( + ParticipantRuntimeLifecyclePhase as ParticipantRuntimeLifecyclePhase, +) from aces_contracts.participant_episode import ( PARTICIPANT_EPISODE_CONTROL_EVENTS, PARTICIPANT_EPISODE_TERMINAL_EVENTS, @@ -1929,6 +1941,38 @@ def _participant_interaction_class_from_payload(value: Any) -> ParticipantIntera return ParticipantInteractionClass(str(value)) +def _participant_lifecycle_phase_from_payload(value: Any) -> ParticipantRuntimeLifecyclePhase | None: + if value is None: + return None + if isinstance(value, ParticipantRuntimeLifecyclePhase): + return value + return ParticipantRuntimeLifecyclePhase(str(value)) + + +def _participant_phase_realization_from_payload(value: Any) -> ParticipantPhaseRealization | None: + if value is None: + return None + if isinstance(value, ParticipantPhaseRealization): + return value + return ParticipantPhaseRealization(str(value)) + + +def _participant_admission_disposition_from_payload(value: Any) -> ParticipantAdmissionDisposition | None: + if value is None: + return None + if isinstance(value, ParticipantAdmissionDisposition): + return value + return ParticipantAdmissionDisposition(str(value)) + + +def _participant_lifecycle_operation_state_from_payload(value: Any) -> ParticipantLifecycleOperationState | None: + if value is None: + return None + if isinstance(value, ParticipantLifecycleOperationState): + return value + return ParticipantLifecycleOperationState(str(value)) + + def _participant_behavior_shared_state_refs_from_payload(value: Any) -> tuple[str, ...]: if value is None: return () @@ -2001,6 +2045,11 @@ class ParticipantBehaviorHistoryEvent: observation_boundary_address: str | None = None observation_status: ParticipantObservationStatus | None = None actor_provenance: str | None = None + lifecycle_phase: ParticipantRuntimeLifecyclePhase | None = None + phase_realization: ParticipantPhaseRealization | None = None + admission_disposition: ParticipantAdmissionDisposition | None = None + operation_ref: str | None = None + operation_state: ParticipantLifecycleOperationState | None = None state_transition_kind: str | None = None post_state_digest: str | None = None joint_action_set_id: str | None = None @@ -2046,6 +2095,11 @@ def from_payload( observation_boundary_address=_optional_payload_string(payload, "observation_boundary_address"), observation_status=_participant_observation_status_from_payload(payload.get("observation_status")), actor_provenance=_optional_payload_string(payload, "actor_provenance"), + lifecycle_phase=_participant_lifecycle_phase_from_payload(payload.get("lifecycle_phase")), + phase_realization=_participant_phase_realization_from_payload(payload.get("phase_realization")), + admission_disposition=_participant_admission_disposition_from_payload(payload.get("admission_disposition")), + operation_ref=_optional_payload_string(payload, "operation_ref"), + operation_state=_participant_lifecycle_operation_state_from_payload(payload.get("operation_state")), state_transition_kind=_optional_payload_string(payload, "state_transition_kind"), post_state_digest=_optional_payload_string(payload, "post_state_digest"), joint_action_set_id=_optional_payload_string(payload, "joint_action_set_id"), @@ -2075,6 +2129,13 @@ def to_payload(self) -> dict[str, Any]: "observation_boundary_address": self.observation_boundary_address, "observation_status": self.observation_status.value if self.observation_status is not None else None, "actor_provenance": self.actor_provenance, + "lifecycle_phase": self.lifecycle_phase.value if self.lifecycle_phase is not None else None, + "phase_realization": self.phase_realization.value if self.phase_realization is not None else None, + "admission_disposition": ( + self.admission_disposition.value if self.admission_disposition is not None else None + ), + "operation_ref": self.operation_ref, + "operation_state": self.operation_state.value if self.operation_state is not None else None, "state_transition_kind": self.state_transition_kind, "post_state_digest": self.post_state_digest, "joint_action_set_id": self.joint_action_set_id, @@ -2119,6 +2180,7 @@ def _validate_common_fields(self) -> None: ): raise TypeError("observation_status must be a ParticipantObservationStatus or None") self._validate_optional_string(self.actor_provenance, "actor_provenance must be a non-empty string or None") + self._validate_lifecycle_fields() self._validate_optional_state_fields() self._validate_realized_order() self._validate_interaction_type() @@ -2147,6 +2209,64 @@ def _validate_optional_state_fields(self) -> None: "joint_action_set_id must be a non-empty string or None", ) + def _validate_lifecycle_fields(self) -> None: + if self.lifecycle_phase is not None and not isinstance( + self.lifecycle_phase, + ParticipantRuntimeLifecyclePhase, + ): + raise TypeError("lifecycle_phase must be a ParticipantRuntimeLifecyclePhase or None") + if self.phase_realization is not None and not isinstance( + self.phase_realization, + ParticipantPhaseRealization, + ): + raise TypeError("phase_realization must be a ParticipantPhaseRealization or None") + if self.admission_disposition is not None and not isinstance( + self.admission_disposition, + ParticipantAdmissionDisposition, + ): + raise TypeError("admission_disposition must be a ParticipantAdmissionDisposition or None") + if self.operation_state is not None and not isinstance( + self.operation_state, + ParticipantLifecycleOperationState, + ): + raise TypeError("operation_state must be a ParticipantLifecycleOperationState or None") + self._validate_optional_string(self.operation_ref, "operation_ref must be a non-empty string or None") + + lifecycle_fields = ( + self.phase_realization, + self.admission_disposition, + self.operation_ref, + self.operation_state, + ) + if self.lifecycle_phase is None: + if any(value is not None for value in lifecycle_fields): + raise ValueError("participant behavior lifecycle fields require lifecycle_phase") + return + if self.phase_realization is None: + raise ValueError("lifecycle_phase requires phase_realization") + self._validate_admission_lifecycle_scope() + self._validate_operation_lifecycle_scope() + + def _validate_admission_lifecycle_scope(self) -> None: + if self.lifecycle_phase == ParticipantRuntimeLifecyclePhase.SELECTION_OR_ADMISSION: + if self.admission_disposition is None: + raise ValueError("selection_or_admission lifecycle_phase requires admission_disposition") + return + if self.admission_disposition is not None: + raise ValueError("admission_disposition requires lifecycle_phase selection_or_admission") + + def _validate_operation_lifecycle_scope(self) -> None: + if ( + self.operation_state is not None + and self.lifecycle_phase != ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT + ): + raise ValueError("operation_state requires lifecycle_phase execution_attempt") + if ( + self.operation_ref is not None + and self.lifecycle_phase != ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT + ): + raise ValueError("operation_ref requires lifecycle_phase execution_attempt") + def _validate_realized_order(self) -> None: if self.realized_order is not None and ( not isinstance(self.realized_order, int) or isinstance(self.realized_order, bool) or self.realized_order < 0 @@ -2248,6 +2368,17 @@ def _validate_interaction_fields(self) -> None: raise ValueError(f"{self.interaction_class.value} events require shared_state_refs") def _validate_action_attempted_fields(self) -> None: + if self.lifecycle_phase is not None: + allowed = { + ParticipantRuntimeLifecyclePhase.INTENT_OR_PROPOSAL, + ParticipantRuntimeLifecyclePhase.SELECTION_OR_ADMISSION, + ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT, + } + if self.lifecycle_phase not in allowed: + raise ValueError( + "action_attempted lifecycle_phase must be one of intent_or_proposal, " + "selection_or_admission, execution_attempt" + ) if self.action_contract_address is None: raise ValueError("action_attempted events require action_contract_address") if self.actor_provenance is None: @@ -2260,6 +2391,11 @@ def _validate_action_attempted_fields(self) -> None: raise ValueError("action_attempted events may not report action_result") def _validate_state_transition_fields(self) -> None: + if ( + self.lifecycle_phase is not None + and self.lifecycle_phase != ParticipantRuntimeLifecyclePhase.STATE_UPDATE_COMMIT + ): + raise ValueError("state_transition_recorded lifecycle_phase must be state_update_commit") if self.action_contract_address is None: raise ValueError("state_transition_recorded events require action_contract_address") if self.state_transition_kind is None: @@ -2272,6 +2408,11 @@ def _validate_state_transition_fields(self) -> None: raise ValueError("state_transition_recorded events may not report action_result") def _validate_observation_emitted_fields(self) -> None: + if ( + self.lifecycle_phase is not None + and self.lifecycle_phase != ParticipantRuntimeLifecyclePhase.OBSERVATION_EMISSION + ): + raise ValueError("observation_emitted lifecycle_phase must be observation_emission") if self.action_contract_address is None: raise ValueError("observation_emitted events require action_contract_address") if self.observation_boundary_address is None: diff --git a/implementations/python/tests/test_run_306_participant_decision_lifecycle.py b/implementations/python/tests/test_run_306_participant_decision_lifecycle.py new file mode 100644 index 000000000..57f7f2e6a --- /dev/null +++ b/implementations/python/tests/test_run_306_participant_decision_lifecycle.py @@ -0,0 +1,243 @@ +"""RUN-306 participant decision/execution lifecycle tests.""" + +from __future__ import annotations + +import pytest +from aces_contracts.contracts import ParticipantBehaviorHistoryEventModel, schema_bundle +from aces_contracts.runtime_state import RuntimeSnapshot +from aces_processor.models import ( + ParticipantAdmissionDisposition, + ParticipantBehaviorHistoryEvent, + ParticipantLifecycleOperationState, + ParticipantObservationStatus, + ParticipantPhaseRealization, + ParticipantRuntimeLifecyclePhase, +) +from aces_runtime.participant_result_contracts import participant_runtime_state_contract_diagnostics +from jsonschema import Draft202012Validator +from pydantic import ValidationError + +PARTICIPANT = "participant.red" +EPISODE = "episode-1" +ACTION_INSTANCE = "scan-0001" +ACTION_ADDRESS = "participant.action-contract.scan" +OBSERVATION_ADDRESS = "participant.observation-boundary.red-view" +T0 = "2026-06-06T08:00:00Z" + + +def _action_payload(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "event_type": "action_attempted", + "timestamp": T0, + "participant_address": PARTICIPANT, + "episode_id": EPISODE, + "action_instance_id": ACTION_INSTANCE, + "action_contract_address": ACTION_ADDRESS, + "actor_provenance": "participant:red", + "details": {}, + } + payload.update(overrides) + return payload + + +def _state_update_payload(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "event_type": "state_transition_recorded", + "timestamp": T0, + "participant_address": PARTICIPANT, + "episode_id": EPISODE, + "action_instance_id": ACTION_INSTANCE, + "action_contract_address": ACTION_ADDRESS, + "state_transition_kind": "participant_knowledge_expanded", + "post_state_digest": "sha256:known", + "details": {}, + } + payload.update(overrides) + return payload + + +def _observation_payload(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "event_type": "observation_emitted", + "timestamp": T0, + "participant_address": PARTICIPANT, + "episode_id": EPISODE, + "action_instance_id": ACTION_INSTANCE, + "action_contract_address": ACTION_ADDRESS, + "observation_boundary_address": OBSERVATION_ADDRESS, + "observation_status": "terminal", + "post_state_digest": "sha256:known", + "details": {}, + } + payload.update(overrides) + return payload + + +def _snapshot_payload(event: dict[str, object]) -> dict[str, object]: + return { + "schema_version": "runtime-snapshot/v1", + "entries": {}, + "orchestration_results": {}, + "orchestration_history": {}, + "evaluation_results": {}, + "evaluation_history": {}, + "participant_episode_results": {}, + "participant_episode_history": {}, + "participant_behavior_history": {PARTICIPANT: [event]}, + "metadata": {}, + } + + +def test_lifecycle_execution_attempt_round_trips_and_schema_accepts_envelope() -> None: + payload = _action_payload( + lifecycle_phase="execution_attempt", + phase_realization="runtime_mediated", + operation_ref="runtime.operation.scan-0001", + operation_state="running", + ) + + model = ParticipantBehaviorHistoryEventModel.model_validate(payload) + assert model.model_dump(mode="json")["lifecycle_phase"] == "execution_attempt" + + validator = Draft202012Validator(schema_bundle()["runtime-snapshot-v1"]) + assert list(validator.iter_errors(_snapshot_payload(payload))) == [] + + event = ParticipantBehaviorHistoryEvent.from_payload(payload) + assert event.lifecycle_phase == ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT + assert event.phase_realization == ParticipantPhaseRealization.RUNTIME_MEDIATED + assert event.operation_state == ParticipantLifecycleOperationState.RUNNING + assert ParticipantBehaviorHistoryEvent.from_payload(event.to_payload()) == event + + +@pytest.mark.parametrize( + "phase_realization", + [ + "observed", + "runtime_mediated", + "externally_supplied", + "opaque", + "unknown", + "not_applicable", + "unsupported", + ], +) +def test_lifecycle_phase_realization_values_are_not_collapsed(phase_realization: str) -> None: + payload = _action_payload( + lifecycle_phase="intent_or_proposal", + phase_realization=phase_realization, + ) + + event = ParticipantBehaviorHistoryEvent.from_payload(payload) + + assert event.lifecycle_phase == ParticipantRuntimeLifecyclePhase.INTENT_OR_PROPOSAL + assert event.phase_realization.value == phase_realization + model = ParticipantBehaviorHistoryEventModel.model_validate(payload) + assert model.model_dump(mode="json")["phase_realization"] == phase_realization + + +def test_selection_admission_records_use_separate_disposition_vocabulary() -> None: + payload = _action_payload( + lifecycle_phase="selection_or_admission", + phase_realization="externally_supplied", + admission_disposition="admitted", + ) + + event = ParticipantBehaviorHistoryEvent.from_payload(payload) + + assert event.admission_disposition == ParticipantAdmissionDisposition.ADMITTED + assert event.to_payload()["admission_disposition"] == "admitted" + + +def test_admission_disposition_is_only_valid_for_selection_or_admission() -> None: + payload = _action_payload( + lifecycle_phase="execution_attempt", + phase_realization="observed", + admission_disposition="admitted", + ) + + with pytest.raises( + ValueError, + match="admission_disposition requires lifecycle_phase selection_or_admission", + ): + ParticipantBehaviorHistoryEvent.from_payload(payload) + with pytest.raises( + ValidationError, + match="admission_disposition requires lifecycle_phase selection_or_admission", + ): + ParticipantBehaviorHistoryEventModel.model_validate(payload) + + diagnostics = participant_runtime_state_contract_diagnostics( + RuntimeSnapshot(participant_behavior_history={PARTICIPANT: [payload]}) + ) + assert any( + "admission_disposition requires lifecycle_phase selection_or_admission" in item.message for item in diagnostics + ) + + +def test_operation_state_is_only_valid_for_execution_attempt() -> None: + payload = _action_payload( + lifecycle_phase="selection_or_admission", + phase_realization="observed", + admission_disposition="admitted", + operation_ref="runtime.operation.scan-0001", + operation_state="running", + ) + + with pytest.raises( + ValueError, + match="operation_state requires lifecycle_phase execution_attempt", + ): + ParticipantBehaviorHistoryEvent.from_payload(payload) + with pytest.raises( + ValidationError, + match="operation_state requires lifecycle_phase execution_attempt", + ): + ParticipantBehaviorHistoryEventModel.model_validate(payload) + + diagnostics = participant_runtime_state_contract_diagnostics( + RuntimeSnapshot(participant_behavior_history={PARTICIPANT: [payload]}) + ) + assert any("operation_state requires lifecycle_phase execution_attempt" in item.message for item in diagnostics) + + +def test_lifecycle_phase_must_match_behavior_event_type() -> None: + invalid_state_update = _state_update_payload( + lifecycle_phase="observation_emission", + phase_realization="observed", + ) + with pytest.raises( + ValueError, + match="state_transition_recorded lifecycle_phase must be state_update_commit", + ): + ParticipantBehaviorHistoryEvent.from_payload(invalid_state_update) + + invalid_observation = _observation_payload( + lifecycle_phase="state_update_commit", + phase_realization="observed", + ) + with pytest.raises( + ValueError, + match="observation_emitted lifecycle_phase must be observation_emission", + ): + ParticipantBehaviorHistoryEvent.from_payload(invalid_observation) + + valid_observation = ParticipantBehaviorHistoryEvent.from_payload( + _observation_payload( + lifecycle_phase="observation_emission", + phase_realization="observed", + ) + ) + assert valid_observation.observation_status == ParticipantObservationStatus.TERMINAL + + +def test_schema_rejects_unknown_lifecycle_vocabulary() -> None: + payload = _action_payload( + lifecycle_phase="opaque", + phase_realization="observed", + ) + + with pytest.raises(ValidationError): + ParticipantBehaviorHistoryEventModel.model_validate(payload) + + validator = Draft202012Validator(schema_bundle()["runtime-snapshot-v1"]) + assert list(validator.iter_errors(_snapshot_payload(payload))) From b270a53c001ac44822c1ba554c94f5a35ac4cc57 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 6 Jun 2026 19:11:05 +0200 Subject: [PATCH 03/70] Fix SonarCloud findings (cycle 1) --- changelog.d/193.added.md | 2 +- .../packages/aces_contracts/contracts.py | 68 +--- .../aces_contracts/participant_behavior.py | 345 ++++++++++++------ .../python/packages/aces_processor/models.py | 129 +++---- 4 files changed, 297 insertions(+), 247 deletions(-) diff --git a/changelog.d/193.added.md b/changelog.d/193.added.md index 4ddf77ca8..265ca89b2 100644 --- a/changelog.d/193.added.md +++ b/changelog.d/193.added.md @@ -1 +1 @@ -Added RUN-306 participant runtime lifecycle fields to behavior-history event contracts, schemas, and validators. +Added RUN-306 participant runtime lifecycle fields and shared validation to behavior-history event contracts, schemas, and validators. diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index 0e73c6ea6..14d58b667 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -53,6 +53,7 @@ ParticipantObservationStatus, ParticipantPhaseRealization, ParticipantRuntimeLifecyclePhase, + participant_lifecycle_field_violation_messages, ) from .versions import ( BACKEND_MANIFEST_V2_SCHEMA_VERSION, @@ -768,67 +769,18 @@ class ParticipantBehaviorHistoryEventModel(ContractModel): @model_validator(mode="after") def _validate_lifecycle_fields(self) -> ParticipantBehaviorHistoryEventModel: - lifecycle_fields = ( - self.phase_realization, - self.admission_disposition, - self.operation_ref, - self.operation_state, + messages = participant_lifecycle_field_violation_messages( + event_type=self.event_type, + lifecycle_phase=self.lifecycle_phase, + phase_realization=self.phase_realization, + admission_disposition=self.admission_disposition, + operation_ref=self.operation_ref, + operation_state=self.operation_state, ) - if self.lifecycle_phase is None: - if any(value is not None for value in lifecycle_fields): - raise ValueError("participant behavior lifecycle fields require lifecycle_phase") - return self - if self.phase_realization is None: - raise ValueError("lifecycle_phase requires phase_realization") - self._validate_admission_lifecycle_scope() - self._validate_operation_lifecycle_scope() - self._validate_lifecycle_phase_matches_event_type() + if messages: + raise ValueError(messages[0]) return self - def _validate_admission_lifecycle_scope(self) -> None: - if self.lifecycle_phase == ParticipantRuntimeLifecyclePhase.SELECTION_OR_ADMISSION: - if self.admission_disposition is None: - raise ValueError("selection_or_admission lifecycle_phase requires admission_disposition") - return - if self.admission_disposition is not None: - raise ValueError("admission_disposition requires lifecycle_phase selection_or_admission") - - def _validate_operation_lifecycle_scope(self) -> None: - if ( - self.operation_state is not None - and self.lifecycle_phase != ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT - ): - raise ValueError("operation_state requires lifecycle_phase execution_attempt") - if ( - self.operation_ref is not None - and self.lifecycle_phase != ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT - ): - raise ValueError("operation_ref requires lifecycle_phase execution_attempt") - - def _validate_lifecycle_phase_matches_event_type(self) -> None: - if self.event_type == ParticipantBehaviorHistoryEventType.ACTION_ATTEMPTED: - allowed = { - ParticipantRuntimeLifecyclePhase.INTENT_OR_PROPOSAL, - ParticipantRuntimeLifecyclePhase.SELECTION_OR_ADMISSION, - ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT, - } - if self.lifecycle_phase not in allowed: - raise ValueError( - "action_attempted lifecycle_phase must be one of intent_or_proposal, " - "selection_or_admission, execution_attempt" - ) - return - if ( - self.event_type == ParticipantBehaviorHistoryEventType.STATE_TRANSITION_RECORDED - and self.lifecycle_phase != ParticipantRuntimeLifecyclePhase.STATE_UPDATE_COMMIT - ): - raise ValueError("state_transition_recorded lifecycle_phase must be state_update_commit") - if ( - self.event_type == ParticipantBehaviorHistoryEventType.OBSERVATION_EMITTED - and self.lifecycle_phase != ParticipantRuntimeLifecyclePhase.OBSERVATION_EMISSION - ): - raise ValueError("observation_emitted lifecycle_phase must be observation_emission") - class PlanOperationModel(ContractModel): action: str diff --git a/implementations/python/packages/aces_contracts/participant_behavior.py b/implementations/python/packages/aces_contracts/participant_behavior.py index 9572ba255..f9740f15d 100644 --- a/implementations/python/packages/aces_contracts/participant_behavior.py +++ b/implementations/python/packages/aces_contracts/participant_behavior.py @@ -117,6 +117,153 @@ class ParticipantLifecycleOperationState(str, Enum): ) +def _enum_values(enum_type: type[Enum]) -> frozenset[str]: + return frozenset(str(item.value) for item in enum_type.__members__.values()) + + +_PARTICIPANT_BEHAVIOR_EVENT_TYPE_VALUES = _enum_values(ParticipantBehaviorHistoryEventType) +_PARTICIPANT_OBSERVATION_STATUS_VALUES = _enum_values(ParticipantObservationStatus) +_PARTICIPANT_RUNTIME_LIFECYCLE_PHASE_VALUES = _enum_values(ParticipantRuntimeLifecyclePhase) +_PARTICIPANT_PHASE_REALIZATION_VALUES = _enum_values(ParticipantPhaseRealization) +_PARTICIPANT_ADMISSION_DISPOSITION_VALUES = _enum_values(ParticipantAdmissionDisposition) +_PARTICIPANT_LIFECYCLE_OPERATION_STATE_VALUES = _enum_values(ParticipantLifecycleOperationState) +_ACTION_ATTEMPTED_LIFECYCLE_PHASE_VALUES = frozenset( + { + ParticipantRuntimeLifecyclePhase.INTENT_OR_PROPOSAL.value, + ParticipantRuntimeLifecyclePhase.SELECTION_OR_ADMISSION.value, + ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT.value, + } +) +_LIFECYCLE_ENUM_FIELDS = ( + ("lifecycle_phase", _PARTICIPANT_RUNTIME_LIFECYCLE_PHASE_VALUES), + ("phase_realization", _PARTICIPANT_PHASE_REALIZATION_VALUES), + ("admission_disposition", _PARTICIPANT_ADMISSION_DISPOSITION_VALUES), + ("operation_state", _PARTICIPANT_LIFECYCLE_OPERATION_STATE_VALUES), +) +_LIFECYCLE_PHASE_BY_EVENT_TYPE = { + ParticipantBehaviorHistoryEventType.ACTION_ATTEMPTED.value: _ACTION_ATTEMPTED_LIFECYCLE_PHASE_VALUES, + ParticipantBehaviorHistoryEventType.STATE_TRANSITION_RECORDED.value: frozenset( + {ParticipantRuntimeLifecyclePhase.STATE_UPDATE_COMMIT.value} + ), + ParticipantBehaviorHistoryEventType.OBSERVATION_EMITTED.value: frozenset( + {ParticipantRuntimeLifecyclePhase.OBSERVATION_EMISSION.value} + ), +} +_LIFECYCLE_PHASE_BY_EVENT_TYPE_MESSAGES = { + ParticipantBehaviorHistoryEventType.ACTION_ATTEMPTED.value: ( + "action_attempted lifecycle_phase must be one of intent_or_proposal, selection_or_admission, execution_attempt" + ), + ParticipantBehaviorHistoryEventType.STATE_TRANSITION_RECORDED.value: ( + "state_transition_recorded lifecycle_phase must be state_update_commit" + ), + ParticipantBehaviorHistoryEventType.OBSERVATION_EMITTED.value: ( + "observation_emitted lifecycle_phase must be observation_emission" + ), +} + + +def _enum_scalar(value: object) -> object: + return value.value if isinstance(value, Enum) else value + + +def _optional_enum_violation_messages(field_values: Mapping[str, object]) -> list[str]: + messages: list[str] = [] + for field, allowed_values in _LIFECYCLE_ENUM_FIELDS: + value = field_values[field] + if value is not None and value not in allowed_values: + messages.append(f"participant behavior {field} {value!r} is not supported") + return messages + + +def _lifecycle_presence_violation_messages( + lifecycle_phase: object, + phase_realization: object, + lifecycle_fields: tuple[object, ...], +) -> list[str]: + if lifecycle_phase is None and any(value is not None for value in lifecycle_fields): + return ["participant behavior lifecycle fields require lifecycle_phase"] + if lifecycle_phase is not None and phase_realization is None: + return ["lifecycle_phase requires phase_realization"] + return [] + + +def _lifecycle_scope_violation_messages( + lifecycle_phase: object, + admission_disposition: object, + operation_ref: object, + operation_state: object, +) -> list[str]: + messages: list[str] = [] + execution_attempt = ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT.value + if lifecycle_phase == ParticipantRuntimeLifecyclePhase.SELECTION_OR_ADMISSION.value: + if admission_disposition is None: + messages.append("selection_or_admission lifecycle_phase requires admission_disposition") + elif admission_disposition is not None: + messages.append("admission_disposition requires lifecycle_phase selection_or_admission") + if operation_state is not None and lifecycle_phase != execution_attempt: + messages.append("operation_state requires lifecycle_phase execution_attempt") + if operation_ref is not None and lifecycle_phase != execution_attempt: + messages.append("operation_ref requires lifecycle_phase execution_attempt") + return messages + + +def _lifecycle_event_type_violation_messages(event_type: object, lifecycle_phase: object) -> list[str]: + allowed_phases = _LIFECYCLE_PHASE_BY_EVENT_TYPE.get(event_type) + if allowed_phases is not None and lifecycle_phase not in allowed_phases: + return [_LIFECYCLE_PHASE_BY_EVENT_TYPE_MESSAGES[str(event_type)]] + return [] + + +def participant_lifecycle_field_violation_messages( + *, + event_type: object, + lifecycle_phase: object, + phase_realization: object, + admission_disposition: object, + operation_ref: object, + operation_state: object, +) -> tuple[str, ...]: + """Return RUN-306 participant lifecycle validation messages.""" + + values = { + "lifecycle_phase": _enum_scalar(lifecycle_phase), + "phase_realization": _enum_scalar(phase_realization), + "admission_disposition": _enum_scalar(admission_disposition), + "operation_state": _enum_scalar(operation_state), + } + lifecycle_phase_value = values["lifecycle_phase"] + phase_realization_value = values["phase_realization"] + messages = _optional_enum_violation_messages(values) + messages.extend( + _lifecycle_presence_violation_messages( + lifecycle_phase_value, + phase_realization_value, + ( + phase_realization_value, + values["admission_disposition"], + operation_ref, + values["operation_state"], + ), + ) + ) + + if lifecycle_phase_value not in _PARTICIPANT_RUNTIME_LIFECYCLE_PHASE_VALUES: + return tuple(messages) + if phase_realization_value is not None and phase_realization_value not in _PARTICIPANT_PHASE_REALIZATION_VALUES: + return tuple(messages) + + messages.extend( + _lifecycle_scope_violation_messages( + lifecycle_phase_value, + values["admission_disposition"], + operation_ref, + values["operation_state"], + ) + ) + messages.extend(_lifecycle_event_type_violation_messages(_enum_scalar(event_type), lifecycle_phase_value)) + return tuple(messages) + + def iter_participant_behavior_snapshot_violations( participant_behavior_history: object, *, @@ -239,141 +386,119 @@ def _iter_behavior_event_shape_violations( locator: str, outer_key: str, event: Mapping[object, object], -) -> Iterator[tuple[str, str]]: +) -> list[tuple[str, str]]: + violations = _behavior_event_required_shape_violations(locator, event) + if violations: + return violations + + event_type = event["event_type"] + observation_status = event.get("observation_status") + violations.extend(_behavior_event_type_violations(locator, outer_key, event_type, event["participant_address"])) + violations.extend(_behavior_event_observation_status_violations(locator, observation_status)) + violations.extend(_behavior_event_optional_field_violations(locator, event)) + violations.extend(_behavior_event_shared_state_ref_violations(locator, event.get("shared_state_refs", []))) + + realized_order = event.get("realized_order") + if realized_order is not None and ( + isinstance(realized_order, bool) or not isinstance(realized_order, int) or realized_order < 0 + ): + violations.append((locator, "participant behavior realized_order must be a non-negative integer or None")) + + details = event.get("details", {}) + if not isinstance(details, Mapping): + violations.append((locator, "participant behavior details must be a mapping")) + + violations.extend(_iter_behavior_event_lifecycle_violations(locator, event_type, event)) + return violations + + +def _behavior_event_required_shape_violations( + locator: str, + event: Mapping[object, object], +) -> list[tuple[str, str]]: missing = [field for field in _REQUIRED_BEHAVIOR_EVENT_FIELDS if field not in event] if missing: - yield (locator, "participant behavior history event is missing required fields: " + ", ".join(missing)) - return + return [(locator, "participant behavior history event is missing required fields: " + ", ".join(missing))] for field in _REQUIRED_BEHAVIOR_EVENT_FIELDS: value = event[field] if not isinstance(value, str) or not value: - yield (locator, f"participant behavior history event field {field} must be a non-empty string") - return + return [(locator, f"participant behavior history event field {field} must be a non-empty string")] + return [] - event_type = event["event_type"] - if event_type not in {kind.value for kind in ParticipantBehaviorHistoryEventType}: - yield (locator, f"participant behavior history event_type {event_type!r} is not supported") - participant_address = event["participant_address"] +def _behavior_event_type_violations( + locator: str, + outer_key: str, + event_type: object, + participant_address: object, +) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + if event_type not in _PARTICIPANT_BEHAVIOR_EVENT_TYPE_VALUES: + violations.append((locator, f"participant behavior history event_type {event_type!r} is not supported")) if participant_address != outer_key: - yield ( - locator, + violations.append( ( - f"participant behavior history event outer key {outer_key!r} " - f"does not match inner participant_address {participant_address!r}" - ), + locator, + ( + f"participant behavior history event outer key {outer_key!r} " + f"does not match inner participant_address {participant_address!r}" + ), + ) ) + return violations + + +def _behavior_event_observation_status_violations( + locator: str, + observation_status: object, +) -> list[tuple[str, str]]: + if observation_status is not None and observation_status not in _PARTICIPANT_OBSERVATION_STATUS_VALUES: + return [(locator, f"participant behavior observation_status {observation_status!r} is not supported")] + return [] - observation_status = event.get("observation_status") - if observation_status is not None and observation_status not in { - status.value for status in ParticipantObservationStatus - }: - yield (locator, f"participant behavior observation_status {observation_status!r} is not supported") +def _behavior_event_optional_field_violations( + locator: str, + event: Mapping[object, object], +) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] for field in _OPTIONAL_NON_EMPTY_STRING_FIELDS: value = event.get(field) if value is not None and (not isinstance(value, str) or not value): - yield (locator, f"participant behavior history event field {field} must be a non-empty string or None") - - realized_order = event.get("realized_order") - if realized_order is not None and ( - isinstance(realized_order, bool) or not isinstance(realized_order, int) or realized_order < 0 - ): - yield (locator, "participant behavior realized_order must be a non-negative integer or None") - - shared_state_refs = event.get("shared_state_refs", []) - if not isinstance(shared_state_refs, list): - yield (locator, "participant behavior shared_state_refs must be a list") - elif any(not isinstance(ref, str) or not ref for ref in shared_state_refs): - yield (locator, "participant behavior shared_state_refs entries must be non-empty strings") - elif len(set(shared_state_refs)) != len(shared_state_refs): - yield (locator, "participant behavior shared_state_refs entries must be unique") - - details = event.get("details", {}) - if not isinstance(details, Mapping): - yield (locator, "participant behavior details must be a mapping") - - yield from _iter_behavior_event_lifecycle_violations(locator, event_type, event) - - -def _enum_values(enum_type: type[Enum]) -> set[str]: - return {item.value for item in enum_type} + violations.append( + (locator, f"participant behavior history event field {field} must be a non-empty string or None") + ) + return violations -def _iter_optional_enum_violations( +def _behavior_event_shared_state_ref_violations( locator: str, - event: Mapping[object, object], - field: str, - enum_type: type[Enum], -) -> Iterator[tuple[str, str]]: - value = event.get(field) - if value is not None and value not in _enum_values(enum_type): - yield (locator, f"participant behavior {field} {value!r} is not supported") + shared_state_refs: object, +) -> list[tuple[str, str]]: + if not isinstance(shared_state_refs, list): + return [(locator, "participant behavior shared_state_refs must be a list")] + if any(not isinstance(ref, str) or not ref for ref in shared_state_refs): + return [(locator, "participant behavior shared_state_refs entries must be non-empty strings")] + if len(set(shared_state_refs)) != len(shared_state_refs): + return [(locator, "participant behavior shared_state_refs entries must be unique")] + return [] def _iter_behavior_event_lifecycle_violations( locator: str, event_type: object, event: Mapping[object, object], -) -> Iterator[tuple[str, str]]: - lifecycle_phase = event.get("lifecycle_phase") - phase_realization = event.get("phase_realization") - admission_disposition = event.get("admission_disposition") - operation_ref = event.get("operation_ref") - operation_state = event.get("operation_state") - - for field, enum_type in ( - ("lifecycle_phase", ParticipantRuntimeLifecyclePhase), - ("phase_realization", ParticipantPhaseRealization), - ("admission_disposition", ParticipantAdmissionDisposition), - ("operation_state", ParticipantLifecycleOperationState), - ): - yield from _iter_optional_enum_violations(locator, event, field, enum_type) - - if lifecycle_phase is None: - lifecycle_fields = (phase_realization, admission_disposition, operation_ref, operation_state) - if any(value is not None for value in lifecycle_fields): - yield (locator, "participant behavior lifecycle fields require lifecycle_phase") - return - if lifecycle_phase not in _enum_values(ParticipantRuntimeLifecyclePhase): - return - if phase_realization is None: - yield (locator, "lifecycle_phase requires phase_realization") - elif phase_realization not in _enum_values(ParticipantPhaseRealization): - return - - if lifecycle_phase == ParticipantRuntimeLifecyclePhase.SELECTION_OR_ADMISSION.value: - if admission_disposition is None: - yield (locator, "selection_or_admission lifecycle_phase requires admission_disposition") - elif admission_disposition is not None: - yield (locator, "admission_disposition requires lifecycle_phase selection_or_admission") - - if operation_state is not None and lifecycle_phase != ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT.value: - yield (locator, "operation_state requires lifecycle_phase execution_attempt") - if operation_ref is not None and lifecycle_phase != ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT.value: - yield (locator, "operation_ref requires lifecycle_phase execution_attempt") - - if event_type == ParticipantBehaviorHistoryEventType.ACTION_ATTEMPTED.value: - allowed = { - ParticipantRuntimeLifecyclePhase.INTENT_OR_PROPOSAL.value, - ParticipantRuntimeLifecyclePhase.SELECTION_OR_ADMISSION.value, - ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT.value, - } - if lifecycle_phase not in allowed: - yield ( - locator, - ( - "action_attempted lifecycle_phase must be one of intent_or_proposal, " - "selection_or_admission, execution_attempt" - ), - ) - elif event_type == ParticipantBehaviorHistoryEventType.STATE_TRANSITION_RECORDED.value: - if lifecycle_phase != ParticipantRuntimeLifecyclePhase.STATE_UPDATE_COMMIT.value: - yield (locator, "state_transition_recorded lifecycle_phase must be state_update_commit") - elif event_type == ParticipantBehaviorHistoryEventType.OBSERVATION_EMITTED.value: - if lifecycle_phase != ParticipantRuntimeLifecyclePhase.OBSERVATION_EMISSION.value: - yield (locator, "observation_emitted lifecycle_phase must be observation_emission") +) -> list[tuple[str, str]]: + messages = participant_lifecycle_field_violation_messages( + event_type=event_type, + lifecycle_phase=event.get("lifecycle_phase"), + phase_realization=event.get("phase_realization"), + admission_disposition=event.get("admission_disposition"), + operation_ref=event.get("operation_ref"), + operation_state=event.get("operation_state"), + ) + return [(locator, message) for message in messages] def _iter_behavior_event_episode_reference_violations( diff --git a/implementations/python/packages/aces_processor/models.py b/implementations/python/packages/aces_processor/models.py index 103fbe47f..d45a0999a 100644 --- a/implementations/python/packages/aces_processor/models.py +++ b/implementations/python/packages/aces_processor/models.py @@ -67,6 +67,9 @@ from aces_contracts.participant_behavior import ( ParticipantRuntimeLifecyclePhase as ParticipantRuntimeLifecyclePhase, ) +from aces_contracts.participant_behavior import ( + participant_lifecycle_field_violation_messages as participant_lifecycle_field_violation_messages, +) from aces_contracts.participant_episode import ( PARTICIPANT_EPISODE_CONTROL_EVENTS, PARTICIPANT_EPISODE_TERMINAL_EVENTS, @@ -1941,7 +1944,9 @@ def _participant_interaction_class_from_payload(value: Any) -> ParticipantIntera return ParticipantInteractionClass(str(value)) -def _participant_lifecycle_phase_from_payload(value: Any) -> ParticipantRuntimeLifecyclePhase | None: +def _participant_lifecycle_phase_from_payload( + value: str | ParticipantRuntimeLifecyclePhase | None, +) -> ParticipantRuntimeLifecyclePhase | None: if value is None: return None if isinstance(value, ParticipantRuntimeLifecyclePhase): @@ -1949,7 +1954,9 @@ def _participant_lifecycle_phase_from_payload(value: Any) -> ParticipantRuntimeL return ParticipantRuntimeLifecyclePhase(str(value)) -def _participant_phase_realization_from_payload(value: Any) -> ParticipantPhaseRealization | None: +def _participant_phase_realization_from_payload( + value: str | ParticipantPhaseRealization | None, +) -> ParticipantPhaseRealization | None: if value is None: return None if isinstance(value, ParticipantPhaseRealization): @@ -1957,7 +1964,9 @@ def _participant_phase_realization_from_payload(value: Any) -> ParticipantPhaseR return ParticipantPhaseRealization(str(value)) -def _participant_admission_disposition_from_payload(value: Any) -> ParticipantAdmissionDisposition | None: +def _participant_admission_disposition_from_payload( + value: str | ParticipantAdmissionDisposition | None, +) -> ParticipantAdmissionDisposition | None: if value is None: return None if isinstance(value, ParticipantAdmissionDisposition): @@ -1965,7 +1974,9 @@ def _participant_admission_disposition_from_payload(value: Any) -> ParticipantAd return ParticipantAdmissionDisposition(str(value)) -def _participant_lifecycle_operation_state_from_payload(value: Any) -> ParticipantLifecycleOperationState | None: +def _participant_lifecycle_operation_state_from_payload( + value: str | ParticipantLifecycleOperationState | None, +) -> ParticipantLifecycleOperationState | None: if value is None: return None if isinstance(value, ParticipantLifecycleOperationState): @@ -2210,62 +2221,45 @@ def _validate_optional_state_fields(self) -> None: ) def _validate_lifecycle_fields(self) -> None: - if self.lifecycle_phase is not None and not isinstance( - self.lifecycle_phase, - ParticipantRuntimeLifecyclePhase, - ): - raise TypeError("lifecycle_phase must be a ParticipantRuntimeLifecyclePhase or None") - if self.phase_realization is not None and not isinstance( - self.phase_realization, - ParticipantPhaseRealization, - ): - raise TypeError("phase_realization must be a ParticipantPhaseRealization or None") - if self.admission_disposition is not None and not isinstance( - self.admission_disposition, - ParticipantAdmissionDisposition, - ): - raise TypeError("admission_disposition must be a ParticipantAdmissionDisposition or None") - if self.operation_state is not None and not isinstance( - self.operation_state, - ParticipantLifecycleOperationState, - ): - raise TypeError("operation_state must be a ParticipantLifecycleOperationState or None") + self._validate_lifecycle_enum_types() self._validate_optional_string(self.operation_ref, "operation_ref must be a non-empty string or None") - - lifecycle_fields = ( - self.phase_realization, - self.admission_disposition, - self.operation_ref, - self.operation_state, + messages = participant_lifecycle_field_violation_messages( + event_type=self.event_type, + lifecycle_phase=self.lifecycle_phase, + phase_realization=self.phase_realization, + admission_disposition=self.admission_disposition, + operation_ref=self.operation_ref, + operation_state=self.operation_state, ) - if self.lifecycle_phase is None: - if any(value is not None for value in lifecycle_fields): - raise ValueError("participant behavior lifecycle fields require lifecycle_phase") - return - if self.phase_realization is None: - raise ValueError("lifecycle_phase requires phase_realization") - self._validate_admission_lifecycle_scope() - self._validate_operation_lifecycle_scope() - - def _validate_admission_lifecycle_scope(self) -> None: - if self.lifecycle_phase == ParticipantRuntimeLifecyclePhase.SELECTION_OR_ADMISSION: - if self.admission_disposition is None: - raise ValueError("selection_or_admission lifecycle_phase requires admission_disposition") - return - if self.admission_disposition is not None: - raise ValueError("admission_disposition requires lifecycle_phase selection_or_admission") + if messages: + raise ValueError(messages[0]) - def _validate_operation_lifecycle_scope(self) -> None: - if ( - self.operation_state is not None - and self.lifecycle_phase != ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT - ): - raise ValueError("operation_state requires lifecycle_phase execution_attempt") - if ( - self.operation_ref is not None - and self.lifecycle_phase != ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT - ): - raise ValueError("operation_ref requires lifecycle_phase execution_attempt") + def _validate_lifecycle_enum_types(self) -> None: + expectations = ( + ( + self.lifecycle_phase, + ParticipantRuntimeLifecyclePhase, + "lifecycle_phase must be a ParticipantRuntimeLifecyclePhase or None", + ), + ( + self.phase_realization, + ParticipantPhaseRealization, + "phase_realization must be a ParticipantPhaseRealization or None", + ), + ( + self.admission_disposition, + ParticipantAdmissionDisposition, + "admission_disposition must be a ParticipantAdmissionDisposition or None", + ), + ( + self.operation_state, + ParticipantLifecycleOperationState, + "operation_state must be a ParticipantLifecycleOperationState or None", + ), + ) + for value, enum_type, message in expectations: + if value is not None and not isinstance(value, enum_type): + raise TypeError(message) def _validate_realized_order(self) -> None: if self.realized_order is not None and ( @@ -2368,17 +2362,6 @@ def _validate_interaction_fields(self) -> None: raise ValueError(f"{self.interaction_class.value} events require shared_state_refs") def _validate_action_attempted_fields(self) -> None: - if self.lifecycle_phase is not None: - allowed = { - ParticipantRuntimeLifecyclePhase.INTENT_OR_PROPOSAL, - ParticipantRuntimeLifecyclePhase.SELECTION_OR_ADMISSION, - ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT, - } - if self.lifecycle_phase not in allowed: - raise ValueError( - "action_attempted lifecycle_phase must be one of intent_or_proposal, " - "selection_or_admission, execution_attempt" - ) if self.action_contract_address is None: raise ValueError("action_attempted events require action_contract_address") if self.actor_provenance is None: @@ -2391,11 +2374,6 @@ def _validate_action_attempted_fields(self) -> None: raise ValueError("action_attempted events may not report action_result") def _validate_state_transition_fields(self) -> None: - if ( - self.lifecycle_phase is not None - and self.lifecycle_phase != ParticipantRuntimeLifecyclePhase.STATE_UPDATE_COMMIT - ): - raise ValueError("state_transition_recorded lifecycle_phase must be state_update_commit") if self.action_contract_address is None: raise ValueError("state_transition_recorded events require action_contract_address") if self.state_transition_kind is None: @@ -2408,11 +2386,6 @@ def _validate_state_transition_fields(self) -> None: raise ValueError("state_transition_recorded events may not report action_result") def _validate_observation_emitted_fields(self) -> None: - if ( - self.lifecycle_phase is not None - and self.lifecycle_phase != ParticipantRuntimeLifecyclePhase.OBSERVATION_EMISSION - ): - raise ValueError("observation_emitted lifecycle_phase must be observation_emission") if self.action_contract_address is None: raise ValueError("observation_emitted events require action_contract_address") if self.observation_boundary_address is None: From 913be3aba1f078fb2bf6b1f8a427d1e644b70ef7 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 6 Jun 2026 19:40:15 +0200 Subject: [PATCH 04/70] Fix SonarCloud findings (cycle 2) --- changelog.d/193.added.md | 2 +- .../packages/aces_contracts/participant_behavior.py | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/changelog.d/193.added.md b/changelog.d/193.added.md index 265ca89b2..0be036585 100644 --- a/changelog.d/193.added.md +++ b/changelog.d/193.added.md @@ -1 +1 @@ -Added RUN-306 participant runtime lifecycle fields and shared validation to behavior-history event contracts, schemas, and validators. +Added RUN-306 participant runtime lifecycle fields and Sonar-clean shared validation to behavior-history event contracts, schemas, and validators. diff --git a/implementations/python/packages/aces_contracts/participant_behavior.py b/implementations/python/packages/aces_contracts/participant_behavior.py index f9740f15d..8fc8232e0 100644 --- a/implementations/python/packages/aces_contracts/participant_behavior.py +++ b/implementations/python/packages/aces_contracts/participant_behavior.py @@ -476,13 +476,14 @@ def _behavior_event_shared_state_ref_violations( locator: str, shared_state_refs: object, ) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] if not isinstance(shared_state_refs, list): - return [(locator, "participant behavior shared_state_refs must be a list")] - if any(not isinstance(ref, str) or not ref for ref in shared_state_refs): - return [(locator, "participant behavior shared_state_refs entries must be non-empty strings")] - if len(set(shared_state_refs)) != len(shared_state_refs): - return [(locator, "participant behavior shared_state_refs entries must be unique")] - return [] + violations.append((locator, "participant behavior shared_state_refs must be a list")) + elif any(not isinstance(ref, str) or not ref for ref in shared_state_refs): + violations.append((locator, "participant behavior shared_state_refs entries must be non-empty strings")) + elif len(set(shared_state_refs)) != len(shared_state_refs): + violations.append((locator, "participant behavior shared_state_refs entries must be unique")) + return violations def _iter_behavior_event_lifecycle_violations( From 89a349d4eebb673f0afc9b2c23d5ae802c2c2062 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 6 Jun 2026 20:46:54 +0200 Subject: [PATCH 05/70] Remove runtime secret-name value omission --- changelog.d/471.fixed.md | 3 + .../schemas/sdl/instantiated-scenario-v1.json | 10 +- .../schemas/sdl/sdl-authoring-input-v1.json | 10 +- .../scn010-expressivity-gap-analysis.md | 4 +- docs/decisions/adrs/README.md | 2 + ...ntime-secret-name-classifier-boundaries.md | 115 ++++++++++++++++++ docs/explain/sdl/lineage.md | 18 +-- docs/explain/sdl/precedents.md | 4 +- docs/explain/sdl/sections.md | 47 ++++--- docs/explain/sdl/validation.md | 58 +++++---- .../packages/aces_sdl/image_provenance.py | 14 --- .../aces_sdl/runtime_app_authorization.py | 28 ----- .../packages/aces_sdl/runtime_application.py | 16 +-- .../aces_sdl/runtime_configuration.py | 10 -- .../packages/aces_sdl/runtime_database.py | 15 +-- .../aces_sdl/runtime_datastore_partitions.py | 14 +-- .../aces_sdl/runtime_directory_identity.py | 2 - .../python/packages/aces_sdl/runtime_dns.py | 2 - .../aces_sdl/runtime_forwarding_agent.py | 15 +-- .../packages/aces_sdl/runtime_mail_service.py | 2 - .../runtime_platform_application_content.py | 22 +--- .../aces_sdl/runtime_security_monitoring.py | 2 - .../packages/aces_sdl/runtime_values.py | 111 +++++++++-------- .../tests/test_runtime_app_authorization.py | 18 ++- .../python/tests/test_runtime_datastore.py | 7 +- .../tests/test_runtime_forwarding_agent.py | 7 +- .../python/tests/test_runtime_mail_service.py | 17 +-- .../tests/test_runtime_observed_values.py | 114 +++++++++++++++-- .../test_runtime_platform_application.py | 21 ++-- .../tests/test_runtime_security_monitoring.py | 17 +-- .../python/tests/test_sdl_models.py | 39 +++--- 31 files changed, 460 insertions(+), 304 deletions(-) create mode 100644 changelog.d/471.fixed.md create mode 100644 docs/decisions/adrs/adr-057-runtime-secret-name-classifier-boundaries.md diff --git a/changelog.d/471.fixed.md b/changelog.d/471.fixed.md new file mode 100644 index 000000000..f8257eec1 --- /dev/null +++ b/changelog.d/471.fixed.md @@ -0,0 +1,3 @@ +Removed name-derived raw-value omission from runtime SDL observed-value +validators so credential-shaped values remain realizable scenario content unless +they are explicitly classified as `redacted` or `operator_secret`. diff --git a/contracts/schemas/sdl/instantiated-scenario-v1.json b/contracts/schemas/sdl/instantiated-scenario-v1.json index 64144671b..c3a2f0678 100644 --- a/contracts/schemas/sdl/instantiated-scenario-v1.json +++ b/contracts/schemas/sdl/instantiated-scenario-v1.json @@ -944,7 +944,7 @@ }, "DatabaseSetting": { "additionalProperties": false, - "description": "An observed database runtime setting with provenance and sensitivity.\n\nSettings that may carry credentials, hashes, connection strings, or\noperator-only values must omit their raw ``value`` and classify it as\n``redacted``/``operator_secret`` (ADR-029 \u00a75).", + "description": "An observed database runtime setting with provenance and sensitivity.\n\nExplicit ``redacted``/``operator_secret`` classifications omit raw values;\ncredential-shaped names remain scenario content unless the author marks the\nvalue withheld.", "properties": { "description": { "default": "", @@ -5336,7 +5336,7 @@ }, "RuntimeApplicationExposedField": { "additionalProperties": false, - "description": "A route-visible fixture secret or intentionally exposed diagnostic field.\n\nThe sensitivity vocabulary is shared with the rest of the runtime surface.\nA ``redacted`` or ``operator_secret`` field must omit its raw ``value``;\nonly intentionally participant-visible ``secret_fixture``/``plain`` material\nis safe to record.", + "description": "A route-visible fixture secret or intentionally exposed diagnostic field.\n\nThe sensitivity vocabulary is shared with the rest of the runtime surface.\nA ``redacted`` or ``operator_secret`` field must omit its raw ``value``.\nOther values, including credential-shaped fixture facts, are scenario\ncontent needed for range realization and participant observation.", "properties": { "description": { "default": "", @@ -7120,7 +7120,7 @@ }, "RuntimeDatastoreSetting": { "additionalProperties": false, - "description": "An observed datastore runtime setting with scope, provenance, and class.\n\nSettings that may carry credentials, hashes, or operator-only values must\nomit their raw ``value`` and classify it as ``redacted`` / ``operator_secret``\n\u2014 enforced via the shared ``name_indicates_secret`` helper even when the\nsubmitter left ``classification`` at its default.", + "description": "An observed datastore runtime setting with scope, provenance, and class.\n\nExplicit ``redacted`` / ``operator_secret`` classifications omit raw values;\nnames that look credential-bearing remain scenario content unless the\nauthor marks the value withheld.", "properties": { "classification": { "anyOf": [ @@ -8636,7 +8636,7 @@ }, "RuntimeForwardingSetting": { "additionalProperties": false, - "description": "An observed forwarding-agent runtime setting with provenance and class.\n\nSettings that may carry credentials or operator-only values must omit their\nraw ``value`` and classify it ``redacted`` / ``operator_secret`` \u2014 enforced\nvia the shared ``name_indicates_secret`` helper even when the submitter left\n``classification`` at its default.", + "description": "An observed forwarding-agent runtime setting with provenance and class.\n\nExplicit ``redacted`` / ``operator_secret`` classifications omit raw values;\ncredential-shaped names remain scenario content unless the author marks the\nvalue withheld.", "properties": { "classification": { "anyOf": [ @@ -12881,7 +12881,7 @@ }, "RuntimePlatformApplicationSetting": { "additionalProperties": false, - "description": "An observed platform setting with provenance and sensitivity.\n\nSettings whose name signals secret content must omit their raw ``value``\nand classify it as ``redacted`` / ``operator_secret``.", + "description": "An observed platform setting with provenance and sensitivity.\n\nExplicitly redacted/operator-secret settings must omit their raw ``value``.\nA setting name does not by itself redact SDL scenario content.", "properties": { "classification": { "anyOf": [ diff --git a/contracts/schemas/sdl/sdl-authoring-input-v1.json b/contracts/schemas/sdl/sdl-authoring-input-v1.json index 550db9d1e..4b0827fa8 100644 --- a/contracts/schemas/sdl/sdl-authoring-input-v1.json +++ b/contracts/schemas/sdl/sdl-authoring-input-v1.json @@ -944,7 +944,7 @@ }, "DatabaseSetting": { "additionalProperties": false, - "description": "An observed database runtime setting with provenance and sensitivity.\n\nSettings that may carry credentials, hashes, connection strings, or\noperator-only values must omit their raw ``value`` and classify it as\n``redacted``/``operator_secret`` (ADR-029 \u00a75).", + "description": "An observed database runtime setting with provenance and sensitivity.\n\nExplicit ``redacted``/``operator_secret`` classifications omit raw values;\ncredential-shaped names remain scenario content unless the author marks the\nvalue withheld.", "properties": { "description": { "default": "", @@ -5336,7 +5336,7 @@ }, "RuntimeApplicationExposedField": { "additionalProperties": false, - "description": "A route-visible fixture secret or intentionally exposed diagnostic field.\n\nThe sensitivity vocabulary is shared with the rest of the runtime surface.\nA ``redacted`` or ``operator_secret`` field must omit its raw ``value``;\nonly intentionally participant-visible ``secret_fixture``/``plain`` material\nis safe to record.", + "description": "A route-visible fixture secret or intentionally exposed diagnostic field.\n\nThe sensitivity vocabulary is shared with the rest of the runtime surface.\nA ``redacted`` or ``operator_secret`` field must omit its raw ``value``.\nOther values, including credential-shaped fixture facts, are scenario\ncontent needed for range realization and participant observation.", "properties": { "description": { "default": "", @@ -7120,7 +7120,7 @@ }, "RuntimeDatastoreSetting": { "additionalProperties": false, - "description": "An observed datastore runtime setting with scope, provenance, and class.\n\nSettings that may carry credentials, hashes, or operator-only values must\nomit their raw ``value`` and classify it as ``redacted`` / ``operator_secret``\n\u2014 enforced via the shared ``name_indicates_secret`` helper even when the\nsubmitter left ``classification`` at its default.", + "description": "An observed datastore runtime setting with scope, provenance, and class.\n\nExplicit ``redacted`` / ``operator_secret`` classifications omit raw values;\nnames that look credential-bearing remain scenario content unless the\nauthor marks the value withheld.", "properties": { "classification": { "anyOf": [ @@ -8636,7 +8636,7 @@ }, "RuntimeForwardingSetting": { "additionalProperties": false, - "description": "An observed forwarding-agent runtime setting with provenance and class.\n\nSettings that may carry credentials or operator-only values must omit their\nraw ``value`` and classify it ``redacted`` / ``operator_secret`` \u2014 enforced\nvia the shared ``name_indicates_secret`` helper even when the submitter left\n``classification`` at its default.", + "description": "An observed forwarding-agent runtime setting with provenance and class.\n\nExplicit ``redacted`` / ``operator_secret`` classifications omit raw values;\ncredential-shaped names remain scenario content unless the author marks the\nvalue withheld.", "properties": { "classification": { "anyOf": [ @@ -12881,7 +12881,7 @@ }, "RuntimePlatformApplicationSetting": { "additionalProperties": false, - "description": "An observed platform setting with provenance and sensitivity.\n\nSettings whose name signals secret content must omit their raw ``value``\nand classify it as ``redacted`` / ``operator_secret``.", + "description": "An observed platform setting with provenance and sensitivity.\n\nExplicitly redacted/operator-secret settings must omit their raw ``value``.\nA setting name does not by itself redact SDL scenario content.", "properties": { "classification": { "anyOf": [ diff --git a/docs/aces/inventory/scn010-expressivity-gap-analysis.md b/docs/aces/inventory/scn010-expressivity-gap-analysis.md index 0f9f01c1c..60c1a2cd9 100644 --- a/docs/aces/inventory/scn010-expressivity-gap-analysis.md +++ b/docs/aces/inventory/scn010-expressivity-gap-analysis.md @@ -201,7 +201,7 @@ No new structure — Suricata (`network_detection_engines` + `network_sensors`), **Enum-parse helper (#442) — factual correction first.** Verified: **every `runtime_*.py` already uses `runtime_values.parse_runtime_enum_or_var`** (lowercase + `-`→`_`); `_base.parse_enum_or_var` (lowercase-only) is imported only by core non-runtime modules. So `#442`'s enum-helper work is *not* a runtime migration — it is (a) every NEW surface uses `parse_runtime_enum_or_var`, and (b) an asserting lint that no `runtime_*.py` regresses. The stale "migrate runtime_configuration/runtime_mounts" claim is dropped. -**Validation wiring (#442).** Every NEW model wires validation as in-class `@field_validator(mode=before)` + `@model_validator(mode=after)` called from a single `validate_` method (like `RuntimeDatabaseService.validate_service`), **no free functions**. Reconcile the verified outlier: `runtime_mail_service.py:111–128` free functions (`_name_indicates_secret`, `_reject_duplicates`, `_reject_duplicate_local_ref_ids`) migrate into `RuntimeMailService.validate_service` as private methods. New families ship `validate_` calling private `_reject_duplicate_*` and the `require_profile_for_` guard. +**Validation wiring (#442).** Every NEW model wires validation as in-class `@field_validator(mode=before)` + `@model_validator(mode=after)` called from a single `validate_` method (like `RuntimeDatabaseService.validate_service`), **no free functions**. Reconcile the verified outlier: `runtime_mail_service.py:111–128` duplicate-local-ref helpers migrate into `RuntimeMailService.validate_service` as private methods; the old `_name_indicates_secret` omission path is superseded by ADR-057. New families ship `validate_` calling private `_reject_duplicate_*` and the `require_profile_for_` guard. **Required-profile guard (new invariant).** Every discriminated spine ships `require_profile_for_` tied to a discriminator enum so under-populated instances FAIL validation — making "the abstraction cannot silently shallow-encode a defining fact" *executable*. @@ -211,7 +211,7 @@ No new structure — Suricata (`network_detection_engines` + `network_sensors`), **Module filename / container plurality / enum sentinels (#443).** Each family in `runtime_.py` (closed enums + maps in `runtime__vocab.py` when >600 lines per ADR-015); every container field plural. Reconcile the verified twin `runtime_configuration.py:358–359` (`process` scalar + `processes` list) → remove the scalar, keep `processes`. Enum-sentinel rule: **OPEN taxonomy carries both `unknown` + `other`; CLOSED protocol/structural vocab carries neither** — `data_model`, `agent_kind`, `last_result`, `platform_kind`, `content_object.kind`, `resource_kind` reclassified OPEN per the verdicts; `markings.scheme`, `schedule.kind`, `replication_strategy`, fixed wire protocols CLOSED. -**Secret redaction (unified).** Verified the `_SECRET_NAME_TOKENS`/`_REDACTED_SENSITIVITIES`/`_name_indicates_secret` triplication across `runtime_security_monitoring.py`/`runtime_dns.py`/`runtime_database.py`/`runtime_directory_identity.py`/`runtime_mail_service.py` → **extract into one shared `runtime_values` helper** reused everywhere incl. all new families; `app_authorization` principal credentials and forwarder/sync secret inputs follow identically. +**Explicit redaction (unified, ADR-057).** The shared `runtime_values.enforce_observed_value_redaction()` helper enforces explicit `redacted`/`operator_secret` classifications across runtime observed-value surfaces. Name heuristics such as `name_indicates_secret()` are advisory only and must not make SDL scenario values non-realizable; future families reuse the explicit-classification helper rather than reintroducing per-family secret-name omission. **Registration (#440/#441) + executable cross-family invariant lint (#439).** Every new family registers via `RuntimeServiceFamily(key, module, collection_name (plural), id_field (_id), child_refs)` so qualified-ref/nested-alias generation work unchanged; `runtime_service_family_export_names()` global uniqueness must hold; node-scoped lists enumerate inner collections as `RuntimeReferenceChild`. **One executable lint** introspects every registered family and asserts the whole invariant set: `Runtime*` names, `_id` + `require_symbol`, plural typed-child containers, open/closed enum-sentinel rule, in-class `@model_validator(after)` wiring (no free functions), `parse_runtime_enum_or_var` only, registry registration with child_refs, the shared secret-redaction helper, and `require_profile_for_` on each spine. diff --git a/docs/decisions/adrs/README.md b/docs/decisions/adrs/README.md index 555946c17..f35603121 100644 --- a/docs/decisions/adrs/README.md +++ b/docs/decisions/adrs/README.md @@ -42,6 +42,7 @@ adr-053-sdl-module-composition-for-inventory-backed-scenarios adr-054-participant-runtime-observable-lifecycle adr-055-experiment-core-contract-boundary adr-056-runtime-observed-values-and-credential-posture +adr-057-runtime-secret-name-classifier-boundaries ``` | ADR | Title | Status | Date | @@ -103,3 +104,4 @@ adr-056-runtime-observed-values-and-credential-posture | [054](adr-054-participant-runtime-observable-lifecycle.md) | Participant Runtime Observable Lifecycle | proposed | 2026-06-05 | | [055](adr-055-experiment-core-contract-boundary.md) | Experiment Core Contract Boundary | accepted | 2026-05-26 | | [056](adr-056-runtime-observed-values-and-credential-posture.md) | Runtime Observed Values and Credential Posture | accepted | 2026-06-05 | +| [057](adr-057-runtime-secret-name-classifier-boundaries.md) | Runtime Scenario Value Realizability and Explicit Redaction | accepted | 2026-06-06 | diff --git a/docs/decisions/adrs/adr-057-runtime-secret-name-classifier-boundaries.md b/docs/decisions/adrs/adr-057-runtime-secret-name-classifier-boundaries.md new file mode 100644 index 000000000..184cf391f --- /dev/null +++ b/docs/decisions/adrs/adr-057-runtime-secret-name-classifier-boundaries.md @@ -0,0 +1,115 @@ +# ADR-057: Runtime Scenario Value Realizability and Explicit Redaction + +## Status + +accepted + +## Date + +2026-06-06 + +## Context + +[ADR-056](adr-056-runtime-observed-values-and-credential-posture.md) adopted a +shared observed-value helper for runtime SDL surfaces. Its original invariant +combined two different rules: + +- explicit `redacted` and `operator_secret` classifications omit raw values; +- concrete names that looked secret-bearing also forced raw-value omission, + except for deliberate `secret_fixture` values. + +Issue #471 first exposed false positives in that name-derived omission rule: +`GPG_KEY` can be a public package-signing fingerprint, `secret_key_length` can +be an integer, `LABADMIN_SSH_KEY_FILE` can be a path, and `PWD` can be a working +directory. The downstream APTL TechVault inventory then exposed the deeper +problem: the SDL is consumed by a backend to stand up a synthetic range. Runtime +values such as Wazuh API credentials, OpenSearch `internal_users` hashes, +mutual-TLS key material, Flask/JWT weak secrets, database passwords, and image +environment defaults are not incidental leaks from an operator system; they are +scenario facts required for realization and, often, participant exploitation. + +The ACES lineage supports that distinction. ADR-026 records route-visible +application facts as participant-observable scenario surface. ADR-033 separates +portable scenario/runtime evidence from backend-native payloads and keeps +withholding as an explicit classification. ADR-056 correctly keeps credential +posture, credential strength, and observed settings distinct, but its +name-based omission rule overreached for SDL scenario values. The lineage guide +frames ACES as a scenario-meaning layer, not a sanitizer for an operator's +out-of-scenario host, cloud, CI, or SSH credentials. + +## Decision + +Drop the secret-name omission obligation from SDL runtime validators. This +decision supersedes the name-driven omission clauses in ADR-056 and in earlier +runtime-family ADRs while preserving their explicit-redaction clauses. + +`enforce_observed_value_redaction()` now enforces only explicit withholding: +when a value is classified as `redacted` or `operator_secret`, the raw value +must be omitted. A field name such as `JWT_SECRET`, `DB_PASSWORD`, +`admin_password`, `api_key`, `update_key`, `GPG_KEY`, `PWD`, or +`secret_key_length` does not by itself require omission or a redaction +classification. The same rule applies across runtime environment variables, +image build arguments, image default environment, route exposed fields, +database/DNS/mail/identity/security-monitoring/datastore/platform/forwarding +settings, and other surfaces using the shared helper. + +Posture-only models also must not infer mandatory redaction from a name. +Application-authorization principals and platform connectors may still carry an +explicit credential classification, but a secret-shaped `name` alone does not +make `none` or `plain` invalid. Those models still carry no raw credential +value fields. + +`secret_fixture` remains meaningful as an author classification for deliberate +exercise fixtures, but it is no longer a bypass around a name-derived omission +rule. Generated scenario credentials and keys that are required to realize the +range may be recorded as ordinary scenario content unless the author explicitly +chooses to withhold them. + +`runtime_values.name_indicates_secret()` may remain as advisory helper logic for +future defaults, warnings, or user-interface hints, but it is not a validation +gate for raw-value omission. + +Operator secrets are out of scope for SDL node inventory. Host SSH keys, cloud +credentials, CI tokens, and other real operational secrets must not be captured +as facts of a described scenario system. If a projection or publication channel +needs a sanitized view of an SDL, that is a separate export/redaction concern, +not a structural-validity rule for the authoritative scenario specification. + +## Consequences + +Positive consequences: + +- Scenario documents remain executable: generated credentials, hashes, keys, + route-visible weak secrets, and other scenario values are available to the + backend that realizes the range. +- False positives from name heuristics no longer block SDL parsing for paths, + public key fingerprints, working-directory variables, or scalar metadata. +- Explicit redaction remains enforceable and centralized: a field classified + `redacted` or `operator_secret` still omits raw values across the runtime + families. +- The difference between scenario content and operator environment secrets is + documented instead of hidden inside a brittle classifier. + +Trade-offs and risks: + +- SDL artifacts may contain exploit-relevant scenario credentials. That is a + property of executable range content, not an accidental leak, and downstream + handling must treat authoritative SDL files accordingly. +- Name heuristics no longer provide fail-closed protection against an author + accidentally inventorying an out-of-scenario operator secret. The mitigation + is capture-boundary discipline and optional advisory tooling, not structural + omission of scenario values. +- Sanitized external publications require a projection/export redaction layer + if they should hide scenario credentials while preserving the authoritative + executable SDL. + +## References + +- Issue #471: Runtime SDL secret-name omission rule cannot represent + intentional disclosed weak-credential values +- Issue #471 reframing comment: Runtime value omission is a realizability + defect for SDL scenario content +- [ADR-026: Application HTTP Surface Inventory](adr-026-application-http-surface-inventory.md) +- [ADR-033: Scenario/Delivery Boundary for Runtime Node State](adr-033-scenario-delivery-boundary-for-runtime-node-state.md) +- [ADR-056: Runtime Observed Values and Credential Posture](adr-056-runtime-observed-values-and-credential-posture.md) +- [Lineage and Prior Work](../../explain/sdl/lineage.md) diff --git a/docs/explain/sdl/lineage.md b/docs/explain/sdl/lineage.md index 90f286deb..c06890303 100644 --- a/docs/explain/sdl/lineage.md +++ b/docs/explain/sdl/lineage.md @@ -209,9 +209,10 @@ Portable ACES references are stable `security_monitoring_manager_id`, `listener_ `setting_id` symbols. Native manager identifiers, daemon names, file names, ruleset ids, rule ids, decoder names, agent labels, and API ids are preserved as observed data or evidence when needed, but they are not automatically ACES -reference identity. Secret-bearing manager settings such as passwords, -enrollment secrets, API tokens, shared keys, keytabs, or private keys must be -redacted or operator-secret classified and must omit raw values. +reference identity. Manager settings such as passwords, enrollment secrets, API +tokens, shared keys, keytabs, or private keys may be scenario values; explicit +`redacted`/`operator_secret` classifications omit raw values when the author +marks a value withheld. The resulting model follows the same V&V posture as the DNS, mail, database, file-service, and identity-authority surfaces: state which manager concepts @@ -422,8 +423,10 @@ ACES relies on prior work in four ways: and [SP 800-209](https://csrc.nist.gov/publications/detail/sp/800-209/final) (storage security), with [RFC 8446](https://www.rfc-editor.org/rfc/rfc8446) (TLS 1.3) and [RFC 5280](https://www.rfc-editor.org/rfc/rfc5280) (PKIX), frame - the transport-security posture and secret-bearing setting redaction; raw key - material and credentials are never stored. + the transport-security posture and explicit redaction classifications. Raw + key material and credentials may be scenario-realization facts when they + belong to the synthetic range; out-of-scenario operator secrets remain + outside SDL inventory. ## Security-Platform Application Semantics @@ -517,8 +520,9 @@ ACES relies on prior work in four ways: [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) show the recurring source/transform/ship/buffer facts ACES preserves (tailed inputs, pipelines, exporters, back-pressure queues). These are implementation lineage, - not schema authority; enrollment identities and secret-bearing settings are - never stored as raw values. + not schema authority; enrollment identities carry only their closed + classification lattice, while forwarding settings use explicit redaction + classifications rather than name-derived omission. ## Container-Spawn Orchestration-Authority Semantics diff --git a/docs/explain/sdl/precedents.md b/docs/explain/sdl/precedents.md index 3ac1e5a0f..cea556f37 100644 --- a/docs/explain/sdl/precedents.md +++ b/docs/explain/sdl/precedents.md @@ -274,9 +274,9 @@ taxonomy of container, orchestrator, or host-security concerns. | SIEM/security-monitoring manager runtime inventory (manager modules, listeners, enrolled agents/groups, detection content sets, parsed detection definitions, bounded settings, API/control-plane posture) | Log-management/security-monitoring runtime inventory and loaded-definition manifests, not transport bindings, process/unit state, raw config, raw events, alert telemetry, or rule-engine execution | `Node.runtime.security_monitoring_managers` when observed; manager, content-set, and detection-definition refs may be targeted through qualified runtime refs; see ADR-040 and ADR-045 | | Application-internal RBAC store (principals, roles, resource-scoped permission grants, role mappings, tenants) | Application-internal authorization runtime state, not a wire-protocol directory or database engine GRANT surface | `Node.runtime.app_authorizations` when observed; the resource-scoped `permission_grant` is the defining addition; raw credentials are never stored; see ADR-046 | | Recurring scheduled-job cadence and run-state | Product-neutral cadence runtime state, not systemd unit lifecycle or forwarder input/output authoring | `Node.runtime.scheduled_jobs` when observed; cadence + run-state only; systemd lifecycle remains `runtime.service_manager_units`; see ADR-047 | -| Non-relational datastore logical state (search clusters, wide-column stores, key-value stores, partitions, replication geometry, persistence posture, transport security, settings) | Participant-observable non-relational datastore runtime state, not the relational engine/GRANT surface, transport bindings, or software component identity | `Node.runtime.datastore_services` when observed; the open `data_model` discriminator drives a required-profile guard; service and child refs may be targeted through qualified runtime refs; internal RBAC is delegated via `authorization_ref`; raw key material is never stored; see ADR-048 | +| Non-relational datastore logical state (search clusters, wide-column stores, key-value stores, partitions, replication geometry, persistence posture, transport security, settings) | Participant-observable non-relational datastore runtime state, not the relational engine/GRANT surface, transport bindings, or software component identity | `Node.runtime.datastore_services` when observed; the open `data_model` discriminator drives a required-profile guard; service and child refs may be targeted through qualified runtime refs; internal RBAC is delegated via `authorization_ref`; explicit redaction classifications omit raw setting values; see ADR-048 and ADR-057 | | Security-platform application runtime inventory (threat-intel platform, SOAR, analyzer engine, case management, analytics dashboard — bounded content-object manifests, markings, upstream bindings, connectors, settings) | Participant-observable platform-application runtime state and bounded parsed manifests, not HTTP route surface, software component identity, raw object bodies, or playbook execution semantics | `Node.runtime.platform_applications` when observed; the open `platform_kind` discriminator drives a required-profile guard; content objects carry typed references not raw bodies; refs may be targeted through qualified runtime refs; internal RBAC is delegated via `authorization_ref`; see ADR-049 | -| Forwarding / intel-sync agent runtime inventory (sources, transforms, ship targets, buffer policy, reload channels, settings for log forwarders and intel-sync co-processes) | Participant-observable agent-side shipping state, not SIEM manager inventory, detection-engine consumer state, scheduled-job cadence, systemd lifecycle, or a fake scenario node for an infrastructure-only sidecar | `Node.runtime.forwarding_agents` when the agent is node-hosted; top-level `forwarding_agents` when the forwarder is off-node infrastructure realization; the open `agent_kind` discriminator drives a required-profile guard; ship-target node/service refs resolve at scenario scope; enrollment identities and secret-bearing settings are never stored as raw values; the inter-node trust edge is a relationship forwarding edge; see ADR-050 | +| Forwarding / intel-sync agent runtime inventory (sources, transforms, ship targets, buffer policy, reload channels, settings for log forwarders and intel-sync co-processes) | Participant-observable agent-side shipping state, not SIEM manager inventory, detection-engine consumer state, scheduled-job cadence, systemd lifecycle, or a fake scenario node for an infrastructure-only sidecar | `Node.runtime.forwarding_agents` when the agent is node-hosted; top-level `forwarding_agents` when the forwarder is off-node infrastructure realization; the open `agent_kind` discriminator drives a required-profile guard; ship-target node/service refs resolve at scenario scope; enrollment identities use a closed classification lattice and settings use explicit redaction classifications; the inter-node trust edge is a relationship forwarding edge; see ADR-050 and ADR-057 | | Container-spawn orchestration-authority runtime inventory (engine, scope, spawn templates, lifecycle policy, realized children, privilege class referencing a control-interface shell) | Participant-observable container-spawn authority state, not the control-interface shell itself, software component identity, or workflow execution semantics | `Node.runtime.orchestration_authorities` when observed; the open `privilege_class` discriminator drives a required-profile guard; `control_interface_ref` resolves to a same-node `RuntimeControlInterface` (host-root-equivalent requires a read-write docker socket); the control-interface shell is referenced, never duplicated; see ADR-051 | | Vendor-specific AD DS/LDAP/SCIM/IAM/SAML/OIDC schema clone | Provider coupling and false portability | Neutral `Node.runtime.identity_authorities` inventory when observed; provider identifiers remain data; see ADR-032 | | Service-manager unit state (systemd unit load/enable/active/sub/result) | Realized lifecycle state, distinct from installed software, transport services, live processes, container init, restart policy, and authored conditions | `Node.runtime.service_manager_units` when observed; raw `systemctl`/`journalctl`/unit-file output remains evidence rather than schema; see ADR-035 | diff --git a/docs/explain/sdl/sections.md b/docs/explain/sdl/sections.md index 5afa0198f..842c5fa92 100644 --- a/docs/explain/sdl/sections.md +++ b/docs/explain/sdl/sections.md @@ -390,7 +390,7 @@ nodes: privileges: [SELECT, INSERT, UPDATE] settings: - {name: listen_addresses, value: "*", provenance: configuration_file} - - name: password_encryption # secret-bearing settings omit value + - name: password_encryption # explicit redaction omits value value_classification: redacted provenance: operator_override dns_services: # observed DNS authoritative/resolver state @@ -431,7 +431,7 @@ nodes: records: - {address: 10.0.0.20} settings: - - name: tsig_secret # secret-bearing settings omit value + - name: tsig_secret # explicit redaction omits value value_classification: redacted provenance: operator_override network_detection_engines: # observed IDS/NDR engine state @@ -531,7 +531,7 @@ nodes: value: "yes" provenance: configuration_file source_path: /var/ossec/etc/ossec.conf - - setting_id: api-token # secret-bearing settings omit value + - setting_id: api-token # explicit redaction omits value name: api_token value_classification: redacted provenance: operator_override @@ -559,7 +559,7 @@ nodes: node_verification: true authorization_ref: opensearch-rbac # same-node runtime.app_authorizations id settings: - - setting_id: keystore-pw # secret-bearing settings omit value + - setting_id: keystore-pw # explicit redaction omits value name: bootstrap.password classification: redacted platform_applications: # observed security platform application state @@ -615,7 +615,7 @@ nodes: eps: 500 crypto: aes settings: - - setting_id: authd-pass # secret-bearing settings omit value + - setting_id: authd-pass # explicit redaction omits value name: authd.pass classification: redacted orchestration_authorities: # observed container-spawn authority state @@ -873,8 +873,11 @@ weakness placement, `redirects`, observable error/disclosure behavior in `disclosures`, and `exposed_fields` for route-visible fixture secrets or intentionally exposed diagnostic fields classified with the shared runtime sensitivity vocabulary — `redacted` and `operator_secret` fields omit their raw -value (see -[ADR-026](../../decisions/adrs/adr-026-application-http-surface-inventory.md)). +value, while `secret_fixture` fields may carry deliberate exercise fixture +values (see +[ADR-026](../../decisions/adrs/adr-026-application-http-surface-inventory.md) +and +[ADR-057](../../decisions/adrs/adr-057-runtime-secret-name-classifier-boundaries.md)). `runtime.database_services` records the participant-observable database logical state — what an adversary, defender, agent, scanner, or evaluator can @@ -973,8 +976,10 @@ Sieve, or other mail protocols to same-node transport services and carry advertised capabilities, banners, AUTH mechanisms, and TLS/STARTTLS posture. `mailboxes` are service-local runtime records with address, domain/store refs, role/status, authentication mechanisms, and credential-strength classification; -raw passwords and hashes are not representable. `settings` carry provenance and -source paths, and secret-bearing setting names must omit raw values. Mail +raw passwords and hashes are not representable on mailbox records. `settings` +carry provenance and source paths; explicit `redacted`/`operator_secret` +classifications omit raw values, while credential-shaped names remain scenario +content under ADR-057. Mail client, DNS, logging/SIEM, relay, and similar edges stay in top-level `relationships`; a typed `mail_access` block records mail protocol/auth/TLS and mailbox/domain/listener refs when an edge needs mail-specific semantics (see @@ -996,8 +1001,10 @@ remain observed data: use the specific field when one exists (`distinguished_name`, `principal_name`, `service_principal_names`, `issuer`, `tenant_id`, `base_dn`) or a bounded `attributes` entry for values such as AD `objectGUID`/SID, LDAP `entryUUID`, SCIM `id`/`externalId`, SAML -NameID, or the OIDC `iss` + `sub` pair. Secret-bearing attribute or policy -setting names must omit raw values and use the runtime sensitivity vocabulary. +NameID, or the OIDC `iss` + `sub` pair. Attribute and policy setting values use +the runtime sensitivity vocabulary: explicit `redacted`/`operator_secret` +classifications omit raw values, and credential-shaped names do not force +omission under ADR-057. Local authority references resolve against all stable ids in the owning authority; fully qualified references such as `nodes.ad.runtime.identity_authorities.corp-domain.subjects.alice` participate @@ -1021,7 +1028,7 @@ model. `principals` are users, service accounts, API keys, or backend roles with `reserved`/`hidden` flags; a principal never stores a raw bcrypt hash, API key, or password — its credential posture is recorded purely via a `credential_classification` (`none`, `redacted`, `operator_secret`), and a -secret-bearing principal name must declare `redacted` or `operator_secret`. +secret-shaped principal name alone does not force a redaction classification. `roles` are named local roles. The defining addition over a directory is the resource-scoped `permission_grants` entry (role reference → `actions` → `resource_patterns`) with an `allow`/`deny` effect and a `resource_kind`; the @@ -1077,8 +1084,9 @@ and `transport_security` are single nested postures, while `nodes`, `aliases`, `mappings`, `lifecycle_policies`, `ingest_pipelines`, `pubsub_channels`, `queues_streams`, `engine_plugins`, and `backup_targets` are bare reference-name lists. `settings` reuse the shared runtime sensitivity -vocabulary so secret-bearing names omit their raw value and classify -`redacted`/`operator_secret`. Application-internal RBAC is delegated to +vocabulary: explicit `redacted`/`operator_secret` classifications omit raw +values, while credential-shaped setting names remain scenario content unless +the author marks the value withheld. Application-internal RBAC is delegated to `runtime.app_authorizations` via the string `authorization_ref` (resolved to a same-node `app_authorization_id`), so the surface carries no embedded principal/role/grant. Fully qualified refs such as @@ -1132,10 +1140,10 @@ requires at least one `api_pull` source, one `ioc_to_rule` transform, and one `reload_channel`, and rejects a `buffer_policy` and any `ship_target` enrollment endpoint. A ship target's `target_node_ref`, when concrete, resolves to a defined node, and a `target_service_ref` resolves to a service on the referenced node -(or, absent a node ref, on the owning node). Ship-target enrollment identities and -secret-bearing settings never carry raw values: they classify -`redacted`/`operator_secret` through the shared `name_indicates_secret` helper and -the closed enrollment lattice. Cadence composes a `runtime.scheduled_jobs` entry +(or, absent a node ref, on the owning node). Ship-target enrollment identities +use the closed `none`/`redacted`/`operator_secret` lattice and carry no raw value +field; forwarding settings use explicit redaction classifications to omit raw +values, not name-derived omission. Cadence composes a `runtime.scheduled_jobs` entry and the inter-node trust edge composes a relationship forwarding edge — neither is re-typed here. Fully qualified refs such as `nodes.sensor.runtime.forwarding_agents.wazuh-sidecar.ship_targets.manager` @@ -1184,7 +1192,8 @@ recipe text is intentionally not stored, since `${...}` in shell/Dockerfile syntax would collide with SDL variable substitution); the `layers` chain with per-layer `digest`, `created_by`, `size`, and `empty` flag; `build_args` with a `value_classification` so secret build arguments are redacted rather than -recorded; `copied_sources` mapping build-context `source_path` to in-image +recorded, except for explicitly disclosed `secret_fixture` exercise values; +`copied_sources` mapping build-context `source_path` to in-image `destination_path`; `config` recording image *defaults* (entrypoint, command, working directory, exposed ports, native-keyed `labels`, and `default_environment`); `source_inputs` mapping source-package inputs to diff --git a/docs/explain/sdl/validation.md b/docs/explain/sdl/validation.md index d847f7f4f..fb94ba0cc 100644 --- a/docs/explain/sdl/validation.md +++ b/docs/explain/sdl/validation.md @@ -80,10 +80,18 @@ classified as `redacted` or `operator_secret` must omit the corresponding raw value; the Python models and generated JSON Schemas both reject non-empty raw values for redacted/operator-secret labels accepted by the parser's normalization rules, including case-insensitive hyphen/underscore spellings. -Runtime observed-value surfaces share the ADR-056 raw-value helper: redacted -and operator-secret classifications omit raw values, concrete secret-bearing -names reject unclassified raw values, and only explicit `secret_fixture` -classifications may carry deliberate exercise fixture values. +Runtime observed-value surfaces share the ADR-056/ADR-057 raw-value helper: +redacted and operator-secret classifications omit raw values. ADR-057 supersedes +the earlier name-driven omission rule: credential-shaped names do not by +themselves reject values or require redaction, because SDL runtime values are +scenario-realization facts. +[ADR-057](../../decisions/adrs/adr-057-runtime-secret-name-classifier-boundaries.md) +records the realizability decision: generated credentials, hashes, key +material, weak fixture values, public-key fingerprints, working-directory +variables such as `PWD`, and scalar metadata such as `secret_key_length` may all +be recorded when they are facts of the synthetic scenario. Authors still +classify any value as `redacted` or `operator_secret` when the value is +intentionally withheld from the authoritative SDL. The optional `runtime.local_identity` inventory carries its own model-local rules. Local user `username` and local group `name` must be non-empty; user @@ -99,9 +107,9 @@ semantic rules. Authorities, services, subjects, policies, relationships, attributes, and settings use stable non-empty ids or names. Stable ids must be unique across the authority-local reference namespace, not just within each child collection. The model also rejects duplicate attribute and setting names, -normalizes bounded kind/protocol/provenance/value classifications, and keeps -raw values out of secret-bearing attributes or settings. Authority services may -reference only services declared on the same node. Authority-local refs resolve +normalizes bounded kind/protocol/provenance/value classifications, and enforces +explicit redaction classifications on attributes and settings. Authority +services may reference only services declared on the same node. Authority-local refs resolve against all stable ids in the authority: `identity_authority_id`, `service_id`, `subject_id`, `policy_id`, and `relationship_id`. Provider names and external object identifiers are data, not @@ -115,10 +123,10 @@ owner/class/type bindings are unique within a zone. RRsets must have at least one record, TTL and type-code fields are bounded integer-or-variable values, `record_type: other` requires `type_code`, and typed RDATA must match the owning RRset type. A/AAAA typed address payloads are validated as IPv4/IPv6 -respectively. Secret-bearing DNS settings such as TSIG, RNDC, password, token, -or private-key settings must omit raw values and use redacted/operator-secret -classifications. DNS services may reference only services declared on the same -node. File refs under the DNS service and its zones are checked against +respectively. DNS settings such as TSIG, RNDC, password, token, or private-key +settings may carry scenario values unless explicitly classified as +redacted/operator-secret. DNS services may reference only services declared on +the same node. File refs under the DNS service and its zones are checked against `runtime.filesystem_inventory` when that inventory is non-empty. The optional `runtime.network_sensors` inventory has model-local and semantic @@ -168,10 +176,10 @@ manager kinds, listener roles, component kinds/statuses, agent statuses, content kinds/formats, detection engines/kinds, field-predicate operators, setting provenance, and value classifications are normalized from bounded enums while allowing full-value variables where the model permits. -Secret-bearing settings such as passwords, API tokens, credentials, shared -keys, keytabs, or private keys must omit raw values and use -redacted/operator-secret classifications. Managers and listeners may reference -only services declared on the same node. Manager configuration/log/evidence +Settings such as passwords, API tokens, credentials, shared keys, keytabs, or +private keys may carry scenario values unless explicitly classified as +redacted/operator-secret. Managers and listeners may reference only services +declared on the same node. Manager configuration/log/evidence refs, agent-group configuration refs, content-set file refs, detection-definition source/evidence refs, and setting source paths are checked against `runtime.filesystem_inventory` when that inventory is non-empty. Group @@ -192,9 +200,8 @@ its principal, role, permission-grant, role-mapping, and tenant collections. Resource vocabularies, principal kinds, grant effects, and credential classifications are normalized from bounded enums while allowing full-value variables where the model permits. A principal never carries a raw credential -value: its posture is the `credential_classification`, and a principal whose -`name` matches the shared secret-name vocabulary must declare a `redacted` or -`operator_secret` classification rather than `none`. An authorization that +value: its posture is the `credential_classification`, and a principal name +does not force a redaction classification. An authorization that declares a concrete (non-`unknown`) `resource_vocabulary` must carry at least one permission grant whose `resource_kind` matches that vocabulary; a declared but unused vocabulary is rejected, while a `${var}` placeholder or the open @@ -228,8 +235,8 @@ node, partition, and setting ids are unique across the service. Engines, data models, partition kinds, node roles, persistence eviction policies, replication strategies, transport-security modes, and setting scope/provenance/classification are normalized from bounded enums while allowing full-value variables. -Secret-bearing settings must omit raw values and use redacted/operator-secret -classifications. The `require_profile_for_data_model` guard makes the +Explicit redacted/operator-secret setting classifications omit raw values; names +alone do not force omission. The `require_profile_for_data_model` guard makes the discriminator executable: a `${var}` placeholder is exempt and the open `unknown`/`other`/`relational` tail is permissive, but a concrete `search_index` requires at least one `index` partition carrying shard/replica geometry, a @@ -248,8 +255,9 @@ and setting provenance/classification are normalized from bounded enums (the marking `scheme` is a closed `tlp`/`pap`/`distribution` vocabulary) while allowing full-value variables. Content objects are bounded parsed manifests — typed kind, bounded attributes, typed references, marking refs, and evidence refs -— never raw bodies; secret-bearing settings and connector names must use -redacted/operator-secret classifications. The `require_profile_for_platform_kind` +— never raw bodies. Explicit redacted/operator-secret setting classifications +omit raw values, and connector names do not force redaction classifications. The +`require_profile_for_platform_kind` guard makes the discriminator executable: a `${var}` placeholder is exempt and `unknown`/`other` are permissive, but each concrete kind requires its defining content/binding profile (threat-intel taxonomy/galaxy/warninglist/feed/sharing @@ -269,9 +277,9 @@ implementations, kinds, source kinds, parse formats, transform kinds, protocols, buffer crypto, reload-channel kinds, enrollment classifications, and setting provenance/classification are normalized from bounded enums while allowing full-value variables. A ship-target enrollment identity is never recorded — only -the closed `none`/`redacted`/`operator_secret` lattice — and a setting whose name -matches the shared secret-name vocabulary must omit its raw value and declare a -redacted/operator-secret classification. The `require_profile_for_agent_kind` +the closed `none`/`redacted`/`operator_secret` lattice — and explicit +redacted/operator-secret setting classifications omit raw values. The +`require_profile_for_agent_kind` guard makes the `agent_kind` discriminator executable: a `${var}` placeholder is exempt and the open `unknown`/`other` tail is permissive, but a concrete `log_forwarder` requires a `buffer_policy` and at least one `ship_target` carrying diff --git a/implementations/python/packages/aces_sdl/image_provenance.py b/implementations/python/packages/aces_sdl/image_provenance.py index b07b010db..9b14ad746 100644 --- a/implementations/python/packages/aces_sdl/image_provenance.py +++ b/implementations/python/packages/aces_sdl/image_provenance.py @@ -49,12 +49,6 @@ RuntimeEnvironmentValueClassification.REDACTED, RuntimeEnvironmentValueClassification.OPERATOR_SECRET, ) -_IMAGE_ENV_SECRET_NAME_CLASSIFICATIONS = ( - RuntimeEnvironmentValueClassification.REDACTED, - RuntimeEnvironmentValueClassification.OPERATOR_SECRET, - RuntimeEnvironmentValueClassification.SECRET_FIXTURE, -) -_IMAGE_ENV_RAW_SECRET_NAME_CLASSIFICATIONS = (RuntimeEnvironmentValueClassification.SECRET_FIXTURE,) class DockerfileInstructionKind(str, Enum): @@ -190,14 +184,10 @@ def normalize_value_classification( def validate_redacted_value(self) -> "ImageBuildArg": enforce_observed_value_redaction( owner_label=f"build argument '{self.name}'", - name=self.name, value=self.value, classification=self.value_classification, redacted_classifications=_IMAGE_ENV_REDACTED_CLASSIFICATIONS, - classification_field="value_classification", raw_value_label="value", - secret_name_classifications=_IMAGE_ENV_SECRET_NAME_CLASSIFICATIONS, - raw_secret_name_classifications=_IMAGE_ENV_RAW_SECRET_NAME_CLASSIFICATIONS, redacted_raw_message="redacted build arguments must omit value" if self.value_classification == RuntimeEnvironmentValueClassification.REDACTED else None, @@ -238,14 +228,10 @@ def normalize_value_classification( def validate_redacted_value(self) -> "ImageEnvironmentDefault": enforce_observed_value_redaction( owner_label=f"image environment variable '{self.name}'", - name=self.name, value=self.value, classification=self.value_classification, redacted_classifications=_IMAGE_ENV_REDACTED_CLASSIFICATIONS, - classification_field="value_classification", raw_value_label="value", - secret_name_classifications=_IMAGE_ENV_SECRET_NAME_CLASSIFICATIONS, - raw_secret_name_classifications=_IMAGE_ENV_RAW_SECRET_NAME_CLASSIFICATIONS, redacted_raw_message="redacted image environment variables must omit value" if self.value_classification == RuntimeEnvironmentValueClassification.REDACTED else None, diff --git a/implementations/python/packages/aces_sdl/runtime_app_authorization.py b/implementations/python/packages/aces_sdl/runtime_app_authorization.py index 820030b21..b505b12dc 100644 --- a/implementations/python/packages/aces_sdl/runtime_app_authorization.py +++ b/implementations/python/packages/aces_sdl/runtime_app_authorization.py @@ -22,7 +22,6 @@ from .runtime_values import ( coerce_string_list, is_variable_ref, - name_indicates_secret, parse_optional_bool_or_var, parse_runtime_enum_or_var, require_symbol, @@ -96,14 +95,6 @@ class RuntimeAppAuthorizationGrantEffect(str, Enum): DENY = "deny" -_OMIT_RAW_CLASSIFICATIONS: frozenset[RuntimeAppAuthorizationCredentialClassification] = frozenset( - { - RuntimeAppAuthorizationCredentialClassification.REDACTED, - RuntimeAppAuthorizationCredentialClassification.OPERATOR_SECRET, - } -) - - def _normalize_enum(value: object, enum_cls: type[Enum], *, field_name: str) -> object: return parse_runtime_enum_or_var(value, enum_cls, field_name=field_name) @@ -165,27 +156,8 @@ def coerce_backend_roles(cls, v: object) -> object: @model_validator(mode="after") def validate_principal(self) -> "RuntimeAppAuthorizationPrincipal": _reject_duplicate_values(self.backend_roles, field_name="backend_roles", owner=self.principal_id) - self._enforce_secret_name_redaction() return self - def _enforce_secret_name_redaction(self) -> None: - """A secret-bearing principal name must be redaction-classified. - - A principal whose ``name`` matches the shared secret-name vocabulary - (e.g. an api-key principal named for the key material it fronts) must - declare a ``redacted`` / ``operator_secret`` classification, never a - ``none`` posture that would imply a plaintext-equivalent identity. - """ - if not self.name or is_variable_ref(self.name) or not name_indicates_secret(self.name): - return - if is_variable_ref(self.credential_classification): - return - if self.credential_classification not in _OMIT_RAW_CLASSIFICATIONS: - raise ValueError( - f"principal '{self.principal_id}' carries a secret-bearing name; " - f"credential_classification must be 'redacted' or 'operator_secret'" - ) - class RuntimeAppAuthorizationRole(SDLModel): """A named role defined within the application authorization store.""" diff --git a/implementations/python/packages/aces_sdl/runtime_application.py b/implementations/python/packages/aces_sdl/runtime_application.py index f9dc41bab..fce17a3ea 100644 --- a/implementations/python/packages/aces_sdl/runtime_application.py +++ b/implementations/python/packages/aces_sdl/runtime_application.py @@ -64,12 +64,6 @@ RuntimeSensitivityClassification.REDACTED, RuntimeSensitivityClassification.OPERATOR_SECRET, ) -_EXPOSED_FIELD_SECRET_CLASSIFICATIONS = ( - RuntimeSensitivityClassification.REDACTED, - RuntimeSensitivityClassification.OPERATOR_SECRET, - RuntimeSensitivityClassification.SECRET_FIXTURE, -) -_RAW_EXPOSED_FIELD_SECRET_CLASSIFICATIONS = (RuntimeSensitivityClassification.SECRET_FIXTURE,) class RuntimeApplicationProtocol(str, Enum): @@ -254,9 +248,9 @@ class RuntimeApplicationExposedField(SDLModel): """A route-visible fixture secret or intentionally exposed diagnostic field. The sensitivity vocabulary is shared with the rest of the runtime surface. - A ``redacted`` or ``operator_secret`` field must omit its raw ``value``; - only intentionally participant-visible ``secret_fixture``/``plain`` material - is safe to record. + A ``redacted`` or ``operator_secret`` field must omit its raw ``value``. + Other values, including credential-shaped fixture facts, are scenario + content needed for range realization and participant observation. """ name: str @@ -283,13 +277,9 @@ def normalize_sensitivity( def validate_redacted_value(self) -> "RuntimeApplicationExposedField": enforce_observed_value_redaction( owner_label=f"exposed field '{self.name}'", - name=self.name, value=self.value, classification=self.sensitivity, redacted_classifications=_REDACTED_SENSITIVITIES, - classification_field="sensitivity", - secret_name_classifications=_EXPOSED_FIELD_SECRET_CLASSIFICATIONS, - raw_secret_name_classifications=_RAW_EXPOSED_FIELD_SECRET_CLASSIFICATIONS, ) return self diff --git a/implementations/python/packages/aces_sdl/runtime_configuration.py b/implementations/python/packages/aces_sdl/runtime_configuration.py index 6ae19a27e..19cf9f9e9 100644 --- a/implementations/python/packages/aces_sdl/runtime_configuration.py +++ b/implementations/python/packages/aces_sdl/runtime_configuration.py @@ -190,12 +190,6 @@ class RuntimeEnvironmentValueClassification(str, Enum): RuntimeEnvironmentValueClassification.REDACTED, RuntimeEnvironmentValueClassification.OPERATOR_SECRET, ) -_ENV_SECRET_NAME_CLASSIFICATIONS = ( - RuntimeEnvironmentValueClassification.REDACTED, - RuntimeEnvironmentValueClassification.OPERATOR_SECRET, - RuntimeEnvironmentValueClassification.SECRET_FIXTURE, -) -_ENV_RAW_SECRET_NAME_CLASSIFICATIONS = (RuntimeEnvironmentValueClassification.SECRET_FIXTURE,) class RuntimeEnvironmentVariableProvenance(str, Enum): @@ -264,14 +258,10 @@ def normalize_provenance( def validate_redacted_value(self) -> "RuntimeEnvironmentVariable": enforce_observed_value_redaction( owner_label=f"runtime environment variable '{self.name}'", - name=self.name, value=self.value, classification=self.value_classification, redacted_classifications=_ENV_REDACTED_CLASSIFICATIONS, - classification_field="value_classification", raw_value_label="value", - secret_name_classifications=_ENV_SECRET_NAME_CLASSIFICATIONS, - raw_secret_name_classifications=_ENV_RAW_SECRET_NAME_CLASSIFICATIONS, ) return self diff --git a/implementations/python/packages/aces_sdl/runtime_database.py b/implementations/python/packages/aces_sdl/runtime_database.py index 47955c9f1..abc6c2b27 100644 --- a/implementations/python/packages/aces_sdl/runtime_database.py +++ b/implementations/python/packages/aces_sdl/runtime_database.py @@ -348,9 +348,9 @@ def parse_with_grant_option(cls, v: bool | str) -> bool | str: class DatabaseSetting(SDLModel): """An observed database runtime setting with provenance and sensitivity. - Settings that may carry credentials, hashes, connection strings, or - operator-only values must omit their raw ``value`` and classify it as - ``redacted``/``operator_secret`` (ADR-029 §5). + Explicit ``redacted``/``operator_secret`` classifications omit raw values; + credential-shaped names remain scenario content unless the author marks the + value withheld. """ name: str @@ -379,18 +379,13 @@ def normalize_provenance(cls, v: DatabaseSettingProvenance | str) -> DatabaseSet @model_validator(mode="after") def validate_redacted_value(self) -> "DatabaseSetting": - # Settings whose name signals secret content (passwords, hashes, - # connection strings, auth files, private keys) must omit their raw - # value even when the submitter left ``value_classification`` at the - # default ``unknown`` — otherwise the protection is opt-in for the - # author and absent for adversarial inputs (ADR-029 §5). + # Explicit redaction classifications omit raw values; credential-shaped + # names remain scenario content unless the author marks them withheld. enforce_observed_value_redaction( owner_label=f"database setting '{self.name}'", - name=self.name, value=self.value, classification=self.value_classification, redacted_classifications=_REDACTED_SENSITIVITIES, - classification_field="value_classification", ) return self diff --git a/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py b/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py index 553d55551..405da09a9 100644 --- a/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py +++ b/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py @@ -7,9 +7,8 @@ its ``require_profile_for_data_model`` guard live in ``runtime_datastore.py``. Every typed child carries a ``_id`` validated by ``require_symbol``. -Settings whose name signals secret content omit their raw value and classify -``redacted`` / ``operator_secret`` via the shared ``name_indicates_secret`` -helper, exactly as the relational ``DatabaseSetting`` does. +Settings explicitly classified ``redacted`` / ``operator_secret`` omit their raw +value, exactly as the relational ``DatabaseSetting`` does. """ from enum import Enum @@ -253,10 +252,9 @@ def parse_verifications(cls, v: object) -> bool | str | None: class RuntimeDatastoreSetting(SDLModel): """An observed datastore runtime setting with scope, provenance, and class. - Settings that may carry credentials, hashes, or operator-only values must - omit their raw ``value`` and classify it as ``redacted`` / ``operator_secret`` - — enforced via the shared ``name_indicates_secret`` helper even when the - submitter left ``classification`` at its default. + Explicit ``redacted`` / ``operator_secret`` classifications omit raw values; + names that look credential-bearing remain scenario content unless the + author marks the value withheld. """ setting_id: str @@ -296,10 +294,8 @@ def normalize_classification(cls, v: RuntimeSensitivityClassification | str) -> def validate_setting(self) -> "RuntimeDatastoreSetting": enforce_observed_value_redaction( owner_label=f"datastore setting '{self.setting_id}'", - name=self.name, value=self.value, classification=self.classification, redacted_classifications=_REDACTED_SENSITIVITIES, - classification_field="classification", ) return self diff --git a/implementations/python/packages/aces_sdl/runtime_directory_identity.py b/implementations/python/packages/aces_sdl/runtime_directory_identity.py index 16d170943..18e696540 100644 --- a/implementations/python/packages/aces_sdl/runtime_directory_identity.py +++ b/implementations/python/packages/aces_sdl/runtime_directory_identity.py @@ -238,11 +238,9 @@ def align_origin_and_provenance(self) -> "RuntimeIdentityAttribute": def validate_redacted_values(self) -> "RuntimeIdentityAttribute": enforce_observed_value_redaction( owner_label=f"identity attribute '{self.name}'", - name=self.name, value=self.values, classification=self.value_classification, redacted_classifications=_REDACTED_SENSITIVITIES, - classification_field="value_classification", raw_value_label="raw values", ) return self diff --git a/implementations/python/packages/aces_sdl/runtime_dns.py b/implementations/python/packages/aces_sdl/runtime_dns.py index 80f6aeb8f..b228e13d3 100644 --- a/implementations/python/packages/aces_sdl/runtime_dns.py +++ b/implementations/python/packages/aces_sdl/runtime_dns.py @@ -264,11 +264,9 @@ def normalize_provenance(cls, v: DnsSettingProvenance | str) -> DnsSettingProven def validate_redacted_value(self) -> "DnsRuntimeSetting": enforce_observed_value_redaction( owner_label=f"DNS setting '{self.name}'", - name=self.name, value=self.value, classification=self.value_classification, redacted_classifications=_REDACTED_SENSITIVITIES, - classification_field="value_classification", ) return self diff --git a/implementations/python/packages/aces_sdl/runtime_forwarding_agent.py b/implementations/python/packages/aces_sdl/runtime_forwarding_agent.py index c28a98a68..476878117 100644 --- a/implementations/python/packages/aces_sdl/runtime_forwarding_agent.py +++ b/implementations/python/packages/aces_sdl/runtime_forwarding_agent.py @@ -18,9 +18,9 @@ permissive. This is observed runtime state attached to ``Node.runtime``. Secret-bearing -settings and ship-target enrollment identities never carry raw values; they -classify ``redacted`` / ``operator_secret`` via the shared -``name_indicates_secret`` helper and the closed enrollment lattice. +setting values are scenario content unless explicitly classified +``redacted`` / ``operator_secret``; ship-target enrollment identities use the +closed enrollment lattice because they intentionally carry no raw value field. """ from enum import Enum @@ -249,10 +249,9 @@ def normalize_kind(cls, v: RuntimeForwardingReloadChannelKind | str) -> object: class RuntimeForwardingSetting(SDLModel): """An observed forwarding-agent runtime setting with provenance and class. - Settings that may carry credentials or operator-only values must omit their - raw ``value`` and classify it ``redacted`` / ``operator_secret`` — enforced - via the shared ``name_indicates_secret`` helper even when the submitter left - ``classification`` at its default. + Explicit ``redacted`` / ``operator_secret`` classifications omit raw values; + credential-shaped names remain scenario content unless the author marks the + value withheld. """ setting_id: str @@ -281,11 +280,9 @@ def normalize_classification(cls, v: RuntimeForwardingSettingClassification | st def validate_setting(self) -> "RuntimeForwardingSetting": enforce_observed_value_redaction( owner_label=f"forwarding setting '{self.setting_id}'", - name=self.name, value=self.value, classification=self.classification, redacted_classifications=_REDACTED_SETTING_CLASSIFICATIONS, - classification_field="classification", ) return self diff --git a/implementations/python/packages/aces_sdl/runtime_mail_service.py b/implementations/python/packages/aces_sdl/runtime_mail_service.py index 6aec635dc..ba916636f 100644 --- a/implementations/python/packages/aces_sdl/runtime_mail_service.py +++ b/implementations/python/packages/aces_sdl/runtime_mail_service.py @@ -426,11 +426,9 @@ def validate_source_path(cls, v: str) -> str: def validate_redacted_value(self) -> "RuntimeMailSetting": enforce_observed_value_redaction( owner_label=f"mail setting '{self.name}'", - name=self.name, value=self.value, classification=self.value_classification, redacted_classifications=_REDACTED_SENSITIVITIES, - classification_field="value_classification", ) return self diff --git a/implementations/python/packages/aces_sdl/runtime_platform_application_content.py b/implementations/python/packages/aces_sdl/runtime_platform_application_content.py index 5dc289815..f0a525e01 100644 --- a/implementations/python/packages/aces_sdl/runtime_platform_application_content.py +++ b/implementations/python/packages/aces_sdl/runtime_platform_application_content.py @@ -12,7 +12,7 @@ from pydantic import Field, field_validator, model_validator -from ._base import SDLModel, is_variable_ref, parse_int_or_var +from ._base import SDLModel, parse_int_or_var from .runtime_platform_application_vocab import ( RuntimePlatformApplicationConnectorKind, RuntimePlatformApplicationContentObjectKind, @@ -24,7 +24,6 @@ from .runtime_values import ( coerce_string_list, enforce_observed_value_redaction, - name_indicates_secret, parse_optional_bool_or_var, parse_runtime_enum_or_var, require_symbol, @@ -214,21 +213,8 @@ def parse_enabled(cls, v: object) -> bool | str | None: @model_validator(mode="after") def validate_connector(self) -> "RuntimePlatformApplicationConnector": - self._enforce_secret_name_redaction() return self - def _enforce_secret_name_redaction(self) -> None: - """A secret-bearing connector name must carry a redaction classification.""" - if not self.name or is_variable_ref(self.name) or not name_indicates_secret(self.name): - return - if is_variable_ref(self.credential_classification): - return - if self.credential_classification not in _OMIT_RAW_CLASSIFICATIONS: - raise ValueError( - f"connector '{self.connector_id}' carries a secret-bearing name; " - f"credential_classification must be 'redacted' or 'operator_secret'" - ) - class RuntimePlatformApplicationExecutionPolicy(SDLModel): """The platform's job/analyzer execution model (rate limits, runner).""" @@ -254,8 +240,8 @@ def parse_max_concurrent_jobs(cls, v: object) -> object: class RuntimePlatformApplicationSetting(SDLModel): """An observed platform setting with provenance and sensitivity. - Settings whose name signals secret content must omit their raw ``value`` - and classify it as ``redacted`` / ``operator_secret``. + Explicitly redacted/operator-secret settings must omit their raw ``value``. + A setting name does not by itself redact SDL scenario content. """ setting_id: str @@ -287,10 +273,8 @@ def normalize_classification(cls, v: RuntimePlatformApplicationSettingClassifica def validate_setting(self) -> "RuntimePlatformApplicationSetting": enforce_observed_value_redaction( owner_label=f"platform setting '{self.name}'", - name=self.name, value=self.value, classification=self.classification, redacted_classifications=_OMIT_RAW_CLASSIFICATIONS, - classification_field="classification", ) return self diff --git a/implementations/python/packages/aces_sdl/runtime_security_monitoring.py b/implementations/python/packages/aces_sdl/runtime_security_monitoring.py index ad257c89a..f001a82ec 100644 --- a/implementations/python/packages/aces_sdl/runtime_security_monitoring.py +++ b/implementations/python/packages/aces_sdl/runtime_security_monitoring.py @@ -441,11 +441,9 @@ def validate_source_path(cls, v: str) -> str: def validate_redacted_value(self) -> "RuntimeSecurityMonitoringSetting": enforce_observed_value_redaction( owner_label=f"security-monitoring setting '{self.name}'", - name=self.name, value=self.value, classification=self.value_classification, redacted_classifications=_REDACTED_SENSITIVITIES, - classification_field="value_classification", ) return self diff --git a/implementations/python/packages/aces_sdl/runtime_values.py b/implementations/python/packages/aces_sdl/runtime_values.py index 02c97dc10..1f172ee2f 100644 --- a/implementations/python/packages/aces_sdl/runtime_values.py +++ b/implementations/python/packages/aces_sdl/runtime_values.py @@ -30,14 +30,11 @@ _RAM_MIN_BYTES_ERROR = "RAM must be >= 1 byte" _WINDOWS_NAMED_PIPE_PREFIXES = ("\\\\.\\pipe\\", "\\\\?\\pipe\\") -# Identifier-shape tokens used to detect secret-bearing setting names, not -# secrets themselves; the string-concatenation / ``noqa: S105`` markers silence -# bandit without dressing each line up as actual credential material. This is -# the de-duplicated union of every per-family token set that previously drifted -# across runtime_database, runtime_dns, runtime_directory_identity, -# runtime_mail_service, and runtime_security_monitoring. A setting whose name -# matches one of these may not carry a raw value regardless of how the submitter -# classified it. +# Identifier-shape tokens used to identify names that may benefit from advisory +# sensitivity defaults. They are not a validation rule: SDL values are scenario +# realization facts unless the author explicitly classifies them as withheld. +# The string-concatenation / ``noqa: S105`` markers silence bandit without +# dressing each line up as actual credential material. SECRET_NAME_TOKENS: tuple[str, ...] = ( "access_key", "access_token", # noqa: S105 @@ -63,31 +60,76 @@ "privatekey", "pwd", "refresh_token", # noqa: S105 + "rndc.key", "sasl_passwd", "sasl_password", # noqa: S105 "sec" + "ret", "shared_key", + "ssh_key", # noqa: S105 "supplementalcredentials", "token", "tsig", + "update_key", ) -# Whole-word parts (alnum-split) that independently mark a name as secret-bearing -# even when no token is a substring (sourced from runtime_dns). +# Whole alphanumeric parts that can conservatively mark compound names as +# credential-like for advisory consumers. Reference, metadata, and public-key +# context exclusions below keep this from labeling key fingerprints, key-file +# paths, or scalar key facts as credential material. SECRET_NAME_PARTS: frozenset[str] = frozenset({"key"}) +SECRET_NAME_REFERENCE_PARTS: frozenset[str] = frozenset( + { + "file", + "filepath", + "filename", + "fingerprint", + "path", + } +) +SECRET_NAME_METADATA_PARTS: frozenset[str] = frozenset( + { + "bits", + "bytes", + "count", + "len", + "length", + "size", + } +) +PUBLIC_KEY_CONTEXT_PARTS: frozenset[str] = frozenset({"gpg", "pgp", "public"}) + + +def _name_parts(normalized_name: str) -> tuple[str, ...]: + return tuple(part for part in re.split(r"[^a-z0-9]+", normalized_name) if part) + + +def _names_secret_reference_or_metadata(normalized_name: str, parts: tuple[str, ...]) -> bool: + if not parts: + return False + if parts[-1] in SECRET_NAME_REFERENCE_PARTS | SECRET_NAME_METADATA_PARTS: + return True + return normalized_name.endswith("keyfile") + + +def _names_public_key_context(parts: tuple[str, ...]) -> bool: + return len(parts) >= 2 and parts[-1] == "key" and parts[-2] in PUBLIC_KEY_CONTEXT_PARTS def name_indicates_secret(name: str) -> bool: - """Return whether a setting name suggests secret-bearing content. + """Return whether a setting name looks credential-bearing. - A name is secret-bearing when any :data:`SECRET_NAME_TOKENS` entry is a - substring (after lowercasing and normalizing ``-`` to ``_``) or when its - alphanumeric-split parts intersect :data:`SECRET_NAME_PARTS`. + This helper is advisory. It does not impose raw-value omission: SDL runtime + values are scenario content unless their explicit classification says they + are withheld. """ lowered = name.lower().replace("-", "_") + parts = _name_parts(lowered) + if _names_secret_reference_or_metadata(lowered, parts): + return False + if _names_public_key_context(parts): + return False if any(token in lowered for token in SECRET_NAME_TOKENS): return True - parts = frozenset(part for part in re.split(r"[^a-z0-9]+", lowered) if part) - return bool(parts & SECRET_NAME_PARTS) + return bool(frozenset(parts) & SECRET_NAME_PARTS) def _has_raw_value(value: object) -> bool: @@ -109,36 +151,21 @@ def _classification_in(value: object, candidates: Iterable[object]) -> bool: return any(value == candidate or value_label == _classification_label(candidate) for candidate in candidates) -def _format_classification_set(values: Iterable[object]) -> str: - labels = tuple(_classification_label(value) for value in values) - if not labels: - return "a permitted classification" - if len(labels) == 1: - return f"'{labels[0]}'" - if len(labels) == 2: - return f"'{labels[0]}' or '{labels[1]}'" - return ", ".join(f"'{label}'" for label in labels[:-1]) + f", or '{labels[-1]}'" - - def enforce_observed_value_redaction( *, owner_label: str, - name: object, value: object, classification: object, redacted_classifications: tuple[object, ...], - classification_field: str, raw_value_label: str = "raw value", - secret_name_classifications: tuple[object, ...] | None = None, - raw_secret_name_classifications: tuple[object, ...] = (), redacted_raw_message: str | None = None, ) -> None: - """Validate the shared runtime observed-value secret/redaction invariant. + """Validate explicit redaction classifications for runtime observed values. - The helper covers only the common policy: a redacted/operator-secret - classification omits raw data, and a concrete secret-bearing name may carry - raw data only for explicitly allowed fixture classifications. Family models - still own their ids, scopes, refs, provenance enums, and closed lattices. + SDL runtime values are scenario-realization facts. A name that resembles a + secret does not by itself force omission; only an explicit redacted or + operator-secret classification withholds raw data. Family models still own + their ids, scopes, refs, provenance enums, and closed lattices. """ has_raw = _has_raw_value(value) if has_raw and _classification_in(classification, redacted_classifications): @@ -147,18 +174,6 @@ def enforce_observed_value_redaction( raise ValueError( f"{owner_label} classified '{_classification_label(classification)}' must omit its {raw_value_label}" ) - if isinstance(name, str) and not is_variable_ref(name) and name_indicates_secret(name): - if has_raw: - if _classification_in(classification, raw_secret_name_classifications): - return - raise ValueError(f"{owner_label} carries a secret-bearing name and must omit its {raw_value_label}") - if is_variable_ref(classification): - return - allowed = secret_name_classifications or redacted_classifications - if not _classification_in(classification, allowed): - expected = _format_classification_set(allowed) - raise ValueError(f"{owner_label} carries a secret-bearing name; {classification_field} must be {expected}") - return def require_symbol(value: str, *, field_name: str) -> str: diff --git a/implementations/python/tests/test_runtime_app_authorization.py b/implementations/python/tests/test_runtime_app_authorization.py index 051537c60..60d1b27aa 100644 --- a/implementations/python/tests/test_runtime_app_authorization.py +++ b/implementations/python/tests/test_runtime_app_authorization.py @@ -287,16 +287,14 @@ def test_principal_carries_classification_only() -> None: assert principal.credential_classification == RuntimeAppAuthorizationCredentialClassification.REDACTED -def test_secret_named_principal_must_be_redaction_classified() -> None: - with pytest.raises( - ValidationError, - match="carries a secret-bearing name; credential_classification must be 'redacted' or 'operator_secret'", - ): - RuntimeAppAuthorizationPrincipal( - principal_id="leaky", - name="root-api-key", - credential_classification="none", - ) +def test_secret_named_principal_may_use_none_classification() -> None: + principal = RuntimeAppAuthorizationPrincipal( + principal_id="root-api-key", + name="root-api-key", + credential_classification="none", + ) + + assert principal.credential_classification == RuntimeAppAuthorizationCredentialClassification.NONE def test_secret_named_principal_with_redacted_classification_is_valid() -> None: diff --git a/implementations/python/tests/test_runtime_datastore.py b/implementations/python/tests/test_runtime_datastore.py index 2128df795..1e9a79e73 100644 --- a/implementations/python/tests/test_runtime_datastore.py +++ b/implementations/python/tests/test_runtime_datastore.py @@ -233,9 +233,10 @@ def test_rejects_duplicate_string_list_entries() -> None: RuntimeDatastoreService(**_search_index_service(engine_plugins=["a", "a"])) -def test_secret_named_setting_must_omit_raw_value() -> None: - with pytest.raises(ValidationError, match="secret-bearing name and must omit its raw value"): - RuntimeDatastoreSetting(setting_id="admin-pw", name="admin_password", value="hunter2") +def test_secret_named_setting_may_carry_scenario_value() -> None: + setting = RuntimeDatastoreSetting(setting_id="admin-pw", name="admin_password", value="hunter2") + + assert setting.value == "hunter2" def test_secret_named_setting_with_redacted_class_is_valid() -> None: diff --git a/implementations/python/tests/test_runtime_forwarding_agent.py b/implementations/python/tests/test_runtime_forwarding_agent.py index b2004ee0f..346adbcd0 100644 --- a/implementations/python/tests/test_runtime_forwarding_agent.py +++ b/implementations/python/tests/test_runtime_forwarding_agent.py @@ -228,9 +228,10 @@ def test_buffer_policy_id_participates_in_uniqueness() -> None: ) -def test_secret_named_setting_must_omit_raw_value() -> None: - with pytest.raises(ValidationError, match="secret-bearing name and must omit its raw value"): - RuntimeForwardingSetting(setting_id="enroll", name="enrollment_key", value="hunter2") +def test_secret_named_setting_may_carry_scenario_value() -> None: + setting = RuntimeForwardingSetting(setting_id="enroll", name="enrollment_key", value="hunter2") + + assert setting.value == "hunter2" def test_secret_named_setting_redacted_is_accepted() -> None: diff --git a/implementations/python/tests/test_runtime_mail_service.py b/implementations/python/tests/test_runtime_mail_service.py index e58a99ffc..d302d2541 100644 --- a/implementations/python/tests/test_runtime_mail_service.py +++ b/implementations/python/tests/test_runtime_mail_service.py @@ -205,14 +205,15 @@ def test_runtime_mail_service_rejects_duplicate_stable_ids() -> None: ) -def test_runtime_mail_setting_rejects_secret_bearing_raw_value() -> None: - with pytest.raises(ValidationError, match="must omit its raw value"): - RuntimeMailSetting( - setting_id="relay-auth", - component_ref="postfix", - name="smtp_sasl_password_maps", - value="user:plaintext", - ) +def test_runtime_mail_setting_accepts_secret_named_scenario_value() -> None: + setting = RuntimeMailSetting( + setting_id="relay-auth", + component_ref="postfix", + name="smtp_sasl_password_maps", + value="user:plaintext", + ) + + assert setting.value == "user:plaintext" def test_runtime_mail_setting_rejects_redacted_raw_value() -> None: diff --git a/implementations/python/tests/test_runtime_observed_values.py b/implementations/python/tests/test_runtime_observed_values.py index e6ec64ba4..6a74a0ce6 100644 --- a/implementations/python/tests/test_runtime_observed_values.py +++ b/implementations/python/tests/test_runtime_observed_values.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from typing import Any import pytest from aces_sdl.image_provenance import ImageBuildArg, ImageEnvironmentDefault @@ -20,37 +21,65 @@ from pydantic import ValidationError SecretValueFactory = Callable[[], object] +SecretValueReader = Callable[[object], Any] @pytest.mark.parametrize( - ("_surface", "factory"), + ("_surface", "factory", "read_value", "expected"), [ - ("database setting", lambda: DatabaseSetting(name="admin_password", value="hunter2")), - ("DNS setting", lambda: DnsRuntimeSetting(name="tsig_secret", value="base64-secret")), + ( + "database setting", + lambda: DatabaseSetting(name="admin_password", value="hunter2"), + lambda obj: obj.value, + "hunter2", + ), + ( + "DNS setting", + lambda: DnsRuntimeSetting(name="tsig_secret", value="base64-secret"), + lambda obj: obj.value, + "base64-secret", + ), ( "mail setting", lambda: RuntimeMailSetting(setting_id="relay-pw", name="relay_password", value="hunter2"), + lambda obj: obj.value, + "hunter2", ), ( "security-monitoring setting", lambda: RuntimeSecurityMonitoringSetting(setting_id="api", name="api_token", value="hunter2"), + lambda obj: obj.value, + "hunter2", + ), + ( + "identity attribute", + lambda: RuntimeIdentityAttribute(name="unicodePwd", values=["hunter2"]), + lambda obj: obj.values, + ["hunter2"], ), - ("identity attribute", lambda: RuntimeIdentityAttribute(name="unicodePwd", values=["hunter2"])), ( "datastore setting", lambda: RuntimeDatastoreSetting(setting_id="admin-pw", name="admin_password", value="hunter2"), + lambda obj: obj.value, + "hunter2", ), ( "platform setting", lambda: RuntimePlatformApplicationSetting(setting_id="api", name="api_key", value="hunter2"), + lambda obj: obj.value, + "hunter2", ), ( "forwarding setting", lambda: RuntimeForwardingSetting(setting_id="enroll", name="enrollment_key", value="hunter2"), + lambda obj: obj.value, + "hunter2", ), ( "application exposed field", lambda: RuntimeApplicationExposedField(name="operator_api_key", sensitivity="plain", value="hunter2"), + lambda obj: obj.value, + "hunter2", ), ( "runtime environment variable", @@ -59,25 +88,32 @@ value="hunter2", value_classification="plain", ), + lambda obj: obj.value, + "hunter2", ), ( "image build argument", lambda: ImageBuildArg(name="PIP_INDEX_TOKEN", value="hunter2", value_classification="plain"), + lambda obj: obj.value, + "hunter2", ), ( "image default environment", lambda: ImageEnvironmentDefault(name="API_TOKEN", value="hunter2", value_classification="plain"), + lambda obj: obj.value, + "hunter2", ), ], ) -def test_secret_bearing_observed_values_reject_unclassified_raw_values( +def test_secret_named_observed_values_remain_representable_as_scenario_content( _surface: str, factory: SecretValueFactory, + read_value: SecretValueReader, + expected: object, ) -> None: - """Secret-looking names must not carry raw values as plain/unknown data.""" + """Secret-looking names do not by themselves redact SDL scenario content.""" - with pytest.raises(ValidationError, match="secret-bearing name"): - factory() + assert read_value(factory()) == expected def test_runtime_environment_operator_secret_omits_raw_value() -> None: @@ -94,9 +130,14 @@ def test_runtime_environment_operator_secret_omits_raw_value() -> None: ) -def test_secret_bearing_environment_name_requires_redaction_or_fixture_class() -> None: - with pytest.raises(ValidationError, match="value_classification must be"): - RuntimeEnvironmentVariable(name="OPERATOR_API_KEY", value_classification="plain") +def test_secret_named_environment_values_do_not_require_redaction_or_fixture_class() -> None: + env = RuntimeEnvironmentVariable( + name="OPERATOR_API_KEY", + value="scenario-api-key", + value_classification="plain", + ) + + assert env.value == "scenario-api-key" def test_explicit_secret_fixture_values_are_allowed() -> None: @@ -127,6 +168,57 @@ def test_explicit_secret_fixture_values_are_allowed() -> None: assert image_env.value == "fixture-token" +def test_non_secret_key_metadata_values_are_representable() -> None: + gpg = RuntimeEnvironmentVariable(name="GPG_KEY", value="0xDEADBEEF", value_classification="plain") + length = RuntimeApplicationExposedField(name="secret_key_length", sensitivity="plain", value="25") + key_path = RuntimeEnvironmentVariable( + name="LABADMIN_SSH_KEY_FILE", + value="/keys/labadmin-ssh-key.pub", + value_classification="plain", + ) + working_directory = RuntimeEnvironmentVariable(name="PWD", value="/srv/app", value_classification="plain") + image_gpg = ImageEnvironmentDefault(name="GPG_KEY", value="0xDEADBEEF", value_classification="plain") + + assert gpg.value == "0xDEADBEEF" + assert length.value == "25" + assert key_path.value == "/keys/labadmin-ssh-key.pub" + assert working_directory.value == "/srv/app" + assert image_gpg.value == "0xDEADBEEF" + + +@pytest.mark.parametrize( + ("name", "value"), + [ + ("SECRET_KEY", "techvault-secret-key-2024"), + ("JWT_SECRET", "techvault-jwt-weak"), + ("DB_PASSWORD", "techvault_db_pass"), + ], +) +def test_route_visible_secret_fixture_values_are_preserved(name: str, value: str) -> None: + field = RuntimeApplicationExposedField(name=name, sensitivity="secret_fixture", value=value) + + assert field.value == value + + +@pytest.mark.parametrize( + ("name", "value"), + [ + ("SERVICE_KEY", "service-key"), + ("backup_key", "backup-key"), + ("CONFIG-KEY", "config-key"), + ("PUBLIC_API_KEY", "api-key"), + ("SSH_KEY", "ssh-key"), + ("enrollment_key", "enrollment-key"), + ("rndc.key", "rndc-key"), + ("update_key", "update-key"), + ], +) +def test_credential_shaped_environment_names_remain_realizable(name: str, value: str) -> None: + env = RuntimeEnvironmentVariable(name=name, value=value, value_classification="plain") + + assert env.value == value + + def test_credential_posture_surfaces_do_not_gain_raw_secret_fields() -> None: """Posture-only credentials stay separate from observed key/value settings.""" diff --git a/implementations/python/tests/test_runtime_platform_application.py b/implementations/python/tests/test_runtime_platform_application.py index 217339bf6..82cb564ee 100644 --- a/implementations/python/tests/test_runtime_platform_application.py +++ b/implementations/python/tests/test_runtime_platform_application.py @@ -323,12 +323,14 @@ def test_content_object_has_no_raw_body_field() -> None: # --------------------------------------------------------------------------- # -def test_secret_named_connector_must_be_redaction_classified() -> None: - with pytest.raises( - ValidationError, - match="carries a secret-bearing name; credential_classification must be 'redacted' or 'operator_secret'", - ): - RuntimePlatformApplicationConnector(connector_id="c", name="cortex-api-key", credential_classification="plain") +def test_secret_named_connector_may_use_plain_scenario_classification() -> None: + connector = RuntimePlatformApplicationConnector( + connector_id="c", + name="cortex-api-key", + credential_classification="plain", + ) + + assert connector.credential_classification == RuntimePlatformApplicationSettingClassification.PLAIN def test_secret_named_connector_with_redacted_classification_is_valid() -> None: @@ -338,9 +340,10 @@ def test_secret_named_connector_with_redacted_classification_is_valid() -> None: assert connector.credential_classification == RuntimePlatformApplicationSettingClassification.OPERATOR_SECRET -def test_secret_named_setting_must_omit_raw_value() -> None: - with pytest.raises(ValidationError, match="carries a secret-bearing name and must omit its raw value"): - RuntimePlatformApplicationSetting(setting_id="s", name="admin_password", value="hunter2") +def test_secret_named_setting_may_carry_scenario_value() -> None: + setting = RuntimePlatformApplicationSetting(setting_id="s", name="admin_password", value="hunter2") + + assert setting.value == "hunter2" def test_secret_named_setting_redacted_is_valid() -> None: diff --git a/implementations/python/tests/test_runtime_security_monitoring.py b/implementations/python/tests/test_runtime_security_monitoring.py index 89ffaa999..6fd7573de 100644 --- a/implementations/python/tests/test_runtime_security_monitoring.py +++ b/implementations/python/tests/test_runtime_security_monitoring.py @@ -387,14 +387,15 @@ def test_detection_definition_digest_requires_algorithm_pair() -> None: @pytest.mark.parametrize("setting_name", ["api_token", "api_key", "shared_key"]) -def test_security_monitoring_setting_rejects_secret_bearing_raw_value(setting_name: str) -> None: - with pytest.raises(ValidationError, match="must omit its raw value"): - RuntimeSecurityMonitoringSetting( - setting_id="api-token", - name=setting_name, - value="plaintext-token", - value_classification="plain", - ) +def test_security_monitoring_setting_accepts_secret_named_scenario_value(setting_name: str) -> None: + setting = RuntimeSecurityMonitoringSetting( + setting_id="api-token", + name=setting_name, + value="plaintext-token", + value_classification="plain", + ) + + assert setting.value == "plaintext-token" class TestRuntimeSecurityMonitoringSemanticValidation: diff --git a/implementations/python/tests/test_sdl_models.py b/implementations/python/tests/test_sdl_models.py index 640ac31a9..91697b6fb 100644 --- a/implementations/python/tests/test_sdl_models.py +++ b/implementations/python/tests/test_sdl_models.py @@ -1738,13 +1738,14 @@ def test_vm_runtime_identity_authority_inventory_surfaces(self): assert authority.policies[0].applies_to_refs == ["techvault-domain"] assert authority.policies[0].settings[0].values == ["14"] - def test_runtime_identity_secret_bearing_attribute_rejects_raw_values(self): - with pytest.raises(ValidationError, match="secret-bearing"): - RuntimeIdentityAttribute( - name="unicodePwd", - values=["not-for-fixtures"], - value_classification="plain", - ) + def test_runtime_identity_secret_named_attribute_accepts_scenario_values(self): + attribute = RuntimeIdentityAttribute( + name="unicodePwd", + values=["not-for-fixtures"], + value_classification="plain", + ) + + assert attribute.values == ["not-for-fixtures"] def test_runtime_identity_attribute_accepts_observation_provenance(self): attr = RuntimeIdentityAttribute(name="misp_role", values=["admin"], provenance="runtime_created") @@ -3538,9 +3539,10 @@ def test_dns_unknown_record_type_preserves_type_code_and_rdata(self): assert rrset.records[0].rdata == "1 . alpn=h2,h3" @pytest.mark.parametrize("name", ["tsig_secret", "api_key", "update_key", "rndc.key", "key"]) - def test_dns_secret_bearing_setting_must_omit_raw_value(self, name): - with pytest.raises(ValidationError, match="must omit its raw value"): - DnsRuntimeSetting(name=name, value="base64secret", value_classification="plain") + def test_dns_secret_named_setting_accepts_scenario_value(self, name): + setting = DnsRuntimeSetting(name=name, value="base64secret", value_classification="plain") + + assert setting.value == "base64secret" def test_dns_setting_secret_name_detection_is_boundary_aware(self): setting = DnsRuntimeSetting(name="keyboard_layout", value="us", value_classification="plain") @@ -3771,18 +3773,17 @@ def test_redacted_setting_without_value_is_valid(self): "service_credential", ], ) - def test_secret_bearing_name_must_omit_value_regardless_of_classification(self, secret_name): - # Defaulting value_classification to 'unknown' must not let plaintext - # secrets through (the cycle-2 codex finding). - with pytest.raises(ValidationError, match="must omit its raw value"): - DatabaseSetting(name=secret_name, value="leak") + def test_secret_bearing_name_preserves_value_regardless_of_classification(self, secret_name): + setting = DatabaseSetting(name=secret_name, value="leak") + + assert setting.value == "leak" + + def test_secret_bearing_name_with_empty_value_allows_plain_classification(self): + setting = DatabaseSetting(name="password", value_classification="plain") - def test_secret_bearing_name_with_empty_value_still_requires_redacted_classification(self): - with pytest.raises(ValidationError, match="value_classification must be 'redacted' or 'operator_secret'"): - DatabaseSetting(name="password", value_classification="plain") + assert setting.value_classification == RuntimeSensitivityClassification.PLAIN def test_secret_bearing_name_with_variable_classification_is_skipped(self): - # ${var} classification is deferred to instantiation revalidation. setting = DatabaseSetting(name="password", value_classification="${CLS}") assert setting.value_classification == "${CLS}" From e7d28b0b95950927f1f19e86670d4b45e2913717 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 7 Jun 2026 09:33:04 +0200 Subject: [PATCH 06/70] Register gap remediation overlay skill --- .codex | 8 +++ changelog.d/445.added.md | 1 + .../tests/test_agent_inventory_skill.py | 66 +++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 changelog.d/445.added.md diff --git a/.codex b/.codex index b4375f821..d38f524a1 100644 --- a/.codex +++ b/.codex @@ -33,3 +33,11 @@ such as `GOV-918`. - For Claude Code, use `.claude/skills/aces-asset-inventory-capture/SKILL.md`. This server also links it at `~/.claude/skills/aces-asset-inventory-capture`. +- For Codex, use `.codex-skills/aces-gap-remediation-implement/SKILL.md` + when remediating ACES/APTL gaps found by the asset-inventory methodology. + This server also links it at + `~/.codex/skills/aces-gap-remediation-implement`. +- For Claude Code, use + `.claude/skills/aces-gap-remediation-implement/SKILL.md` for the same + overlay. This server also links it at + `~/.claude/skills/aces-gap-remediation-implement`. diff --git a/changelog.d/445.added.md b/changelog.d/445.added.md new file mode 100644 index 000000000..09179c19a --- /dev/null +++ b/changelog.d/445.added.md @@ -0,0 +1 @@ +### Added - Registered the ACES gap-remediation implement overlay for Codex/Claude discovery and regression coverage. diff --git a/implementations/python/tests/test_agent_inventory_skill.py b/implementations/python/tests/test_agent_inventory_skill.py index 089cba022..18029ea06 100644 --- a/implementations/python/tests/test_agent_inventory_skill.py +++ b/implementations/python/tests/test_agent_inventory_skill.py @@ -5,10 +5,16 @@ REPO_ROOT = Path(__file__).resolve().parents[3] CLAUDE_SKILL_DIR = REPO_ROOT / ".claude" / "skills" / "aces-asset-inventory-capture" CODEX_SKILL_DIR = REPO_ROOT / ".codex-skills" / "aces-asset-inventory-capture" +GAP_CLAUDE_SKILL_DIR = REPO_ROOT / ".claude" / "skills" / "aces-gap-remediation-implement" +GAP_CODEX_SKILL_DIR = REPO_ROOT / ".codex-skills" / "aces-gap-remediation-implement" SKILL_PATHS = ( CLAUDE_SKILL_DIR / "SKILL.md", CODEX_SKILL_DIR / "SKILL.md", ) +GAP_SKILL_PATHS = ( + GAP_CLAUDE_SKILL_DIR / "SKILL.md", + GAP_CODEX_SKILL_DIR / "SKILL.md", +) CODEX_RULES_PATH = REPO_ROOT / ".codex" @@ -113,3 +119,63 @@ def test_asset_inventory_skill_blocks_known_agent_failure_modes() -> None: ] assert not missing + + +def test_gap_remediation_skill_is_cross_agent_and_discoverable_by_codex() -> None: + claude_skill = (GAP_CLAUDE_SKILL_DIR / "SKILL.md").read_text(encoding="utf-8") + codex_skill = (GAP_CODEX_SKILL_DIR / "SKILL.md").read_text(encoding="utf-8") + codex_rules = CODEX_RULES_PATH.read_text(encoding="utf-8") + + assert "TODO" not in claude_skill + assert "TODO" not in codex_skill + assert claude_skill == codex_skill + assert "aces-gap-remediation-implement" in codex_rules + assert ".codex-skills/aces-gap-remediation-implement/SKILL.md" in codex_rules + assert ".claude/skills/aces-gap-remediation-implement/SKILL.md" in codex_rules + assert "~/.codex/skills/aces-gap-remediation-implement" in codex_rules + assert "~/.claude/skills/aces-gap-remediation-implement" in codex_rules + + +def test_gap_remediation_skill_metadata_is_agent_runnable() -> None: + for skill_path in GAP_SKILL_PATHS: + skill = skill_path.read_text(encoding="utf-8") + openai_yaml = (skill_path.parent / "agents" / "openai.yaml").read_text(encoding="utf-8") + + assert "name: aces-gap-remediation-implement" in skill + assert "description: Architecture-first overlay" in skill + assert "default_prompt:" in openai_yaml + assert "$aces-gap-remediation-implement" in openai_yaml + + +def test_gap_remediation_skill_encodes_overlay_contract() -> None: + skills = [path.read_text(encoding="utf-8") for path in GAP_SKILL_PATHS] + required_terms = ( + "overlay before the normal Ground Control implementation", + "does not replace `/implement`", + "academic peer review for tier-1 publication", + "Remediation Brief", + "Gap Claim", + "Existing Surface Audit", + "Lineage and Precedent", + "Literature and Practice", + "Alternatives", + "Chosen Architecture", + "Documentation Defense", + "Verification Plan", + "Whole-Surface Gate", + "Anti-Local-Optimization Gates", + "duplicate parallel surfaces", + "primary literature", + "No downstream-only fix", + "Delegation to Ground Control", + "workflow-improvement recommendations", + ) + + missing = [ + (path, term) + for path, skill in zip(GAP_SKILL_PATHS, skills, strict=True) + for term in required_terms + if term not in skill + ] + + assert not missing From e7827228285f15e94da94caf574d8b4775d8fc61 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 7 Jun 2026 10:37:50 +0200 Subject: [PATCH 07/70] Add structured datastore mapping manifests --- changelog.d/469.added.md | 1 + .../schemas/sdl/instantiated-scenario-v1.json | 191 ++++++++++++- .../schemas/sdl/sdl-authoring-input-v1.json | 191 ++++++++++++- ...048-datastore-service-runtime-inventory.md | 24 +- docs/explain/sdl/limitations.md | 7 +- docs/explain/sdl/lineage.md | 5 +- docs/explain/sdl/precedents.md | 2 +- docs/explain/sdl/sections.md | 37 ++- docs/explain/sdl/validation.md | 26 +- .../aces_sdl/_runtime_service_families.py | 2 + .../packages/aces_sdl/runtime_datastore.py | 57 +++- .../aces_sdl/runtime_datastore_partitions.py | 88 ++++++ .../python/tests/test_runtime_datastore.py | 253 +++++++++++++++++- 13 files changed, 842 insertions(+), 42 deletions(-) create mode 100644 changelog.d/469.added.md diff --git a/changelog.d/469.added.md b/changelog.d/469.added.md new file mode 100644 index 000000000..1cb9d6ab9 --- /dev/null +++ b/changelog.d/469.added.md @@ -0,0 +1 @@ +Add structured `runtime.datastore_services` mapping and template manifests so search-index schemas can carry bounded field counts, dynamic policy, digests, and evidence refs instead of name-only lists. diff --git a/contracts/schemas/sdl/instantiated-scenario-v1.json b/contracts/schemas/sdl/instantiated-scenario-v1.json index c3a2f0678..9a7724bd3 100644 --- a/contracts/schemas/sdl/instantiated-scenario-v1.json +++ b/contracts/schemas/sdl/instantiated-scenario-v1.json @@ -6632,6 +6632,127 @@ "title": "RuntimeDatastoreEvictionPolicy", "type": "string" }, + "RuntimeDatastoreMapping": { + "additionalProperties": false, + "description": "A bounded manifest of an observed search-index mapping/schema.\n\nCarries schema geometry and digest/evidence facts, never the raw\nOpenSearch/Elasticsearch ``_mapping`` response body.", + "properties": { + "date_detection": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Date Detection" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "dynamic_policy": { + "default": "", + "title": "Dynamic Policy", + "type": "string" + }, + "dynamic_template_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dynamic Template Count" + }, + "evidence_refs": { + "items": { + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "field_type_census": { + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "title": "Field Type Census", + "type": "object" + }, + "leaf_field_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Leaf Field Count" + }, + "mapping_id": { + "title": "Mapping Id", + "type": "string" + }, + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "partition_ref": { + "default": "", + "title": "Partition Ref", + "type": "string" + }, + "schema_digest": { + "default": "", + "title": "Schema Digest", + "type": "string" + }, + "top_level_field_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Top Level Field Count" + } + }, + "required": [ + "mapping_id" + ], + "title": "RuntimeDatastoreMapping", + "type": "object" + }, "RuntimeDatastoreNode": { "additionalProperties": false, "description": "An observed node participating in a datastore cluster.", @@ -7022,7 +7143,7 @@ }, "mappings": { "items": { - "type": "string" + "$ref": "#/$defs/RuntimeDatastoreMapping" }, "title": "Mappings", "type": "array" @@ -7090,7 +7211,7 @@ }, "templates": { "items": { - "type": "string" + "$ref": "#/$defs/RuntimeDatastoreTemplate" }, "title": "Templates", "type": "array" @@ -7209,6 +7330,72 @@ "title": "RuntimeDatastoreSettingScope", "type": "string" }, + "RuntimeDatastoreTemplate": { + "additionalProperties": false, + "description": "A bounded manifest of an observed index template body.\n\nThe template captures patterns, selected settings, optional mapping linkage,\ndigest, and evidence refs without embedding the backend's raw template JSON.", + "properties": { + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "evidence_refs": { + "items": { + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "index_patterns": { + "items": { + "type": "string" + }, + "title": "Index Patterns", + "type": "array" + }, + "mapping_ref": { + "default": "", + "title": "Mapping Ref", + "type": "string" + }, + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "settings_summary": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "boolean" + } + ] + }, + "title": "Settings Summary", + "type": "object" + }, + "template_digest": { + "default": "", + "title": "Template Digest", + "type": "string" + }, + "template_id": { + "title": "Template Id", + "type": "string" + } + }, + "required": [ + "template_id" + ], + "title": "RuntimeDatastoreTemplate", + "type": "object" + }, "RuntimeDatastoreTransportSecurity": { "additionalProperties": false, "description": "The single observed transport-security posture of a datastore service.\n\nRecords the intra-cluster transport TLS mode and whether client/node\ncertificate verification is enforced \u2014 the observable ``xpack.security`` /\n``SKIPSSL_VERIFY`` posture, never the certificate material itself.", diff --git a/contracts/schemas/sdl/sdl-authoring-input-v1.json b/contracts/schemas/sdl/sdl-authoring-input-v1.json index 4b0827fa8..9844bedb0 100644 --- a/contracts/schemas/sdl/sdl-authoring-input-v1.json +++ b/contracts/schemas/sdl/sdl-authoring-input-v1.json @@ -6632,6 +6632,127 @@ "title": "RuntimeDatastoreEvictionPolicy", "type": "string" }, + "RuntimeDatastoreMapping": { + "additionalProperties": false, + "description": "A bounded manifest of an observed search-index mapping/schema.\n\nCarries schema geometry and digest/evidence facts, never the raw\nOpenSearch/Elasticsearch ``_mapping`` response body.", + "properties": { + "date_detection": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Date Detection" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "dynamic_policy": { + "default": "", + "title": "Dynamic Policy", + "type": "string" + }, + "dynamic_template_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dynamic Template Count" + }, + "evidence_refs": { + "items": { + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "field_type_census": { + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "title": "Field Type Census", + "type": "object" + }, + "leaf_field_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Leaf Field Count" + }, + "mapping_id": { + "title": "Mapping Id", + "type": "string" + }, + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "partition_ref": { + "default": "", + "title": "Partition Ref", + "type": "string" + }, + "schema_digest": { + "default": "", + "title": "Schema Digest", + "type": "string" + }, + "top_level_field_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Top Level Field Count" + } + }, + "required": [ + "mapping_id" + ], + "title": "RuntimeDatastoreMapping", + "type": "object" + }, "RuntimeDatastoreNode": { "additionalProperties": false, "description": "An observed node participating in a datastore cluster.", @@ -7022,7 +7143,7 @@ }, "mappings": { "items": { - "type": "string" + "$ref": "#/$defs/RuntimeDatastoreMapping" }, "title": "Mappings", "type": "array" @@ -7090,7 +7211,7 @@ }, "templates": { "items": { - "type": "string" + "$ref": "#/$defs/RuntimeDatastoreTemplate" }, "title": "Templates", "type": "array" @@ -7209,6 +7330,72 @@ "title": "RuntimeDatastoreSettingScope", "type": "string" }, + "RuntimeDatastoreTemplate": { + "additionalProperties": false, + "description": "A bounded manifest of an observed index template body.\n\nThe template captures patterns, selected settings, optional mapping linkage,\ndigest, and evidence refs without embedding the backend's raw template JSON.", + "properties": { + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "evidence_refs": { + "items": { + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "index_patterns": { + "items": { + "type": "string" + }, + "title": "Index Patterns", + "type": "array" + }, + "mapping_ref": { + "default": "", + "title": "Mapping Ref", + "type": "string" + }, + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "settings_summary": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "boolean" + } + ] + }, + "title": "Settings Summary", + "type": "object" + }, + "template_digest": { + "default": "", + "title": "Template Digest", + "type": "string" + }, + "template_id": { + "title": "Template Id", + "type": "string" + } + }, + "required": [ + "template_id" + ], + "title": "RuntimeDatastoreTemplate", + "type": "object" + }, "RuntimeDatastoreTransportSecurity": { "additionalProperties": false, "description": "The single observed transport-security posture of a datastore service.\n\nRecords the intra-cluster transport TLS mode and whether client/node\ncertificate verification is enforced \u2014 the observable ``xpack.security`` /\n``SKIPSSL_VERIFY`` posture, never the certificate material itself.", diff --git a/docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md b/docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md index bdbd70c60..99d3c6789 100644 --- a/docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md +++ b/docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md @@ -67,10 +67,15 @@ concrete structural data model requires its defining geometry: The service owns single nested postures (`cluster`, `persistence`, `transport_security`) and id-bearing child collections (`nodes`, `partitions`, -`settings`); `templates`, `aliases`, `mappings`, `lifecycle_policies`, -`ingest_pipelines`, `pubsub_channels`, `queues_streams`, `engine_plugins`, and -`backup_targets` are bare reference-name lists. Secret-bearing settings omit -their raw value and classify `redacted` / `operator_secret`. +`templates`, `mappings`, and `settings`). `templates` and `mappings` are bounded +manifests rather than raw engine payloads: templates carry index patterns, +selected settings, optional mapping refs, digests, and evidence refs; mappings +carry partition refs, field-count/type census, dynamic policy, dynamic-template +count, date-detection posture, schema digests, and evidence refs. `aliases`, +`lifecycle_policies`, `ingest_pipelines`, `pubsub_channels`, `queues_streams`, +`engine_plugins`, and `backup_targets` remain bare reference-name lists. +Secret-bearing settings omit their raw value and classify `redacted` / +`operator_secret`. ### 4. Keep datastore inventory targetable but not executable @@ -80,6 +85,8 @@ qualified refs: - `nodes..runtime.datastore_services.` - `nodes..runtime.datastore_services..nodes.` - `nodes..runtime.datastore_services..partitions.` +- `nodes..runtime.datastore_services..templates.` +- `nodes..runtime.datastore_services..mappings.` - `nodes..runtime.datastore_services..settings.` These refs are inventory targets. They do not imply query execution, indexing, @@ -91,14 +98,17 @@ replication, or persistence behavior. fields are normalized through the single runtime enum-parse helper. Duplicate service ids and duplicate service-local child ids fail early. - Required-profile gate: the `require_profile_for_data_model` guard fails an - under-populated `search_index` / `wide_column` / `key_value` instance. + under-populated `search_index` / `wide_column` / `key_value` instance. A + concrete `search_index` also requires at least one structured mapping manifest + so index schema inventory cannot collapse to a name-only partition. - Semantic validation gate: the owning `service` ref resolves to a same-node binding; a non-empty, non-variable `authorization_ref` resolves to a same-node `app_authorization`. - Relationship/reference gate: service and child qualified refs resolve in generic relationships and survive module import namespacing. -- Secret/payload gate: raw key material, credentials, and secret-bearing setting - values stay out of SDL model data. +- Secret/payload gate: raw key material, credentials, secret-bearing setting + values, and raw `_mapping` / `_template` response bodies stay out of SDL model + data; bounded manifests retain digests and evidence refs instead. - Contract/schema gate: published schemas are regenerated from Python model sources; generated JSON schemas are not edited by hand. diff --git a/docs/explain/sdl/limitations.md b/docs/explain/sdl/limitations.md index 8679dfbfa..c7a3ebfde 100644 --- a/docs/explain/sdl/limitations.md +++ b/docs/explain/sdl/limitations.md @@ -51,9 +51,10 @@ recurring scheduled-job cadence and run-state (closed interval/cron/calendar recurrence plus observed last/next run and last result, cadence-only - [ADR-047](../../decisions/adrs/adr-047-scheduled-job-runtime-inventory.md)), non-relational datastore logical state (search/wide-column/key-value clusters, -partitions with shard/replica or replication geometry, key-value persistence -posture, transport security, and settings, with an executable required-profile -guard and internal RBAC delegated via `authorization_ref` - +structured index mapping/template manifests, partitions with shard/replica or +replication geometry, key-value persistence posture, transport security, and +settings, with an executable required-profile guard and internal RBAC delegated +via `authorization_ref` - [ADR-048](../../decisions/adrs/adr-048-datastore-service-runtime-inventory.md)), security-platform application runtime inventory (threat-intel/SOAR/analyzer/ case-management/dashboard kinds, bounded parsed content-object manifests, diff --git a/docs/explain/sdl/lineage.md b/docs/explain/sdl/lineage.md index c06890303..c48e11dff 100644 --- a/docs/explain/sdl/lineage.md +++ b/docs/explain/sdl/lineage.md @@ -388,7 +388,10 @@ irreducibly-relational `runtime.database_services` cannot shape. Its defining addition is the open `data_model` discriminator paired with a `require_profile_for_data_model` guard that makes each data model's defining geometry (search shard/replica counts, wide-column replication, key-value -persistence) executable rather than optional. +persistence, and bounded search-index mapping manifests) executable rather than +optional. Search-index mappings and templates are captured as bounded manifests +with counts, summaries, digests, refs, and evidence pointers rather than as raw +backend JSON bodies. ACES relies on prior work in four ways: diff --git a/docs/explain/sdl/precedents.md b/docs/explain/sdl/precedents.md index cea556f37..a9140fef3 100644 --- a/docs/explain/sdl/precedents.md +++ b/docs/explain/sdl/precedents.md @@ -274,7 +274,7 @@ taxonomy of container, orchestrator, or host-security concerns. | SIEM/security-monitoring manager runtime inventory (manager modules, listeners, enrolled agents/groups, detection content sets, parsed detection definitions, bounded settings, API/control-plane posture) | Log-management/security-monitoring runtime inventory and loaded-definition manifests, not transport bindings, process/unit state, raw config, raw events, alert telemetry, or rule-engine execution | `Node.runtime.security_monitoring_managers` when observed; manager, content-set, and detection-definition refs may be targeted through qualified runtime refs; see ADR-040 and ADR-045 | | Application-internal RBAC store (principals, roles, resource-scoped permission grants, role mappings, tenants) | Application-internal authorization runtime state, not a wire-protocol directory or database engine GRANT surface | `Node.runtime.app_authorizations` when observed; the resource-scoped `permission_grant` is the defining addition; raw credentials are never stored; see ADR-046 | | Recurring scheduled-job cadence and run-state | Product-neutral cadence runtime state, not systemd unit lifecycle or forwarder input/output authoring | `Node.runtime.scheduled_jobs` when observed; cadence + run-state only; systemd lifecycle remains `runtime.service_manager_units`; see ADR-047 | -| Non-relational datastore logical state (search clusters, wide-column stores, key-value stores, partitions, replication geometry, persistence posture, transport security, settings) | Participant-observable non-relational datastore runtime state, not the relational engine/GRANT surface, transport bindings, or software component identity | `Node.runtime.datastore_services` when observed; the open `data_model` discriminator drives a required-profile guard; service and child refs may be targeted through qualified runtime refs; internal RBAC is delegated via `authorization_ref`; explicit redaction classifications omit raw setting values; see ADR-048 and ADR-057 | +| Non-relational datastore logical state (search clusters, structured index mapping/template manifests, wide-column stores, key-value stores, partitions, replication geometry, persistence posture, transport security, settings) | Participant-observable non-relational datastore runtime state, not the relational engine/GRANT surface, transport bindings, raw backend mapping/template bodies, or software component identity | `Node.runtime.datastore_services` when observed; the open `data_model` discriminator drives a required-profile guard; mappings/templates are bounded manifests with refs, counts, summaries, digests, and evidence refs; service and child refs may be targeted through qualified runtime refs; internal RBAC is delegated via `authorization_ref`; explicit redaction classifications omit raw setting values; see ADR-048 and ADR-057 | | Security-platform application runtime inventory (threat-intel platform, SOAR, analyzer engine, case management, analytics dashboard — bounded content-object manifests, markings, upstream bindings, connectors, settings) | Participant-observable platform-application runtime state and bounded parsed manifests, not HTTP route surface, software component identity, raw object bodies, or playbook execution semantics | `Node.runtime.platform_applications` when observed; the open `platform_kind` discriminator drives a required-profile guard; content objects carry typed references not raw bodies; refs may be targeted through qualified runtime refs; internal RBAC is delegated via `authorization_ref`; see ADR-049 | | Forwarding / intel-sync agent runtime inventory (sources, transforms, ship targets, buffer policy, reload channels, settings for log forwarders and intel-sync co-processes) | Participant-observable agent-side shipping state, not SIEM manager inventory, detection-engine consumer state, scheduled-job cadence, systemd lifecycle, or a fake scenario node for an infrastructure-only sidecar | `Node.runtime.forwarding_agents` when the agent is node-hosted; top-level `forwarding_agents` when the forwarder is off-node infrastructure realization; the open `agent_kind` discriminator drives a required-profile guard; ship-target node/service refs resolve at scenario scope; enrollment identities use a closed classification lattice and settings use explicit redaction classifications; the inter-node trust edge is a relationship forwarding edge; see ADR-050 and ADR-057 | | Container-spawn orchestration-authority runtime inventory (engine, scope, spawn templates, lifecycle policy, realized children, privilege class referencing a control-interface shell) | Participant-observable container-spawn authority state, not the control-interface shell itself, software component identity, or workflow execution semantics | `Node.runtime.orchestration_authorities` when observed; the open `privilege_class` discriminator drives a required-profile guard; `control_interface_ref` resolves to a same-node `RuntimeControlInterface` (host-root-equivalent requires a read-write docker socket); the control-interface shell is referenced, never duplicated; see ADR-051 | diff --git a/docs/explain/sdl/sections.md b/docs/explain/sdl/sections.md index 842c5fa92..f601b8f76 100644 --- a/docs/explain/sdl/sections.md +++ b/docs/explain/sdl/sections.md @@ -547,12 +547,25 @@ nodes: discovery_mode: zen nodes: - node_id: os-node-1 - roles: [data, master] + roles: [data, cluster_manager] partitions: # search_index requires shard/replica geometry - partition_id: alerts-index kind: index shard_count: 3 replica_count: 1 + mappings: # bounded schema manifest, not raw _mapping JSON + - mapping_id: alerts-mapping + partition_ref: alerts-index + leaf_field_count: 670 + field_type_census: {keyword: 220, date: 9, ip: 12} + dynamic_policy: "true" + dynamic_template_count: 5 + schema_digest: sha256:alerts-mapping + templates: # bounded template manifest, not raw _template JSON + - template_id: wazuh-template + index_patterns: [wazuh-alerts-4.x-*] + settings_summary: {index.number_of_shards: "3"} + mapping_ref: alerts-mapping transport_security: transport_security_id: os-transport mode: tls @@ -1080,17 +1093,25 @@ partition with a `replication_strategy` and `replication_factor`; and a `key_value` store requires a `persistence` profile and rejects relational object-tree (`keyspace`/`column_family`) partitions. `cluster`, `persistence`, and `transport_security` are single nested postures, while `nodes`, -`partitions`, and `settings` are id-bearing child collections; `templates`, -`aliases`, `mappings`, `lifecycle_policies`, `ingest_pipelines`, -`pubsub_channels`, `queues_streams`, `engine_plugins`, and `backup_targets` are -bare reference-name lists. `settings` reuse the shared runtime sensitivity -vocabulary: explicit `redacted`/`operator_secret` classifications omit raw -values, while credential-shaped setting names remain scenario content unless -the author marks the value withheld. Application-internal RBAC is delegated to +`partitions`, `templates`, `mappings`, and `settings` are id-bearing child +collections. `templates` and `mappings` are bounded manifests rather than raw +engine payloads: templates carry index patterns, selected settings, optional +mapping refs, digests, and evidence refs; mappings carry partition refs, +field-count/type census, dynamic policy, dynamic-template count, date-detection +posture, schema digests, and evidence refs. A concrete `search_index` service +must carry at least one structured mapping manifest. `aliases`, +`lifecycle_policies`, `ingest_pipelines`, `pubsub_channels`, `queues_streams`, +`engine_plugins`, and `backup_targets` remain bare reference-name lists. +`settings` reuse the shared runtime sensitivity vocabulary: explicit +`redacted`/`operator_secret` classifications omit raw values, while +credential-shaped setting names remain scenario content unless the author marks +the value withheld. Application-internal RBAC is delegated to `runtime.app_authorizations` via the string `authorization_ref` (resolved to a same-node `app_authorization_id`), so the surface carries no embedded principal/role/grant. Fully qualified refs such as `nodes.indexer.runtime.datastore_services.opensearch-store.partitions.alerts-index` +and +`nodes.indexer.runtime.datastore_services.opensearch-store.mappings.alerts-mapping` participate in relationships, generic reference validation, and module import rewriting (see [ADR-048](../../decisions/adrs/adr-048-datastore-service-runtime-inventory.md)). diff --git a/docs/explain/sdl/validation.md b/docs/explain/sdl/validation.md index 8ecf95381..12312eb00 100644 --- a/docs/explain/sdl/validation.md +++ b/docs/explain/sdl/validation.md @@ -251,20 +251,26 @@ and is therefore not a scheduled job. The optional `runtime.datastore_services` inventory has model-local and semantic rules. Datastore-service ids are stable concrete symbols and are unique within a node runtime block; the service-local cluster, persistence, transport-security, -node, partition, and setting ids are unique across the service. Engines, data -models, partition kinds, node roles, persistence eviction policies, replication -strategies, transport-security modes, and setting scope/provenance/classification -are normalized from bounded enums while allowing full-value variables. +node, partition, template, mapping, and setting ids are unique across the +service. Engines, data models, partition kinds, node roles, persistence eviction +policies, replication strategies, transport-security modes, and setting +scope/provenance/classification are normalized from bounded enums while allowing +full-value variables. Explicit redacted/operator-secret setting classifications omit raw values; names alone do not force omission. The `require_profile_for_data_model` guard makes the discriminator executable: a `${var}` placeholder is exempt and the open `unknown`/`other`/`relational` tail is permissive, but a concrete `search_index` -requires at least one `index` partition carrying shard/replica geometry, a -`wide_column` store requires at least one `keyspace` partition with a replication -strategy and factor, and a `key_value` store requires a `persistence` profile and -rejects relational/wide-column partitions. The owning transport `service` -resolves to a same-node binding, and a non-empty, non-variable `authorization_ref` -resolves to a same-node `app_authorization` (the delegated internal RBAC store). +requires at least one `index` partition carrying shard/replica geometry and at +least one structured mapping manifest, a `wide_column` store requires at least +one `keyspace` partition with a replication strategy and factor, and a +`key_value` store requires a `persistence` profile and rejects +relational/wide-column partitions. Mapping `partition_ref` values resolve to +sibling datastore partitions, and template `mapping_ref` values resolve to +sibling mapping manifests. Raw mapping/template response bodies are not model +data; bounded manifests carry counts, summaries, digests, and evidence refs. +The owning transport `service` resolves to a same-node binding, and a non-empty, +non-variable `authorization_ref` resolves to a same-node `app_authorization` (the +delegated internal RBAC store). The optional `runtime.platform_applications` inventory has model-local and semantic rules. Platform-application ids are stable concrete symbols and unique diff --git a/implementations/python/packages/aces_sdl/_runtime_service_families.py b/implementations/python/packages/aces_sdl/_runtime_service_families.py index 7464ec5a8..da1fde0f1 100644 --- a/implementations/python/packages/aces_sdl/_runtime_service_families.py +++ b/implementations/python/packages/aces_sdl/_runtime_service_families.py @@ -191,6 +191,8 @@ def public_symbols(self) -> tuple[str, ...]: child_refs=( RuntimeReferenceChild("nodes", "node_id"), RuntimeReferenceChild("partitions", "partition_id"), + RuntimeReferenceChild("templates", "template_id"), + RuntimeReferenceChild("mappings", "mapping_id"), RuntimeReferenceChild("settings", "setting_id"), ), ), diff --git a/implementations/python/packages/aces_sdl/runtime_datastore.py b/implementations/python/packages/aces_sdl/runtime_datastore.py index e8390b19a..a8b0e232e 100644 --- a/implementations/python/packages/aces_sdl/runtime_datastore.py +++ b/implementations/python/packages/aces_sdl/runtime_datastore.py @@ -27,10 +27,12 @@ from ._base import SDLModel, is_variable_ref from .runtime_datastore_partitions import ( RuntimeDatastoreCluster, + RuntimeDatastoreMapping, RuntimeDatastoreNode, RuntimeDatastorePartition, RuntimeDatastorePersistence, RuntimeDatastoreSetting, + RuntimeDatastoreTemplate, RuntimeDatastoreTransportSecurity, ) from .runtime_datastore_vocab import ( @@ -55,6 +57,7 @@ "RuntimeDatastoreDataModel", "RuntimeDatastoreEngine", "RuntimeDatastoreEvictionPolicy", + "RuntimeDatastoreMapping", "RuntimeDatastoreNode", "RuntimeDatastoreNodeRole", "RuntimeDatastorePartition", @@ -65,6 +68,7 @@ "RuntimeDatastoreSetting", "RuntimeDatastoreSettingProvenance", "RuntimeDatastoreSettingScope", + "RuntimeDatastoreTemplate", "RuntimeDatastoreTransportSecurity", "RuntimeDatastoreTransportSecurityMode", ] @@ -90,9 +94,9 @@ class RuntimeDatastoreService(SDLModel): cluster: RuntimeDatastoreCluster | None = None nodes: list[RuntimeDatastoreNode] = Field(default_factory=list) partitions: list[RuntimeDatastorePartition] = Field(default_factory=list) - templates: list[str] = Field(default_factory=list) + templates: list[RuntimeDatastoreTemplate] = Field(default_factory=list) aliases: list[str] = Field(default_factory=list) - mappings: list[str] = Field(default_factory=list) + mappings: list[RuntimeDatastoreMapping] = Field(default_factory=list) lifecycle_policies: list[str] = Field(default_factory=list) ingest_pipelines: list[str] = Field(default_factory=list) persistence: RuntimeDatastorePersistence | None = None @@ -121,9 +125,7 @@ def normalize_data_model(cls, v: RuntimeDatastoreDataModel | str) -> object: return parse_runtime_enum_or_var(v, RuntimeDatastoreDataModel, field_name="data_model") @field_validator( - "templates", "aliases", - "mappings", "lifecycle_policies", "ingest_pipelines", "pubsub_channels", @@ -136,18 +138,27 @@ def normalize_data_model(cls, v: RuntimeDatastoreDataModel | str) -> object: def coerce_string_lists(cls, v: object) -> object: return coerce_string_list(v) + @field_validator("templates", mode="before") + @classmethod + def coerce_templates(cls, v: object) -> object: + return _coerce_manifest_entries(v, id_field="template_id") + + @field_validator("mappings", mode="before") + @classmethod + def coerce_mappings(cls, v: object) -> object: + return _coerce_manifest_entries(v, id_field="mapping_id") + @model_validator(mode="after") def validate_datastore_service(self) -> "RuntimeDatastoreService": self._reject_duplicate_string_lists() _reject_duplicate_local_ref_ids(self) self.require_profile_for_data_model() + self._validate_local_manifest_refs() return self def _reject_duplicate_string_lists(self) -> None: for field_name in ( - "templates", "aliases", - "mappings", "lifecycle_policies", "ingest_pipelines", "pubsub_channels", @@ -197,6 +208,11 @@ def _require_search_index_profile(self) -> None: f"datastore service '{self.datastore_service_id}' search_index partition " f"'{partition.partition_id}' must carry shard_count and replica_count geometry" ) + if not self.mappings: + raise ValueError( + f"datastore service '{self.datastore_service_id}' data_model 'search_index' " + f"requires at least one structured mapping manifest" + ) def _require_key_value_profile(self) -> None: if self.persistence is None: @@ -246,6 +262,24 @@ def _has_concrete_replication(partition: RuntimeDatastorePartition) -> bool: return strategy is not RuntimeDatastoreReplicationStrategy.UNKNOWN return False + def _validate_local_manifest_refs(self) -> None: + partition_ids = {partition.partition_id for partition in self.partitions} + mapping_ids = {mapping.mapping_id for mapping in self.mappings} + for mapping in self.mappings: + ref = mapping.partition_ref + if ref and not is_variable_ref(ref) and ref not in partition_ids: + raise ValueError( + f"datastore service '{self.datastore_service_id}' mapping '{mapping.mapping_id}' " + f"partition_ref '{ref}' does not resolve to a partition_id" + ) + for template in self.templates: + ref = template.mapping_ref + if ref and not is_variable_ref(ref) and ref not in mapping_ids: + raise ValueError( + f"datastore service '{self.datastore_service_id}' template '{template.template_id}' " + f"mapping_ref '{ref}' does not resolve to a mapping_id" + ) + def _reject_duplicate_values(values: list[object], *, field_name: str, owner: str) -> None: seen: set[object] = set() @@ -266,6 +300,8 @@ def _reject_duplicate_local_ref_ids(service: RuntimeDatastoreService) -> None: for label, collection_name in ( ("node_id", "nodes"), ("partition_id", "partitions"), + ("template_id", "templates"), + ("mapping_id", "mappings"), ("setting_id", "settings"), ): entries.extend((label, getattr(item, label)) for item in getattr(service, collection_name)) @@ -279,3 +315,12 @@ def _reject_duplicate_local_ref_ids(service: RuntimeDatastoreService) -> None: f"'{service.datastore_service_id}' across {prior} and {label}" ) seen[value] = label + + +def _coerce_manifest_entries(value: object, *, id_field: str) -> object: + """Preserve legacy string entries by lifting them into typed manifests.""" + if isinstance(value, str): + return [{id_field: value, "name": value}] + if not isinstance(value, list): + return value + return [{id_field: item, "name": item} if isinstance(item, str) else item for item in value] diff --git a/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py b/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py index ba4bd9b74..92c11658d 100644 --- a/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py +++ b/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py @@ -34,10 +34,12 @@ __all__ = [ "RuntimeDatastoreCluster", + "RuntimeDatastoreMapping", "RuntimeDatastoreNode", "RuntimeDatastorePartition", "RuntimeDatastorePersistence", "RuntimeDatastoreSetting", + "RuntimeDatastoreTemplate", "RuntimeDatastoreTransportSecurity", ] @@ -173,6 +175,92 @@ def parse_int_maps(cls, v: object, info: ValidationInfo) -> object: return {key: parse_int_or_var(value, minimum=0, field_name=info.field_name) for key, value in v.items()} +class RuntimeDatastoreMapping(SDLModel): + """A bounded manifest of an observed search-index mapping/schema. + + Carries schema geometry and digest/evidence facts, never the raw + OpenSearch/Elasticsearch ``_mapping`` response body. + """ + + mapping_id: str + name: str = "" + partition_ref: str = "" + top_level_field_count: int | str | None = None + leaf_field_count: int | str | None = None + field_type_census: dict[str, int | str] = Field(default_factory=dict) + dynamic_policy: str = "" + dynamic_template_count: int | str | None = None + date_detection: bool | str | None = None + schema_digest: str = "" + evidence_refs: list[str] = Field(default_factory=list) + description: str = "" + + @field_validator("mapping_id") + @classmethod + def validate_mapping_id(cls, v: str) -> str: + return require_symbol(v, field_name="mapping_id") + + @field_validator("top_level_field_count", "leaf_field_count", "dynamic_template_count", mode="before") + @classmethod + def parse_counts(cls, v: object, info: ValidationInfo) -> int | str | None: + return parse_int_or_var(v, minimum=0, field_name=info.field_name) if v is not None else v + + @field_validator("field_type_census", mode="before") + @classmethod + def parse_field_type_census(cls, v: object) -> object: + if not isinstance(v, dict): + return v + return {key: parse_int_or_var(value, minimum=0, field_name="field_type_census") for key, value in v.items()} + + @field_validator("date_detection", mode="before") + @classmethod + def parse_date_detection(cls, v: object) -> bool | str | None: + return parse_optional_bool_or_var(v, field_name="date_detection") + + @field_validator("evidence_refs", mode="before") + @classmethod + def coerce_evidence_refs(cls, v: object) -> object: + return coerce_string_list(v) + + @model_validator(mode="after") + def validate_mapping(self) -> "RuntimeDatastoreMapping": + _reject_duplicate_values(self.evidence_refs, field_name="evidence_refs", owner=self.mapping_id) + return self + + +class RuntimeDatastoreTemplate(SDLModel): + """A bounded manifest of an observed index template body. + + The template captures patterns, selected settings, optional mapping linkage, + digest, and evidence refs without embedding the backend's raw template JSON. + """ + + template_id: str + name: str = "" + index_patterns: list[str] = Field(default_factory=list) + settings_summary: dict[str, str | int | bool] = Field(default_factory=dict) + mapping_ref: str = "" + template_digest: str = "" + evidence_refs: list[str] = Field(default_factory=list) + description: str = "" + + @field_validator("template_id") + @classmethod + def validate_template_id(cls, v: str) -> str: + return require_symbol(v, field_name="template_id") + + @field_validator("index_patterns", "evidence_refs", mode="before") + @classmethod + def coerce_string_fields(cls, v: object) -> object: + return coerce_string_list(v) + + @model_validator(mode="after") + def validate_template(self) -> "RuntimeDatastoreTemplate": + _reject_duplicate_values(self.index_patterns, field_name="index_patterns", owner=self.template_id) + _reject_duplicate_values(self.evidence_refs, field_name="evidence_refs", owner=self.template_id) + return self + + class RuntimeDatastorePersistence(SDLModel): """The single observed persistence posture of a key-value datastore. diff --git a/implementations/python/tests/test_runtime_datastore.py b/implementations/python/tests/test_runtime_datastore.py index 1e9a79e73..b1c886eba 100644 --- a/implementations/python/tests/test_runtime_datastore.py +++ b/implementations/python/tests/test_runtime_datastore.py @@ -9,16 +9,21 @@ from __future__ import annotations +import json + import pytest +from aces_sdl._runtime_service_families import collect_qualified_runtime_family_refs from aces_sdl.runtime_datastore import ( RuntimeDatastoreCluster, RuntimeDatastoreDataModel, RuntimeDatastoreEngine, + RuntimeDatastoreMapping, RuntimeDatastoreNode, RuntimeDatastorePartitionKind, RuntimeDatastorePersistence, RuntimeDatastoreService, RuntimeDatastoreSetting, + RuntimeDatastoreTemplate, RuntimeDatastoreTransportSecurity, ) from aces_sdl.runtime_datastore_vocab import ( @@ -29,8 +34,37 @@ RuntimeDatastoreSettingScope, RuntimeDatastoreTransportSecurityMode, ) +from paths import REPO_ROOT from pydantic import ValidationError +from aces.core.sdl.scenario import Scenario + +_PUBLISHED_SDL_SCHEMA_NAMES = ("instantiated-scenario-v1", "sdl-authoring-input-v1") +_DATASTORE_MAPPING_SCHEMA_FIELDS = { + "date_detection", + "description", + "dynamic_policy", + "dynamic_template_count", + "evidence_refs", + "field_type_census", + "leaf_field_count", + "mapping_id", + "name", + "partition_ref", + "schema_digest", + "top_level_field_count", +} +_DATASTORE_TEMPLATE_SCHEMA_FIELDS = { + "description", + "evidence_refs", + "index_patterns", + "mapping_ref", + "name", + "settings_summary", + "template_digest", + "template_id", +} + def _search_index_service(**overrides) -> dict: service = { @@ -65,9 +99,37 @@ def _search_index_service(**overrides) -> dict: "health": "green", } ], - "templates": ["wazuh-template"], + "templates": [ + { + "template_id": "wazuh-template", + "name": "wazuh", + "index_patterns": ["wazuh-alerts-4.x-*", "wazuh-archives-4.x-*"], + "settings_summary": { + "index.number_of_shards": "3", + "index.number_of_replicas": "0", + "index.refresh_interval": "5s", + }, + "mapping_ref": "wazuh-alerts-mapping", + "template_digest": "sha256:wazuh-template", + "evidence_refs": ["docs/aces/inventory/wazuh.indexer/evidence/wazuh-indexer-templates.json.gz"], + } + ], "aliases": ["wazuh-alerts"], - "mappings": ["index.mapping.total_fields.limit=10000"], + "mappings": [ + { + "mapping_id": "wazuh-alerts-mapping", + "partition_ref": "wazuh-alerts", + "name": "wazuh-alerts-4.x-*", + "top_level_field_count": 25, + "leaf_field_count": 670, + "field_type_census": {"keyword": 220, "date": 9, "ip": 12, "object": 70}, + "dynamic_policy": "true", + "dynamic_template_count": 5, + "date_detection": True, + "schema_digest": "sha256:wazuh-alerts-mapping", + "evidence_refs": ["docs/aces/inventory/wazuh.indexer/evidence/wazuh-indexer-family-mappings.json.gz"], + } + ], "lifecycle_policies": ["wazuh-ism"], "ingest_pipelines": ["geoip"], "engine_plugins": ["opensearch-security", "opensearch-alerting"], @@ -161,6 +223,30 @@ def test_search_index_service_typed_children() -> None: assert svc.nodes[0].roles[0] is RuntimeDatastoreNodeRole.CLUSTER_MANAGER assert svc.partitions[0].kind is RuntimeDatastorePartitionKind.INDEX assert svc.partitions[0].shard_count == 3 + assert isinstance(svc.mappings[0], RuntimeDatastoreMapping) + assert svc.mappings[0].top_level_field_count == 25 + assert svc.mappings[0].leaf_field_count == 670 + assert svc.mappings[0].partition_ref == "wazuh-alerts" + assert svc.mappings[0].field_type_census["keyword"] == 220 + assert svc.mappings[0].dynamic_policy == "true" + assert svc.mappings[0].dynamic_template_count == 5 + assert svc.mappings[0].date_detection is True + assert svc.mappings[0].schema_digest == "sha256:wazuh-alerts-mapping" + assert svc.mappings[0].evidence_refs == [ + "docs/aces/inventory/wazuh.indexer/evidence/wazuh-indexer-family-mappings.json.gz" + ] + assert isinstance(svc.templates[0], RuntimeDatastoreTemplate) + assert svc.templates[0].index_patterns == ["wazuh-alerts-4.x-*", "wazuh-archives-4.x-*"] + assert svc.templates[0].settings_summary == { + "index.number_of_shards": "3", + "index.number_of_replicas": "0", + "index.refresh_interval": "5s", + } + assert svc.templates[0].mapping_ref == "wazuh-alerts-mapping" + assert svc.templates[0].template_digest == "sha256:wazuh-template" + assert svc.templates[0].evidence_refs == [ + "docs/aces/inventory/wazuh.indexer/evidence/wazuh-indexer-templates.json.gz" + ] assert isinstance(svc.transport_security, RuntimeDatastoreTransportSecurity) assert svc.transport_security.mode is RuntimeDatastoreTransportSecurityMode.MUTUAL_TLS assert svc.settings[0].scope is RuntimeDatastoreSettingScope.CLUSTER @@ -199,6 +285,29 @@ def test_kebab_case_enum_inputs_normalize() -> None: assert svc.nodes[0].roles[0] is RuntimeDatastoreNodeRole.CLUSTER_MANAGER +def test_legacy_string_template_mapping_inputs_coerce_to_typed_manifests() -> None: + svc = RuntimeDatastoreService( + **_search_index_service( + templates=["legacy-template"], + mappings=["legacy-mapping"], + ) + ) + + assert svc.templates[0].template_id == "legacy-template" + assert svc.templates[0].name == "legacy-template" + assert svc.mappings[0].mapping_id == "legacy-mapping" + assert svc.mappings[0].name == "legacy-mapping" + + single = RuntimeDatastoreService( + **_search_index_service( + templates="single-template", + mappings="single-mapping", + ) + ) + assert single.templates[0].template_id == "single-template" + assert single.mappings[0].mapping_id == "single-mapping" + + def test_relational_and_open_tail_impose_no_profile() -> None: # relational, unknown, other are permissive — a near-empty instance validates. for model in ("relational", "unknown", "other"): @@ -211,6 +320,31 @@ def test_variable_ref_data_model_is_exempt_from_guard() -> None: assert svc.data_model == "${DATA_MODEL}" +def test_variable_refs_for_mapping_and_template_links_are_exempt_from_resolution_guard() -> None: + svc = RuntimeDatastoreService( + **_search_index_service( + mappings=[ + { + "mapping_id": "deferred-mapping", + "partition_ref": "${PARTITION_ID}", + "top_level_field_count": 1, + "leaf_field_count": 1, + } + ], + templates=[ + { + "template_id": "deferred-template", + "mapping_ref": "${MAPPING_ID}", + "index_patterns": ["wazuh-*"], + } + ], + ) + ) + + assert svc.mappings[0].partition_ref == "${PARTITION_ID}" + assert svc.templates[0].mapping_ref == "${MAPPING_ID}" + + # --------------------------------------------------------------------------- # # Duplicate-id and secret-redaction guards # # --------------------------------------------------------------------------- # @@ -233,6 +367,44 @@ def test_rejects_duplicate_string_list_entries() -> None: RuntimeDatastoreService(**_search_index_service(engine_plugins=["a", "a"])) +def test_rejects_duplicate_mapping_and_template_ids() -> None: + with pytest.raises(ValidationError, match="Duplicate runtime datastore stable id 'dup'"): + RuntimeDatastoreService( + **_search_index_service( + mappings=[ + {"mapping_id": "dup", "partition_ref": "wazuh-alerts"}, + {"mapping_id": "dup", "partition_ref": "wazuh-alerts"}, + ], + ) + ) + + with pytest.raises(ValidationError, match="Duplicate runtime datastore stable id 'dup'"): + RuntimeDatastoreService( + **_search_index_service( + templates=[ + {"template_id": "dup", "mapping_ref": "wazuh-alerts-mapping"}, + {"template_id": "dup", "mapping_ref": "wazuh-alerts-mapping"}, + ], + ) + ) + + +def test_mapping_and_template_reject_duplicate_local_lists() -> None: + with pytest.raises(ValidationError, match="Duplicate runtime datastore evidence_refs"): + RuntimeDatastoreMapping(mapping_id="mapping", evidence_refs=["e1", "e1"]) + + with pytest.raises(ValidationError, match="Duplicate runtime datastore index_patterns"): + RuntimeDatastoreTemplate(template_id="template", index_patterns=["wazuh-*", "wazuh-*"]) + + +def test_mapping_and_template_ids_reject_variable_placeholders() -> None: + with pytest.raises(ValidationError, match="mapping_id must be a stable identifier"): + RuntimeDatastoreMapping(mapping_id="${MAPPING_ID}") + + with pytest.raises(ValidationError, match="template_id must be a stable identifier"): + RuntimeDatastoreTemplate(template_id="${TEMPLATE_ID}") + + def test_secret_named_setting_may_carry_scenario_value() -> None: setting = RuntimeDatastoreSetting(setting_id="admin-pw", name="admin_password", value="hunter2") @@ -275,6 +447,83 @@ def test_search_index_requires_shard_replica_geometry() -> None: ) +def test_search_index_requires_mapping_manifest() -> None: + with pytest.raises(ValidationError, match="requires at least one structured mapping manifest"): + RuntimeDatastoreService(**_search_index_service(mappings=[])) + + +def test_mapping_partition_ref_must_resolve() -> None: + with pytest.raises(ValidationError, match="mapping 'orphan' partition_ref 'missing-index' does not resolve"): + RuntimeDatastoreService( + **_search_index_service( + mappings=[{"mapping_id": "orphan", "partition_ref": "missing-index"}], + ) + ) + + +def test_template_mapping_ref_must_resolve() -> None: + with pytest.raises( + ValidationError, match="template 'orphan-template' mapping_ref 'missing-mapping' does not resolve" + ): + RuntimeDatastoreService( + **_search_index_service( + templates=[{"template_id": "orphan-template", "mapping_ref": "missing-mapping"}], + ) + ) + + +def test_mapping_and_template_refs_are_targetable() -> None: + scenario = Scenario( + name="datastore-refs", + nodes={ + "indexer": { + "type": "vm", + "runtime": {"datastore_services": [_search_index_service()]}, + } + }, + ) + + refs = collect_qualified_runtime_family_refs(scenario, family_keys={"datastore-services"}) + + assert "nodes.indexer.runtime.datastore_services.wazuh-indexer.mappings.wazuh-alerts-mapping" in refs + assert "nodes.indexer.runtime.datastore_services.wazuh-indexer.templates.wazuh-template" in refs + + +def test_published_sdl_schemas_include_mapping_and_template_manifests() -> None: + for schema_name in _PUBLISHED_SDL_SCHEMA_NAMES: + schema_path = REPO_ROOT / "contracts" / "schemas" / "sdl" / f"{schema_name}.json" + schema = json.loads(schema_path.read_text(encoding="utf-8")) + defs = schema["$defs"] + service_properties = defs["RuntimeDatastoreService"]["properties"] + + assert service_properties["mappings"]["items"]["$ref"] == "#/$defs/RuntimeDatastoreMapping" + assert service_properties["templates"]["items"]["$ref"] == "#/$defs/RuntimeDatastoreTemplate" + + mapping_schema = defs["RuntimeDatastoreMapping"] + assert mapping_schema["additionalProperties"] is False + assert set(mapping_schema["properties"]) == _DATASTORE_MAPPING_SCHEMA_FIELDS + assert mapping_schema["required"] == ["mapping_id"] + assert mapping_schema["properties"]["field_type_census"]["additionalProperties"]["anyOf"] == [ + {"type": "integer"}, + {"type": "string"}, + ] + assert mapping_schema["properties"]["date_detection"]["anyOf"] == [ + {"type": "boolean"}, + {"type": "string"}, + {"type": "null"}, + ] + + template_schema = defs["RuntimeDatastoreTemplate"] + assert template_schema["additionalProperties"] is False + assert set(template_schema["properties"]) == _DATASTORE_TEMPLATE_SCHEMA_FIELDS + assert template_schema["required"] == ["template_id"] + assert template_schema["properties"]["settings_summary"]["additionalProperties"]["anyOf"] == [ + {"type": "string"}, + {"type": "integer"}, + {"type": "boolean"}, + ] + + # --------------------------------------------------------------------------- # # require_profile_for_data_model — key_value # # --------------------------------------------------------------------------- # From adf63e5f8baad1426c63495b206ccdd2e3eeb8e4 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 7 Jun 2026 10:39:34 +0200 Subject: [PATCH 08/70] Add datastore cardinality fields Issue: #468 Requirement: DSL-132 --- changelog.d/468.added.md | 1 + .../schemas/sdl/instantiated-scenario-v1.json | 144 +++++++++++++++++- .../schemas/sdl/sdl-authoring-input-v1.json | 144 +++++++++++++++++- ...048-datastore-service-runtime-inventory.md | 22 ++- docs/explain/sdl/sections.md | 22 ++- docs/explain/sdl/validation.md | 5 + .../aces_sdl/runtime_datastore_partitions.py | 37 ++++- .../python/tests/test_runtime_datastore.py | 131 ++++++++++++++++ 8 files changed, 493 insertions(+), 13 deletions(-) create mode 100644 changelog.d/468.added.md diff --git a/changelog.d/468.added.md b/changelog.d/468.added.md new file mode 100644 index 000000000..94060c3b4 --- /dev/null +++ b/changelog.d/468.added.md @@ -0,0 +1 @@ +Added typed datastore cluster and partition fields for native UUIDs, document counts, deleted-document counts, byte-normalized store sizes, creation timestamps, and open/closed status. diff --git a/contracts/schemas/sdl/instantiated-scenario-v1.json b/contracts/schemas/sdl/instantiated-scenario-v1.json index c3a2f0678..2cd088860 100644 --- a/contracts/schemas/sdl/instantiated-scenario-v1.json +++ b/contracts/schemas/sdl/instantiated-scenario-v1.json @@ -6543,7 +6543,7 @@ }, "RuntimeDatastoreCluster": { "additionalProperties": false, - "description": "The single observed cluster posture of a datastore service.\n\nCaptures the cluster-identity facts a search/wide-column cluster exposes:\nhealth, discovery mode, partitioner, and native protocol version.", + "description": "The single observed cluster posture of a datastore service.\n\nCaptures the cluster-identity facts a search/wide-column cluster exposes:\nnative UUID, aggregate cardinality and size, shard totals, health,\ndiscovery mode, partitioner, and native protocol version.", "properties": { "cluster_id": { "title": "Cluster Id", @@ -6559,6 +6559,21 @@ "title": "Discovery Mode", "type": "string" }, + "doc_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Doc Count" + }, "health": { "default": "", "title": "Health", @@ -6574,10 +6589,75 @@ "title": "Native Protocol Version", "type": "string" }, + "node_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Node Count" + }, "partitioner": { "default": "", "title": "Partitioner", "type": "string" + }, + "shard_primaries": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Shard Primaries" + }, + "shard_total": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Shard Total" + }, + "store_size_bytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Store Size Bytes" + }, + "uuid": { + "default": "", + "title": "Uuid", + "type": "string" } }, "required": [ @@ -6709,8 +6789,13 @@ }, "RuntimeDatastorePartition": { "additionalProperties": false, - "description": "An observed partition primitive (index / keyspace / logical_db / family).\n\nCarries the geometry that differentiates one datastore data model from\nanother: shard/replica counts for a search index, replication strategy +\nfactor + per-DC factor map for a wide-column keyspace, and a datatype census\nfor a key-value logical database.", + "description": "An observed partition primitive (index / keyspace / logical_db / family).\n\nCarries the geometry that differentiates one datastore data model from\nanother: native partition/index UUID, shard/replica and document counts for\na search index, replication strategy + factor + per-DC factor map for a\nwide-column keyspace, and a datatype census for a key-value logical\ndatabase.", "properties": { + "creation_timestamp": { + "default": "", + "title": "Creation Timestamp", + "type": "string" + }, "datatype_census": { "additionalProperties": { "anyOf": [ @@ -6730,6 +6815,36 @@ "title": "Description", "type": "string" }, + "doc_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Doc Count" + }, + "doc_count_deleted": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Doc Count Deleted" + }, "durable_writes": { "anyOf": [ { @@ -6767,6 +6882,11 @@ "title": "Name", "type": "string" }, + "open_closed_status": { + "default": "", + "title": "Open Closed Status", + "type": "string" + }, "partition_id": { "title": "Partition Id", "type": "string" @@ -6841,6 +6961,26 @@ ], "default": null, "title": "Shard Count" + }, + "store_size_bytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Store Size Bytes" + }, + "uuid": { + "default": "", + "title": "Uuid", + "type": "string" } }, "required": [ diff --git a/contracts/schemas/sdl/sdl-authoring-input-v1.json b/contracts/schemas/sdl/sdl-authoring-input-v1.json index 4b0827fa8..46dd95baf 100644 --- a/contracts/schemas/sdl/sdl-authoring-input-v1.json +++ b/contracts/schemas/sdl/sdl-authoring-input-v1.json @@ -6543,7 +6543,7 @@ }, "RuntimeDatastoreCluster": { "additionalProperties": false, - "description": "The single observed cluster posture of a datastore service.\n\nCaptures the cluster-identity facts a search/wide-column cluster exposes:\nhealth, discovery mode, partitioner, and native protocol version.", + "description": "The single observed cluster posture of a datastore service.\n\nCaptures the cluster-identity facts a search/wide-column cluster exposes:\nnative UUID, aggregate cardinality and size, shard totals, health,\ndiscovery mode, partitioner, and native protocol version.", "properties": { "cluster_id": { "title": "Cluster Id", @@ -6559,6 +6559,21 @@ "title": "Discovery Mode", "type": "string" }, + "doc_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Doc Count" + }, "health": { "default": "", "title": "Health", @@ -6574,10 +6589,75 @@ "title": "Native Protocol Version", "type": "string" }, + "node_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Node Count" + }, "partitioner": { "default": "", "title": "Partitioner", "type": "string" + }, + "shard_primaries": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Shard Primaries" + }, + "shard_total": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Shard Total" + }, + "store_size_bytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Store Size Bytes" + }, + "uuid": { + "default": "", + "title": "Uuid", + "type": "string" } }, "required": [ @@ -6709,8 +6789,13 @@ }, "RuntimeDatastorePartition": { "additionalProperties": false, - "description": "An observed partition primitive (index / keyspace / logical_db / family).\n\nCarries the geometry that differentiates one datastore data model from\nanother: shard/replica counts for a search index, replication strategy +\nfactor + per-DC factor map for a wide-column keyspace, and a datatype census\nfor a key-value logical database.", + "description": "An observed partition primitive (index / keyspace / logical_db / family).\n\nCarries the geometry that differentiates one datastore data model from\nanother: native partition/index UUID, shard/replica and document counts for\na search index, replication strategy + factor + per-DC factor map for a\nwide-column keyspace, and a datatype census for a key-value logical\ndatabase.", "properties": { + "creation_timestamp": { + "default": "", + "title": "Creation Timestamp", + "type": "string" + }, "datatype_census": { "additionalProperties": { "anyOf": [ @@ -6730,6 +6815,36 @@ "title": "Description", "type": "string" }, + "doc_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Doc Count" + }, + "doc_count_deleted": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Doc Count Deleted" + }, "durable_writes": { "anyOf": [ { @@ -6767,6 +6882,11 @@ "title": "Name", "type": "string" }, + "open_closed_status": { + "default": "", + "title": "Open Closed Status", + "type": "string" + }, "partition_id": { "title": "Partition Id", "type": "string" @@ -6841,6 +6961,26 @@ ], "default": null, "title": "Shard Count" + }, + "store_size_bytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Store Size Bytes" + }, + "uuid": { + "default": "", + "title": "Uuid", + "type": "string" } }, "required": [ diff --git a/docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md b/docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md index bdbd70c60..f4964ac0d 100644 --- a/docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md +++ b/docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md @@ -19,6 +19,8 @@ key-value store that recur across the corpus. Those nodes already fit adjacent ACES surfaces for package identity, processes, filesystem evidence, network attachment, and transport listeners, but no surface carries the datastore facts that matter to participants and downstream inventory consumers: search +cluster identity, per-index identity, document cardinality, deleted-document +cardinality, store size in bytes, creation timestamp, open/closed status, shard/replica geometry, wide-column replication strategy/factor, and key-value persistence posture. @@ -57,7 +59,9 @@ open `unknown` / `other` / `relational` tail imposes no profile, but each concrete structural data model requires its defining geometry: - `search_index` requires at least one `partition` with `kind: index` carrying - shard/replica counts. + shard/replica counts. Search-index clusters and partitions may also carry + native UUIDs, aggregate and per-index document counts, deleted-document counts, + byte-normalized store sizes, creation timestamps, and open/closed status. - `wide_column` requires at least one `keyspace` partition with a concrete replication strategy and a replication factor. - `key_value` requires a `persistence` profile and rejects relational / @@ -72,6 +76,15 @@ The service owns single nested postures (`cluster`, `persistence`, `backup_targets` are bare reference-name lists. Secret-bearing settings omit their raw value and classify `redacted` / `operator_secret`. +`cluster` records the service-local ACES `cluster_id` plus optional native +cluster `uuid`, observed `node_count`, aggregate `doc_count`, +`store_size_bytes`, `shard_total`, and `shard_primaries`. `partitions` record +the service-local ACES `partition_id` plus optional native partition/index +`uuid`, `doc_count`, `doc_count_deleted`, `store_size_bytes`, +`creation_timestamp`, and `open_closed_status`. These are participant-observed +facts, not reference identities; ACES refs continue to target the stable +`cluster_id` / `partition_id` values. + ### 4. Keep datastore inventory targetable but not executable Services and id-bearing children may be referenced from relationships using @@ -89,7 +102,8 @@ replication, or persistence behavior. - Parser/model gate: stable service and child ids are concrete symbols. Enum fields are normalized through the single runtime enum-parse helper. Duplicate - service ids and duplicate service-local child ids fail early. + service ids and duplicate service-local child ids fail early. Count and byte + fields accept only non-negative integers or `${var}` placeholders. - Required-profile gate: the `require_profile_for_data_model` guard fails an under-populated `search_index` / `wide_column` / `key_value` instance. - Semantic validation gate: the owning `service` ref resolves to a same-node @@ -109,6 +123,10 @@ replication, or persistence behavior. config blobs, or prose-only relationships. - Do not embed principals, roles, or grants here; internal RBAC is delegated to `runtime.app_authorizations` via `authorization_ref`. +- Do not use `datatype_census` as a search-index document-count field; it + remains a key-value datatype census. +- Do not preserve native store-size prose such as `92.4mb`; normalize observed + store sizes to bytes before authoring SDL. - Do not make OpenSearch, Cassandra, or Redis the schema authority. They motivate the surface; the SDL model remains product-neutral. diff --git a/docs/explain/sdl/sections.md b/docs/explain/sdl/sections.md index 842c5fa92..87c3f098b 100644 --- a/docs/explain/sdl/sections.md +++ b/docs/explain/sdl/sections.md @@ -543,14 +543,26 @@ nodes: version: "2.13" cluster: cluster_id: os-cluster + uuid: native-cluster-uuid health: green discovery_mode: zen + node_count: 3 + shard_total: 6 + shard_primaries: 3 + doc_count: 1053842 + store_size_bytes: 1391460626 nodes: - node_id: os-node-1 roles: [data, master] partitions: # search_index requires shard/replica geometry - partition_id: alerts-index + uuid: native-index-uuid kind: index + doc_count: 68993 + doc_count_deleted: 0 + store_size_bytes: 96888422 + creation_timestamp: "2026-05-28T00:00:05.253Z" + open_closed_status: open shard_count: 3 replica_count: 1 transport_security: @@ -1075,7 +1087,11 @@ irreducibly-relational `runtime.database_services` cannot shape. Each entry is a `wide_column`, `key_value`, `relational`, `unknown`, `other`). The discriminator drives a required-profile guard so an under-populated instance fails validation: a `search_index` requires at least one `partition` with `kind: index` carrying -shard/replica geometry; a `wide_column` store requires at least one `keyspace` +shard/replica geometry; a search cluster may also record native UUID, +node/shard/document aggregate counts, and byte-normalized store size, while each +index partition may record native UUID, live and deleted document counts, +byte-normalized store size, creation timestamp, and open/closed status. A +`wide_column` store requires at least one `keyspace` partition with a `replication_strategy` and `replication_factor`; and a `key_value` store requires a `persistence` profile and rejects relational object-tree (`keyspace`/`column_family`) partitions. `cluster`, `persistence`, @@ -1086,7 +1102,9 @@ and `transport_security` are single nested postures, while `nodes`, bare reference-name lists. `settings` reuse the shared runtime sensitivity vocabulary: explicit `redacted`/`operator_secret` classifications omit raw values, while credential-shaped setting names remain scenario content unless -the author marks the value withheld. Application-internal RBAC is delegated to +the author marks the value withheld. `datatype_census` remains a key-value +datatype census and is not a search-index document count. Application-internal +RBAC is delegated to `runtime.app_authorizations` via the string `authorization_ref` (resolved to a same-node `app_authorization_id`), so the surface carries no embedded principal/role/grant. Fully qualified refs such as diff --git a/docs/explain/sdl/validation.md b/docs/explain/sdl/validation.md index 8ecf95381..52ce4750f 100644 --- a/docs/explain/sdl/validation.md +++ b/docs/explain/sdl/validation.md @@ -255,6 +255,11 @@ node, partition, and setting ids are unique across the service. Engines, data models, partition kinds, node roles, persistence eviction policies, replication strategies, transport-security modes, and setting scope/provenance/classification are normalized from bounded enums while allowing full-value variables. +Native cluster/index UUIDs are observed datastore facts, not SDL reference +identities; references continue to target the stable ACES ids. Count and byte +fields on clusters and partitions accept only non-negative integers or +full-value variables, keeping document cardinality and byte-normalized store +size distinct from `datatype_census`. Explicit redacted/operator-secret setting classifications omit raw values; names alone do not force omission. The `require_profile_for_data_model` guard makes the discriminator executable: a `${var}` placeholder is exempt and the open diff --git a/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py b/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py index ba4bd9b74..ea8603c43 100644 --- a/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py +++ b/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py @@ -101,15 +101,22 @@ class RuntimeDatastoreCluster(SDLModel): """The single observed cluster posture of a datastore service. Captures the cluster-identity facts a search/wide-column cluster exposes: - health, discovery mode, partitioner, and native protocol version. + native UUID, aggregate cardinality and size, shard totals, health, + discovery mode, partitioner, and native protocol version. """ cluster_id: str + uuid: str = "" name: str = "" health: str = "" discovery_mode: str = "" partitioner: str = "" native_protocol_version: str = "" + node_count: int | str | None = None + shard_total: int | str | None = None + shard_primaries: int | str | None = None + doc_count: int | str | None = None + store_size_bytes: int | str | None = None description: str = "" @field_validator("cluster_id") @@ -117,21 +124,33 @@ class RuntimeDatastoreCluster(SDLModel): def validate_cluster_id(cls, v: str) -> str: return require_symbol(v, field_name="cluster_id") + @field_validator("node_count", "shard_total", "shard_primaries", "doc_count", "store_size_bytes", mode="before") + @classmethod + def parse_counts(cls, v: object, info: ValidationInfo) -> int | str | None: + return parse_int_or_var(v, minimum=0, field_name=info.field_name) if v is not None else v + class RuntimeDatastorePartition(SDLModel): """An observed partition primitive (index / keyspace / logical_db / family). Carries the geometry that differentiates one datastore data model from - another: shard/replica counts for a search index, replication strategy + - factor + per-DC factor map for a wide-column keyspace, and a datatype census - for a key-value logical database. + another: native partition/index UUID, shard/replica and document counts for + a search index, replication strategy + factor + per-DC factor map for a + wide-column keyspace, and a datatype census for a key-value logical + database. """ partition_id: str kind: RuntimeDatastorePartitionKind | str = RuntimeDatastorePartitionKind.UNKNOWN name: str = "" + uuid: str = "" shard_count: int | str | None = None replica_count: int | str | None = None + doc_count: int | str | None = None + doc_count_deleted: int | str | None = None + store_size_bytes: int | str | None = None + creation_timestamp: str = "" + open_closed_status: str = "" replication_strategy: RuntimeDatastoreReplicationStrategy | str = RuntimeDatastoreReplicationStrategy.UNKNOWN replication_factor: int | str | None = None per_dc_factor_map: dict[str, int | str] = Field(default_factory=dict) @@ -155,7 +174,15 @@ def normalize_kind(cls, v: RuntimeDatastorePartitionKind | str) -> object: def normalize_replication_strategy(cls, v: RuntimeDatastoreReplicationStrategy | str) -> object: return parse_runtime_enum_or_var(v, RuntimeDatastoreReplicationStrategy, field_name="replication_strategy") - @field_validator("shard_count", "replica_count", "replication_factor", mode="before") + @field_validator( + "shard_count", + "replica_count", + "doc_count", + "doc_count_deleted", + "store_size_bytes", + "replication_factor", + mode="before", + ) @classmethod def parse_counts(cls, v: object, info: ValidationInfo) -> int | str | None: return parse_int_or_var(v, minimum=0, field_name=info.field_name) if v is not None else v diff --git a/implementations/python/tests/test_runtime_datastore.py b/implementations/python/tests/test_runtime_datastore.py index 1e9a79e73..448ea7543 100644 --- a/implementations/python/tests/test_runtime_datastore.py +++ b/implementations/python/tests/test_runtime_datastore.py @@ -168,6 +168,137 @@ def test_search_index_service_typed_children() -> None: assert svc.authorization_ref == "wazuh-indexer-rbac" +def test_search_index_records_cardinality_size_and_identity() -> None: + svc = RuntimeDatastoreService( + **_search_index_service( + cluster={ + "cluster_id": "wazuh-cluster", + "uuid": "u-vGl1n0Q7e-SKz1tWvb-w", + "name": "wazuh", + "health": "green", + "discovery_mode": "single-node", + "node_count": 1, + "shard_total": 102, + "shard_primaries": 102, + "doc_count": 1_053_842, + "store_size_bytes": 1_391_460_626, + }, + partitions=[ + { + "partition_id": "wazuh-archives-2026-05-28", + "uuid": "s0fv6XlzTEuJ", + "kind": "index", + "name": "wazuh-archives-4.x-2026.05.28", + "shard_count": 3, + "replica_count": 0, + "doc_count": 68_993, + "doc_count_deleted": 0, + "store_size_bytes": 96_888_422, + "creation_timestamp": "2026-05-28T00:00:05.253Z", + "open_closed_status": "open", + "health": "green", + } + ], + ) + ) + + assert svc.cluster is not None + assert svc.cluster.uuid == "u-vGl1n0Q7e-SKz1tWvb-w" + assert svc.cluster.node_count == 1 + assert svc.cluster.shard_total == 102 + assert svc.cluster.shard_primaries == 102 + assert svc.cluster.doc_count == 1_053_842 + assert svc.cluster.store_size_bytes == 1_391_460_626 + + partition = svc.partitions[0] + assert partition.uuid == "s0fv6XlzTEuJ" + assert partition.doc_count == 68_993 + assert partition.doc_count_deleted == 0 + assert partition.store_size_bytes == 96_888_422 + assert partition.creation_timestamp == "2026-05-28T00:00:05.253Z" + assert partition.open_closed_status == "open" + + +def test_datastore_cardinality_fields_accept_variable_refs() -> None: + svc = RuntimeDatastoreService( + **_search_index_service( + cluster={ + "cluster_id": "wazuh-cluster", + "node_count": "${NODE_COUNT}", + "shard_total": "${SHARDS}", + "shard_primaries": "${PRIMARIES}", + "doc_count": "${DOCS}", + "store_size_bytes": "${BYTES}", + }, + partitions=[ + { + "partition_id": "idx", + "kind": "index", + "shard_count": "${SHARDS}", + "replica_count": "${REPLICAS}", + "doc_count": "${DOCS}", + "doc_count_deleted": "${DELETED}", + "store_size_bytes": "${BYTES}", + } + ], + ) + ) + + assert svc.cluster is not None + assert svc.cluster.doc_count == "${DOCS}" + assert svc.cluster.store_size_bytes == "${BYTES}" + assert svc.partitions[0].doc_count_deleted == "${DELETED}" + + +def test_datastore_cluster_rejects_negative_cardinality() -> None: + with pytest.raises(ValidationError, match="node_count must be >= 0"): + RuntimeDatastoreCluster(cluster_id="cluster", node_count=-1) + + +@pytest.mark.parametrize( + "field_name", + ["node_count", "shard_total", "shard_primaries", "doc_count", "store_size_bytes"], +) +def test_datastore_cluster_rejects_non_integer_cardinality_strings(field_name: str) -> None: + with pytest.raises(ValidationError, match=f"{field_name} must be an integer"): + RuntimeDatastoreCluster(cluster_id="cluster", **{field_name: "92.4mb"}) + + +def test_datastore_partition_rejects_negative_cardinality() -> None: + with pytest.raises(ValidationError, match="doc_count_deleted must be >= 0"): + RuntimeDatastoreService( + **_search_index_service( + partitions=[ + { + "partition_id": "idx", + "kind": "index", + "shard_count": 1, + "replica_count": 0, + "doc_count_deleted": -1, + } + ] + ) + ) + + +@pytest.mark.parametrize("field_name", ["doc_count", "doc_count_deleted", "store_size_bytes"]) +def test_datastore_partition_rejects_non_integer_cardinality_strings(field_name: str) -> None: + with pytest.raises(ValidationError, match=f"{field_name} must be an integer"): + RuntimeDatastoreService( + **_search_index_service( + partitions=[ + { + "partition_id": "idx", + "kind": "index", + "shard_count": 1, + "replica_count": 0, + field_name: "92.4mb", + } + ] + ) + ) + + def test_wide_column_service_typed_children() -> None: svc = RuntimeDatastoreService(**_wide_column_service()) From b3c76092e09c1b0e4d95a4f3c69c87a399d52f42 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 7 Jun 2026 11:37:44 +0200 Subject: [PATCH 09/70] Add DSL-141 datastore-node engine provenance and listener topology Extend RuntimeDatastoreNode (runtime.datastore_services, amending ADR-048 via ADR-058) with product-neutral, observed node facts the wazuh.indexer SCN-010 capture (aptl#341) could not type: - engine_version / build_hash / build_type, JVM heap_init_bytes / heap_max_bytes (parse_ram, with init<=max), and memory_locked (mlockall) posture. - RuntimeDatastoreEnginePlugin per-node inventory carrying per-plugin version, replacing the name-only, version-dropping service-level engine_plugins list. - RuntimeDatastoreNodeEndpoint client/peer listener inventory with split address/port and an open role taxonomy, replacing the single ambiguous node address (no engine-named fields; spine stays product-neutral). New plugin/endpoint ids join the datastore service-wide stable-id namespace and register as nested child refs. Regenerated contract schemas, extended the SDL docs set, and added the changelog fragment. --- changelog.d/470.added.md | 1 + .../schemas/sdl/instantiated-scenario-v1.json | 180 ++++++++++++- .../schemas/sdl/sdl-authoring-input-v1.json | 180 ++++++++++++- .../scn010-expressivity-gap-analysis.md | 2 +- docs/decisions/adrs/README.md | 2 + ...re-node-engine-provenance-and-endpoints.md | 170 ++++++++++++ docs/explain/sdl/limitations.md | 10 +- docs/explain/sdl/lineage.md | 13 + docs/explain/sdl/precedents.md | 2 +- docs/explain/sdl/sections.md | 39 ++- docs/explain/sdl/validation.md | 12 +- .../aces_sdl/_runtime_service_families.py | 9 +- .../packages/aces_sdl/runtime_datastore.py | 13 +- .../aces_sdl/runtime_datastore_partitions.py | 106 +++++++- .../aces_sdl/runtime_datastore_vocab.py | 18 ++ .../python/tests/test_runtime_datastore.py | 245 +++++++++++++++++- 16 files changed, 961 insertions(+), 41 deletions(-) create mode 100644 changelog.d/470.added.md create mode 100644 docs/decisions/adrs/adr-058-datastore-node-engine-provenance-and-endpoints.md diff --git a/changelog.d/470.added.md b/changelog.d/470.added.md new file mode 100644 index 000000000..1dae21b29 --- /dev/null +++ b/changelog.d/470.added.md @@ -0,0 +1 @@ +Added DSL-141 datastore-node engine provenance to `runtime.datastore_services`: typed `engine_version`/`build_hash`/`build_type`, JVM heap byte bounds and `memory_locked` posture, a per-node `RuntimeDatastoreEnginePlugin` inventory carrying per-plugin versions, and a product-neutral `RuntimeDatastoreNodeEndpoint` (client/peer) listener inventory — replacing the name-only service-level `engine_plugins` list and the single ambiguous node `address`. diff --git a/contracts/schemas/sdl/instantiated-scenario-v1.json b/contracts/schemas/sdl/instantiated-scenario-v1.json index c3a2f0678..274a25ddf 100644 --- a/contracts/schemas/sdl/instantiated-scenario-v1.json +++ b/contracts/schemas/sdl/instantiated-scenario-v1.json @@ -6615,6 +6615,36 @@ "title": "RuntimeDatastoreEngine", "type": "string" }, + "RuntimeDatastoreEnginePlugin": { + "additionalProperties": false, + "description": "An engine extension/plugin/module installed on a datastore node.\n\nPer-node installed-capability inventory (OpenSearch plugins, Redis modules,\n\u2026). Carries the per-plugin ``version`` the name-only service-level list could\nnot. ``plugin_id`` is a stable symbol; ``name`` is the observed engine name.", + "properties": { + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "plugin_id": { + "title": "Plugin Id", + "type": "string" + }, + "version": { + "default": "", + "title": "Version", + "type": "string" + } + }, + "required": [ + "plugin_id" + ], + "title": "RuntimeDatastoreEnginePlugin", + "type": "object" + }, "RuntimeDatastoreEvictionPolicy": { "description": "A key-value eviction policy.\n\nOPEN taxonomy: carries both ``unknown`` and ``other``. Covers Redis\n``maxmemory-policy`` values.", "enum": [ @@ -6634,11 +6664,16 @@ }, "RuntimeDatastoreNode": { "additionalProperties": false, - "description": "An observed node participating in a datastore cluster.", + "description": "An observed node participating in a datastore cluster.\n\nBeyond cluster membership and roles, a node carries product-neutral engine\nprovenance (version, build hash/type), JVM/process memory posture (initial\nand maximum heap byte bounds, memory-lock state), a typed per-node engine\nplugin inventory, and typed published endpoints (client vs peer listeners).\nAll are observed runtime facts \u2014 never host policy or software-component\nidentity (ADR-058 amending ADR-048).", "properties": { - "address": { + "build_hash": { "default": "", - "title": "Address", + "title": "Build Hash", + "type": "string" + }, + "build_type": { + "default": "", + "title": "Build Type", "type": "string" }, "description": { @@ -6646,6 +6681,48 @@ "title": "Description", "type": "string" }, + "endpoints": { + "items": { + "$ref": "#/$defs/RuntimeDatastoreNodeEndpoint" + }, + "title": "Endpoints", + "type": "array" + }, + "engine_version": { + "default": "", + "title": "Engine Version", + "type": "string" + }, + "heap_init_bytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Heap Init Bytes" + }, + "heap_max_bytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Heap Max Bytes" + }, "is_coordinator": { "anyOf": [ { @@ -6661,6 +6738,21 @@ "default": null, "title": "Is Coordinator" }, + "memory_locked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Locked" + }, "name": { "default": "", "title": "Name", @@ -6670,6 +6762,13 @@ "title": "Node Id", "type": "string" }, + "plugins": { + "items": { + "$ref": "#/$defs/RuntimeDatastoreEnginePlugin" + }, + "title": "Plugins", + "type": "array" + }, "roles": { "items": { "anyOf": [ @@ -6691,6 +6790,74 @@ "title": "RuntimeDatastoreNode", "type": "object" }, + "RuntimeDatastoreNodeEndpoint": { + "additionalProperties": false, + "description": "An observed published listener on a datastore node.\n\nProduct-neutral node listener topology: ``role`` distinguishes the\nparticipant-facing ``client`` listener from the inter-node ``peer`` listener\nwithout encoding engine-native names. ``address`` and ``port`` stay split,\nmatching every other runtime listener surface. A node endpoint records\npublished topology, not proof of an OS bind or host publication (ADR-058).", + "properties": { + "address": { + "default": "", + "title": "Address", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "endpoint_id": { + "title": "Endpoint Id", + "type": "string" + }, + "port": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Port" + }, + "protocol": { + "default": "", + "title": "Protocol", + "type": "string" + }, + "role": { + "anyOf": [ + { + "$ref": "#/$defs/RuntimeDatastoreNodeEndpointRole" + }, + { + "type": "string" + } + ], + "default": "unknown", + "title": "Role" + } + }, + "required": [ + "endpoint_id" + ], + "title": "RuntimeDatastoreNodeEndpoint", + "type": "object" + }, + "RuntimeDatastoreNodeEndpointRole": { + "description": "The role a datastore node's published listener fulfils.\n\nOPEN taxonomy: carries both ``unknown`` and ``other``. ``client`` is the\nparticipant/application-facing listener (OpenSearch ``http``, Cassandra\nnative/CQL, Redis client); ``peer`` is the inter-node/cluster listener\n(OpenSearch ``transport``, Cassandra internode, Redis cluster-bus).\nEngine-native listener names are intentionally not modelled \u2014 the datastore\nspine stays product-neutral (ADR-048, ADR-058).", + "enum": [ + "client", + "peer", + "unknown", + "other" + ], + "title": "RuntimeDatastoreNodeEndpointRole", + "type": "string" + }, "RuntimeDatastoreNodeRole": { "description": "A role a cluster node fulfils.\n\nOPEN taxonomy: carries both ``unknown`` and ``other``. Covers OpenSearch\n``cluster_manager``/``data``/``ingest``/``ml`` roles and Cassandra\n``coordinator``/``seed`` roles uniformly.", "enum": [ @@ -6999,13 +7166,6 @@ "default": "unknown", "title": "Engine" }, - "engine_plugins": { - "items": { - "type": "string" - }, - "title": "Engine Plugins", - "type": "array" - }, "ingest_pipelines": { "items": { "type": "string" diff --git a/contracts/schemas/sdl/sdl-authoring-input-v1.json b/contracts/schemas/sdl/sdl-authoring-input-v1.json index 4b0827fa8..a4a2cb765 100644 --- a/contracts/schemas/sdl/sdl-authoring-input-v1.json +++ b/contracts/schemas/sdl/sdl-authoring-input-v1.json @@ -6615,6 +6615,36 @@ "title": "RuntimeDatastoreEngine", "type": "string" }, + "RuntimeDatastoreEnginePlugin": { + "additionalProperties": false, + "description": "An engine extension/plugin/module installed on a datastore node.\n\nPer-node installed-capability inventory (OpenSearch plugins, Redis modules,\n\u2026). Carries the per-plugin ``version`` the name-only service-level list could\nnot. ``plugin_id`` is a stable symbol; ``name`` is the observed engine name.", + "properties": { + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "plugin_id": { + "title": "Plugin Id", + "type": "string" + }, + "version": { + "default": "", + "title": "Version", + "type": "string" + } + }, + "required": [ + "plugin_id" + ], + "title": "RuntimeDatastoreEnginePlugin", + "type": "object" + }, "RuntimeDatastoreEvictionPolicy": { "description": "A key-value eviction policy.\n\nOPEN taxonomy: carries both ``unknown`` and ``other``. Covers Redis\n``maxmemory-policy`` values.", "enum": [ @@ -6634,11 +6664,16 @@ }, "RuntimeDatastoreNode": { "additionalProperties": false, - "description": "An observed node participating in a datastore cluster.", + "description": "An observed node participating in a datastore cluster.\n\nBeyond cluster membership and roles, a node carries product-neutral engine\nprovenance (version, build hash/type), JVM/process memory posture (initial\nand maximum heap byte bounds, memory-lock state), a typed per-node engine\nplugin inventory, and typed published endpoints (client vs peer listeners).\nAll are observed runtime facts \u2014 never host policy or software-component\nidentity (ADR-058 amending ADR-048).", "properties": { - "address": { + "build_hash": { "default": "", - "title": "Address", + "title": "Build Hash", + "type": "string" + }, + "build_type": { + "default": "", + "title": "Build Type", "type": "string" }, "description": { @@ -6646,6 +6681,48 @@ "title": "Description", "type": "string" }, + "endpoints": { + "items": { + "$ref": "#/$defs/RuntimeDatastoreNodeEndpoint" + }, + "title": "Endpoints", + "type": "array" + }, + "engine_version": { + "default": "", + "title": "Engine Version", + "type": "string" + }, + "heap_init_bytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Heap Init Bytes" + }, + "heap_max_bytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Heap Max Bytes" + }, "is_coordinator": { "anyOf": [ { @@ -6661,6 +6738,21 @@ "default": null, "title": "Is Coordinator" }, + "memory_locked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Locked" + }, "name": { "default": "", "title": "Name", @@ -6670,6 +6762,13 @@ "title": "Node Id", "type": "string" }, + "plugins": { + "items": { + "$ref": "#/$defs/RuntimeDatastoreEnginePlugin" + }, + "title": "Plugins", + "type": "array" + }, "roles": { "items": { "anyOf": [ @@ -6691,6 +6790,74 @@ "title": "RuntimeDatastoreNode", "type": "object" }, + "RuntimeDatastoreNodeEndpoint": { + "additionalProperties": false, + "description": "An observed published listener on a datastore node.\n\nProduct-neutral node listener topology: ``role`` distinguishes the\nparticipant-facing ``client`` listener from the inter-node ``peer`` listener\nwithout encoding engine-native names. ``address`` and ``port`` stay split,\nmatching every other runtime listener surface. A node endpoint records\npublished topology, not proof of an OS bind or host publication (ADR-058).", + "properties": { + "address": { + "default": "", + "title": "Address", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "endpoint_id": { + "title": "Endpoint Id", + "type": "string" + }, + "port": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Port" + }, + "protocol": { + "default": "", + "title": "Protocol", + "type": "string" + }, + "role": { + "anyOf": [ + { + "$ref": "#/$defs/RuntimeDatastoreNodeEndpointRole" + }, + { + "type": "string" + } + ], + "default": "unknown", + "title": "Role" + } + }, + "required": [ + "endpoint_id" + ], + "title": "RuntimeDatastoreNodeEndpoint", + "type": "object" + }, + "RuntimeDatastoreNodeEndpointRole": { + "description": "The role a datastore node's published listener fulfils.\n\nOPEN taxonomy: carries both ``unknown`` and ``other``. ``client`` is the\nparticipant/application-facing listener (OpenSearch ``http``, Cassandra\nnative/CQL, Redis client); ``peer`` is the inter-node/cluster listener\n(OpenSearch ``transport``, Cassandra internode, Redis cluster-bus).\nEngine-native listener names are intentionally not modelled \u2014 the datastore\nspine stays product-neutral (ADR-048, ADR-058).", + "enum": [ + "client", + "peer", + "unknown", + "other" + ], + "title": "RuntimeDatastoreNodeEndpointRole", + "type": "string" + }, "RuntimeDatastoreNodeRole": { "description": "A role a cluster node fulfils.\n\nOPEN taxonomy: carries both ``unknown`` and ``other``. Covers OpenSearch\n``cluster_manager``/``data``/``ingest``/``ml`` roles and Cassandra\n``coordinator``/``seed`` roles uniformly.", "enum": [ @@ -6999,13 +7166,6 @@ "default": "unknown", "title": "Engine" }, - "engine_plugins": { - "items": { - "type": "string" - }, - "title": "Engine Plugins", - "type": "array" - }, "ingest_pipelines": { "items": { "type": "string" diff --git a/docs/aces/inventory/scn010-expressivity-gap-analysis.md b/docs/aces/inventory/scn010-expressivity-gap-analysis.md index 60c1a2cd9..1e6a3c1c7 100644 --- a/docs/aces/inventory/scn010-expressivity-gap-analysis.md +++ b/docs/aces/inventory/scn010-expressivity-gap-analysis.md @@ -141,7 +141,7 @@ These are shipped as a first-class "deliberate confirmation-folds / non-gaps" ar ### 5.1 `runtime.datastore_services` (RuntimeDatastoreService) — new family -**Structure sketch.** `RuntimeDatastoreService{ datastore_service_id (require_symbol); service (same-node listener ref); engine (OPEN); data_model (OPEN — reclassified per verdict: search_index|wide_column|key_value|relational|unknown|other); protocol; version; name }` + typed plural children each with `_id`: `cluster` (health/discovery_mode/partitioner/native_protocol_version), `nodes[]` (roles/is_coordinator), `partitions[]` (kind index|keyspace|logical_db|column_family; shard_count/replica_count/replication_strategy/replication_factor/per_dc_factor_map/durable_writes/datatype_census), `templates[]`, `aliases[]`, `mappings[]` (bounded manifest), `lifecycle_policies[]`, `ingest_pipelines[]`, `persistence` (rdb_save_points/aof/eviction/maxmemory), `pubsub_channels[]`, `queues_streams[]`, `engine_plugins[]`, `transport_security`, `backup_targets[]` (absence typeable), `settings[]` (scope cluster|node|partition|engine + provenance + classification), and `authorization_ref → app_authorizations` (RBAC delegated, **not re-typed** — enforced by a semantic validator asserting no embedded user/role/grant fields and that the ref resolves). +**Structure sketch.** `RuntimeDatastoreService{ datastore_service_id (require_symbol); service (same-node listener ref); engine (OPEN); data_model (OPEN — reclassified per verdict: search_index|wide_column|key_value|relational|unknown|other); protocol; version; name }` + typed plural children each with `_id`: `cluster` (health/discovery_mode/partitioner/native_protocol_version), `nodes[]` (roles/is_coordinator + engine_version/build_hash/build_type/heap_init_bytes/heap_max_bytes/memory_locked + `plugins[]` {plugin_id/name/version} + `endpoints[]` {endpoint_id/role/protocol/address/port}; node engine provenance added per DSL-141/ADR-058), `partitions[]` (kind index|keyspace|logical_db|column_family; shard_count/replica_count/replication_strategy/replication_factor/per_dc_factor_map/durable_writes/datatype_census), `templates[]`, `aliases[]`, `mappings[]` (bounded manifest), `lifecycle_policies[]`, `ingest_pipelines[]`, `persistence` (rdb_save_points/aof/eviction/maxmemory), `pubsub_channels[]`, `queues_streams[]`, `transport_security`, `backup_targets[]` (absence typeable), `settings[]` (scope cluster|node|partition|engine + provenance + classification), and `authorization_ref → app_authorizations` (RBAC delegated, **not re-typed** — enforced by a semantic validator asserting no embedded user/role/grant fields and that the ref resolves). **Guard.** `require_profile_for_data_model` after-validator, **revised for the open tail**: `search_index` REQUIRES `partitions[].kind=index` with shard/replica geometry; `key_value` REQUIRES `persistence` + REJECTS the relational tree; `wide_column` REQUIRES `partitions[].kind=keyspace` with replication_strategy+factor; `relational` REQUIRES the relational object tree + REJECTS shard/RF; **`unknown`/`other` enforce no profile** (no REJECT branch crash). `document` is dropped (no observed container realizes it — speculative dead vocabulary violates the forbidden-completion discipline). diff --git a/docs/decisions/adrs/README.md b/docs/decisions/adrs/README.md index f35603121..051d02f69 100644 --- a/docs/decisions/adrs/README.md +++ b/docs/decisions/adrs/README.md @@ -43,6 +43,7 @@ adr-054-participant-runtime-observable-lifecycle adr-055-experiment-core-contract-boundary adr-056-runtime-observed-values-and-credential-posture adr-057-runtime-secret-name-classifier-boundaries +adr-058-datastore-node-engine-provenance-and-endpoints ``` | ADR | Title | Status | Date | @@ -105,3 +106,4 @@ adr-057-runtime-secret-name-classifier-boundaries | [055](adr-055-experiment-core-contract-boundary.md) | Experiment Core Contract Boundary | accepted | 2026-05-26 | | [056](adr-056-runtime-observed-values-and-credential-posture.md) | Runtime Observed Values and Credential Posture | accepted | 2026-06-05 | | [057](adr-057-runtime-secret-name-classifier-boundaries.md) | Runtime Scenario Value Realizability and Explicit Redaction | accepted | 2026-06-06 | +| [058](adr-058-datastore-node-engine-provenance-and-endpoints.md) | Datastore Node Engine Provenance and Endpoints | accepted | 2026-06-07 | diff --git a/docs/decisions/adrs/adr-058-datastore-node-engine-provenance-and-endpoints.md b/docs/decisions/adrs/adr-058-datastore-node-engine-provenance-and-endpoints.md new file mode 100644 index 000000000..f574682f8 --- /dev/null +++ b/docs/decisions/adrs/adr-058-datastore-node-engine-provenance-and-endpoints.md @@ -0,0 +1,170 @@ +# ADR-058: Datastore Node Engine Provenance and Endpoints + +## Status + +accepted + +## Date + +2026-06-07 + +## Context + +DSL-141 extends the datastore spine introduced by +[ADR-048](adr-048-datastore-service-runtime-inventory.md). The APTL SCN-010 +capture of `wazuh.indexer` observes per-node OpenSearch facts that ADR-048 +cannot type without forcing description prose or losing detail: + +- engine build identity (`version`, `build_hash`, `build_type`); +- JVM heap lower/upper byte bounds and `mlockall` posture; +- installed engine plugins with per-plugin versions; +- separate client-facing and inter-node publish endpoints. + +The existing `RuntimeDatastoreService.engine_plugins: list[str]` is too shallow: +it is service-scoped, name-only, and drops plugin versions. The existing +`RuntimeDatastoreNode.address` is too ambiguous: it cannot distinguish a REST +client listener from a cluster peer listener, and it combines endpoint role, +address, and port into one string. Adjacent surfaces still own narrower +meanings: `runtime.service_listeners` records in-node OS/process listener state, +`Node.services` records authored service identity, `runtime.network` records +network realization and host publication, and `runtime.software_components` +records package/component identity. + +The design risk is to patch the search-cluster example locally by adding +OpenSearch-specific fields such as `http_publish_address` and +`transport_publish_address`, or by copying service-listener semantics into the +datastore model. That would make one engine the schema authority and blur the +boundary between datastore node inventory, process listeners, transport +exposure, and software inventory. + +## Decision + +Amend ADR-048 by extending the existing `RuntimeDatastoreNode` child inventory. +Do not add a new runtime family and do not move these facts to the datastore +service spine. + +Each datastore node may carry product-neutral engine provenance and runtime +posture: engine version, build hash, build type, initial and maximum heap byte +bounds, and `mlockall` state. These are node-scoped observed runtime facts, not +software-component identities, package records, or host policy declarations. + +Remove the service-level name-only `engine_plugins` list and replace it with +typed node-local engine plugin records. Each plugin record carries a stable ACES +id, observed name, and observed version. Plugin ids participate in the same +datastore service-wide stable-id namespace as the service, cluster, persistence +posture, transport-security posture, nodes, partitions, and settings. + +Replace the single ambiguous node address as the authoritative endpoint surface +with typed node endpoint records. Each endpoint record carries a stable ACES id, +an open product-neutral role taxonomy, split address, and split port. The role +taxonomy must include both `unknown` and `other`; the initial portable roles +distinguish client-facing endpoints from inter-node or peer endpoints without +encoding engine-native names such as `http` or `transport`. + +Datastore node plugins and endpoints are targetable child records below the +datastore node ref, for example: + +- `nodes..runtime.datastore_services..nodes..plugins.` +- `nodes..runtime.datastore_services..nodes..endpoints.` + +The service-level `engine_plugins` list is removed rather than retained as a +shallow compatibility surface: it had no consumers outside the datastore family +and ACES is pre-production, so keeping a version-dropping parallel surface would +violate the no-duplicate-surface gate. (The architecture preflight's +keep-as-compat option was considered and superseded by these verified facts.) +All plugin capture uses the node-scoped typed inventory. + +## Security and Validation Gates + +- Parser/model gate: all new stable ids are concrete symbols validated through + the shared runtime id helper; enum fields use the shared runtime enum parser; + byte and port values use the shared integer-or-variable parsing discipline. +- Shape gate: heap byte bounds are non-negative concrete-or-variable values, + and concrete `heap_init_bytes` must not exceed concrete `heap_max_bytes`. + Concrete endpoint ports are in the TCP/UDP port range. Endpoint address and + port stay split. +- Service-wide id gate: datastore service-local duplicate-id rejection includes + plugin and endpoint ids, including nested ids under nodes. +- Registry/reference gate: datastore family `child_refs` includes nested node + plugin and endpoint refs so generic references and module-import aliases work + through the existing runtime-family registry. +- Semantic validation gate: existing same-node `service` and + `authorization_ref` validation remains the datastore service boundary. Node + endpoint records do not by themselves prove an OS process bind, host + publication, or `Node.services` match. +- Secret/payload gate: node provenance, heap posture, plugin names/versions, + and publish endpoints are inventory facts. Raw Nodes Info payloads, curl + credentials, bearer tokens, TLS key material, and backend-native inspect blobs + remain evidence or capture inputs, not SDL fields. +- Contract/schema gate: published JSON Schemas are regenerated from Python model + sources. Generated schema JSON is not edited directly. +- Error-envelope gate: validation uses the existing Pydantic + `ValidationError` and `SDLValidationError` paths. Do not add a datastore + exception hierarchy or messages that echo raw backend payloads. + +## Guardrails + +- Do not add `http_publish_address`, `transport_publish_address`, or other + engine-named endpoint fields. +- Do not store endpoint facts only in `RuntimeDatastoreNode.address` or + `description`. +- Do not duplicate the generic `runtime.service_listeners` surface. Datastore + node endpoints are engine-published topology facts; service listeners remain + OS/process bind facts. +- Do not model engine plugins as OS packages, generic software components, or + service-wide strings when per-node plugin version is observed. +- Do not embed application RBAC users, roles, or grants in node provenance or + plugin records. Internal datastore RBAC remains delegated to + `runtime.app_authorizations`. +- Do not make OpenSearch, Elasticsearch, Cassandra, Redis, or any one API + response the schema authority. They motivate examples only. + +## Non-Goals + +- Building datastore capture adapters or calling engine APIs. +- Proving host exposure, firewall reachability, or process bind state from a + datastore node endpoint. +- Replacing `runtime.service_listeners`, `Node.services`, + `runtime.network.published_ports`, `runtime.software_components`, + `runtime.packages`, or `runtime.app_authorizations`. +- Parsing raw plugin manifests, JVM output, OpenSearch Nodes Info responses, + Cassandra gossip output, Redis cluster output, or backend-native payloads as + first-class SDL records. +- Redesigning the ADR-048 `data_model` discriminator or the relational + confirmation-fold boundary. + +## Consequences + +### Positive + +- Datastore node build identity, plugin capability versions, memory posture, + and listener topology become typed, targetable, and validation-backed. +- The datastore spine remains product-neutral and node-scoped where the + observations are node-scoped. +- Generic listener, software inventory, RBAC, and network surfaces keep their + existing meanings. + +### Negative + +- `RuntimeDatastoreNode` becomes a larger child model, and the registry gains + nested datastore-node child refs. +- The service-level `engine_plugins: list[str]` field is removed; callers use + the node-scoped typed plugin inventory. No consumers existed outside the + datastore family at removal time (ACES pre-production). + +### Risks + +- Downstream consumers may overread a datastore endpoint as proof of host-public + exposure; the model and docs must keep publish topology separate from OS bind + and host publication. +- A future datastore class may need additional endpoint roles; the role enum is + deliberately open so new roles do not require changing the endpoint shape. + +## References + +- Issue #470: SDL gap: search datastore node engine provenance +- DSL-141: Datastore Node Engine Provenance and Listener Topology +- [ADR-048: Datastore Service Runtime Inventory](adr-048-datastore-service-runtime-inventory.md) +- [ADR-043: Generic Runtime Service Listener Surface](adr-043-runtime-service-listener-surface.md) +- [ADR-034: Runtime Software Component Inventory](adr-034-runtime-software-component-inventory.md) +- [ADR-056: Runtime Observed Values and Credential Posture](adr-056-runtime-observed-values-and-credential-posture.md) diff --git a/docs/explain/sdl/limitations.md b/docs/explain/sdl/limitations.md index 8679dfbfa..0eac0656f 100644 --- a/docs/explain/sdl/limitations.md +++ b/docs/explain/sdl/limitations.md @@ -52,9 +52,13 @@ recurrence plus observed last/next run and last result, cadence-only - [ADR-047](../../decisions/adrs/adr-047-scheduled-job-runtime-inventory.md)), non-relational datastore logical state (search/wide-column/key-value clusters, partitions with shard/replica or replication geometry, key-value persistence -posture, transport security, and settings, with an executable required-profile -guard and internal RBAC delegated via `authorization_ref` - -[ADR-048](../../decisions/adrs/adr-048-datastore-service-runtime-inventory.md)), +posture, transport security, settings, and per-node engine provenance — version/ +build hash/build type, heap byte bounds, mlockall, a typed per-plugin-versioned +plugin inventory, and a product-neutral client/peer endpoint inventory (published +topology, not OS-bind or host-publication proof) - with an executable +required-profile guard and internal RBAC delegated via `authorization_ref` - +[ADR-048](../../decisions/adrs/adr-048-datastore-service-runtime-inventory.md), +[ADR-058](../../decisions/adrs/adr-058-datastore-node-engine-provenance-and-endpoints.md)), security-platform application runtime inventory (threat-intel/SOAR/analyzer/ case-management/dashboard kinds, bounded parsed content-object manifests, releasability markings, upstream bindings, connectors, and settings, with an diff --git a/docs/explain/sdl/lineage.md b/docs/explain/sdl/lineage.md index c06890303..f3b666874 100644 --- a/docs/explain/sdl/lineage.md +++ b/docs/explain/sdl/lineage.md @@ -428,6 +428,19 @@ ACES relies on prior work in four ways: belong to the synthetic range; out-of-scenario operator secrets remain outside SDL inventory. +The node-scoped extension (SCN-010 DSL-141, +[ADR-058](../../decisions/adrs/adr-058-datastore-node-engine-provenance-and-endpoints.md)) +closes the `wazuh.indexer` parity gap (Brad-Edwards/aptl#341) that ADR-048 left +at the node level: typed engine provenance (version, build hash, build type), +JVM/process memory posture (initial/maximum heap bytes, `mlockall`), a per-node +engine-plugin inventory with per-plugin version, and a product-neutral +client/peer endpoint inventory. The Elasticsearch/OpenSearch +[Nodes Info API](https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-nodes-info.html) +reports each of these per node; the client/peer listener split is structural +across the search-cluster tech class (OpenSearch http/transport, Cassandra +native/internode, Redis client/cluster-bus), which is why ACES types an +engine-neutral `role` taxonomy rather than engine-named address fields. + ## Security-Platform Application Semantics The `runtime.platform_applications` surface is the SCN-010 (DSL-133) response to diff --git a/docs/explain/sdl/precedents.md b/docs/explain/sdl/precedents.md index cea556f37..d8a787401 100644 --- a/docs/explain/sdl/precedents.md +++ b/docs/explain/sdl/precedents.md @@ -274,7 +274,7 @@ taxonomy of container, orchestrator, or host-security concerns. | SIEM/security-monitoring manager runtime inventory (manager modules, listeners, enrolled agents/groups, detection content sets, parsed detection definitions, bounded settings, API/control-plane posture) | Log-management/security-monitoring runtime inventory and loaded-definition manifests, not transport bindings, process/unit state, raw config, raw events, alert telemetry, or rule-engine execution | `Node.runtime.security_monitoring_managers` when observed; manager, content-set, and detection-definition refs may be targeted through qualified runtime refs; see ADR-040 and ADR-045 | | Application-internal RBAC store (principals, roles, resource-scoped permission grants, role mappings, tenants) | Application-internal authorization runtime state, not a wire-protocol directory or database engine GRANT surface | `Node.runtime.app_authorizations` when observed; the resource-scoped `permission_grant` is the defining addition; raw credentials are never stored; see ADR-046 | | Recurring scheduled-job cadence and run-state | Product-neutral cadence runtime state, not systemd unit lifecycle or forwarder input/output authoring | `Node.runtime.scheduled_jobs` when observed; cadence + run-state only; systemd lifecycle remains `runtime.service_manager_units`; see ADR-047 | -| Non-relational datastore logical state (search clusters, wide-column stores, key-value stores, partitions, replication geometry, persistence posture, transport security, settings) | Participant-observable non-relational datastore runtime state, not the relational engine/GRANT surface, transport bindings, or software component identity | `Node.runtime.datastore_services` when observed; the open `data_model` discriminator drives a required-profile guard; service and child refs may be targeted through qualified runtime refs; internal RBAC is delegated via `authorization_ref`; explicit redaction classifications omit raw setting values; see ADR-048 and ADR-057 | +| Non-relational datastore logical state (search clusters, wide-column stores, key-value stores, partitions, replication geometry, persistence posture, transport security, settings) plus per-node engine provenance (version/build hash/build type), JVM/process memory posture (heap byte bounds, mlockall), a typed per-node engine-plugin inventory with per-plugin version, and a product-neutral client/peer node-endpoint inventory | Participant-observable non-relational datastore runtime state, not the relational engine/GRANT surface, transport bindings, or software component identity; node endpoints are engine-published topology, not OS-bind or host-publication proof | `Node.runtime.datastore_services` when observed; the open `data_model` discriminator drives a required-profile guard; service and child refs (including nested node plugins/endpoints) may be targeted through qualified runtime refs; node plugin/endpoint ids join the service-wide stable-id namespace; internal RBAC is delegated via `authorization_ref`; explicit redaction classifications omit raw setting values; see ADR-048 (amended by ADR-058) and ADR-057 | | Security-platform application runtime inventory (threat-intel platform, SOAR, analyzer engine, case management, analytics dashboard — bounded content-object manifests, markings, upstream bindings, connectors, settings) | Participant-observable platform-application runtime state and bounded parsed manifests, not HTTP route surface, software component identity, raw object bodies, or playbook execution semantics | `Node.runtime.platform_applications` when observed; the open `platform_kind` discriminator drives a required-profile guard; content objects carry typed references not raw bodies; refs may be targeted through qualified runtime refs; internal RBAC is delegated via `authorization_ref`; see ADR-049 | | Forwarding / intel-sync agent runtime inventory (sources, transforms, ship targets, buffer policy, reload channels, settings for log forwarders and intel-sync co-processes) | Participant-observable agent-side shipping state, not SIEM manager inventory, detection-engine consumer state, scheduled-job cadence, systemd lifecycle, or a fake scenario node for an infrastructure-only sidecar | `Node.runtime.forwarding_agents` when the agent is node-hosted; top-level `forwarding_agents` when the forwarder is off-node infrastructure realization; the open `agent_kind` discriminator drives a required-profile guard; ship-target node/service refs resolve at scenario scope; enrollment identities use a closed classification lattice and settings use explicit redaction classifications; the inter-node trust edge is a relationship forwarding edge; see ADR-050 and ADR-057 | | Container-spawn orchestration-authority runtime inventory (engine, scope, spawn templates, lifecycle policy, realized children, privilege class referencing a control-interface shell) | Participant-observable container-spawn authority state, not the control-interface shell itself, software component identity, or workflow execution semantics | `Node.runtime.orchestration_authorities` when observed; the open `privilege_class` discriminator drives a required-profile guard; `control_interface_ref` resolves to a same-node `RuntimeControlInterface` (host-root-equivalent requires a read-write docker socket); the control-interface shell is referenced, never duplicated; see ADR-051 | diff --git a/docs/explain/sdl/sections.md b/docs/explain/sdl/sections.md index 842c5fa92..bf21c9f2f 100644 --- a/docs/explain/sdl/sections.md +++ b/docs/explain/sdl/sections.md @@ -548,6 +548,27 @@ nodes: nodes: - node_id: os-node-1 roles: [data, master] + engine_version: "2.13" # product-neutral node engine provenance + build_type: rpm + build_hash: dae2bfc9389617 + heap_init_bytes: "1 GiB" # human sizes normalize to bytes + heap_max_bytes: "1 GiB" + memory_locked: true # observed mlockall posture + endpoints: + - endpoint_id: http # participant-facing listener + role: client + protocol: https + address: 172.20.0.12 + port: 9200 + - endpoint_id: transport # inter-node listener + role: peer + protocol: transport + address: 172.20.0.12 + port: 9300 + plugins: # per-plugin version retained + - plugin_id: opensearch-security + name: opensearch-security + version: 2.13.0.0 partitions: # search_index requires shard/replica geometry - partition_id: alerts-index kind: index @@ -1082,8 +1103,22 @@ object-tree (`keyspace`/`column_family`) partitions. `cluster`, `persistence`, and `transport_security` are single nested postures, while `nodes`, `partitions`, and `settings` are id-bearing child collections; `templates`, `aliases`, `mappings`, `lifecycle_policies`, `ingest_pipelines`, -`pubsub_channels`, `queues_streams`, `engine_plugins`, and `backup_targets` are -bare reference-name lists. `settings` reuse the shared runtime sensitivity +`pubsub_channels`, `queues_streams`, and `backup_targets` are bare +reference-name lists. Each `node` additionally carries product-neutral engine +provenance and runtime posture (`engine_version`, `build_hash`, `build_type`, +initial/maximum `heap_init_bytes`/`heap_max_bytes` byte bounds, and +`memory_locked`), a typed per-node `plugins` inventory +(`RuntimeDatastoreEnginePlugin{plugin_id, name, version}` — retaining the +per-plugin version the former service-level `engine_plugins` list dropped), and a +typed `endpoints` inventory +(`RuntimeDatastoreNodeEndpoint{endpoint_id, role, protocol, address, port}`) +whose open `client`/`peer` role taxonomy distinguishes the participant-facing +listener from the inter-node one without naming any engine's protocol (replacing +the single ambiguous node `address`). Node plugin and endpoint ids share the +datastore service-wide stable-id namespace and are targetable as nested refs +(see +[ADR-058](../../decisions/adrs/adr-058-datastore-node-engine-provenance-and-endpoints.md) +amending ADR-048). `settings` reuse the shared runtime sensitivity vocabulary: explicit `redacted`/`operator_secret` classifications omit raw values, while credential-shaped setting names remain scenario content unless the author marks the value withheld. Application-internal RBAC is delegated to diff --git a/docs/explain/sdl/validation.md b/docs/explain/sdl/validation.md index 8ecf95381..8bf4b8f40 100644 --- a/docs/explain/sdl/validation.md +++ b/docs/explain/sdl/validation.md @@ -251,10 +251,14 @@ and is therefore not a scheduled job. The optional `runtime.datastore_services` inventory has model-local and semantic rules. Datastore-service ids are stable concrete symbols and are unique within a node runtime block; the service-local cluster, persistence, transport-security, -node, partition, and setting ids are unique across the service. Engines, data -models, partition kinds, node roles, persistence eviction policies, replication -strategies, transport-security modes, and setting scope/provenance/classification -are normalized from bounded enums while allowing full-value variables. +node, partition, setting, node-plugin, and node-endpoint ids are unique across +the service. Engines, data models, partition kinds, node roles, node-endpoint +roles, persistence eviction policies, replication strategies, transport-security +modes, and setting scope/provenance/classification are normalized from bounded +enums while allowing full-value variables. Node engine provenance is observed +inventory: heap byte bounds normalize human sizes to bytes and a concrete +`heap_init_bytes` must not exceed a concrete `heap_max_bytes`; node-endpoint ports +are validated to the 1–65535 range with address and port kept split. Explicit redacted/operator-secret setting classifications omit raw values; names alone do not force omission. The `require_profile_for_data_model` guard makes the discriminator executable: a `${var}` placeholder is exempt and the open diff --git a/implementations/python/packages/aces_sdl/_runtime_service_families.py b/implementations/python/packages/aces_sdl/_runtime_service_families.py index 7464ec5a8..7a295ab39 100644 --- a/implementations/python/packages/aces_sdl/_runtime_service_families.py +++ b/implementations/python/packages/aces_sdl/_runtime_service_families.py @@ -189,7 +189,14 @@ def public_symbols(self) -> tuple[str, ...]: collection_name="datastore_services", id_field="datastore_service_id", child_refs=( - RuntimeReferenceChild("nodes", "node_id"), + RuntimeReferenceChild( + "nodes", + "node_id", + children=( + RuntimeReferenceChild("plugins", "plugin_id"), + RuntimeReferenceChild("endpoints", "endpoint_id"), + ), + ), RuntimeReferenceChild("partitions", "partition_id"), RuntimeReferenceChild("settings", "setting_id"), ), diff --git a/implementations/python/packages/aces_sdl/runtime_datastore.py b/implementations/python/packages/aces_sdl/runtime_datastore.py index e8390b19a..77ffb3e49 100644 --- a/implementations/python/packages/aces_sdl/runtime_datastore.py +++ b/implementations/python/packages/aces_sdl/runtime_datastore.py @@ -27,7 +27,9 @@ from ._base import SDLModel, is_variable_ref from .runtime_datastore_partitions import ( RuntimeDatastoreCluster, + RuntimeDatastoreEnginePlugin, RuntimeDatastoreNode, + RuntimeDatastoreNodeEndpoint, RuntimeDatastorePartition, RuntimeDatastorePersistence, RuntimeDatastoreSetting, @@ -37,6 +39,7 @@ RuntimeDatastoreDataModel, RuntimeDatastoreEngine, RuntimeDatastoreEvictionPolicy, + RuntimeDatastoreNodeEndpointRole, RuntimeDatastoreNodeRole, RuntimeDatastorePartitionKind, RuntimeDatastoreReplicationStrategy, @@ -54,8 +57,11 @@ "RuntimeDatastoreCluster", "RuntimeDatastoreDataModel", "RuntimeDatastoreEngine", + "RuntimeDatastoreEnginePlugin", "RuntimeDatastoreEvictionPolicy", "RuntimeDatastoreNode", + "RuntimeDatastoreNodeEndpoint", + "RuntimeDatastoreNodeEndpointRole", "RuntimeDatastoreNodeRole", "RuntimeDatastorePartition", "RuntimeDatastorePartitionKind", @@ -98,7 +104,6 @@ class RuntimeDatastoreService(SDLModel): persistence: RuntimeDatastorePersistence | None = None pubsub_channels: list[str] = Field(default_factory=list) queues_streams: list[str] = Field(default_factory=list) - engine_plugins: list[str] = Field(default_factory=list) transport_security: RuntimeDatastoreTransportSecurity | None = None backup_targets: list[str] = Field(default_factory=list) settings: list[RuntimeDatastoreSetting] = Field(default_factory=list) @@ -128,7 +133,6 @@ def normalize_data_model(cls, v: RuntimeDatastoreDataModel | str) -> object: "ingest_pipelines", "pubsub_channels", "queues_streams", - "engine_plugins", "backup_targets", mode="before", ) @@ -152,7 +156,6 @@ def _reject_duplicate_string_lists(self) -> None: "ingest_pipelines", "pubsub_channels", "queues_streams", - "engine_plugins", "backup_targets", ): _reject_duplicate_values(getattr(self, field_name), field_name=field_name, owner=self.datastore_service_id) @@ -269,6 +272,10 @@ def _reject_duplicate_local_ref_ids(service: RuntimeDatastoreService) -> None: ("setting_id", "settings"), ): entries.extend((label, getattr(item, label)) for item in getattr(service, collection_name)) + # Node-nested plugin/endpoint ids share the service-wide stable-id namespace. + for node in service.nodes: + entries.extend(("plugin_id", plugin.plugin_id) for plugin in node.plugins) + entries.extend(("endpoint_id", endpoint.endpoint_id) for endpoint in node.endpoints) seen: dict[str, str] = {} for label, value in entries: diff --git a/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py b/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py index ba4bd9b74..7e0855db6 100644 --- a/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py +++ b/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py @@ -16,6 +16,7 @@ from ._base import SDLModel, parse_int_or_var from .runtime_datastore_vocab import ( RuntimeDatastoreEvictionPolicy, + RuntimeDatastoreNodeEndpointRole, RuntimeDatastoreNodeRole, RuntimeDatastorePartitionKind, RuntimeDatastoreReplicationStrategy, @@ -28,13 +29,16 @@ coerce_string_list, enforce_observed_value_redaction, parse_optional_bool_or_var, + parse_ram, parse_runtime_enum_or_var, require_symbol, ) __all__ = [ "RuntimeDatastoreCluster", + "RuntimeDatastoreEnginePlugin", "RuntimeDatastoreNode", + "RuntimeDatastoreNodeEndpoint", "RuntimeDatastorePartition", "RuntimeDatastorePersistence", "RuntimeDatastoreSetting", @@ -63,14 +67,86 @@ def _require_object_name(value: str, *, field_name: str) -> str: return value +class RuntimeDatastoreEnginePlugin(SDLModel): + """An engine extension/plugin/module installed on a datastore node. + + Per-node installed-capability inventory (OpenSearch plugins, Redis modules, + …). Carries the per-plugin ``version`` the name-only service-level list could + not. ``plugin_id`` is a stable symbol; ``name`` is the observed engine name. + """ + + plugin_id: str + name: str = "" + version: str = "" + description: str = "" + + @field_validator("plugin_id") + @classmethod + def validate_plugin_id(cls, v: str) -> str: + return require_symbol(v, field_name="plugin_id") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + return _require_object_name(v, field_name="plugin name") if v else v + + +class RuntimeDatastoreNodeEndpoint(SDLModel): + """An observed published listener on a datastore node. + + Product-neutral node listener topology: ``role`` distinguishes the + participant-facing ``client`` listener from the inter-node ``peer`` listener + without encoding engine-native names. ``address`` and ``port`` stay split, + matching every other runtime listener surface. A node endpoint records + published topology, not proof of an OS bind or host publication (ADR-058). + """ + + endpoint_id: str + role: RuntimeDatastoreNodeEndpointRole | str = RuntimeDatastoreNodeEndpointRole.UNKNOWN + protocol: str = "" + address: str = "" + port: int | str | None = None + description: str = "" + + @field_validator("endpoint_id") + @classmethod + def validate_endpoint_id(cls, v: str) -> str: + return require_symbol(v, field_name="endpoint_id") + + @field_validator("role", mode="before") + @classmethod + def normalize_role(cls, v: object) -> object: + return parse_runtime_enum_or_var(v, RuntimeDatastoreNodeEndpointRole, field_name="role") + + @field_validator("port", mode="before") + @classmethod + def parse_port(cls, v: object) -> int | str | None: + return parse_int_or_var(v, minimum=1, maximum=65535, field_name="port") if v is not None else v + + class RuntimeDatastoreNode(SDLModel): - """An observed node participating in a datastore cluster.""" + """An observed node participating in a datastore cluster. + + Beyond cluster membership and roles, a node carries product-neutral engine + provenance (version, build hash/type), JVM/process memory posture (initial + and maximum heap byte bounds, memory-lock state), a typed per-node engine + plugin inventory, and typed published endpoints (client vs peer listeners). + All are observed runtime facts — never host policy or software-component + identity (ADR-058 amending ADR-048). + """ node_id: str name: str = "" roles: list[RuntimeDatastoreNodeRole | str] = Field(default_factory=list) is_coordinator: bool | str | None = None - address: str = "" + engine_version: str = "" + build_hash: str = "" + build_type: str = "" + heap_init_bytes: int | str | None = None + heap_max_bytes: int | str | None = None + memory_locked: bool | str | None = None + endpoints: list[RuntimeDatastoreNodeEndpoint] = Field(default_factory=list) + plugins: list[RuntimeDatastoreEnginePlugin] = Field(default_factory=list) description: str = "" @field_validator("node_id") @@ -91,11 +167,37 @@ def normalize_roles(cls, v: object) -> object: def parse_is_coordinator(cls, v: object) -> bool | str | None: return parse_optional_bool_or_var(v, field_name="is_coordinator") + @field_validator("heap_init_bytes", "heap_max_bytes", mode="before") + @classmethod + def parse_heap_bytes(cls, v: object) -> int | str | None: + return parse_ram(v) if v is not None else v + + @field_validator("memory_locked", mode="before") + @classmethod + def parse_memory_locked(cls, v: object) -> bool | str | None: + return parse_optional_bool_or_var(v, field_name="memory_locked") + @model_validator(mode="after") def validate_node(self) -> "RuntimeDatastoreNode": _reject_duplicate_values(self.roles, field_name="roles", owner=self.node_id) + _reject_duplicate_values( + [plugin.plugin_id for plugin in self.plugins], field_name="plugin_id", owner=self.node_id + ) + _reject_duplicate_values( + [endpoint.endpoint_id for endpoint in self.endpoints], field_name="endpoint_id", owner=self.node_id + ) + self._reject_heap_inversion() return self + def _reject_heap_inversion(self) -> None: + init_bytes = self.heap_init_bytes + max_bytes = self.heap_max_bytes + if isinstance(init_bytes, int) and isinstance(max_bytes, int) and init_bytes > max_bytes: + raise ValueError( + f"datastore node '{self.node_id}' heap_init_bytes ({init_bytes}) " + f"must not exceed heap_max_bytes ({max_bytes})" + ) + class RuntimeDatastoreCluster(SDLModel): """The single observed cluster posture of a datastore service. diff --git a/implementations/python/packages/aces_sdl/runtime_datastore_vocab.py b/implementations/python/packages/aces_sdl/runtime_datastore_vocab.py index e0a7d804c..030e07e3a 100644 --- a/implementations/python/packages/aces_sdl/runtime_datastore_vocab.py +++ b/implementations/python/packages/aces_sdl/runtime_datastore_vocab.py @@ -15,6 +15,7 @@ "RuntimeDatastoreDataModel", "RuntimeDatastoreEngine", "RuntimeDatastoreEvictionPolicy", + "RuntimeDatastoreNodeEndpointRole", "RuntimeDatastoreNodeRole", "RuntimeDatastorePartitionKind", "RuntimeDatastoreReplicationStrategy", @@ -95,6 +96,23 @@ class RuntimeDatastoreNodeRole(str, Enum): OTHER = "other" +class RuntimeDatastoreNodeEndpointRole(str, Enum): + """The role a datastore node's published listener fulfils. + + OPEN taxonomy: carries both ``unknown`` and ``other``. ``client`` is the + participant/application-facing listener (OpenSearch ``http``, Cassandra + native/CQL, Redis client); ``peer`` is the inter-node/cluster listener + (OpenSearch ``transport``, Cassandra internode, Redis cluster-bus). + Engine-native listener names are intentionally not modelled — the datastore + spine stays product-neutral (ADR-048, ADR-058). + """ + + CLIENT = "client" + PEER = "peer" + UNKNOWN = "unknown" + OTHER = "other" + + class RuntimeDatastoreEvictionPolicy(str, Enum): """A key-value eviction policy. diff --git a/implementations/python/tests/test_runtime_datastore.py b/implementations/python/tests/test_runtime_datastore.py index 1e9a79e73..5c79fdf80 100644 --- a/implementations/python/tests/test_runtime_datastore.py +++ b/implementations/python/tests/test_runtime_datastore.py @@ -14,7 +14,9 @@ RuntimeDatastoreCluster, RuntimeDatastoreDataModel, RuntimeDatastoreEngine, + RuntimeDatastoreEnginePlugin, RuntimeDatastoreNode, + RuntimeDatastoreNodeEndpoint, RuntimeDatastorePartitionKind, RuntimeDatastorePersistence, RuntimeDatastoreService, @@ -23,6 +25,7 @@ ) from aces_sdl.runtime_datastore_vocab import ( RuntimeDatastoreEvictionPolicy, + RuntimeDatastoreNodeEndpointRole, RuntimeDatastoreNodeRole, RuntimeDatastoreReplicationStrategy, RuntimeDatastoreSettingProvenance, @@ -53,6 +56,32 @@ def _search_index_service(**overrides) -> dict: "name": "wazuh.indexer", "roles": ["cluster_manager", "data", "ingest"], "is_coordinator": True, + "engine_version": "2.19.1", + "build_hash": "dae2bfc93896178873b43cdf4781f183c72b238f", + "build_type": "rpm", + "heap_init_bytes": "1 GiB", + "heap_max_bytes": "1 GiB", + "memory_locked": True, + "endpoints": [ + { + "endpoint_id": "http", + "role": "client", + "protocol": "https", + "address": "172.20.0.12", + "port": 9200, + }, + { + "endpoint_id": "transport", + "role": "peer", + "protocol": "transport", + "address": "172.20.0.12", + "port": 9300, + }, + ], + "plugins": [ + {"plugin_id": "opensearch-security", "name": "opensearch-security", "version": "2.19.1.0"}, + {"plugin_id": "opensearch-alerting", "name": "opensearch-alerting", "version": "2.19.1.0"}, + ], } ], "partitions": [ @@ -70,7 +99,6 @@ def _search_index_service(**overrides) -> dict: "mappings": ["index.mapping.total_fields.limit=10000"], "lifecycle_policies": ["wazuh-ism"], "ingest_pipelines": ["geoip"], - "engine_plugins": ["opensearch-security", "opensearch-alerting"], "transport_security": { "transport_security_id": "indexer-tls", "mode": "mutual_tls", @@ -158,7 +186,22 @@ def test_search_index_service_typed_children() -> None: assert svc.engine is RuntimeDatastoreEngine.OPENSEARCH assert svc.data_model is RuntimeDatastoreDataModel.SEARCH_INDEX assert isinstance(svc.cluster, RuntimeDatastoreCluster) - assert svc.nodes[0].roles[0] is RuntimeDatastoreNodeRole.CLUSTER_MANAGER + node = svc.nodes[0] + assert node.roles[0] is RuntimeDatastoreNodeRole.CLUSTER_MANAGER + assert node.engine_version == "2.19.1" + assert node.build_hash == "dae2bfc93896178873b43cdf4781f183c72b238f" + assert node.build_type == "rpm" + assert node.heap_init_bytes == 1_073_741_824 + assert node.heap_max_bytes == 1_073_741_824 + assert node.memory_locked is True + assert [e.role for e in node.endpoints] == [ + RuntimeDatastoreNodeEndpointRole.CLIENT, + RuntimeDatastoreNodeEndpointRole.PEER, + ] + assert node.endpoints[0].port == 9200 + assert node.endpoints[1].port == 9300 + assert node.plugins[0].name == "opensearch-security" + assert node.plugins[0].version == "2.19.1.0" assert svc.partitions[0].kind is RuntimeDatastorePartitionKind.INDEX assert svc.partitions[0].shard_count == 3 assert isinstance(svc.transport_security, RuntimeDatastoreTransportSecurity) @@ -229,8 +272,8 @@ def test_rejects_duplicate_stable_ids_across_children() -> None: def test_rejects_duplicate_string_list_entries() -> None: - with pytest.raises(ValidationError, match="Duplicate runtime datastore engine_plugins"): - RuntimeDatastoreService(**_search_index_service(engine_plugins=["a", "a"])) + with pytest.raises(ValidationError, match="Duplicate runtime datastore templates"): + RuntimeDatastoreService(**_search_index_service(templates=["a", "a"])) def test_secret_named_setting_may_carry_scenario_value() -> None: @@ -326,3 +369,197 @@ def test_wide_column_rejects_keyspace_missing_factor() -> None: ], ) ) + + +# --------------------------------------------------------------------------- # +# DSL-141 — node engine provenance, heap posture, plugins, endpoints # +# --------------------------------------------------------------------------- # + + +def test_node_provenance_defaults_are_empty() -> None: + node = RuntimeDatastoreNode(node_id="n1") + assert node.engine_version == "" + assert node.build_hash == "" + assert node.build_type == "" + assert node.heap_init_bytes is None + assert node.heap_max_bytes is None + assert node.memory_locked is None + assert node.endpoints == [] + assert node.plugins == [] + + +def test_node_heap_bytes_accept_human_int_and_var() -> None: + node = RuntimeDatastoreNode(node_id="n1", heap_init_bytes="512 MiB", heap_max_bytes=1_073_741_824) + assert node.heap_init_bytes == 536_870_912 + assert node.heap_max_bytes == 1_073_741_824 + + var_node = RuntimeDatastoreNode(node_id="n2", heap_max_bytes="${HEAP}") + assert var_node.heap_max_bytes == "${HEAP}" + + +def test_node_rejects_heap_init_above_max() -> None: + with pytest.raises(ValidationError, match="heap_init_bytes.*heap_max_bytes"): + RuntimeDatastoreNode(node_id="n1", heap_init_bytes="2 GiB", heap_max_bytes="1 GiB") + + +def test_node_heap_ordering_exempt_for_variable_bounds() -> None: + node = RuntimeDatastoreNode(node_id="n1", heap_init_bytes="${INIT}", heap_max_bytes=1024) + assert node.heap_init_bytes == "${INIT}" + + +def test_node_memory_locked_parses_bool_and_var() -> None: + assert RuntimeDatastoreNode(node_id="n1", memory_locked="true").memory_locked is True + assert RuntimeDatastoreNode(node_id="n2", memory_locked="${MLOCK}").memory_locked == "${MLOCK}" + + +def test_engine_plugin_retains_per_plugin_version() -> None: + plugin = RuntimeDatastoreEnginePlugin( + plugin_id="opensearch-security", name="opensearch-security", version="2.19.1.0" + ) + assert plugin.plugin_id == "opensearch-security" + assert plugin.version == "2.19.1.0" + + +def test_engine_plugin_id_must_be_stable_symbol() -> None: + with pytest.raises(ValidationError, match="plugin_id"): + RuntimeDatastoreEnginePlugin(plugin_id="${PLUGIN}", name="x") + with pytest.raises(ValidationError, match="plugin_id"): + RuntimeDatastoreEnginePlugin(plugin_id="", name="x") + + +def test_node_endpoint_typed_fields() -> None: + endpoint = RuntimeDatastoreNodeEndpoint( + endpoint_id="http", role="client", protocol="https", address="172.20.0.12", port=9200 + ) + assert endpoint.role is RuntimeDatastoreNodeEndpointRole.CLIENT + assert endpoint.address == "172.20.0.12" + assert endpoint.port == 9200 + + +def test_endpoint_role_normalizes_hyphen_alias_and_open_sentinels() -> None: + assert RuntimeDatastoreNodeEndpointRole.UNKNOWN.value == "unknown" + assert RuntimeDatastoreNodeEndpointRole.OTHER.value == "other" + endpoint = RuntimeDatastoreNodeEndpoint(endpoint_id="e1", role="PEER") + assert endpoint.role is RuntimeDatastoreNodeEndpointRole.PEER + + +def test_endpoint_role_rejects_unrecognized_value() -> None: + # An unrecognized (non-var) role must raise with the closed-set error + # envelope, never silently pass through as an arbitrary string. + with pytest.raises(ValidationError, match="role must be one of: client, peer, unknown, other"): + RuntimeDatastoreNodeEndpoint(endpoint_id="e1", role="gossip") + + +def test_endpoint_id_must_be_stable_symbol() -> None: + with pytest.raises(ValidationError, match="endpoint_id"): + RuntimeDatastoreNodeEndpoint(endpoint_id="${E}") + + +def test_endpoint_port_range_enforced() -> None: + # Both bounds: above the 65535 ceiling and below the minimum of 1. + with pytest.raises(ValidationError, match="port"): + RuntimeDatastoreNodeEndpoint(endpoint_id="e1", port=70000) + with pytest.raises(ValidationError, match="port"): + RuntimeDatastoreNodeEndpoint(endpoint_id="e1", port=0) + + +def test_node_rejects_duplicate_plugin_ids() -> None: + with pytest.raises(ValidationError, match="plugin"): + RuntimeDatastoreNode( + node_id="n1", + plugins=[{"plugin_id": "dup", "name": "a"}, {"plugin_id": "dup", "name": "b"}], + ) + + +def test_node_rejects_duplicate_endpoint_ids() -> None: + with pytest.raises(ValidationError, match="endpoint"): + RuntimeDatastoreNode( + node_id="n1", + endpoints=[{"endpoint_id": "dup"}, {"endpoint_id": "dup"}], + ) + + +def test_service_wide_id_namespace_includes_plugin_and_endpoint_ids() -> None: + # A plugin id colliding with the node id is a service-wide stable-id clash. + with pytest.raises(ValidationError, match="Duplicate runtime datastore stable id 'indexer-1'"): + RuntimeDatastoreService( + **_search_index_service( + nodes=[ + { + "node_id": "indexer-1", + "roles": ["data"], + "plugins": [{"plugin_id": "indexer-1", "name": "x"}], + } + ] + ) + ) + + +def test_endpoint_id_collision_with_partition_is_service_wide() -> None: + with pytest.raises(ValidationError, match="Duplicate runtime datastore stable id 'wazuh-alerts'"): + RuntimeDatastoreService( + **_search_index_service( + nodes=[ + { + "node_id": "indexer-1", + "roles": ["data"], + "endpoints": [{"endpoint_id": "wazuh-alerts"}], + } + ] + ) + ) + + +def test_same_plugin_id_on_two_nodes_is_rejected_service_wide() -> None: + # The service-wide namespace spans ALL nodes: the same plugin_id appearing + # on two distinct nodes is a collision the per-node check cannot catch. A + # service-scoped check that only deduped within each node would pass this. + with pytest.raises(ValidationError, match="Duplicate runtime datastore stable id 'opensearch-security'"): + RuntimeDatastoreService( + **_search_index_service( + nodes=[ + { + "node_id": "indexer-1", + "roles": ["cluster_manager", "data"], + "plugins": [{"plugin_id": "opensearch-security", "name": "x"}], + }, + { + "node_id": "indexer-2", + "roles": ["data"], + "plugins": [{"plugin_id": "opensearch-security", "name": "y"}], + }, + ] + ) + ) + + +def test_same_endpoint_id_on_two_nodes_is_rejected_service_wide() -> None: + # Same cross-node obligation for endpoint ids: the same endpoint_id on two + # distinct nodes collides in the service-wide stable-id namespace. + with pytest.raises(ValidationError, match="Duplicate runtime datastore stable id 'transport'"): + RuntimeDatastoreService( + **_search_index_service( + nodes=[ + { + "node_id": "indexer-1", + "roles": ["cluster_manager", "data"], + "endpoints": [{"endpoint_id": "transport", "role": "peer"}], + }, + { + "node_id": "indexer-2", + "roles": ["data"], + "endpoints": [{"endpoint_id": "transport", "role": "peer"}], + }, + ] + ) + ) + + +def test_removed_node_address_field_is_rejected() -> None: + with pytest.raises(ValidationError, match="address"): + RuntimeDatastoreNode(node_id="n1", address="172.20.0.12") + + +def test_removed_service_engine_plugins_field_is_rejected() -> None: + with pytest.raises(ValidationError, match="engine_plugins"): + RuntimeDatastoreService(**_search_index_service(engine_plugins=["opensearch-security"])) From a0ee3538822937197c88eb347779ebc4850aec2f Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Wed, 10 Jun 2026 20:21:41 +0200 Subject: [PATCH 10/70] Add ADR amendment policy (ADR-059) and acceptance-content pin gate docs/decisions/adrs/README.md claimed ADRs are "immutable once accepted", but git history shows accepted ADRs substantively edited post-acceptance (ADR-048, ADR-052, and ADR-025/029/032/038/041/050), so the citability claim was unenforced and violated (review finding ADR-1, GOV-941). - ADR-059 defines the amendment policy: a substantive change to an accepted ADR is legitimate only as a superseding ADR or a recorded ## Amendments row plus an updated pin, in the same change. - adr-index.yaml pins every accepted ADR's canonical-content sha256 (canonical content = file minus its ## Amendments section). - tools/check_adr_immutability.py enforces the manifest shape, pin==hash, accepted-coverage both ways, amendment-record 1:1, and (under --base-rev/--staged) that a canonical-content change carries an amendment record. Wired into the policy nox session. - Extract shared ADR parsers into tools/policy/adr.py and move safe_repo_path into tools/policy/common.py so repo_policy and the new gate share one sanitizer/parser. - Reconcile the 8 already-amended ADRs with honest ## Amendments records so the gate starts green; rewrite the README immutability principle. - Map GOV-941 into a decision-record-governance phase in requirement_order.yaml. --- changelog.d/481.added.md | 2 + docs/decisions/adrs/README.md | 17 +- ...5-container-network-realization-surface.md | 6 + ...-database-logical-state-runtime-surface.md | 6 + ...rectory-domain-identity-runtime-surface.md | 6 + ...-038-runtime-mail-service-logical-state.md | 6 + ...-implementation-manifest-and-provenance.md | 6 + ...048-datastore-service-runtime-inventory.md | 7 + ...-050-forwarding-agent-runtime-inventory.md | 6 + ...052-typed-runtime-relationship-subtypes.md | 7 + ...r-059-adr-amendment-policy-and-pin-gate.md | 157 +++++++ docs/decisions/adrs/adr-index.yaml | 209 +++++++++ .../python/tests/test_repo_policy_tools.py | 408 +++++++++++++++++ noxfile.py | 14 + tools/check_adr_immutability.py | 423 ++++++++++++++++++ tools/policy/adr.py | 65 +++ tools/policy/common.py | 17 + tools/policy/repo_policy.py | 68 +-- tools/policy/requirement_order.yaml | 5 + 19 files changed, 1376 insertions(+), 59 deletions(-) create mode 100644 changelog.d/481.added.md create mode 100644 docs/decisions/adrs/adr-059-adr-amendment-policy-and-pin-gate.md create mode 100644 docs/decisions/adrs/adr-index.yaml create mode 100644 tools/check_adr_immutability.py create mode 100644 tools/policy/adr.py diff --git a/changelog.d/481.added.md b/changelog.d/481.added.md new file mode 100644 index 000000000..d91171677 --- /dev/null +++ b/changelog.d/481.added.md @@ -0,0 +1,2 @@ +### Added +- Add an ADR amendment policy (ADR-059) and an acceptance-content pin gate: `docs/decisions/adrs/adr-index.yaml` pins every accepted ADR's canonical-content `sha256`, and `tools/check_adr_immutability.py` (wired into the `policy` nox session) fails when an accepted ADR changes without a recorded `## Amendments` entry or a superseding ADR. Reconciled the already-amended ADRs (025, 029, 032, 038, 041, 048, 050, 052) with honest amendment records so the gate starts green. diff --git a/docs/decisions/adrs/README.md b/docs/decisions/adrs/README.md index 051d02f69..ce716063d 100644 --- a/docs/decisions/adrs/README.md +++ b/docs/decisions/adrs/README.md @@ -16,8 +16,19 @@ Each ADR includes: ## Principles -- ADRs are **immutable** once accepted. To reverse a decision, create a new ADR - that supersedes it. +- An **accepted** ADR's content is **pinned** and citable. Its acceptance (or + last-amendment) content hash is recorded in + [`adr-index.yaml`](adr-index.yaml) and enforced by the `policy` nox session + (`tools/check_adr_immutability.py`). A substantive change to an accepted ADR + is legitimate only as a new **superseding** ADR, or as a recorded **amendment** + (a `## Amendments` row plus an updated pin, in the same change). Editorial-only + fixes (typos, formatting) also record a one-line amendment row — the gate + cannot tell editorial from substantive, so every canonical-content change is + recorded. See + [ADR-059](adr-059-adr-amendment-policy-and-pin-gate.md) for the full policy. +- `proposed` ADRs are still being decided and may change freely; + `superseded`/`deprecated` ADRs leave the pinned set (the citable decision has + moved to the replacing ADR). - ADRs are **numbered sequentially** and never reused. - ADRs are **versioned with code** and live in the repo. @@ -44,6 +55,7 @@ adr-055-experiment-core-contract-boundary adr-056-runtime-observed-values-and-credential-posture adr-057-runtime-secret-name-classifier-boundaries adr-058-datastore-node-engine-provenance-and-endpoints +adr-059-adr-amendment-policy-and-pin-gate ``` | ADR | Title | Status | Date | @@ -107,3 +119,4 @@ adr-058-datastore-node-engine-provenance-and-endpoints | [056](adr-056-runtime-observed-values-and-credential-posture.md) | Runtime Observed Values and Credential Posture | accepted | 2026-06-05 | | [057](adr-057-runtime-secret-name-classifier-boundaries.md) | Runtime Scenario Value Realizability and Explicit Redaction | accepted | 2026-06-06 | | [058](adr-058-datastore-node-engine-provenance-and-endpoints.md) | Datastore Node Engine Provenance and Endpoints | accepted | 2026-06-07 | +| [059](adr-059-adr-amendment-policy-and-pin-gate.md) | ADR Amendment Policy and Acceptance-Content Pin Gate | accepted | 2026-06-10 | diff --git a/docs/decisions/adrs/adr-025-container-network-realization-surface.md b/docs/decisions/adrs/adr-025-container-network-realization-surface.md index d2ed85b21..35513d46e 100644 --- a/docs/decisions/adrs/adr-025-container-network-realization-surface.md +++ b/docs/decisions/adrs/adr-025-container-network-realization-surface.md @@ -202,3 +202,9 @@ creating a second network schema elsewhere. - A free-form backend payload would bypass closed-world schema validation and make redaction, stability classification, and cross-backend comparison brittle. + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-05-25 | 4b959c3 | Corrected the control-plane API cross-reference from `aces_processor.control_plane_api` to `aces_runtime.control_plane_api`. | diff --git a/docs/decisions/adrs/adr-029-database-logical-state-runtime-surface.md b/docs/decisions/adrs/adr-029-database-logical-state-runtime-surface.md index e34c27c70..792946ac2 100644 --- a/docs/decisions/adrs/adr-029-database-logical-state-runtime-surface.md +++ b/docs/decisions/adrs/adr-029-database-logical-state-runtime-surface.md @@ -286,3 +286,9 @@ database schema elsewhere. - Overfitting to PostgreSQL would make the ACES surface poor at representing other SQL engines, embedded databases, document stores, or scanner-observed database surfaces. + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-05-30 | d0b4332 | Corrected the runtime field reference `runtime.process` to `runtime.processes` during the DSL-139 family reconciliation. | diff --git a/docs/decisions/adrs/adr-032-directory-domain-identity-runtime-surface.md b/docs/decisions/adrs/adr-032-directory-domain-identity-runtime-surface.md index f18dbec53..5c93a16f5 100644 --- a/docs/decisions/adrs/adr-032-directory-domain-identity-runtime-surface.md +++ b/docs/decisions/adrs/adr-032-directory-domain-identity-runtime-surface.md @@ -251,3 +251,9 @@ directory exports. invite later incompatible surfaces. - Recording raw directory secrets would leak sensitive material into fixtures, generated artifacts, diagnostics, or logs. + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-05-25 | 5e42bd8 | Added primary-standards and access-control literature grounding (LDAP/Kerberos/SCIM/SAML/OAuth/OIDC RFCs, NIST SP 800-63C/162/207, RBAC/ABAC) for identity authorities and authority boundaries. | diff --git a/docs/decisions/adrs/adr-038-runtime-mail-service-logical-state.md b/docs/decisions/adrs/adr-038-runtime-mail-service-logical-state.md index a2868dfe9..cda8a4154 100644 --- a/docs/decisions/adrs/adr-038-runtime-mail-service-logical-state.md +++ b/docs/decisions/adrs/adr-038-runtime-mail-service-logical-state.md @@ -169,3 +169,9 @@ Rejected alternatives: - [SDL Semantic Validation](../../explain/sdl/validation.md), [Runtime Architecture](../../explain/sdl/runtime-architecture.md), and [SDL Limitations](../../explain/sdl/limitations.md). + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-06-06 | 6958fed | Added Security and Validation Gates and Guardrails sections: shared enum-or-var parser, secret-classification boundaries, and service-local reference resolution. | diff --git a/docs/decisions/adrs/adr-041-participant-implementation-manifest-and-provenance.md b/docs/decisions/adrs/adr-041-participant-implementation-manifest-and-provenance.md index 623fe0819..a2d074020 100644 --- a/docs/decisions/adrs/adr-041-participant-implementation-manifest-and-provenance.md +++ b/docs/decisions/adrs/adr-041-participant-implementation-manifest-and-provenance.md @@ -162,3 +162,9 @@ results. - [Contract publication manifest](../../../contracts/schema-publication-manifest.json) and [Controlled vocabularies](../../../contracts/concept-authority/controlled-vocabularies-v1.json). + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-06-06 | 6958fed | Added Security and Validation Gates and Guardrails sections: controlled-vocabulary resolution and references/digests as the portable manifest/provenance surface. | diff --git a/docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md b/docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md index cc274d1a6..1785b6a61 100644 --- a/docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md +++ b/docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md @@ -177,3 +177,10 @@ replication, or persistence behavior. - [Scenario/Delivery Boundary for Runtime Node State](adr-033-scenario-delivery-boundary-for-runtime-node-state.md) - [Lineage and Prior Work](../../explain/sdl/lineage.md) and [Design Precedents](../../explain/sdl/precedents.md) + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-06-07 | e782722 | Added structured datastore mapping manifests. | +| 2026-06-07 | adf63e5 | Added datastore cardinality fields. | diff --git a/docs/decisions/adrs/adr-050-forwarding-agent-runtime-inventory.md b/docs/decisions/adrs/adr-050-forwarding-agent-runtime-inventory.md index b8c654436..1e4c6f6cb 100644 --- a/docs/decisions/adrs/adr-050-forwarding-agent-runtime-inventory.md +++ b/docs/decisions/adrs/adr-050-forwarding-agent-runtime-inventory.md @@ -158,3 +158,9 @@ resolves across both node-hosted and scenario-level forwarding-agent registries. - [Scenario/Delivery Boundary for Runtime Node State](adr-033-scenario-delivery-boundary-for-runtime-node-state.md) - [Lineage and Prior Work](../../explain/sdl/lineage.md) and [Design Precedents](../../explain/sdl/precedents.md) + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-05-31 | e815f27 | Added scenario-level forwarding agents (top-level `Scenario.forwarding_agents`) with cross-registry ref resolution and uniqueness. | diff --git a/docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md b/docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md index dd6fd4548..d6aae4de4 100644 --- a/docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md +++ b/docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md @@ -151,3 +151,10 @@ silently contradict: NIST SP 800-44; Kubernetes Ingress / Gateway API. - [Lineage and Prior Work](../../explain/sdl/lineage.md) and [Design Precedents](../../explain/sdl/precedents.md) + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-05-31 | d727d84 | Tightened SCN-010 SDL validation closure: `authorization_ref` and `upstream_service_ref` resolution rules. | +| 2026-05-31 | e815f27 | Extended typed relationship handling for scenario-level forwarding agents. | diff --git a/docs/decisions/adrs/adr-059-adr-amendment-policy-and-pin-gate.md b/docs/decisions/adrs/adr-059-adr-amendment-policy-and-pin-gate.md new file mode 100644 index 000000000..6dce7b498 --- /dev/null +++ b/docs/decisions/adrs/adr-059-adr-amendment-policy-and-pin-gate.md @@ -0,0 +1,157 @@ +# ADR-059: ADR Amendment Policy and Acceptance-Content Pin Gate + +## Status + +accepted + +## Date + +2026-06-10 + +## Context + +[ADR-000](adr-000-use-adrs.md) and `docs/decisions/adrs/README.md` state that +ADRs are **immutable once accepted** and that the corpus is therefore citable: +a reference to "ADR-048 as accepted" should mean a fixed, knowable piece of +text. That claim was aspirational, not enforced. Git history shows accepted +ADRs edited after acceptance — for example ADR-048 (`e782722` added structured +mapping manifests, `adf63e5` added cardinality fields), ADR-052 (`d727d84`, +`e815f27`), and the same pattern on ADR-025/029/032/038/041/050. Some of those +edits were substantive design changes, not typos. Nothing in the verification +graph noticed, so the citability property the README advertised was silently +false. + +Two failure modes follow from an unenforced claim: + +- a reader cannot trust that "the accepted ADR" is the text they last read; and +- a genuinely needed correction has no recorded, reviewable form — it either + masquerades as "still the original" or it is avoided, both of which corrode + the record. + +The fix is not to forbid all change (decisions legitimately evolve) but to make +change **recorded and detectable**: pin each accepted ADR's content, and require +that any substantive change be either a new superseding ADR or an explicit, +in-band amendment record. + +## Decision + +### 1. What "accepted" pins + +An ADR's **canonical content** is its file with the `## Amendments` section +removed, per-line trailing whitespace stripped, and exactly one file-final +newline. Only the file-final newline run is normalized (so a final-newline +toggle is not a change); leading and interior blank lines are significant, so +adding or removing one is a real content change the pin detects. The corpus pins +the `sha256` of the canonical content of every ADR whose status is exactly +`accepted`. Because the `## Amendments` section is excluded, recording an +amendment never perturbs the pin — keeping history honest cannot itself look +like tampering. + +Pin scope follows status: + +- `accepted` ADRs are pinned and governed by this policy. +- `proposed` ADRs are still being decided and are freely mutable; they are not + pinned. +- `superseded by ADR-NNN` and `deprecated` ADRs leave the pinned set. The + citable decision has moved to the superseding/replacing ADR; the old text is + frozen by virtue of no longer being the live decision, and its manifest pin is + removed when its status changes. + +### 2. The pin manifest + +`docs/decisions/adrs/adr-index.yaml` is the machine-readable, **mutable** pin +manifest (the README index table remains the human-facing index, validated +separately by the existing repo-policy ADR-index check). Its shape: + +```yaml +hash_algorithm: sha256 # the only supported algorithm today +adrs: + - id: ADR-048 # ADR-NNN, unique + path: docs/decisions/adrs/adr-048-...md # repo-relative, non-escaping + pin: + amendments: # optional; present for amended ADRs + - { date: 2026-06-07, ref: e782722, summary: "..." } +``` + +The algorithm lives in the manifest rather than baked into a rule id or +filename, so a future migration to another digest is a manifest change, not a +code change. + +### 3. Amending an accepted ADR + +A **substantive** change to an accepted ADR — anything that changes its meaning: +the decision, its scope, normative references, invariants, or the artifacts it +points at — is legitimate only when recorded one of two ways: + +1. **Supersession.** Add a new ADR that supersedes it, and set the old ADR's + status to `superseded by ADR-NNN` (the ADR-005→ADR-006 pattern). The old ADR + leaves the pinned set; the new ADR is pinned. +2. **In-band amendment.** Keep the ADR `accepted`, append a row to its + `## Amendments` table, and update its pin in `adr-index.yaml` — **in the same + change**. A pin bump on its own is not enough: the change must also add the + amendment record. + +An **editorial-only** change — pure formatting, whitespace, or typo fixes that +change no reference and no meaning — is still a change to canonical content, so +it still updates the pin **and** records a `## Amendments` row (a one-line +"editorial: …" summary is enough). The gate is filesystem-only and cannot tell +an editorial fix from a substantive one, so it requires the same in-band record +for both: every canonical-content change to an accepted ADR is recorded, with no +silent-edit escape hatch. The friction of a one-line row is the price of the +citability guarantee — an editorial amendment row is cheap; an unrecorded edit is +the failure this ADR exists to prevent. + +The `## Amendments` section is a Markdown table at the end of the ADR: + +```markdown +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-06-07 | e782722 | Added structured datastore mapping manifests. | +``` + +Each table row corresponds 1:1 (by commit/PR ref) to a manifest `amendments[]` +entry for that ADR. + +### 4. Enforcement + +`tools/check_adr_immutability.py`, wired into the `policy` nox session, enforces +this filesystem-only and deterministically (it never calls Ground Control, so it +cannot become flaky in CI): + +- the manifest is well-formed (`sha256`, unique ids, repo-relative non-escaping + paths validated by the shared `safe_repo_path`); +- every `accepted` ADR is pinned and every manifest entry names an `accepted` + ADR (coverage both ways); +- each pin equals its ADR's current canonical hash — the unrecorded-edit + detector; +- manifest `amendments[]` refs match the `## Amendments` rows 1:1; +- under `--base-rev`/`--staged` (CI runs `--base-rev`), an accepted ADR whose + canonical content changed versus the base must have gained a `## Amendments` + record in the same change — closing the "bare pin bump blesses an edit" gap. + +## Consequences + +**Positive** + +- The README's citability claim becomes true and is mechanically defended. +- Legitimate evolution is preserved with a low-friction, reviewable path. +- The manifest gives tooling a stable map from ADR to acceptance-content hash. + +**Negative / costs** + +- Editing an accepted ADR now requires a manifest pin update plus a + `## Amendments` row — even for an editorial fix. This is the intended friction; + it keeps the gate filesystem-only with no silent-edit escape hatch. +- The editorial-vs-substantive line affects only how much you write in the + amendment summary, not whether you record one; the policy resolves doubt toward + recording. + +**Risks** + +- The pin is over normalized canonical bytes; a future change to the + normalization rule would re-pin the whole corpus. The rule is therefore kept + minimal (drop the `## Amendments` section; strip per-line trailing whitespace; + normalize only the file-final newline) and lives in one place shared by the + manifest generator and the checker. diff --git a/docs/decisions/adrs/adr-index.yaml b/docs/decisions/adrs/adr-index.yaml new file mode 100644 index 000000000..e44046033 --- /dev/null +++ b/docs/decisions/adrs/adr-index.yaml @@ -0,0 +1,209 @@ +# Acceptance-content pin manifest for accepted ADRs (ADR-059). +# +# `pin` is the sha256 of each accepted ADR's canonical content (the file +# with its `## Amendments` section removed, per-line trailing whitespace +# trimmed). Enforced by tools/check_adr_immutability.py in the `policy` nox +# session. A substantive change to an accepted ADR must add a `## Amendments` +# row + update its pin here in the same change, or supersede it with a new ADR. +hash_algorithm: sha256 +adrs: + - id: ADR-000 + path: docs/decisions/adrs/adr-000-use-adrs.md + pin: 9fbb099fd8321b1351c0afd13f7502bc5cd4863c3853a087ba5fbf4940cc8634 + - id: ADR-001 + path: docs/decisions/adrs/adr-001-scenario-description-language.md + pin: 29d53078da3314320bca34148440e8ad80a0deb6fc0471177197d2ab90043389 + - id: ADR-002 + path: docs/decisions/adrs/adr-002-declarative-sdl-objectives.md + pin: 7550c868306aa4f6ff7a1522796b71ae8797aa0015d7faec37c8f7011e60c536 + - id: ADR-003 + path: docs/decisions/adrs/adr-003-workflows-targetable-subobjects-and-enum-variables.md + pin: 0039bea0de4e4d5d08ccf300a7fb7516a0ea01977f38f1e2e111409a13e108df + - id: ADR-004 + path: docs/decisions/adrs/adr-004-sdl-runtime-layer.md + pin: 1fbd1578afe343fe869b0298aab24f1d5339db40188cc39a66e38f1dcd98370b + - id: ADR-006 + path: docs/decisions/adrs/adr-006-workflow-control-language-redesign.md + pin: 14565096df37ebc65239db018da85a609701b7978523b44f20d06f189f085900 + - id: ADR-007 + path: docs/decisions/adrs/adr-007-lightweight-formal-methods-policy.md + pin: 1ca00bf9c4610117c7cb8c42fe9be828292dfff22828793b32e504bca4379d85 + - id: ADR-008 + path: docs/decisions/adrs/adr-008-processor-layer-and-execution-artifact-boundaries.md + pin: 55343496d8dc22a8b7348f1d1197cd9f446d40a0a9af5d9d7b40f0c7cc9a8966 + - id: ADR-009 + path: docs/decisions/adrs/adr-009-normative-artifact-authority-and-repository-structure.md + pin: f7a74751a70e8d5d96266e2c45d94b1aafda832afe7b418df819de1cb1983fcc + - id: ADR-010 + path: docs/decisions/adrs/adr-010-repository-realignment-order-and-compatibility-policy.md + pin: 752d8577e900e9b6257ff0db362c275bf7b26e14162a892dcd9578cbd71717c3 + - id: ADR-011 + path: docs/decisions/adrs/adr-011-narrow-end-to-end-mvp-validation.md + pin: b18bc89bddce74a677321b2f5429243907491235266f71a3ba6f02829b32f7c0 + - id: ADR-012 + path: docs/decisions/adrs/adr-012-shared-concept-authority-and-aces-extension-discipline.md + pin: 3027ece9635715383318f0deb7ca8b9092631f95959a294034ebeadc5bc488a0 + - id: ADR-013 + path: docs/decisions/adrs/adr-013-participant-episode-lifecycle-boundaries.md + pin: aaf414e1da0eb329432562b16ff9e132522e19e8a35933a1c99329b0be27cae8 + - id: ADR-014 + path: docs/decisions/adrs/adr-014-nox-as-canonical-verification-graph.md + pin: 940ce80b1f1d94e3efb4a9f56cb9147d09c9a9dc1453a4977c7a5e194238d708 + - id: ADR-015 + path: docs/decisions/adrs/adr-015-sdl-processor-layering-and-source-file-size-cap.md + pin: 1e6000c3405e84765a993d326a8d97822e7b28a6714463bcd4d1550951f20ec7 + - id: ADR-016 + path: docs/decisions/adrs/adr-016-semantic-layer-scope-and-coverage-model.md + pin: ff9a71be7f6d427b88ff26317da36e14b6cc5943418cd941df88dca29033449b + - id: ADR-017 + path: docs/decisions/adrs/adr-017-conversation-surface-hardening.md + pin: 09d78645690d4ae3f416a72380317148662b3f252a96d31bbc28c969f2c7eb81 + - id: ADR-018 + path: docs/decisions/adrs/adr-018-classification-based-assurance-policy.md + pin: 168195279ea2e3dafb12d3d308f750af2804bc09700efa3dc258a31cb05e8d2f + - id: ADR-019 + path: docs/decisions/adrs/adr-019-normative-authority-boundary-manifest.md + pin: 66b4ea3eb2482d6f8855462f4921c9cd7853ddb4c9adfaa21f311a07422f248b + - id: ADR-024 + path: docs/decisions/adrs/adr-024-local-identity-inventory-surface.md + pin: 74f1a3df1a7c0f8dc6f44ff006abe88b20b9444fa93a736ac20fca1ca800e665 + - id: ADR-025 + path: docs/decisions/adrs/adr-025-container-network-realization-surface.md + pin: 5442b545bb5a038500dce5a66a0bccddbeea6449269481c8b4f960d9b6a9f488 + amendments: + - date: 2026-05-25 + ref: 4b959c3 + summary: "Corrected the control-plane API cross-reference from `aces_processor.control_plane_api` to `aces_runtime.control_plane_api`." + - id: ADR-026 + path: docs/decisions/adrs/adr-026-application-http-surface-inventory.md + pin: 8818f870d79498f3322fd5edb9d2269948d04079ef5fbc7238811911ab7f5373 + - id: ADR-027 + path: docs/decisions/adrs/adr-027-container-init-reaper-runtime-surface.md + pin: d568a5a77d55b995d23c057ed3ccc56d221509a6068374d3884b2608c926cdea + - id: ADR-028 + path: docs/decisions/adrs/adr-028-container-seccomp-security-options-surface.md + pin: 8798070e5aaa08a1de27686e8fd346030f00c4f3cace56c83602c0f9a312ac39 + - id: ADR-029 + path: docs/decisions/adrs/adr-029-database-logical-state-runtime-surface.md + pin: b4fc7ca2d8996f0edf284385c7d731dbbe14794e7e6be2b66d0b2369c5c53440 + amendments: + - date: 2026-05-30 + ref: d0b4332 + summary: "Corrected the runtime field reference `runtime.process` to `runtime.processes` during the DSL-139 family reconciliation." + - id: ADR-030 + path: docs/decisions/adrs/adr-030-process-scoped-linux-capability-policy.md + pin: d0574426f9b249fa078f364988e309b14d73c03a9f00115ab04e6ddeab76ffa1 + - id: ADR-031 + path: docs/decisions/adrs/adr-031-ssh-server-configuration-surface.md + pin: c77a768a27cf1d457aaf143557588e259b550fb5037acd0ea1e4d21026ba7cee + - id: ADR-032 + path: docs/decisions/adrs/adr-032-directory-domain-identity-runtime-surface.md + pin: 518395ad00cd7ef19d9c762c87702eb1677333a76e0d26e5f0d404e6fe20f216 + amendments: + - date: 2026-05-25 + ref: 5e42bd8 + summary: "Added primary-standards and access-control literature grounding (LDAP/Kerberos/SCIM/SAML/OAuth/OIDC RFCs, NIST SP 800-63C/162/207, RBAC/ABAC) for identity authorities and authority boundaries." + - id: ADR-033 + path: docs/decisions/adrs/adr-033-scenario-delivery-boundary-for-runtime-node-state.md + pin: da01715747264384779a9bb203f4081ca401edaf7a947ecc48d791a18c97b69b + - id: ADR-034 + path: docs/decisions/adrs/adr-034-runtime-software-component-inventory.md + pin: eb62ece791c5c0b442d5c77d89867832c01f0f5bc108b577f8636089be7c647e + - id: ADR-035 + path: docs/decisions/adrs/adr-035-service-manager-unit-state-runtime-surface.md + pin: a8eb4df39f8b88781fee62ce83a2d2abee3951ebbeed5dc0c544a2a929ffad06 + - id: ADR-036 + path: docs/decisions/adrs/adr-036-sdl-processor-runtime-module-boundaries.md + pin: 80274b6f6f7529fdce17d3451acb2201f6ecf07095dbc5f6b36aa81399ddcdd9 + - id: ADR-037 + path: docs/decisions/adrs/adr-037-runtime-file-service-and-filesystem-presence-semantics.md + pin: d58c39b1132fe59dedb53fa389fc9c9bee063c37fca4d30aecff056683550baa + - id: ADR-038 + path: docs/decisions/adrs/adr-038-runtime-mail-service-logical-state.md + pin: e06becb845b360a89afa3921f01705ce08143c8af4df2091b83b7a915c70371d + amendments: + - date: 2026-06-06 + ref: 6958fed + summary: "Added Security and Validation Gates and Guardrails sections: shared enum-or-var parser, secret-classification boundaries, and service-local reference resolution." + - id: ADR-039 + path: docs/decisions/adrs/adr-039-dns-service-runtime-inventory.md + pin: b6cf6dce2611e64e565b80bb25acbbe5dad9d76f0922e9cbaf3bc39d19e8aa80 + - id: ADR-040 + path: docs/decisions/adrs/adr-040-security-monitoring-manager-runtime-inventory.md + pin: 57efb0ca019ca74bc0dfa4ff722491813c569c7039236be0af464d9e5730c3aa + - id: ADR-041 + path: docs/decisions/adrs/adr-041-participant-implementation-manifest-and-provenance.md + pin: 62b9c2b348aaa2594086abae91432fc404e95f51255ab793aea32ce45aa1eaad + amendments: + - date: 2026-06-06 + ref: 6958fed + summary: "Added Security and Validation Gates and Guardrails sections: controlled-vocabulary resolution and references/digests as the portable manifest/provenance surface." + - id: ADR-042 + path: docs/decisions/adrs/adr-042-network-sensor-runtime-monitoring.md + pin: d2d244bdd3bbfe2d6eaecc2e69bc73a474f405ae7e1d4d8bbb5dd75dbed7826d + - id: ADR-043 + path: docs/decisions/adrs/adr-043-runtime-service-listener-surface.md + pin: 10e261d7027b769097ae70fc139d8d2c452b48b48146ed67d6af171f970163c6 + - id: ADR-044 + path: docs/decisions/adrs/adr-044-network-detection-engine-runtime-inventory.md + pin: 9272830dd30284ab864c62eb7bd74a563403bbfdff59992173d154785d55d3f7 + - id: ADR-045 + path: docs/decisions/adrs/adr-045-security-monitoring-detection-definition-semantics.md + pin: aa199dde8b9d5baf3792e8ee4c1b1cca7b16af08041074d96d82fd8ef342179a + - id: ADR-046 + path: docs/decisions/adrs/adr-046-app-authorization-runtime-inventory.md + pin: e98452dba70a26fadf21bd8f6cf0d2b5a24829a256a3af80325f114b4cf157a7 + - id: ADR-047 + path: docs/decisions/adrs/adr-047-scheduled-job-runtime-inventory.md + pin: f674658c76e3d4581f8ef64f189e558e6873959b6292081b343bd5c9ae8a6181 + - id: ADR-048 + path: docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md + pin: a53fbb437134c173879324b9a912dfc35f2a368c7ca2cfd0e049ef6632979d9c + amendments: + - date: 2026-06-07 + ref: e782722 + summary: "Added structured datastore mapping manifests." + - date: 2026-06-07 + ref: adf63e5 + summary: "Added datastore cardinality fields." + - id: ADR-049 + path: docs/decisions/adrs/adr-049-platform-application-runtime-inventory.md + pin: efd2bda17b286ff8da753428439df26feabea21ea21aa7fc817916f329ce9bba + - id: ADR-050 + path: docs/decisions/adrs/adr-050-forwarding-agent-runtime-inventory.md + pin: 8eaecd0c498353822c5fec91f83fa6af6b7a7bb32451025f97661673cc1b832c + amendments: + - date: 2026-05-31 + ref: e815f27 + summary: "Added scenario-level forwarding agents (top-level `Scenario.forwarding_agents`) with cross-registry ref resolution and uniqueness." + - id: ADR-051 + path: docs/decisions/adrs/adr-051-orchestration-authority-runtime-inventory.md + pin: 080e29f079007f86d1be96f5bf94a29d44cf138778b0adf43ddefa16bafdd099 + - id: ADR-052 + path: docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md + pin: 304d33ad2b5e43ccc382d2001625e09039ae920ed924627f16ef02de5dca6227 + amendments: + - date: 2026-05-31 + ref: d727d84 + summary: "Tightened SCN-010 SDL validation closure: `authorization_ref` and `upstream_service_ref` resolution rules." + - date: 2026-05-31 + ref: e815f27 + summary: "Extended typed relationship handling for scenario-level forwarding agents." + - id: ADR-053 + path: docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md + pin: e6eae89e31205274087e6c4e775744fba945091cddef1aa5dad3daad47921993 + - id: ADR-055 + path: docs/decisions/adrs/adr-055-experiment-core-contract-boundary.md + pin: 2e6666f2e066700df3dbf38d0592a2f24e062ad4f94d695113824f2ba73ff695 + - id: ADR-056 + path: docs/decisions/adrs/adr-056-runtime-observed-values-and-credential-posture.md + pin: 3aa602de53f7607cf531b4a9da3f58192eb87ad21ddd92846fd2ae2a7526a4c3 + - id: ADR-057 + path: docs/decisions/adrs/adr-057-runtime-secret-name-classifier-boundaries.md + pin: 44c638bf47df8545b8af8a3850476fabc46a4ea42b3a8003b4f9ea1e89f72f49 + - id: ADR-058 + path: docs/decisions/adrs/adr-058-datastore-node-engine-provenance-and-endpoints.md + pin: 1a3d389978318de260c1a8b603c62fc49ca340eab120ba5cbdeeb9f12d7cf726 + - id: ADR-059 + path: docs/decisions/adrs/adr-059-adr-amendment-policy-and-pin-gate.md + pin: dd089a4cd415280fb87d278b70537d4a1144d2f28198314a22031fc88eaf5ce2 diff --git a/implementations/python/tests/test_repo_policy_tools.py b/implementations/python/tests/test_repo_policy_tools.py index 7366f5708..f0dae842d 100644 --- a/implementations/python/tests/test_repo_policy_tools.py +++ b/implementations/python/tests/test_repo_policy_tools.py @@ -2,6 +2,7 @@ import importlib.util import shutil +import subprocess import sys import types from pathlib import Path @@ -14,6 +15,12 @@ import pytest import tools.check_generated_schemas as check_generated_schemas import yaml +from tools.check_adr_immutability import ( + amendment_refs, + canonical_content, + content_hash, + evaluate_adr_immutability, +) from tools.check_generated_schemas import _extra_published_schema_paths from tools.check_json_artifacts import collect_validation_targets, should_run_full_validation from tools.check_schema_publication import validate_schema_publication_manifest @@ -1111,3 +1118,404 @@ def test_check_generated_schemas_main_rejects_stale_extra_schema_files( monkeypatch.setitem(sys.modules, "aces_contracts.contracts", fake_contracts) assert check_generated_schemas.main() == 1 + + +# --- ADR acceptance-content pin gate (ADR-059 / GOV-941) ---------------------- + +AMENDMENTS_TABLE_HEADER = "## Amendments\n\n| Date | Commit/PR | Summary |\n|------|-----------|---------|\n" + + +def _adr_dir(tmp_path: Path) -> Path: + adr_dir = tmp_path / "docs" / "decisions" / "adrs" + adr_dir.mkdir(parents=True, exist_ok=True) + return adr_dir + + +def _make_adr( + adr_dir: Path, + number: str, + *, + status: str = "accepted", + body: str = "\n## Context\n\nThe decision body.\n", + amendment_rows: list[tuple[str, str, str]] | None = None, +) -> str: + text = _adr_text(number, status=status, body=body) + if amendment_rows: + rows = "".join(f"| {date} | {ref} | {summary} |\n" for date, ref, summary in amendment_rows) + text += "\n" + AMENDMENTS_TABLE_HEADER + rows + write_text(adr_dir / f"adr-{number}-example.md", text) + return text + + +def _write_manifest(adr_dir: Path, entries: list[dict], *, algorithm: str = "sha256") -> None: + write_text( + adr_dir / "adr-index.yaml", + yaml.safe_dump({"hash_algorithm": algorithm, "adrs": entries}, sort_keys=False), + ) + + +def _entry(number: str, text: str, *, pin_text: str | None = None, amendments: list[dict] | None = None) -> dict: + # ``pin_text`` lets a caller pin over content that is NOT byte-identical to the + # on-disk ADR (``text``). Recorded-amendment tests rely on this to pin over the + # *unamended* body so the gate only stays green if ``canonical_content`` truly + # strips the ``## Amendments`` section — pinning over the amended text would be + # tautological (both sides hashed from the same amended bytes). + entry = { + "id": f"ADR-{number}", + "path": f"docs/decisions/adrs/adr-{number}-example.md", + "pin": content_hash(pin_text if pin_text is not None else text), + } + if amendments is not None: + entry["amendments"] = amendments + return entry + + +def _adr_text(number: str, *, status: str = "accepted", body: str = "\n## Context\n\nThe decision body.\n") -> str: + """The unamended ADR text ``_make_adr`` writes for ``(number, status, body)``, + without touching disk. Used to compute a pin over body-only content so the + amendment-stripping invariant is falsifiable.""" + return f"# ADR-{number}: Example {number}\n\n## Status\n\n{status}\n\n## Date\n\n2026-04-05\n{body}" + + +def _rule_ids(failures: list[PolicyFailure]) -> list[str]: + return [failure.rule_id for failure in failures] + + +def _init_git_repo(repo_root: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=repo_root, check=True, capture_output=True, text=True) + subprocess.run(["git", "config", "user.email", "t@example.com"], cwd=repo_root, check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=repo_root, check=True, capture_output=True) + + +def _git_commit_all(repo_root: Path, message: str) -> None: + subprocess.run(["git", "add", "-A"], cwd=repo_root, check=True, capture_output=True, text=True) + subprocess.run(["git", "commit", "-q", "-m", message], cwd=repo_root, check=True, capture_output=True, text=True) + + +def _git_add_all(repo_root: Path) -> None: + subprocess.run(["git", "add", "-A"], cwd=repo_root, check=True, capture_output=True, text=True) + + +def test_adr_pin_gate_passes_on_pinned_accepted_corpus(tmp_path: Path) -> None: + adr_dir = _adr_dir(tmp_path) + accepted_one = _make_adr(adr_dir, "001") + accepted_two = _make_adr(adr_dir, "002") + _make_adr(adr_dir, "003", status="proposed") # proposed ADRs are not pinned + _write_manifest(adr_dir, [_entry("001", accepted_one), _entry("002", accepted_two)]) + + assert evaluate_adr_immutability(tmp_path) == [] + + +def test_adr_pin_gate_flags_unrecorded_edit(tmp_path: Path) -> None: + adr_dir = _adr_dir(tmp_path) + original = _make_adr(adr_dir, "001") + _write_manifest(adr_dir, [_entry("001", original)]) + # Edit the ADR body without updating the pin. + _make_adr(adr_dir, "001", body="\n## Context\n\nA substantively different body.\n") + + failures = evaluate_adr_immutability(tmp_path) + assert "adr-pin-stale" in _rule_ids(failures) + + +def test_adr_pin_gate_accepts_recorded_amendment(tmp_path: Path) -> None: + adr_dir = _adr_dir(tmp_path) + text = _make_adr(adr_dir, "001", amendment_rows=[("2026-06-07", "abc1234", "added a field")]) + # Pin over the *unamended* body. If canonical_content stops stripping the + # ## Amendments section, the on-disk (amended) hash diverges from this pin and + # the gate fires adr-pin-stale — so this assertion genuinely verifies the + # "amendment records never change the pin" invariant rather than tautologically + # hashing the same amended bytes on both sides. + _write_manifest( + adr_dir, + [ + _entry( + "001", + text, + pin_text=_adr_text("001"), + amendments=[{"date": "2026-06-07", "ref": "abc1234", "summary": "added a field"}], + ) + ], + ) + + # The pin is over canonical content (amendments excluded), so the recorded + # amendment does not perturb it, and the manifest refs match the table 1:1. + assert evaluate_adr_immutability(tmp_path) == [] + + +def test_adr_pin_gate_flags_missing_pin(tmp_path: Path) -> None: + adr_dir = _adr_dir(tmp_path) + pinned = _make_adr(adr_dir, "001") + _make_adr(adr_dir, "002") # accepted but absent from the manifest + _write_manifest(adr_dir, [_entry("001", pinned)]) + + failures = evaluate_adr_immutability(tmp_path) + assert "adr-pin-missing" in _rule_ids(failures) + + +def test_adr_pin_gate_flags_orphan_entry(tmp_path: Path) -> None: + adr_dir = _adr_dir(tmp_path) + proposed = _make_adr(adr_dir, "001", status="proposed") + _write_manifest(adr_dir, [_entry("001", proposed)]) # pins a non-accepted ADR + + failures = evaluate_adr_immutability(tmp_path) + assert "adr-pin-orphan" in _rule_ids(failures) + + +def test_adr_pin_gate_flags_amendment_record_mismatch(tmp_path: Path) -> None: + adr_dir = _adr_dir(tmp_path) + text = _make_adr(adr_dir, "001", amendment_rows=[("2026-06-07", "abc1234", "added a field")]) + _write_manifest(adr_dir, [_entry("001", text, amendments=[])]) # table row not mirrored in manifest + + failures = evaluate_adr_immutability(tmp_path) + assert "adr-amendment-record-mismatch" in _rule_ids(failures) + + +def test_adr_pin_gate_rejects_unsupported_algorithm(tmp_path: Path) -> None: + adr_dir = _adr_dir(tmp_path) + text = _make_adr(adr_dir, "001") + _write_manifest(adr_dir, [_entry("001", text)], algorithm="md5") + + failures = evaluate_adr_immutability(tmp_path) + assert _rule_ids(failures) == ["adr-manifest-malformed"] + + +def test_adr_pin_gate_rejects_duplicate_id(tmp_path: Path) -> None: + adr_dir = _adr_dir(tmp_path) + text = _make_adr(adr_dir, "001") + _write_manifest(adr_dir, [_entry("001", text), _entry("001", text)]) + + failures = evaluate_adr_immutability(tmp_path) + assert "adr-manifest-malformed" in _rule_ids(failures) + + +def test_adr_pin_gate_rejects_unsafe_path(tmp_path: Path) -> None: + adr_dir = _adr_dir(tmp_path) + _write_manifest( + adr_dir, + [{"id": "ADR-001", "path": "../outside-the-repo.md", "pin": "0" * 64}], + ) + + failures = evaluate_adr_immutability(tmp_path) + assert "adr-manifest-path-unsafe" in _rule_ids(failures) + + +def test_adr_pin_gate_missing_manifest_fails_cleanly(tmp_path: Path) -> None: + _adr_dir(tmp_path) + failures = evaluate_adr_immutability(tmp_path) + assert _rule_ids(failures) == ["adr-manifest-malformed"] + + +def test_adr_pin_gate_base_rev_flags_pin_bump_without_amendment(tmp_path: Path) -> None: + adr_dir = _adr_dir(tmp_path) + original = _make_adr(adr_dir, "001") + _write_manifest(adr_dir, [_entry("001", original)]) + _init_git_repo(tmp_path) + _git_commit_all(tmp_path, "base") + + # Edit the body AND bump the pin, but record no amendment. + edited = _make_adr(adr_dir, "001", body="\n## Context\n\nA materially changed body.\n") + _write_manifest(adr_dir, [_entry("001", edited)]) + + failures = evaluate_adr_immutability(tmp_path, base_rev="HEAD") + assert _rule_ids(failures) == ["adr-amendment-unrecorded"] + + +def test_adr_pin_gate_base_rev_accepts_recorded_amendment(tmp_path: Path) -> None: + adr_dir = _adr_dir(tmp_path) + original = _make_adr(adr_dir, "001") + _write_manifest(adr_dir, [_entry("001", original)]) + _init_git_repo(tmp_path) + _git_commit_all(tmp_path, "base") + + changed_body = "\n## Context\n\nA materially changed body.\n" + edited = _make_adr( + adr_dir, + "001", + body=changed_body, + amendment_rows=[("2026-06-08", "def5678", "changed the body")], + ) + # Pin over the unamended edited body so the recorded amendment is what makes + # the change legitimate; a canonical_content regression that stops stripping + # amendments would diverge this pin from the on-disk hash and fail the test. + _write_manifest( + adr_dir, + [ + _entry( + "001", + edited, + pin_text=_adr_text("001", body=changed_body), + amendments=[{"date": "2026-06-08", "ref": "def5678", "summary": "changed the body"}], + ) + ], + ) + + assert evaluate_adr_immutability(tmp_path, base_rev="HEAD") == [] + + +def test_adr_pin_gate_base_rev_allows_supersession(tmp_path: Path) -> None: + adr_dir = _adr_dir(tmp_path) + original = _make_adr(adr_dir, "001") + _write_manifest(adr_dir, [_entry("001", original)]) + _init_git_repo(tmp_path) + _git_commit_all(tmp_path, "base") + + # Supersede ADR-001: status changes (so it leaves the accepted/pinned set) + # and a new superseding ADR-002 is added; drop ADR-001 from the manifest. + _make_adr(adr_dir, "001", status="superseded by ADR-002") + superseding = _make_adr(adr_dir, "002") + _write_manifest(adr_dir, [_entry("002", superseding)]) + + assert evaluate_adr_immutability(tmp_path, base_rev="HEAD") == [] + + +def test_adr_pin_gate_staged_flags_pin_bump_without_amendment(tmp_path: Path) -> None: + # The pre-commit invocation: ``staged=True`` compares the git *index* + # (``git show :``) against HEAD, a distinct code path from ``base_rev`` + # (which reads the working tree from disk). A staged pin bump without an + # amendment must be flagged just like the base_rev case. + adr_dir = _adr_dir(tmp_path) + original = _make_adr(adr_dir, "001") + _write_manifest(adr_dir, [_entry("001", original)]) + _init_git_repo(tmp_path) + _git_commit_all(tmp_path, "base") + + edited = _make_adr(adr_dir, "001", body="\n## Context\n\nA materially changed body.\n") + _write_manifest(adr_dir, [_entry("001", edited)]) + _git_add_all(tmp_path) + + failures = evaluate_adr_immutability(tmp_path, staged=True) + assert _rule_ids(failures) == ["adr-amendment-unrecorded"] + + +def test_adr_pin_gate_staged_accepts_recorded_amendment(tmp_path: Path) -> None: + # A staged edit that records its amendment (and bumps the pin) passes — proving + # the staged branch does not over-fire on legitimately recorded changes. + adr_dir = _adr_dir(tmp_path) + original = _make_adr(adr_dir, "001") + _write_manifest(adr_dir, [_entry("001", original)]) + _init_git_repo(tmp_path) + _git_commit_all(tmp_path, "base") + + changed_body = "\n## Context\n\nA materially changed body.\n" + edited = _make_adr( + adr_dir, + "001", + body=changed_body, + amendment_rows=[("2026-06-08", "def5678", "changed the body")], + ) + # Pin over the unamended edited body (see recorded-amendment tests above): the + # green result must depend on canonical_content actually stripping amendments. + _write_manifest( + adr_dir, + [ + _entry( + "001", + edited, + pin_text=_adr_text("001", body=changed_body), + amendments=[{"date": "2026-06-08", "ref": "def5678", "summary": "changed the body"}], + ) + ], + ) + _git_add_all(tmp_path) + + assert evaluate_adr_immutability(tmp_path, staged=True) == [] + + +def test_adr_pin_gate_staged_sources_head_text_from_index_not_disk(tmp_path: Path) -> None: + # Pins down that ``head_text`` in the staged branch comes from the git *index* + # (``git show :``), not the working tree. We stage an unrecorded ADR edit + # (with a bumped pin) and then restore the working-tree file to the committed + # original. Now the index holds the edit while disk == HEAD, so: + # * the pin-hash check (which reads disk) sees the original and stays green; + # * the corpus checks all pass against the staged pin too, so evaluation + # reaches the staged unrecorded-edit detector; + # * only an index-sourced ``head_text`` can observe the edit and flag it. + # If the detector read disk instead, head_text would equal base_text and the + # edit would silently pass — exactly the false exit-0 this test forbids. + adr_dir = _adr_dir(tmp_path) + original = _make_adr(adr_dir, "001") + _write_manifest(adr_dir, [_entry("001", original)]) + _init_git_repo(tmp_path) + _git_commit_all(tmp_path, "base") + + # Stage the edited ADR. The bumped pin must match the *index* (edited) content + # so the disk-based pin-hash check will be green once we restore disk below. + edited = _make_adr(adr_dir, "001", body="\n## Context\n\nA materially changed body.\n") + _git_add_all(tmp_path) # index now holds the edited ADR + + # Restore the working tree to the committed original while keeping the index + # edit. Pin stays over the original content so the disk-read pin-hash is green. + write_text(adr_dir / "adr-001-example.md", original) + _write_manifest(adr_dir, [_entry("001", original)]) + + failures = evaluate_adr_immutability(tmp_path, staged=True) + assert _rule_ids(failures) == ["adr-amendment-unrecorded"] + + +def test_amendment_refs_parses_table_rows() -> None: + text = ( + "# ADR-001: Example\n\n## Status\n\naccepted\n\n## Date\n\n2026-04-05\n\n" + + AMENDMENTS_TABLE_HEADER + + "| 2026-06-07 | abc1234 | first |\n| 2026-06-08 | def5678 | second |\n" + ) + assert amendment_refs(text) == ["abc1234", "def5678"] + + +def test_amendment_parsing_ignores_fenced_examples(tmp_path: Path) -> None: + # An ADR that documents the amendment format (like ADR-059) embeds a + # ``## Amendments`` example inside a fenced code block. That example must + # not be read as a real section, or the ADR's pin would truncate and a + # bogus amendment would be parsed from the example row. + adr_dir = _adr_dir(tmp_path) + text = ( + "# ADR-001: Policy\n\n## Status\n\naccepted\n\n## Date\n\n2026-04-05\n\n" + "## Decision\n\nRecord amendments like:\n\n" + "```markdown\n## Amendments\n\n| Date | Commit/PR | Summary |\n" + "|------|-----------|---------|\n| 2026-06-07 | deadbee | example |\n```\n\n" + "## Consequences\n\nDone.\n" + ) + write_text(adr_dir / "adr-001-example.md", text) + assert amendment_refs(text) == [] + _write_manifest(adr_dir, [_entry("001", text)]) # _entry hashes full text; no amendments + + assert evaluate_adr_immutability(tmp_path) == [] + + +def test_canonical_content_detects_boundary_blank_line_edits() -> None: + # ADR-059 declares only per-line trailing whitespace and the file-final + # newline as normalized. A leading or interior blank line is significant, so + # adding one must change the canonical hash; toggling the final newline must + # not. This keeps the pin from silently absorbing boundary blank-line edits. + base = "# ADR-001: Example\n\n## Context\n\nThe body.\n" + leading_blank = "\n" + base + interior_blank = "# ADR-001: Example\n\n\n## Context\n\nThe body.\n" + no_final_newline = base.rstrip("\n") + trailing_blank = base + "\n" + + assert canonical_content(base) != canonical_content(leading_blank) + assert canonical_content(base) != canonical_content(interior_blank) + assert canonical_content(base) == canonical_content(no_final_newline) + assert canonical_content(base) == canonical_content(trailing_blank) + + +def test_adr_pin_gate_base_rev_flags_boundary_blank_line_edit(tmp_path: Path) -> None: + adr_dir = _adr_dir(tmp_path) + original = _make_adr(adr_dir, "001") + _write_manifest(adr_dir, [_entry("001", original)]) + _init_git_repo(tmp_path) + _git_commit_all(tmp_path, "base") + + # Prepend a blank line (a boundary-only edit) and bump the pin, no amendment. + edited = "\n" + original + write_text(adr_dir / "adr-001-example.md", edited) + _write_manifest(adr_dir, [_entry("001", edited)]) + + failures = evaluate_adr_immutability(tmp_path, base_rev="HEAD") + assert _rule_ids(failures) == ["adr-amendment-unrecorded"] + + +def test_real_repo_adr_index_is_green() -> None: + """The committed adr-index.yaml must pin every accepted ADR honestly so the + gate starts (and stays) green on the real corpus.""" + failures = evaluate_adr_immutability(REPO_ROOT) + assert failures == [], "\n".join(failure.render() for failure in failures) diff --git a/noxfile.py b/noxfile.py index 17fad8ba7..00a86b5d5 100644 --- a/noxfile.py +++ b/noxfile.py @@ -484,6 +484,12 @@ def _run_policy(session: nox.Session, reporter: SessionReporter, *args: str) -> ), ) repo_args, requirement_args, skip_requirement = _split_policy_session_args(list(args)) + arg_list = list(args) + adr_pin_args: list[str] = [] + if "--base-rev" in arg_list: + base_index = arg_list.index("--base-rev") + if base_index + 1 < len(arg_list): + adr_pin_args = ["--base-rev", arg_list[base_index + 1]] reporter.run( "policy / repo policy", lambda: _run_project_python(session, "tools/check_repo_policy.py", *repo_args), @@ -519,6 +525,10 @@ def _run_policy(session: nox.Session, reporter: SessionReporter, *args: str) -> "policy / example library catalog", "skipped on staged check; runs on push and verify", ) + reporter.skip( + "policy / ADR acceptance-content pin", + "skipped on staged check; runs on push and verify", + ) else: reporter.run( "policy / semantic coverage ADR", @@ -540,6 +550,10 @@ def _run_policy(session: nox.Session, reporter: SessionReporter, *args: str) -> "policy / example library catalog", lambda: _run_project_python(session, "tools/check_example_library.py"), ) + reporter.run( + "policy / ADR acceptance-content pin", + lambda: _run_project_python(session, "tools/check_adr_immutability.py", *adr_pin_args), + ) def _run_contracts(session: nox.Session, reporter: SessionReporter, *args: str) -> None: diff --git a/tools/check_adr_immutability.py b/tools/check_adr_immutability.py new file mode 100644 index 000000000..a8499e50b --- /dev/null +++ b/tools/check_adr_immutability.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +# ruff: noqa: E402, I001 +"""Acceptance-content pin gate for accepted ADRs (ADR-059). + +``docs/decisions/adrs/README.md`` claims the ADR corpus is citable — an +accepted ADR's content does not silently change under you. ADR-059 makes that +claim enforceable instead of aspirational: ``docs/decisions/adrs/adr-index.yaml`` +pins every *accepted* ADR to the ``sha256`` of its **canonical content** (the +file with its ``## Amendments`` section removed), and a substantive change to an +accepted ADR is only legitimate when it is recorded — as a new ``## Amendments`` +row plus an updated pin, or by superseding the ADR with a new one. + +What this gate enforces, filesystem-only and deterministically (it never calls +Ground Control, so it cannot become flaky in CI): + +* the manifest is well-formed: a mapping with ``hash_algorithm: sha256`` and an + ``adrs`` list of ``{id, path, pin, amendments?}`` entries, unique ids, repo- + relative non-escaping paths (via the shared ``safe_repo_path``); +* every ADR whose on-disk status is ``accepted`` has exactly one manifest entry + (``adr-pin-missing``), and every manifest entry names an ADR that is + ``accepted`` on disk (``adr-pin-orphan``) — only ``accepted`` ADRs are pinned; + ``proposed`` stay mutable and ``superseded``/``deprecated`` leave the pin set; +* each pinned ADR's current canonical hash equals its pin (``adr-pin-stale``) — + the unrecorded-edit detector; +* the manifest ``amendments[]`` refs match the ADR's ``## Amendments`` table rows + 1:1 (``adr-amendment-record-mismatch``); +* with ``--base-rev``/``--staged``: an accepted ADR whose canonical content + changed versus the base must also have gained a ``## Amendments`` record in the + same change (``adr-amendment-unrecorded``) — this is what stops a bare pin bump + from blessing an undocumented edit. (Status transitions to + superseded/deprecated and superseding ADRs naturally leave the accepted set, so + they are not flagged.) + +Failures use ``tools.policy.common.PolicyFailure``; the CLI honours ``--json`` and +the shared ``tools/policy/exceptions.yaml`` waiver mechanism like the other +``policy`` nox-stage entry points. +""" + +from __future__ import annotations + +import argparse +import hashlib +import re +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from tools.policy.adr import parse_adr_file +from tools.policy.common import ( + PolicyFailure, + apply_exceptions, + failures_to_json, + load_exceptions, + load_yaml, + safe_repo_path, +) + +ADR_DIR = "docs/decisions/adrs" +MANIFEST_PATH = f"{ADR_DIR}/adr-index.yaml" +SUPPORTED_HASH_ALGORITHM = "sha256" + +ADR_ID_RE = re.compile(r"^ADR-\d{3}$") +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +AMENDMENTS_HEADING_RE = re.compile(r"##\s+Amendments\s*") +SECTION_HEADING_RE = re.compile(r"##\s+") + +RULE_MALFORMED = "adr-manifest-malformed" +RULE_PATH_UNSAFE = "adr-manifest-path-unsafe" +RULE_PIN_STALE = "adr-pin-stale" +RULE_PIN_MISSING = "adr-pin-missing" +RULE_PIN_ORPHAN = "adr-pin-orphan" +RULE_AMENDMENT_MISMATCH = "adr-amendment-record-mismatch" +RULE_AMENDMENT_UNRECORDED = "adr-amendment-unrecorded" + + +def _amendments_section_bounds(text: str) -> tuple[int, int] | None: + """Character bounds ``(start, end)`` of the ``## Amendments`` section — its + heading through the line before the next ``##`` heading (or end of file). + Headings inside fenced code blocks are ignored, so an ``## Amendments`` + *example* in an ADR's prose (e.g. ADR-059) is not mistaken for a real + section. Returns None when the ADR has no Amendments section.""" + lines = text.splitlines(keepends=True) + starts: list[int] = [] + cursor = 0 + for line in lines: + starts.append(cursor) + cursor += len(line) + total = cursor + + in_fence = False + heading_line: int | None = None + for index, line in enumerate(lines): + if line.lstrip().startswith("```"): + in_fence = not in_fence + continue + if in_fence: + continue + body = line.rstrip("\n") + if heading_line is None: + if AMENDMENTS_HEADING_RE.fullmatch(body): + heading_line = index + elif SECTION_HEADING_RE.match(body): + return starts[heading_line], starts[index] + if heading_line is None: + return None + return starts[heading_line], total + + +def _strip_amendments(text: str) -> str: + """Return ``text`` with its ``## Amendments`` section removed.""" + bounds = _amendments_section_bounds(text) + if bounds is None: + return text + start, end = bounds + return text[:start] + text[end:] + + +def canonical_content(text: str) -> str: + """The bytes the pin is taken over: the ADR minus its ``## Amendments`` + section, with per-line trailing whitespace removed and exactly one trailing + newline. Recording an amendment therefore never perturbs the pin, and + per-line trailing-whitespace churn is not treated as a substantive edit. + Only the file-final newline run is normalized (a text file with or without a + final newline hashes the same); leading blank lines and interior blank lines + are significant, so adding or removing one is a detectable content change.""" + body = _strip_amendments(text) + lines = [line.rstrip() for line in body.splitlines()] + return "\n".join(lines).rstrip("\n") + "\n" + + +def content_hash(text: str) -> str: + return hashlib.sha256(canonical_content(text).encode("utf-8")).hexdigest() + + +def amendment_refs(text: str) -> list[str]: + """The commit/PR ref column of the ``## Amendments`` markdown table + (``| Date | Commit/PR | Summary |``), in document order. Header and + separator rows are skipped.""" + bounds = _amendments_section_bounds(text) + if bounds is None: + return [] + start, end = bounds + section = text[start:end] + refs: list[str] = [] + for line in section.splitlines(): + stripped = line.strip() + if not stripped.startswith("|"): + continue + cells = [cell.strip() for cell in stripped.strip("|").split("|")] + if len(cells) < 3: + continue + if cells[0].lower() == "date": # header row + continue + if set("".join(cells)) <= set("-: "): # separator row + continue + refs.append(cells[1]) + return refs + + +def _coerce_manifest(raw: object, manifest_rel: str) -> tuple[list[dict] | None, list[PolicyFailure]]: + """Validate the manifest shape. Returns ``(entries, [])`` on success, or + ``(None, failures)`` when the manifest is malformed — shape errors are + reported before any hash comparison so a broken manifest fails cleanly.""" + + def fail(message: str) -> PolicyFailure: + return PolicyFailure(RULE_MALFORMED, message, manifest_rel) + + if not isinstance(raw, dict): + return None, [fail("manifest root must be a mapping")] + algorithm = raw.get("hash_algorithm") + if algorithm != SUPPORTED_HASH_ALGORITHM: + return None, [fail(f"hash_algorithm must be '{SUPPORTED_HASH_ALGORITHM}'; got {algorithm!r}")] + entries = raw.get("adrs") + if not isinstance(entries, list): + return None, [fail("'adrs' must be a list")] + + failures: list[PolicyFailure] = [] + normalized: list[dict] = [] + seen_ids: set[str] = set() + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + failures.append(fail(f"adrs[{index}] must be a mapping")) + continue + adr_id = entry.get("id") + path = entry.get("path") + pin = entry.get("pin") + if not isinstance(adr_id, str) or not ADR_ID_RE.match(adr_id): + failures.append(fail(f"adrs[{index}].id must match ADR-NNN; got {adr_id!r}")) + continue + if adr_id in seen_ids: + failures.append(fail(f"duplicate manifest entry for {adr_id}")) + continue + seen_ids.add(adr_id) + if not isinstance(path, str) or not path: + failures.append(fail(f"{adr_id}: path must be a non-empty string")) + continue + if not isinstance(pin, str) or not SHA256_RE.match(pin): + failures.append(fail(f"{adr_id}: pin must be a 64-character sha256 hex digest")) + continue + amendments = entry.get("amendments") or [] + if not isinstance(amendments, list): + failures.append(fail(f"{adr_id}: amendments must be a list")) + continue + refs: list[str] = [] + malformed_amendment = False + for aindex, amendment in enumerate(amendments): + if not isinstance(amendment, dict) or not isinstance(amendment.get("ref"), str): + failures.append(fail(f"{adr_id}: amendments[{aindex}] must be a mapping with a string 'ref'")) + malformed_amendment = True + break + refs.append(amendment["ref"]) + if malformed_amendment: + continue + normalized.append({"id": adr_id, "path": path, "pin": pin, "amendment_refs": refs}) + + if failures: + return None, failures + return normalized, [] + + +def _rel(repo_root: Path, path: Path) -> str: + try: + return path.resolve().relative_to(repo_root.resolve()).as_posix() + except ValueError: + return str(path) + + +def _accepted_adrs(repo_root: Path) -> dict[str, Path]: + """Map ``ADR-NNN`` → file for every ADR whose on-disk status is accepted. + Unparseable ADR files are repo_policy's concern, not this gate's.""" + accepted: dict[str, Path] = {} + adr_dir = repo_root / ADR_DIR + for adr_file in sorted(adr_dir.glob("adr-*.md")): + if adr_file.name == "README.md": + continue + try: + number, _title, status, _date = parse_adr_file(adr_file) + except ValueError: + continue + if status == "accepted": + accepted[f"ADR-{number}"] = adr_file + return accepted + + +def _git_show(repo_root: Path, gitref: str) -> str | None: + """Content of a path at a git ref (``:`` or ``:`` for the + index). Returns None when the path does not exist at that ref.""" + proc = subprocess.run( + ["git", "show", gitref], + cwd=repo_root, + capture_output=True, + text=True, + ) + if proc.returncode != 0: + return None + return proc.stdout + + +def _check_unrecorded_edits( + repo_root: Path, + accepted: dict[str, Path], + *, + base_rev: str | None, + staged: bool, +) -> list[PolicyFailure]: + """A pin bump alone must not bless an edit: when an accepted ADR's canonical + content changed versus the base, the same change must also have added a + ``## Amendments`` record.""" + failures: list[PolicyFailure] = [] + for adr_id, disk_path in accepted.items(): + rel = _rel(repo_root, disk_path) + base_ref = f"HEAD:{rel}" if staged else f"{base_rev}:{rel}" + base_text = _git_show(repo_root, base_ref) + if base_text is None: + continue # not present at base (newly added) — nothing to compare + head_text = _git_show(repo_root, f":{rel}") if staged else disk_path.read_text(encoding="utf-8") + if head_text is None or content_hash(base_text) == content_hash(head_text): + continue + new_refs = set(amendment_refs(head_text)) - set(amendment_refs(base_text)) + if new_refs: + continue + failures.append( + PolicyFailure( + RULE_AMENDMENT_UNRECORDED, + f"{adr_id}: accepted ADR content changed without a new ## Amendments record; " + "add an amendment (and update its pin) or supersede it with a new ADR", + rel, + ) + ) + return failures + + +def evaluate_adr_immutability( + repo_root: Path, + *, + base_rev: str | None = None, + staged: bool = False, +) -> list[PolicyFailure]: + manifest_rel = MANIFEST_PATH + manifest_file = repo_root / manifest_rel + if not manifest_file.is_file(): + return [PolicyFailure(RULE_MALFORMED, "ADR pin manifest is missing", manifest_rel)] + try: + raw = load_yaml(manifest_file) + except Exception: # noqa: BLE001 — surface a parse error as a clean failure, not a traceback + return [PolicyFailure(RULE_MALFORMED, "ADR pin manifest is not valid YAML", manifest_rel)] + + entries, failures = _coerce_manifest(raw, manifest_rel) + if entries is None: + return failures + + # Path safety before any file read. + resolved: dict[str, Path] = {} + for entry in entries: + safe = safe_repo_path(repo_root, entry["path"]) + if safe is None: + failures.append( + PolicyFailure(RULE_PATH_UNSAFE, f"{entry['id']}: path escapes the repository", entry["path"]) + ) + continue + resolved[entry["id"]] = safe + if failures: + return failures + + accepted = _accepted_adrs(repo_root) + manifest_ids = {entry["id"] for entry in entries} + + # Coverage both ways. + for adr_id, disk_path in accepted.items(): + if adr_id not in manifest_ids: + failures.append( + PolicyFailure( + RULE_PIN_MISSING, + f"{adr_id} is accepted but not pinned in the manifest", + _rel(repo_root, disk_path), + ) + ) + for entry in entries: + if entry["id"] not in accepted: + failures.append( + PolicyFailure( + RULE_PIN_ORPHAN, + f"manifest pins {entry['id']} but it is missing or not 'accepted' on disk", + entry["path"], + ) + ) + + # Pin + amendment-record checks for entries that are accepted on disk. + for entry in entries: + adr_id = entry["id"] + disk_path = accepted.get(adr_id) + if disk_path is None: + continue # already reported as orphan + if resolved[adr_id] != disk_path.resolve(): + failures.append( + PolicyFailure( + RULE_MALFORMED, + f"{adr_id}: manifest path does not resolve to the ADR file on disk", + entry["path"], + ) + ) + continue + text = disk_path.read_text(encoding="utf-8") + if content_hash(text) != entry["pin"]: + failures.append( + PolicyFailure( + RULE_PIN_STALE, + f"{adr_id} content differs from its pinned hash; record an amendment " + "(and update the pin) or supersede it with a new ADR", + entry["path"], + ) + ) + document_refs = amendment_refs(text) + if sorted(document_refs) != sorted(entry["amendment_refs"]): + failures.append( + PolicyFailure( + RULE_AMENDMENT_MISMATCH, + f"{adr_id}: manifest amendment refs {sorted(entry['amendment_refs'])} do not match " + f"the ADR ## Amendments rows {sorted(document_refs)}", + entry["path"], + ) + ) + + if failures: + return failures + + if base_rev or staged: + failures.extend(_check_unrecorded_edits(repo_root, accepted, base_rev=base_rev, staged=staged)) + return failures + + +def parse_args(argv: list[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Enforce ADR-059 acceptance-content pins for accepted ADRs.") + parser.add_argument("--staged", action="store_true", help="Compare staged content against HEAD.") + parser.add_argument("--base-rev", help="Compare against a specific git revision.") + parser.add_argument("--json", action="store_true", help="Emit JSON failures.") + # Accepted for CLI parity with the other policy entry points. The pin gate + # always evaluates the whole accepted-ADR corpus — a partial path list could + # not prove corpus-wide coverage — so an explicit path list is not consulted. + parser.add_argument("--paths", nargs="*", help=argparse.SUPPRESS) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + failures = evaluate_adr_immutability(REPO_ROOT, base_rev=args.base_rev, staged=args.staged) + failures = apply_exceptions(failures, load_exceptions(REPO_ROOT)) + if failures: + if args.json: + print(failures_to_json(failures)) + else: + for failure in failures: + print(failure.render(), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/policy/adr.py b/tools/policy/adr.py new file mode 100644 index 000000000..c1433b4a2 --- /dev/null +++ b/tools/policy/adr.py @@ -0,0 +1,65 @@ +"""Shared parsing helpers for Architecture Decision Records. + +These were extracted from ``tools/policy/repo_policy.py`` so the README↔ADR +index check there and the ADR acceptance-content pin gate +(``tools/check_adr_immutability.py``, ADR-059) parse ADR headers, status, and +date the same way instead of each growing its own parser. ``repo_policy.py`` +re-imports these under their historical private names. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +# A canonical MADR ADR header, e.g. ``# ADR-048: Datastore Service Runtime +# Inventory``. Group 1 is the zero-padded number, group 2 the title. +ADR_HEADER_RE = re.compile(r"^# ADR-(\d{3}): (.+)$", re.MULTILINE) + + +def extract_markdown_section(text: str, section: str) -> str: + """Return the first non-empty line of the ``##
`` block (or a + legacy ``**
:**`` inline marker). Raises ``ValueError`` when the + section is absent.""" + marker = f"## {section}" + start = text.find(marker) + if start != -1: + body = text[start + len(marker) :] + body = body.lstrip() + next_header = body.find("\n## ") + if next_header != -1: + body = body[:next_header] + return body.strip().splitlines()[0].strip() + + legacy_marker = re.search(rf"^\*\*{re.escape(section)}:\*\*\s*(.+)$", text, re.MULTILINE) + if legacy_marker: + return legacy_marker.group(1).strip() + + raise ValueError(f"missing {section} section") + + +def normalize_adr_status(status: str) -> str: + """Collapse whitespace and canonicalise the ADR status vocabulary. + Recognised values are ``accepted``/``proposed``/``deprecated`` and + ``superseded by ADR-NNN``; anything else is returned whitespace-normalised + but otherwise untouched.""" + normalized = " ".join(status.split()) + lowered = normalized.lower() + if lowered in {"accepted", "proposed", "deprecated"}: + return lowered + superseded = re.fullmatch(r"superseded by (adr-\d{3})", lowered) + if superseded: + return f"superseded by {superseded.group(1).upper()}" + return normalized + + +def parse_adr_file(path: Path) -> tuple[str, str, str, str]: + """Parse an ADR file into ``(number, title, status, date)``. Raises + ``ValueError`` when the header or a required section is missing.""" + text = path.read_text(encoding="utf-8") + header = ADR_HEADER_RE.search(text) + if not header: + raise ValueError(f"{path} is missing ADR header") + status = normalize_adr_status(extract_markdown_section(text, "Status")) + date = extract_markdown_section(text, "Date") + return header.group(1), header.group(2).strip(), status.strip(), date.strip() diff --git a/tools/policy/common.py b/tools/policy/common.py index 2079c05e5..03c073024 100644 --- a/tools/policy/common.py +++ b/tools/policy/common.py @@ -35,6 +35,23 @@ def repo_path(value: str | Path) -> str: return Path(value).as_posix().strip("/") +def safe_repo_path(repo_root: Path, rel_path: str) -> Path | None: + """Resolve ``rel_path`` against ``repo_root`` and return the resolved + path only if it stays inside the repository. Returns None for absolute + paths, parent-traversal segments, or symlinks that resolve outside the + repo. This guards every place a policy reads a path that ultimately comes + from PR-controlled config or the changed-file list.""" + candidate = Path(rel_path) + if candidate.is_absolute() or any(part == ".." for part in candidate.parts): + return None + try: + resolved = (repo_root / candidate).resolve() + resolved.relative_to(repo_root.resolve()) + except (ValueError, OSError): + return None + return resolved + + def run_git(args: list[str], repo_root: Path = REPO_ROOT) -> str: proc = subprocess.run( ["git", *args], diff --git a/tools/policy/repo_policy.py b/tools/policy/repo_policy.py index 1f3cdcfb3..25ffd07ce 100644 --- a/tools/policy/repo_policy.py +++ b/tools/policy/repo_policy.py @@ -5,7 +5,14 @@ from collections.abc import Callable, Iterator from pathlib import Path +from .adr import ( + normalize_adr_status as _normalize_adr_status, +) +from .adr import ( + parse_adr_file as _parse_adr_file, +) from .common import PolicyFailure, load_yaml, path_matches_prefix +from .common import safe_repo_path as _safe_repo_path from .conftest_tool import run_conftest_policy StructuralPolicyRunner = Callable[[dict], list[PolicyFailure]] @@ -161,23 +168,6 @@ def _str_list_ok(value: object, *, allow_empty: bool) -> bool: return all(_is_str(item) for item in value) -def _safe_repo_path(repo_root: Path, rel_path: str) -> Path | None: - """Resolve ``rel_path`` against ``repo_root`` and return the resolved - path only if it stays inside the repository. Returns None for absolute - paths, parent-traversal segments, or symlinks that resolve outside the - repo. This guards every place the policy reads a path that ultimately - comes from PR-controlled config or the changed-file list.""" - candidate = Path(rel_path) - if candidate.is_absolute() or any(part == ".." for part in candidate.parts): - return None - try: - resolved = (repo_root / candidate).resolve() - resolved.relative_to(repo_root.resolve()) - except (ValueError, OSError): - return None - return resolved - - def _validate_oversized_config( config: object, ) -> tuple[dict | None, list[PolicyFailure]]: @@ -949,52 +939,16 @@ def _check_changelog_versioned(repo_root: Path) -> list[PolicyFailure]: return [] -ADR_HEADER_RE = re.compile(r"^# ADR-(\d{3}): (.+)$", re.MULTILINE) +# ADR-file parsing (header regex, status/date extraction, status normalisation) +# lives in ``tools/policy/adr.py`` and is imported above so this README↔index +# check and the ADR pin gate (ADR-059) parse ADRs identically. ``README_ROW_RE`` +# stays here because it parses the README index table, not an ADR file. README_ROW_RE = re.compile( r"^\| \[(\d{3})\]\(([^)]+)\) \| (.+?) \| (.+?) \| (\d{4}-\d{2}-\d{2}) \|$", re.MULTILINE, ) -def _parse_adr_file(path: Path) -> tuple[str, str, str, str]: - text = path.read_text(encoding="utf-8") - header = ADR_HEADER_RE.search(text) - if not header: - raise ValueError(f"{path} is missing ADR header") - status = _normalize_adr_status(_extract_markdown_section(text, "Status")) - date = _extract_markdown_section(text, "Date") - return header.group(1), header.group(2).strip(), status.strip(), date.strip() - - -def _extract_markdown_section(text: str, section: str) -> str: - marker = f"## {section}" - start = text.find(marker) - if start != -1: - body = text[start + len(marker) :] - body = body.lstrip() - next_header = body.find("\n## ") - if next_header != -1: - body = body[:next_header] - return body.strip().splitlines()[0].strip() - - legacy_marker = re.search(rf"^\*\*{re.escape(section)}:\*\*\s*(.+)$", text, re.MULTILINE) - if legacy_marker: - return legacy_marker.group(1).strip() - - raise ValueError(f"missing {section} section") - - -def _normalize_adr_status(status: str) -> str: - normalized = " ".join(status.split()) - lowered = normalized.lower() - if lowered in {"accepted", "proposed", "deprecated"}: - return lowered - superseded = re.fullmatch(r"superseded by (adr-\d{3})", lowered) - if superseded: - return f"superseded by {superseded.group(1).upper()}" - return normalized - - def _check_adr_index(repo_root: Path, policy: dict, changed: list[str]) -> list[PolicyFailure]: index_path = policy["adr_index"]["index_path"] if not any(path.startswith("docs/decisions/adrs/") for path in changed): diff --git a/tools/policy/requirement_order.yaml b/tools/policy/requirement_order.yaml index 9d6fcd847..aa5408932 100644 --- a/tools/policy/requirement_order.yaml +++ b/tools/policy/requirement_order.yaml @@ -74,6 +74,9 @@ phases: - EXP-703 - EXP-704 - EXP-705 + - id: decision-record-governance + requirements: + - GOV-941 ownership: gov-concept-authority: @@ -176,6 +179,8 @@ ownership: - docs/explain/sdl - docs/index.md - CHANGELOG.md + decision-record-governance: + - docs/decisions/adrs experiment-core: - contracts/README.md - contracts/schema-publication-manifest.json From 93cb5935e83dfb553b82af1efb5e7be41874613f Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Thu, 11 Jun 2026 17:29:00 +0200 Subject: [PATCH 11/70] Harden participant-semantics lineage with missing primary theory Add interpreted systems, dynamic epistemic logic, Kuhn information sets, noninterference/declassification, STRIPS/PDDL/PDDL2.1/PPDDL/RDDL, Dec-POMDP (Oliehoek-Amato), mean-field games, Fidge/Mattern vector time, Schwarz-Mattern, Winskel/Mazurkiewicz, Allen/Koymans/Alur-Dill, and Chockler-Halpern to the participant-semantics primary-source review and the lineage source map. Fix citation defects: CyGIL title conflation (2109.03331 vs 2304.01244), CRACK venue (Computers & Security 2020), Dec-POMDP complexity authors/venue (Bernstein, Givan, Immerman, Zilberstein; MOR 2002), HLA edition (IEEE Std 1516-2010), and CybORG sim-to-emulation claim grounding (results discussion of Standen et al. 2021). --- docs/explain/sdl/lineage.md | 52 ++++++- specs/formal/participant-semantics/README.md | 153 ++++++++++++++++--- 2 files changed, 180 insertions(+), 25 deletions(-) diff --git a/docs/explain/sdl/lineage.md b/docs/explain/sdl/lineage.md index 126b2699a..041ca233e 100644 --- a/docs/explain/sdl/lineage.md +++ b/docs/explain/sdl/lineage.md @@ -697,14 +697,34 @@ which dynamic queue/log/config details remain evidence or bounded settings. and [OpenSpiel](https://arxiv.org/abs/1908.09453) inform the agent-facing interface vocabulary: actions, observations, rewards, resets, local histories, imperfect information, and multi-agent interaction. -- POMDP, Dec-POMDP, POSG, and Markov-game literature is the theoretical lineage - behind ACES's insistence that participant-visible observations are not world - truth, and that multi-participant behavior cannot be reduced to a single - centralized state stream. +- POMDP, Dec-POMDP, POSG, and Markov-game literature — with + [Bernstein, Givan, Immerman, and Zilberstein's complexity result](https://doi.org/10.1287/moor.27.4.819.297) + and [Oliehoek and Amato's Dec-POMDP monograph](https://doi.org/10.1007/978-3-319-28929-8) + as anchors — is the theoretical lineage behind ACES's insistence that + participant-visible observations are not world truth, and that + multi-participant behavior cannot be reduced to a single centralized state + stream. [Mean-field game theory](https://doi.org/10.1007/s11537-007-0657-8) + covers the population-limit regime ACES records as mean-field nodes. +- Interpreted systems + ([Fagin, Halpern, Moses, Vardi](https://mitpress.mit.edu/9780262562003/reasoning-about-knowledge/)), + [dynamic epistemic logic](https://doi.org/10.1007/978-1-4020-5839-4), and + [Kuhn's extensive-form information sets](https://doi.org/10.1515/9781400881970-012) + are the formal lineage for participant information states, view transitions, + and perfect-recall claims. + [Goguen-Meseguer noninterference](https://doi.org/10.1109/SP.1982.10014) and + [Sabelfeld-Sands declassification](https://doi.org/10.3233/JCS-2009-0352) + ground the hidden-truth boundary and disclosure-rule semantics. +- [STRIPS](https://doi.org/10.1016/0004-3702(71)90010-5), + [PDDL](https://doi.org/10.2200/S00900ED2V01Y201902AIM042), + [PDDL2.1](https://doi.org/10.1613/jair.1129), and the probabilistic planning + languages ([PPDDL](https://doi.org/10.1613/jair.1880), + [RDDL](https://users.cecs.anu.edu.au/~ssanner/IPPC_2011/RDDL.pdf)) are the + action-language lineage behind participant precondition/effect contracts. - [CybORG](https://arxiv.org/abs/2108.09118), [CyberBattleSim](https://www.microsoft.com/en-us/research/project/cyberbattlesim/), - and [CyGIL](https://arxiv.org/abs/2304.01244) are the cyber-agent environment - precedents. They show the value of explicit + [CyGIL](https://arxiv.org/abs/2109.03331), and CyGIL's + [unified emulation-simulation training environment](https://arxiv.org/abs/2304.01244) + are the cyber-agent environment precedents. They show the value of explicit action/observation/reward/episode interfaces, and also expose the sim-to-emulation gap that ACES must record through realization disclosure and evidence provenance. @@ -759,7 +779,7 @@ which dynamic queue/log/config details remain evidence or bounded settings. ## Runtime, Time, And Causality - [TENA](https://www.trmc.osd.mil/tena-about.html) and the - [IEEE High Level Architecture](https://standards.ieee.org/ieee/1516/3744/) + [IEEE High Level Architecture (IEEE Std 1516-2010)](https://standards.ieee.org/ieee/1516/3744/) are the main runtime/federation precedents for distributed exercise services, time management, and object publication. - [SISO Cyber DEM](https://cdn.ymaws.com/www.sisostandards.org/resource/resmgr/standards_products/siso-std-025-2023_cyberdem.pdf) @@ -767,9 +787,27 @@ which dynamic queue/log/config details remain evidence or bounded settings. - Lamport logical clocks, HLA time management, Time Warp, DEVS, SimPy, ROS 2 time, ns-3 realtime mode, and FMI inform ACES's separation of timestamp, ordering, clock authority, pacing, synchronization, and causality. +- [Fidge](https://fileadmin.cs.lth.se/cs/Personal/Amr_Ergawy/dist-algos-papers/4.pdf)/[Mattern](https://www.vs.inf.ethz.ch/publ/papers/VirtTimeGlobStates.pdf) + vector time and the + [Schwarz-Mattern causality survey](https://doi.org/10.1007/BF02277859) are + the basis for vector-clock ordering claims: scalar Lamport clocks respect + causality one way, vector clocks characterize it. + [Winskel's event structures](https://doi.org/10.1007/3-540-17906-2_31) and + [Mazurkiewicz's trace theory](https://doi.org/10.1007/3-540-17906-2_30) + ground partial-order realized ordering with simultaneity groups. +- [Allen's interval algebra](https://doi.org/10.1145/182.358434), + [Koymans' metric temporal logic](https://doi.org/10.1007/BF01995674), and + [Alur-Dill timed automata](https://doi.org/10.1016/0304-3975(94)90010-8) + are the formal temporal-contract lineage for schedules, deadlines, dwell, + and windows. +- [Berenson et al.'s ANSI SQL isolation critique](https://doi.org/10.1145/223784.223785) + and [Adya's generalized isolation theory](https://pmg.csail.mit.edu/papers/adya-phd.pdf) + anchor the shared-state isolation-guarantee vocabulary. - Halpern-Pearl structural causality informs ACES's treatment of attribution: a participant action followed by an alert is not automatically a causal explanation without an evidence basis. + [Chockler-Halpern responsibility and blame](https://doi.org/10.1613/jair.1391) + extends this to graded multi-cause attribution. ## Adversary Emulation And Security Knowledge diff --git a/specs/formal/participant-semantics/README.md b/specs/formal/participant-semantics/README.md index 184536dd7..4f39d55e4 100644 --- a/specs/formal/participant-semantics/README.md +++ b/specs/formal/participant-semantics/README.md @@ -82,19 +82,84 @@ observation stream is not the environment state. Local history and belief matter when interpreting behavior. Littman's Markov games and the Dec-POMDP/POSG literature generalize this to -multi-agent interaction. Bernstein, Zilberstein, and Immerman show that -decentralized control under partial observability is fundamentally harder than -centralized MDP/POMDP control. ACES should not pretend that one global state and -one global observation stream are enough for multi-participant experiments. +multi-agent interaction. Bernstein, Givan, Immerman, and Zilberstein +(Mathematics of Operations Research, 2002) show that decentralized control +under partial observability is fundamentally harder than centralized MDP/POMDP +control. Oliehoek and Amato's Dec-POMDP monograph fixes the standard vocabulary +ACES reuses for joint policies, action-observation histories, and information +states. ACES should not pretend that one global state and one global +observation stream are enough for multi-participant experiments. + +Mean-field game theory — Huang, Caines, and Malhamé (2006) and Lasry and Lions +(2007), brought to MARL by Yang et al. (2018) — covers the population-limit +regime where individual interaction is replaced by interaction with a +population distribution. ACES's runtime layer records mean-field updates as +environment state over a population scope, not as hidden participant actions; +this lineage is why population-distribution disclosure is a first-class runtime +record in `specs/formal/participant-runtime/`. + +### Knowledge, Information Structure, And Information Flow + +Fagin, Halpern, Moses, and Vardi's interpreted-systems framework gives the +formal basis for participant-relative information: an agent's knowledge at a +point is determined by indistinguishability over its local state across global +runs. ACES's view relation `V_p,t`, local history `H_p,t`, and the +visible-history indistinguishability relation in +`specs/formal/participant-runtime/` are interpreted-systems constructions, not +ad hoc bookkeeping. + +Dynamic epistemic logic (Baltag, Moss, and Solecki's announcement logics; van +Ditmarsch, van der Hoek, and Kooi's treatment) models information change as +explicit epistemic actions. This is the lineage for SEM-210's time-indexed +`view_transition` discipline: discovery, inference, disclosure, concealment, +and deception are recorded transition events that update a participant's view +relation, never silent side effects of topology or scheduling. + +Kuhn's extensive-form game analysis (1953) is the original formal source for +information sets and perfect recall. Perfect recall is a property of an +information partition; a runtime claim that a participant history satisfies it +therefore needs a constructive, checkable witness over the visible projection. +The participant-runtime specification defines that witness; this document only +records the obligation. + +Goguen and Meseguer's noninterference (1982) gives the information-flow reading +of the hidden-truth boundary (I2): hidden world state and adjudication assets +must be noninterfering with participant-visible projections in the absence of +an explicit disclosure rule. Sabelfeld and Sands' declassification dimensions +(2009) frame ACES disclosure rules as governed declassification policies: every +permitted release of hidden information declares what is released, where in the +view relation, when (the transition anchor), and by whose authority. + +### Action Languages And Planning Formalisms + +The precondition/effect/failure discipline in SEM-211 descends from the +planning literature. STRIPS (Fikes and Nilsson, 1971) introduced the +precondition/add/delete action model; PDDL standardized typed action schemata +with explicit preconditions and effects (Haslum et al.'s language introduction +is the consolidated reference); PDDL2.1 (Fox and Long, 2003) added durative +actions, temporal preconditions/effects, and numeric resources — the direct +ancestors of ACES temporal preconditions and resource preconditions; PPDDL +(Younes et al., 2005) and RDDL (Sanner, 2010) added probabilistic effects and +stochastic transition models, the lineage for ACES's non-deterministic effect +and `unknown_effect` classes. + +ACES action contracts deliberately exceed this lineage: planning formalisms do +not carry visibility effects, evidence expectations, realization profiles, +fidelity claims, or mapping-loss labels, and they assume a closed-world effect +axiomatization that a cyber range cannot honestly claim. ACES therefore fails +closed on unresolved preconditions instead of assuming closed-world frame +axioms, and treats declared effect classes as disclosure obligations rather +than complete world models. ### Cyber Agent Environments [CybORG](https://arxiv.org/abs/2108.09118) is the closest cyber-agent precedent. It defines scenarios with agents, action spaces, observations, rewards, and -reset; it also supports simulation and emulation. Its reported sim-to-emulation -transfer failures are directly relevant: an agent can overfit to an observation -artifact that does not exist in the emulator. ACES must therefore record -observation provenance and realized backend disclosure, not just action results. +reset; it also supports simulation and emulation. The sim-to-emulation transfer +failures reported in the results discussion of Standen et al. (2021) are +directly relevant: an agent can overfit to an observation artifact that does +not exist in the emulator. ACES must therefore record observation provenance +and realized backend disclosure, not just action results. [CyberBattleSim](https://www.microsoft.com/en-us/research/project/cyberbattlesim/) shows the value and limits of abstract cyber-network simulation. It is useful @@ -102,11 +167,12 @@ for studying automated agents, but its high-level abstraction reinforces the need to disclose which action/effect/observation semantics are realized rather than assuming simulation results transfer to operational environments. -[CyGIL's unified emulation/simulation training design](https://arxiv.org/abs/2304.01244) -is relevant because it derives simulation transitions from emulated traces, -preserving the same action space across the sim-to-real loop. It motivates -ACES's requirement that action and observation contracts survive across backend -fidelity modes. +[CyGIL](https://arxiv.org/abs/2109.03331) and its follow-on +[unified emulation-simulation training environment](https://arxiv.org/abs/2304.01244) +are relevant because the unified design derives simulation transitions from +emulated traces, preserving the same action space across the sim-to-real loop. +They motivate ACES's requirement that action and observation contracts survive +across backend fidelity modes. CyGIL also exposes a negative design lesson: an abstract action such as "network discovery" is too coarse to transfer honestly to a real or emulated @@ -203,6 +269,29 @@ Lamport's happened-before relation establishes the key warning: distributed systems have partial ordering, and physical timestamps alone are not the same as causal ordering. +Lamport's scalar clocks only respect causality in one direction; they cannot +prove that two events are causally unrelated. Fidge (1988) and Mattern (1989) +introduced vector time, which characterizes the causal partial order exactly, +and Schwarz and Mattern (1994) survey what causal claims each clock mechanism +can and cannot support. This distinction is load-bearing for ACES: any +`VectorClock` ordering basis in the runtime layer claims the stronger +characterization and therefore needs the Fidge/Mattern lineage, not just +Lamport's. + +Winskel's event structures and Mazurkiewicz's trace theory provide the +true-concurrency semantics behind ACES's realized-order model: a record of +"what happened" in a concurrent run is a labelled partial order with explicit +simultaneity/independence structure, not a single forced interleaving. This is +why ACES joint-action records carry partial orders and simultaneity groups +instead of a backend-chosen total order presented as ground truth. + +Formal temporal contracts also have direct precedent: Allen's interval algebra +(1983) for window and interval relations, Koymans' metric temporal logic (1990) +for deadline/latency bounds, and Alur and Dill's timed automata (1994) for +machine-checkable real-time behavior. SEM-213's schedule, cadence, deadline, +dwell, and time-window contracts are bounded fragments of these formalisms with +declared time domains and clock authorities. + HLA time-management literature, Time Warp, DEVS, SimPy scheduling, ROS 2 clock design, ns-3 realtime mode, and FMI all reinforce that clock authority, time domain, advancement, pacing, synchronization, and event ordering are separate @@ -217,7 +306,11 @@ semantics around those events. Halpern and Pearl's structural-model causality motivates the attribution rule in this spec: an observed alert after an action is not automatically caused by that action. Causal claims require an evidence basis and, for strong claims, a -counterfactual or intervention model. +counterfactual or intervention model. Chockler and Halpern's structural-model +treatment of responsibility and blame (2004) extends this to the +multi-participant case SEM-212 must handle: when several actions jointly +produce an effect, attribution edges need graded responsibility semantics, not +a single binary cause label. ### DSL Evaluation And Language Critique @@ -987,9 +1080,24 @@ Future implementation PRs should include: - [PettingZoo](https://papers.nips.cc/paper/2021/hash/7ed2d3454c5eea71148b11d0c25104ff-Abstract.html) - [OpenSpiel](https://arxiv.org/abs/1908.09453) - [Planning and Acting in Partially Observable Stochastic Domains](https://people.smp.uq.edu.au/YoniNazarathy/Control4406_2014/resources/KaelblingLittmanCassandra1998.pdf) -- [The Complexity of Decentralized Control of Markov Decision Processes](https://arxiv.org/abs/1301.3836) +- [Bernstein, Givan, Immerman, Zilberstein — The Complexity of Decentralized Control of Markov Decision Processes (Mathematics of Operations Research 27(4), 2002)](https://doi.org/10.1287/moor.27.4.819.297) +- [Oliehoek and Amato — A Concise Introduction to Decentralized POMDPs (Springer, 2016)](https://doi.org/10.1007/978-3-319-28929-8) +- [Kuhn — Extensive Games and the Problem of Information (Contributions to the Theory of Games II, 1953)](https://doi.org/10.1515/9781400881970-012) +- [Fagin, Halpern, Moses, Vardi — Reasoning About Knowledge (MIT Press, 1995)](https://mitpress.mit.edu/9780262562003/reasoning-about-knowledge/) +- [van Ditmarsch, van der Hoek, Kooi — Dynamic Epistemic Logic (Springer, 2007)](https://doi.org/10.1007/978-1-4020-5839-4) +- [Goguen and Meseguer — Security Policies and Security Models (IEEE S&P, 1982)](https://doi.org/10.1109/SP.1982.10014) +- [Sabelfeld and Sands — Declassification: Dimensions and Principles (Journal of Computer Security 17(5), 2009)](https://doi.org/10.3233/JCS-2009-0352) +- [Huang, Caines, Malhamé — Large Population Stochastic Dynamic Games (Communications in Information and Systems 6(3), 2006)](https://doi.org/10.4310/CIS.2006.v6.n3.a5) +- [Lasry and Lions — Mean Field Games (Japanese Journal of Mathematics 2, 2007)](https://doi.org/10.1007/s11537-007-0657-8) +- [Yang et al. — Mean Field Multi-Agent Reinforcement Learning (ICML, 2018)](https://arxiv.org/abs/1802.05438) +- [Fikes and Nilsson — STRIPS (Artificial Intelligence 2, 1971)](https://doi.org/10.1016/0004-3702(71)90010-5) +- [Haslum, Lipovetzky, Magazzeni, Muise — An Introduction to the Planning Domain Definition Language (Morgan & Claypool, 2019)](https://doi.org/10.2200/S00900ED2V01Y201902AIM042) +- [Fox and Long — PDDL2.1: An Extension to PDDL for Expressing Temporal Planning Domains (JAIR 20, 2003)](https://doi.org/10.1613/jair.1129) +- [Younes, Littman, Weissman, Asmuth — The First Probabilistic Track of the International Planning Competition (JAIR 24, 2005)](https://doi.org/10.1613/jair.1880) +- [Sanner — Relational Dynamic Influence Diagram Language (RDDL): Language Description (2010)](https://users.cecs.anu.edu.au/~ssanner/IPPC_2011/RDDL.pdf) - [CybORG](https://arxiv.org/abs/2108.09118) -- [CyGIL unified emulation-simulation training](https://arxiv.org/abs/2304.01244) +- [CyGIL: A Cyber Gym for Training Autonomous Agents over Emulated Network Systems](https://arxiv.org/abs/2109.03331) +- [Unified Emulation-Simulation Training Environment for Autonomous Cyber Agents](https://arxiv.org/abs/2304.01244) - [CyberBattleSim](https://www.microsoft.com/en-us/research/project/cyberbattlesim/) - [Cybench](https://arxiv.org/abs/2408.08926) - [AutoPenBench](https://arxiv.org/abs/2410.03225) @@ -1000,13 +1108,22 @@ Future implementation PRs should include: - [CyRIS](https://www.jaist.ac.jp/~razvan/publications/cyris_facilitating_training.pdf) - [Automated Cyber Range Design](https://arxiv.org/abs/2307.04416) - [Open Cyber Range SDL](https://documentation.opencyberrange.ee/docs/sdl/) -- [CRACK: Building next generation Cyber Ranges](https://iris.imtlucca.it/handle/20.500.11771/15672) +- [Russo, Costa, Armando — Building Next Generation Cyber Ranges with CRACK (Computers & Security 95, 2020)](https://doi.org/10.1016/j.cose.2020.101837) - [CACAO Security Playbooks v2.0](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/security-playbooks-v2.0.pdf) - [OCSF](https://ocsf.io/) - [MITRE ATT&CK Design and Philosophy](https://www.mitre.org/news-insights/publication/mitre-attck-design-and-philosophy) - [CALDERA planning and acting with unknowns](https://www.mitre.org/sites/default/files/2021-11/prs-18-0944-1-automated-adversary-emulation-planning-acting.pdf) - [Halpern and Pearl, structural-model causality](https://arxiv.org/abs/cs/0011012) +- [Chockler and Halpern — Responsibility and Blame: A Structural-Model Approach (JAIR 22, 2004)](https://doi.org/10.1613/jair.1391) - [Lamport, Time, Clocks, and the Ordering of Events](https://systems.cs.columbia.edu/ds2-class/papers/lamport-time.pdf) -- [IEEE HLA 1516 family](https://standards.ieee.org/ieee/1516/3744/) +- [Fidge — Timestamps in Message-Passing Systems That Preserve the Partial Ordering (Australian Computer Science Communications 10(1), 1988)](https://fileadmin.cs.lth.se/cs/Personal/Amr_Ergawy/dist-algos-papers/4.pdf) +- [Mattern — Virtual Time and Global States of Distributed Systems (Parallel and Distributed Algorithms, 1989)](https://www.vs.inf.ethz.ch/publ/papers/VirtTimeGlobStates.pdf) +- [Schwarz and Mattern — Detecting Causal Relationships in Distributed Computations: In Search of the Holy Grail (Distributed Computing 7(3), 1994)](https://doi.org/10.1007/BF02277859) +- [Winskel — Event Structures (Advances in Petri Nets, 1986)](https://doi.org/10.1007/3-540-17906-2_31) +- [Mazurkiewicz — Trace Theory (Advances in Petri Nets, 1986)](https://doi.org/10.1007/3-540-17906-2_30) +- [Allen — Maintaining Knowledge about Temporal Intervals (CACM 26(11), 1983)](https://doi.org/10.1145/182.358434) +- [Koymans — Specifying Real-Time Properties with Metric Temporal Logic (Real-Time Systems 2, 1990)](https://doi.org/10.1007/BF01995674) +- [Alur and Dill — A Theory of Timed Automata (Theoretical Computer Science 126, 1994)](https://doi.org/10.1016/0304-3975(94)90010-8) +- [IEEE Std 1516-2010 — High Level Architecture: Framework and Rules](https://standards.ieee.org/ieee/1516/3744/) - [SISO Cyber DEM](https://cdn.ymaws.com/www.sisostandards.org/resource/resmgr/standards_products/siso-std-025-2023_cyberdem.pdf) - [Do Software Languages Engineers Evaluate their Languages?](https://arxiv.org/abs/1109.6794) From 6c0d2d762a9383970e6daab8d38bebd9f22a9e9b Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Thu, 11 Jun 2026 17:29:13 +0200 Subject: [PATCH 12/70] Fix participant-runtime spec defects and lineage attribution Close the mapping_loss vocabulary (I16) and add mapping_loss_detail; add attribution_edge_refs and outcome_interpretation_refs to LifecycleEnvelope with SEM-212/SEM-215 ownership cross-refs; define the declared delivery point (DeliveryBasis, delivery_point_ref, delivered_at) and bind the visible-history projection to it; scope stable redaction tokens and their stability claims; add rollback vs append-only visible-history rules; define marking vs visibility-projection precedence (deny-first intersection); define ClassificationClaim for the event_classification null rule; bound opaque participants with a minimal-observable-trace section; make the capability meet total over affecting components (missing declarations contribute unsupported, not skipped); require a versioned reconstruction-algorithm registry for reconstruction refs. Lineage: attribute PettingZoo vs OpenSpiel contributions correctly, add Fidge/Mattern + Schwarz-Mattern for the VectorClock basis, Winskel/Mazurkiewicz for partial-order realized ordering, Berenson/Adya for the isolation vocabulary, Kuhn for perfect recall as a constructive witness, and update the structural examples for the new contract fields. --- specs/formal/participant-runtime/README.md | 304 +++++++++++++++++++-- 1 file changed, 282 insertions(+), 22 deletions(-) diff --git a/specs/formal/participant-runtime/README.md b/specs/formal/participant-runtime/README.md index 64f5abd80..4e7bc0e1c 100644 --- a/specs/formal/participant-runtime/README.md +++ b/specs/formal/participant-runtime/README.md @@ -73,17 +73,31 @@ This design is constrained by the primary sources listed in reset, and seeding. ACES follows that boundary, while adding multi-participant provenance and shared-state records and without requiring access to private policy internals. -- PettingZoo and OpenSpiel require per-agent observations, local histories, - action masks or legal-action surfaces, rewards, termination/truncation, - simultaneous or sequential interaction, possible/live/active-agent and - current-actor semantics, chance-node disclosure, mean-field update disclosure, - and information-state discipline. ACES therefore separates hidden state, - participant-visible - observations, action-observation histories, centralized-training state, - reward/return signals, interaction context, and review evidence. +- PettingZoo contributes the multi-agent environment API discipline: per-agent + observations and rewards, local histories, action masks, termination and + truncation, possible/live/active-agent membership, and the sequential Agent + Environment Cycle versus parallel API split. OpenSpiel contributes the + game-theoretic surface PettingZoo does not model: chance nodes, + information-state discipline, simultaneous-move games, current-player + semantics, and mean-field game support. ACES therefore separates hidden + state, participant-visible observations, action-observation histories, + centralized-training state, reward/return signals, interaction context, and + review evidence, attributing each requirement to the source family that + actually defines it rather than treating the two ecosystems as + interchangeable. - POMDP, Dec-POMDP, POSG, and Markov-game lineage means a participant's observation is not world truth. Strong information-state claims require a reconstructible observation history, not just a final state dump. +- Interpreted systems (Fagin, Halpern, Moses, and Vardi), dynamic epistemic + logic, and Kuhn's extensive-form information sets ground the + information-state semantics below: a participant's information is defined by + indistinguishability over its visible local history, view changes are + explicit events, and perfect recall is an information-partition property + that runtime claims must witness constructively. Winskel's event structures + and Mazurkiewicz's trace theory ground the partial-order realized-ordering + model, and the ANSI SQL isolation critique (Berenson et al.) together with + Adya's generalized isolation theory grounds the isolation-guarantee + vocabulary. - CybORG, CyberBattleSim, CyGIL, CALDERA, OpenC2, CACAO, and ATT&CK show that cyber actions carry command, target, session, credential, knowledge, detection, foothold, and outcome semantics. ACES records those as portable @@ -96,7 +110,11 @@ This design is constrained by the primary sources listed in runtime literature require ACES to separate wall-clock timestamps, logical ordering, simulation time, time-advance grants, lookahead, message send/receive causality, rollback/anti-message handling, pacing, and - synchronization. + synchronization. Vector time (Fidge; Mattern) and the Schwarz-Mattern + causality survey supply the stronger basis the `VectorClock` ordering value + claims: scalar Lamport clocks respect causality in one direction only, while + vector clocks characterize the causal partial order, including causal + independence. - Cybench, AutoPenBench, CAIBench, AI Agents That Matter, and related agent benchmark critiques require run records, scaffold/tool exposure, seeds, repeated-run ids, statistical repetition plans, resource/cost traces, @@ -620,11 +638,58 @@ LifecycleEnvelope = shared_state_read_refs shared_state_write_refs emitted_state_update_refs + attribution_edge_refs + outcome_interpretation_refs joint_action_set_ref source_status_label mapping_loss + mapping_loss_detail ``` +`attribution_edge_refs` and `outcome_interpretation_refs` link the boundary +record to the evidence-labeled attribution edges (`SEM-212`) and outcome +interpretation records (`SEM-215`) defined in +`specs/formal/participant-semantics/`. This document binds where those records +attach in the runtime trace — the lifecycle envelope for the event they +qualify — while their internal semantics remain owned by the +participant-semantics design. They are how the behavior-history overview claim +that runtime records carry attribution and outcome interpretation is realized +at the envelope level; without these fields that claim has no contract carrier. + +Mapping loss is a closed vocabulary: + +```text +MappingLoss = + None + PrivateApparatusDetail + SourceFieldsOmitted + SemanticsApproximated + RedactedByPolicy + TemporalDetailCollapsed + Unknown + Unsupported +``` + +- `None`: the portable record carries the source semantics relevant to its + claims without loss. +- `PrivateApparatusDetail`: an internal apparatus counterpart (prompt, policy + trace, private selection or plan) exists or may exist but is not exposed + across the apparatus boundary. +- `SourceFieldsOmitted`: source-record fields with no portable counterpart + were dropped. +- `SemanticsApproximated`: source semantics were mapped to a broader, + narrower, or approximate portable meaning. +- `RedactedByPolicy`: content was removed, tokenized, or summarized by a + marking or redaction policy. +- `TemporalDetailCollapsed`: source timing or ordering detail could not be + preserved distinctly. +- `Unknown` and `Unsupported` keep their standard meanings. + +`mapping_loss` is nullable only when the record makes no source-mapping claim. +A record projected from a source system declares its loss explicitly, `None` +included. `mapping_loss_detail` is optional free text for review context; like +source labels, it does not define semantics. + Observation information guarantees are: ```text @@ -675,6 +740,9 @@ ObservationEnvelope = phase_ref visibility_projection_ref information_guarantee + delivery_basis + delivery_point_ref + delivered_at action_observation_history_ref information_state_ref hidden_state_refs @@ -688,6 +756,30 @@ ObservationEnvelope = redacted_field_refs ``` +Delivery basis is a closed vocabulary: + +```text +DeliveryBasis = + EmissionIsDelivery + RuntimeDelivery + ParticipantAcknowledgement + ExternalDelivery + Unknown + Unsupported +``` + +The declared delivery point of an observation is the order point recorded by +`delivery_point_ref`, interpreted under `delivery_basis`, with `delivered_at` +as an optional wall-clock fact that never substitutes for the order point. +`EmissionIsDelivery` declares that the runtime treats emission as delivery and +`delivery_point_ref` equals the emission order point. `RuntimeDelivery` cites +a runtime delivery event distinct from emission. `ParticipantAcknowledgement` +cites a participant acknowledgement record. `ExternalDelivery` cites an +external channel's delivery record. `Unknown` and `Unsupported` keep their +standard meanings; under either, participant-visible history membership for +the observation has no portable delivery order, and dependent claims must +downgrade. + Participant interface and step-signal records carry the RL/MARL-facing pieces that Gymnasium, PettingZoo, and OpenSpiel make first-class, without making them the ACES protocol: @@ -1212,10 +1304,24 @@ Where: `EventClassificationOK(ev)`, `SourceStatusOK(ev)`, `SourcePipelineOK(ev)`, `RawDataIntegrityOK(ev)`, and `EvidenceRefIntegrityOK(ev)`. - `EventClassificationOK(ev)` is true when `event_classification` is null and - the record makes no classification/severity/security-telemetry claim, or when - the classification tuple is present in the ACES classification registry for - the record schema version. OCSF compatibility is true only with a cited OCSF - mapping and OCSF-schema-valid values. + `ClassificationClaim(ev)` is false, or when the classification tuple is + present in the ACES classification registry for the record schema version. + OCSF compatibility is true only with a cited OCSF mapping and + OCSF-schema-valid values. +- `ClassificationClaim(ev)` defines "makes a claim" for classification, status, + severity, and security-telemetry purposes. It is true exactly when at least + one of the following holds: the ACES classification registry marks the + record's `(schema_name, schema_version, event_type)` as + classification-bearing; the record populates `event_classification` or maps + `source_status` into a normalized status/severity vocabulary; another record + in the trace cites this record as the basis for a normalized status, + severity, detection, finding, or security-telemetry conclusion; or the + record's source mapping declares OCSF/STIX compatibility. When all of these + are false the record makes no claim, and `event_classification` and the + normalized `source_status` pair must be null rather than populated with + unregistered values. A consumer that wants to draw a classification-dependent + conclusion from a no-claim record must first upgrade the record, not + reinterpret it. - `SourceStatusOK(ev)` is true when `source_status` is null and the record makes no status claim, or when the normalized `status_id`/`status` pair is in the governed status vocabulary and source labels are preserved separately. @@ -1497,6 +1603,34 @@ or claim a capability stronger than the effective declared support. rewritten, and unfinished operations are completed, cancelled, timed out, or explicitly marked unsupported/unknown. +### Minimal Observable Trace For Opaque Participants + +Opaqueness is bounded; this section resolves what I2 requires of a participant +implementation that exposes nothing voluntarily. + +- For an action attempt to support any portable claim, the trace must contain + at least one lifecycle envelope for it whose phase is `ExecutionAttempt` or + `StateUpdateCommit`, or an operation record that reaches a terminal + operation state. Proposal and selection may be opaque or absent, but a fully + unobservable execution is not a recordable action attempt. +- The minimal valid trace for a fully opaque participant action is therefore + one `ExecutionAttempt` envelope (or terminal operation record) with a + declared action-validity basis, plus whatever observations and state updates + were actually observed. Intent and admission envelopes with `Opaque` + realization are permitted disclosures, not requirements. +- World or shared-state change with no admissible execution boundary must not + be presented as a participant action attempt. It may enter the trace only as + state updates or observations whose `actor_provenance` disclosure records + the unattributed or external basis; attributing it to a participant then + requires `SEM-212` attribution evidence, not narrative convenience. +- A silent participant is valid. An episode whose behavior history is empty is + a valid trace — I2 requires recording observable attempts, not inventing + them — but it supports only episode lifecycle facts. Behavior, + information-state, interaction, and outcome claims about that participant + are unsupported for that episode, and capability or benchmark claims that + quantify over participant behavior must disclose the silent episode rather + than dropping it from denominators. + ## Observation And Information-State Semantics The runtime may emit an observation without claiming a complete information @@ -1565,6 +1699,22 @@ unless the visibility policy explicitly projects it; otherwise the review/audit mapping from `visible_occurrence_id` back to the global event is held in controlled evidence, not in `VisibleHistory`. +Stable redaction tokens are scoped, and the scope is declared, not implied. A +redaction policy that emits stable tokens must declare its token scope — at +minimum the `(participant, episode, redaction policy version, projection +version)` tuple within which token stability holds. Within one scope, the same +hidden value projects to the same token, and token equality may support +reconstruction and information-state claims. Across scopes, token equality +means nothing: reuse of a token string across participants, episodes, or +policy/projection versions must not be readable as identity of the hidden +values, and no claim may rely on cross-scope token equality. Token-to-value +mappings are controlled evidence, never part of `VisibleHistory`. A redaction +policy that cannot declare its token scope, or that cannot keep tokens stable +within it, downgrades dependent information-state claims to `LossyProjection`, +`Unknown`, or `Unsupported`. `visible_occurrence_id` uniqueness is unaffected +by token reuse: distinct deliveries of identically redacted payloads remain +distinct occurrences within their declared scope. + `VisibleHistory.occurrences` is an occurrence-preserving finite collection, not a mathematical set of payloads. Equal projected payloads, equal redaction tokens, or repeated no-op observations remain distinct visible occurrences when they @@ -1722,6 +1872,24 @@ redaction, aggregation, delayed delivery, unsupported ordering, or unknown stochastic branch that cannot prove equality with the claimed information state, it returns `Lossy` or `Unknown`. +Reconstruction algorithms and proofs are registry entries, not prose refs. +`reconstruction_algorithm_ref` and `reconstruction_proof_ref` must resolve +through a versioned reconstruction registry keyed by +`(algorithm_id, algorithm_version, schema_version, projection_version)`. A +registry entry declares: the executable algorithm or normative specification; +its determinism basis; its input contract (total delivery order consumed, +partial order consumed directly, or order-invariance across all visible linear +extensions, with the proof obligation for the invariance case); the fixture +format its conformance tests use; and the proof-artifact format that +`reconstruction_proof_ref` entries must satisfy. A ref that does not resolve +to a registry entry, or whose entry's tests or proof cannot be executed or +audited for the schema/projection version making the claim, makes +`Reconstruct_p` return `Unsupported` and downgrades dependent +history-consistency and perfect-recall claims. Like capability concerns, +reconstruction algorithms cannot be introduced by prose in an adapter +manifest; they are added to the governed registry or they do not exist for +conformance purposes. + `HistoryConsistent` requires: ```text @@ -1781,6 +1949,17 @@ participant-visible histories are prefixes or lower sets of Forgetting prior participant-visible actions while keeping only current observation is therefore not a perfect-recall claim. +`PerfectRecall_{p,tr,policy}` is a constructive witness, not a restatement of +the game-theoretic definition. Perfect recall in Kuhn's sense is a condition on +a participant's information partition — every information set remembers the +participant's prior actions and information sets. The predicate above is a +checkable sufficient condition for that property relative to the recorded +visible projection: prefix embedding plus stable visible identity and order +witnesses that the partition induced by `~_{p,policy}` refines correctly over +time. A conforming implementation claims the witness, and reviewers judge the +partition property through it; asserting the partition property without the +witness is not a valid `PerfectRecall` claim. + For belief-state consumers, ACES may record a belief support: ```text @@ -1816,8 +1995,26 @@ Rules: an explicit visibility rule projects them to `p`. - Observation emission, delivery, consumption, and acknowledgement are distinct when the backend can observe them. A participant-visible history may include - an observation only after the declared delivery point, not merely because the - backend generated the observation. + an observation only at or after its declared delivery point — the order point + recorded by `delivery_point_ref` under the declared `delivery_basis` — not + merely because the backend generated the observation. For observation + emissions, the declared order point used by the `H_{tr,policy}` projection is + therefore the delivery point, not the emission point, whenever the two + differ. If the delivery basis is `Unknown` or `Unsupported`, the observation + has no portable position in the visible delivery order, and + history-consistency and perfect-recall claims that depend on delivery order + must downgrade. +- Rollback, anti-message, compensation, and supersession never rewrite + participant-visible history. A visible occurrence delivered before a rollback + remains in `H_{tr,policy}(p,e,t)` at its original order point with its + original `visible_occurrence_id`. The effect of a rollback on a participant's + view is expressed only by appending: either a superseding disclosure + projected as a new visible occurrence, or no participant-visible effect plus + an evidence-only supersession record. An information-state claim at an order + point after a rollback must either incorporate the participant-visible + rollback disclosure into the visible history or downgrade to + `LossyProjection`, `Unknown`, or `Unsupported`; it must never be computed + against a retroactively edited history. - Redacted fields remain part of the record shape as redacted tokens or omitted marked fields; the raw hidden value is not part of `H_{tr,policy}(p,e,t)`. - Stochastic or noisy observations must either disclose a reproducible generator @@ -2103,7 +2300,10 @@ any of the following hold: The realized order relation is a directed acyclic relation over event ids plus optional simultaneity groups. It may be total, partial, or explicitly -unsupported. +unsupported. This is the event-structure/trace-theory model of concurrency +(Winskel; Mazurkiewicz): independence and simultaneity are recorded structure, +not an artifact of missing timestamps, and a backend-chosen interleaving is not +ground truth about concurrency. Rules: @@ -2115,6 +2315,13 @@ Rules: guarantee. - Logical-clock and vector-clock contexts are evidence for happens-before claims only when the clock authority and update rules are declared. +- `LogicalClock` and `VectorClock` are different claim strengths. A + `VectorClock` basis claims the Fidge/Mattern characterization: vector + comparison decides both ordering and causal independence. A scalar + `LogicalClock` basis is one-directional; it can support `before(a,b)` + evidence but can never prove that two events are causally unrelated, so + causal-independence or simultaneity claims from scalar clocks must + downgrade. - Simulation tick order, control-plane order, and serialized backend order are different claims and must not be collapsed. @@ -2162,6 +2369,14 @@ RollbackOK(r) = ### Isolation And Atomicity +The isolation vocabulary is anchored in the transaction-isolation literature: +Berenson et al.'s critique of the ANSI SQL isolation levels defines the anomaly +taxonomy and snapshot isolation, and Adya's generalized isolation theory gives +the implementation-independent serialization-graph definitions that make these +levels portable claims rather than vendor labels. ACES uses those meanings; +`Causal` follows the causal-consistency usage in the distributed-systems +literature rather than an ANSI level. + Every joint action that reads or writes shared operational state declares: - `snapshot_basis`: the state revisions each attempt read; @@ -2346,6 +2561,17 @@ Rules: - `not_applicable` is neutral only when the contract states that the concern has no semantic role for the claim. - Missing value for a required concern is `Reject`, not `not_applicable`. +- The meet for a concern ranges over every component that affects that + concern, not only over the components that chose to declare it. A component + that affects a concern but declares no value contributes `unsupported` to + the meet; silence is never neutral. This is distinct from the rule above: + when the concern is required by the claim, the undeclared value is a + capability validation failure (`Reject`) before any meet is computed; when + the concern is optional for the claim, the undeclared component still drags + the effective value to `unsupported` rather than being skipped. +- `not_applicable` must be declared, never inferred from absence. Only a + declared `not_applicable` — with the contract stating the concern has no + semantic role for that component — is removed from the meet. `G` satisfies `R` only when every required concern appears in the effective vector with strength greater than or equal to the required strength: @@ -2412,6 +2638,15 @@ Rules: specific evidence contract defines a safe representation. - Redaction must preserve enough stable identity to support provenance and replay claims. If it cannot, the affected claim must be downgraded. +- Marking enforcement and visibility projection compose by intersection, + deny-first. A field value is participant-visible only when the visibility + projection projects it and the marking/authorization policy authorizes that + participant as a consumer. Neither surface can widen the other: a visibility + rule that purports to expose a field a marking denies does not win — the + trace is invalid (`MarkingOK` fails) — and a marking authorization alone + never makes a field participant-visible without a projecting visibility + rule. Redaction policy is evaluated after this intersection and determines + representation (omission, stable token, summary, hash), never authorization. ## Asynchronous And Long-Running Operations @@ -3789,8 +4024,9 @@ guarantees must be visible. Lifecycle phase, phase realization, admission disposition, operation state, information guarantee, ordering basis, isolation guarantee, conflict policy, -and capability strength values are closed at the portable contract layer. -Source labels may be preserved, but source labels do not define ACES semantics. +mapping loss, delivery basis, and capability strength values are closed at the +portable contract layer. Source labels may be preserved, but source labels do +not define ACES semantics. ### I17 - Missingness Distinctions @@ -4026,9 +4262,12 @@ lifecycle_envelope: - evidence.collection.red@rev9 emitted_state_update_refs: - state-update-red-17 + attribution_edge_refs: [] + outcome_interpretation_refs: [] joint_action_set_ref: null source_status_label: tool_call_completed - mapping_loss: selection_private_to_model + mapping_loss: private_apparatus_detail + mapping_loss_detail: selection_private_to_model selection_envelope: event_id: evt-llm-16-selection schema_name: aces.participant_runtime.lifecycle @@ -4103,9 +4342,12 @@ selection_envelope: shared_state_read_refs: [] shared_state_write_refs: [] emitted_state_update_refs: [] + attribution_edge_refs: [] + outcome_interpretation_refs: [] joint_action_set_ref: null source_status_label: model_private_choice - mapping_loss: private_policy_trace_not_exposed + mapping_loss: private_apparatus_detail + mapping_loss_detail: private_policy_trace_not_exposed ``` The runtime records the observable action attempt. It does not require prompt @@ -4286,6 +4528,9 @@ observation_envelope: phase_ref: evt-rl-42-exec visibility_projection_ref: projections.blue.local.telemetry.v1 information_guarantee: observation_only + delivery_basis: emission_is_delivery + delivery_point_ref: order.sim.42.blue.obs43 + delivered_at: 2026-05-26T10:20:10Z action_observation_history_ref: history.blue.ep002.prefix43 information_state_ref: null hidden_state_refs: @@ -4478,9 +4723,12 @@ lifecycle_envelope: - incident.queue.web01@rev12 shared_state_write_refs: [] emitted_state_update_refs: [] + attribution_edge_refs: [] + outcome_interpretation_refs: [] joint_action_set_ref: null source_status_label: submitted_by_operator - mapping_loss: null + mapping_loss: none + mapping_loss_detail: null ``` The human operator is represented through the same participant runtime boundary @@ -5280,7 +5528,19 @@ families: chance nodes, mean-field updates, simultaneous moves, and information-state discipline. - POMDP, Dec-POMDP, POSG, and Markov-game literature for partial observability - and multi-agent information boundaries. + and multi-agent information boundaries, with Oliehoek and Amato's Dec-POMDP + monograph as the consolidated vocabulary reference. +- Interpreted systems (Fagin, Halpern, Moses, Vardi), dynamic epistemic logic + (Baltag-Moss-Solecki; van Ditmarsch, van der Hoek, Kooi), and Kuhn's + extensive-form information sets and perfect recall for information-state, + indistinguishability, and history semantics. +- Fidge/Mattern vector time and the Schwarz-Mattern causality survey for the + `VectorClock` ordering basis; Winskel event structures and Mazurkiewicz trace + theory for partial-order realized ordering with simultaneity groups. +- Berenson et al.'s ANSI SQL isolation critique and Adya's generalized + isolation theory for the isolation-guarantee vocabulary. +- Mean-field game theory (Huang, Caines, Malhamé; Lasry and Lions; Yang et al.) + for mean-field node and population-distribution semantics. - CybORG, CyberBattleSim, CyGIL, CALDERA, ATT&CK, OpenC2, and CACAO for cyber action, sensing, command/response, playbook, knowledge, foothold, detection, and sim-to-emulation realization disclosure. From 5d130d4dae293638b1bed895f51d08648d8721c7 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Thu, 11 Jun 2026 17:29:23 +0200 Subject: [PATCH 13/70] Correct SEM-214/SEM-215 coverage row and time-semantics waves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the coverage row that claimed SEM-214 active: SEM-215 (participant outcome interpretation) keeps its active row and artifacts; SEM-214 (derived operational context views) gets its own planned row, matching its DRAFT/wave-3 state with no artifacts. Ground Control: assign wave 2 to SEM-227, SEM-228, and SEM-229 — MUST requirements that ACTIVE SEM-213 explicitly defers to, previously wave-less (priority inversion). --- docs/explain/reference/shared-semantic-integrity.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/explain/reference/shared-semantic-integrity.md b/docs/explain/reference/shared-semantic-integrity.md index 3efed7df0..846cb09f2 100644 --- a/docs/explain/reference/shared-semantic-integrity.md +++ b/docs/explain/reference/shared-semantic-integrity.md @@ -233,7 +233,8 @@ so they are tracked by their own requirements, not here. | Participant preconditions, effects, failure, causality, and attribution semantics | SEM-211, SEM-212 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_action_semantics.py`, `implementations/python/packages/aces_sdl/participant_attribution_semantics.py`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_conformance/conformance.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/tests/test_sem_211_participant_action_semantics.py`, `implementations/python/tests/test_sem_212_participant_attribution_semantics.py`, `implementations/python/tests/test_runtime_conformance.py` | partial | | Participant temporal, tool/affordance, and decision-surface semantics | SEM-213, SEM-219, SEM-220 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md` | partial | | Participant reference trajectories, demonstrations, budgets, and quota/exhaustion semantics | SEM-221, SEM-223 | — | — | planned | -| Participant outcome interpretation and derived operational context views | SEM-214, SEM-215 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_outcome_semantics.py`, `implementations/python/packages/aces_sdl/semantics/participant_outcome.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/tests/test_sem_215_participant_outcome_interpretation.py` | active | +| Participant outcome interpretation | SEM-215 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_outcome_semantics.py`, `implementations/python/packages/aces_sdl/semantics/participant_outcome.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/tests/test_sem_215_participant_outcome_interpretation.py` | active | +| Derived operational context views (portable meaning and comparability) | SEM-214 | — | — | planned | | Evidence, evaluation, view-boundary, and observability-plane semantics | SEM-216, SEM-224, SEM-225 | — | — | planned | | External knowledge bindings semantics | SEM-217 | — | — | planned | | Explicitness and realization semantics (binding declarations vs processor/backend realization) | SEM-218 | validation | `specs/formal/realization/explicitness-and-realization.md`, `specs/formal/realization/README.md`, `docs/explain/reference/explicitness-realization-semantics.md`, `implementations/python/packages/aces_contracts/apparatus.py`, `implementations/python/packages/aces_contracts/vocabulary.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/packages/aces_backend_protocols/manifest.py`, `implementations/python/tests/test_backend_manifest.py`, `implementations/python/tests/test_processor_manifest.py`, `implementations/python/tests/test_runtime_contracts.py` | partial | From 62163b2d0fc31ca0bc41220c19868ad10fb33c69 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Thu, 11 Jun 2026 18:55:51 +0200 Subject: [PATCH 14/70] Supplement remediation: ADR-054 attribution fix and changelog fragments Fix the PettingZoo/OpenSpiel contribution conflation inside ADR-054's context (chance nodes, mean-field states, and information-state discipline are OpenSpiel/theory surfaces, not PettingZoo), ground the framework claims in their underlying theory (Kuhn; Fagin-Halpern- Moses-Vardi; Huang-Caines-Malhame; Lasry-Lions; Fidge/Mattern; Schwarz-Mattern), and add changelog fragments for the remediation issues #511, #512, #513. --- changelog.d/511.changed.md | 17 +++++++++++++++ changelog.d/512.fixed.md | 17 +++++++++++++++ changelog.d/513.fixed.md | 8 +++++++ ...articipant-runtime-observable-lifecycle.md | 21 ++++++++++++------- 4 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 changelog.d/511.changed.md create mode 100644 changelog.d/512.fixed.md create mode 100644 changelog.d/513.fixed.md diff --git a/changelog.d/511.changed.md b/changelog.d/511.changed.md new file mode 100644 index 000000000..59823db4a --- /dev/null +++ b/changelog.d/511.changed.md @@ -0,0 +1,17 @@ +### Changed + +- Hardened the participant-semantics and participant-runtime literature + lineage with the missing primary theory: interpreted systems and dynamic + epistemic logic for information states and view transitions, Kuhn's + extensive-form information sets and perfect recall, Goguen-Meseguer + noninterference and Sabelfeld-Sands declassification for the hidden-truth + boundary, STRIPS/PDDL/PDDL2.1/PPDDL/RDDL for precondition/effect contracts, + the Oliehoek-Amato Dec-POMDP monograph, mean-field game theory, + Fidge/Mattern vector time with the Schwarz-Mattern causality survey for the + `VectorClock` ordering basis, Winskel/Mazurkiewicz partial-order + concurrency, Allen/Koymans/Alur-Dill temporal formalisms, and + Chockler-Halpern responsibility for multi-cause attribution. Corrected the + PettingZoo/OpenSpiel contribution attribution (including in ADR-054's + context), the CyGIL title conflation, the CRACK venue, the Dec-POMDP + complexity authors/venue, the HLA edition, and grounded the CybORG + sim-to-emulation claim in Standen et al. (2021). diff --git a/changelog.d/512.fixed.md b/changelog.d/512.fixed.md new file mode 100644 index 000000000..4fa06b850 --- /dev/null +++ b/changelog.d/512.fixed.md @@ -0,0 +1,17 @@ +### Fixed + +- Closed the participant-runtime formal-spec defects found in the 2026-06 + review: `mapping_loss` is now a closed vocabulary (with + `mapping_loss_detail`); `LifecycleEnvelope` carries the + attribution-edge and outcome-interpretation references its overview names + (SEM-212/SEM-215 cross-ref); the declared delivery point is defined and + carried (`delivery_basis`, `delivery_point_ref`, `delivered_at`) and the + visible-history projection binds to it; stable redaction tokens have a + declared stability scope; rollback/supersession can never rewrite + participant-visible history; marking enforcement and visibility projection + compose deny-first; `ClassificationClaim` defines when + `event_classification`/`source_status` may be null; fully opaque + participants have a defined minimal observable trace; the capability meet + is total over affecting components (missing declarations contribute + `unsupported`, never skipped); and reconstruction algorithm/proof refs must + resolve through a versioned reconstruction registry. diff --git a/changelog.d/513.fixed.md b/changelog.d/513.fixed.md new file mode 100644 index 000000000..bce446384 --- /dev/null +++ b/changelog.d/513.fixed.md @@ -0,0 +1,8 @@ +### Fixed + +- Corrected the shared-semantic-integrity coverage table: SEM-214 (derived + operational context views, DRAFT/wave-3, no artifacts) no longer shares an + `active` row with SEM-215; it now has its own `planned` row. Resolved the + scheduling inversion for the MUST-priority time-model requirements by + assigning wave 2 to SEM-227, SEM-228, and SEM-229 in Ground Control, which + ACTIVE SEM-213 temporal semantics explicitly defer to. diff --git a/docs/decisions/adrs/adr-054-participant-runtime-observable-lifecycle.md b/docs/decisions/adrs/adr-054-participant-runtime-observable-lifecycle.md index 1e2fbef06..d1bcfb019 100644 --- a/docs/decisions/adrs/adr-054-participant-runtime-observable-lifecycle.md +++ b/docs/decisions/adrs/adr-054-participant-runtime-observable-lifecycle.md @@ -44,14 +44,19 @@ stochastic, or not reported to ACES at all. The design therefore needs a runtime-observable lifecycle, not a required participant-internal loop. The primary lineage reinforces that boundary: -Gymnasium, PettingZoo, OpenSpiel, CybORG, CyberBattleSim, and CyGIL expose -actions, observations, rewards or returns, legal-action spaces or masks, -termination/truncation, episode control, possible/live/active-agent and -current-actor state, chance nodes, mean-field updates, and multi-agent -interaction without requiring access to private agent internals. OpenSpiel's -information-state discipline -reinforces that observation, action-observation history, and perfect recall are -separate claims. Lamport clocks, HLA time management, Time Warp, DEVS, and FMI +Gymnasium, PettingZoo, CybORG, CyberBattleSim, and CyGIL expose actions, +observations, rewards or returns, action masks, termination/truncation, +episode control, and possible/live/active-agent and current-actor state +without requiring access to private agent internals; OpenSpiel additionally +exposes legal-action surfaces, explicit chance nodes, mean-field game states, +and the information-state discipline that keeps observation, +action-observation history, and perfect recall separate claims. The +underlying theory predates these frameworks — chance moves and perfect +recall are extensive-form game theory (Kuhn), information states over local +histories are runs-and-systems epistemics (Fagin, Halpern, Moses, and +Vardi), and mean-field interaction is mean-field game theory (Huang, Caines, +and Malhamé; Lasry and Lions). Lamport clocks, vector clocks (Fidge; +Mattern; Schwarz and Mattern), HLA time management, Time Warp, DEVS, and FMI separate timestamp, ordering, causality, pacing, synchronization, lookahead, rollback, and realization. OCSF, STIX, CACAO, OpenC2, and CALDERA show that portable event and command semantics need typed envelopes, identity, From 5df546910426617992c9d20618566efd0d35d9b4 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Thu, 11 Jun 2026 18:56:17 +0200 Subject: [PATCH 15/70] Add participant backend-contract design: ADR-060, spec section, research notes Joint design artifacts for issue #76 (API-405/406/407/408/411): ADR-060 establishing the participant backend-facing contract surface (one carrier family on the ADR-054 base envelope; API-405 ratified as shipped; API-407 as feature_support on the same manifest block using the guarantee-strength scale; API-408 as retrieval projections with SEM-214 semantics deferred; API-411 as SEM-215 interpretation records), the normative spec section under specs/formal/runtime-contracts/, research notes with prior art, totality analysis, and design criteria, plus the architecture preflight guardrails. --- changelog.d/76.added.md | 17 ++ docs/decisions/adrs/README.md | 1 + ...icipant-backend-facing-contract-surface.md | 175 ++++++++++++++ .../participant-backend-contracts/index.md | 59 +++++ .../preflight-guardrails.md | 169 ++++++++++++++ .../prior-art-and-design-criteria.md | 213 ++++++++++++++++++ specs/formal/runtime-contracts/README.md | 3 + .../participant-backend-contracts.md | 205 +++++++++++++++++ 8 files changed, 842 insertions(+) create mode 100644 changelog.d/76.added.md create mode 100644 docs/decisions/adrs/adr-060-participant-backend-facing-contract-surface.md create mode 100644 docs/research/participant-backend-contracts/index.md create mode 100644 docs/research/participant-backend-contracts/preflight-guardrails.md create mode 100644 docs/research/participant-backend-contracts/prior-art-and-design-criteria.md create mode 100644 specs/formal/runtime-contracts/participant-backend-contracts.md diff --git a/changelog.d/76.added.md b/changelog.d/76.added.md new file mode 100644 index 000000000..a5b6771c3 --- /dev/null +++ b/changelog.d/76.added.md @@ -0,0 +1,17 @@ +### Added + +- Published the joint participant backend-facing contract surface (ADR-060; + API-405/406/407/408/411 design issue #76): a `participant-runtime` schema + family (`participant-lifecycle-event-v1`, + `participant-observation-envelope-v1`, + `participant-shared-state-record-v1`, `participant-outcome-report-v1`), + control-plane retrieval projections (`participant-status-view-v1`, + `participant-history-view-v1`, `participant-context-view-v1`), an API-407 + `feature_support` extension of the backend-manifest + `capabilities.participant_runtime` block on the ADR-054 guarantee-strength + scale (with the `participant-runtime-feature-support-levels` controlled + vocabulary), the normative spec section + `specs/formal/runtime-contracts/participant-backend-contracts.md`, and the + research notes under `docs/research/participant-backend-contracts/`. + Design-issue scope only: shapes, schemas, and fixtures; runtime emission + and conformance land on #200-#203. diff --git a/docs/decisions/adrs/README.md b/docs/decisions/adrs/README.md index ce716063d..1c69e9cc4 100644 --- a/docs/decisions/adrs/README.md +++ b/docs/decisions/adrs/README.md @@ -120,3 +120,4 @@ adr-059-adr-amendment-policy-and-pin-gate | [057](adr-057-runtime-secret-name-classifier-boundaries.md) | Runtime Scenario Value Realizability and Explicit Redaction | accepted | 2026-06-06 | | [058](adr-058-datastore-node-engine-provenance-and-endpoints.md) | Datastore Node Engine Provenance and Endpoints | accepted | 2026-06-07 | | [059](adr-059-adr-amendment-policy-and-pin-gate.md) | ADR Amendment Policy and Acceptance-Content Pin Gate | accepted | 2026-06-10 | +| [060](adr-060-participant-backend-facing-contract-surface.md) | Participant Backend-Facing Contract Surface | proposed | 2026-06-11 | diff --git a/docs/decisions/adrs/adr-060-participant-backend-facing-contract-surface.md b/docs/decisions/adrs/adr-060-participant-backend-facing-contract-surface.md new file mode 100644 index 000000000..15df8bd26 --- /dev/null +++ b/docs/decisions/adrs/adr-060-participant-backend-facing-contract-surface.md @@ -0,0 +1,175 @@ +# ADR-060: Participant Backend-Facing Contract Surface + +## Status + +proposed + +## Date + +2026-06-11 + +## Context + +Issue #76 is the joint design surface for `API-405`, `API-406`, `API-407`, +`API-408`, and `API-411`: the backend-facing participant contract surface. +These five requirements cannot be designed independently. `API-406` defines +the plain-data shapes for participant actions, observations, state snapshots, +histories, and state-change reports; `API-405` and `API-407` declare backend +support for that surface; `API-408` retrieves what `API-406` serializes; and +`API-411` reports participant-local outcomes against `API-406` state shapes. +Designed separately, the contracts diverge. + +The semantic and runtime ground is already fixed and is not reopened here: + +- ADR-022 and `specs/formal/participant-semantics/` define what participant + actions, observations, visibility, failures, attribution, temporal + behavior, and outcomes mean. +- ADR-054 and `specs/formal/participant-runtime/` define the observable + lifecycle, the base-envelope discipline (identity, three timestamps, + ordering basis, markings, evidence integrity), the closed vocabularies + (lifecycle phase, phase realization, admission disposition, information + guarantee, ordering basis, isolation guarantee, conflict policy, mapping + loss, delivery basis, capability strength), shared operational state, + concurrency, and capability guarantee vectors. +- ADR-041 defines participant implementation manifests and provenance — the + apparatus identity surface these contracts compose with. +- ADR-009 makes schemas authoritative artifacts generated from contract + models; ADR-012 governs vocabulary authority and extension discipline. + +`API-405` is already `ACTIVE`: PR #405 shipped +`backend-manifest/v2` `capabilities.participant_runtime` with governed +role/feature vocabularies, the `x-:` extension rule, and +term-level evidence criteria, ahead of this joint design. This ADR must take +an explicit position on that shipped surface. + +What is missing is the carrier layer: no portable boundary-record contracts +for actions, observations, or state-change reports; no per-feature support +and constraint declaration; no portable retrieval projections for participant +status, context views, and histories; and no participant-local outcome +reporting contract. + +The research basis, prior-art survey, totality analysis, and numbered design +criteria for this decision are recorded in +`docs/research/participant-backend-contracts/`. + +## Decision + +Adopt one joint backend-facing participant contract surface with the +following structure. + +1. **One carrier family, one base discipline.** New participant runtime + carriers form a `participant-runtime` contract family generated into + `contracts/schemas/participant-runtime/`. Every carrier embeds the ADR-054 + base envelope (single shared model, defined once) and uses the ADR-054 + closed vocabularies. No carrier defines local identity, versioning, + marking, or extension semantics. Carriers serialize the formal specs by + reference; they add shape, requiredness, and vocabulary bindings only. + +2. **`API-406` carriers.** The plain-data contracts are: + `participant-lifecycle-event-v1` (action attempts and lifecycle boundary + records, including attribution-edge and outcome-interpretation references), + `participant-observation-envelope-v1` (participant-visible observations, + including information guarantee and declared delivery point), and + `participant-shared-state-record-v1` (state-change reports with revision, + digest, access records, ordering basis, and conflict policy). State + snapshots and histories are already carried by `runtime-snapshot-v1`, + `participant-episode-state-envelope-v1`, and the episode/behavior history + event streams; this ADR ratifies those as the `API-406` snapshot and + history carriers. Operation records, step signals, interaction contexts, + joint actions, and time-management contexts remain ADR-054 surfaces owned + by the `RUN-30x` implementation issues; `API-406` carriers reference them + and do not absorb them. + +3. **`API-405` ratified as shipped.** The + `capabilities.participant_runtime` block from PR #405 is the participant + capability declaration surface: governed roles, behavior features, + interaction features, governed extension terms, and term-level evidence + criteria. No amendment is required. + +4. **`API-407` extends the same manifest block.** Feature support and + constraint declarations are `feature_support` entries on + `capabilities.participant_runtime`, one per governed behavior or + interaction feature term, each declaring a support level on the ADR-054 + guarantee-strength scale (`unsupported`, `disclosed_weak`, `bounded`, + `exact`) plus constraint references and disclosure references. Rules: + a feature absent from `feature_support` defaults to the `API-405` + presence claim (`exact` is *not* implied; absence of an entry makes no + strength claim); any entry below `exact` requires a disclosure reference; + a term listed in the `API-405` supported lists may not be declared + `unsupported` (contradiction fails validation); terms not in the governed + vocabularies fail validation. `API-407` declarations are participant + feature support and remain distinct from SEM-218 + explicitness/realization declarations, which cover cross-domain + realization handling; the manifest carries both without merging them. + +5. **`API-408` retrieval is projection.** The control-plane retrieval + contracts are read-shapes over recorded carriers, keyed by participant, + episode, and order point: `participant-status-view-v1` (episode state and + lifecycle facts), `participant-history-view-v1` (episode/behavior history + retrieval with visibility projection applied), and + `participant-context-view-v1` (derived operational context views). All + three apply visibility projection and marking/redaction enforcement before + publication and carry no retrieval-only state that does not exist in + recorded contracts. The *semantics* of derived context views (meaning and + comparability) belong to `SEM-214` (wave 3); `participant-context-view-v1` + carries the view reference, provenance, and marking discipline only and + makes no comparability claim. + +6. **`API-411` outcomes are interpretation records.** + `participant-outcome-report-v1` reports participant-local outcomes as + SEM-215 interpretation records: outcome source grounding (action result, + episode terminal state, evidence references), the interpretation rule + reference, and explicit relationship references to scenario, workflow, + and objective state. The carrier has no score, reward, or + objective-success field; reward and return remain ADR-054 step signals. + +7. **Vocabulary and evidence governance.** Support levels and any new + declarable terms are governed vocabulary entries under the concept + authority; backend-specific terms use `x-:`. Every declarable + role, feature, and support term binds to required evidence contracts, + extending the API-405 evidence-criteria table, so conformance can falsify + any declaration (ADR-021). + +8. **Scope honesty.** This design issue publishes the contract models, the + generated schema set, and the formal spec sections. Runtime emission, + semantic validation beyond shape and vocabulary, conformance checks, and + tests belong to the per-UID implementation issues (#200–#203; #199 is + already merged). A schema published here is a settled shape, not a claim + that any backend or the reference runtime produces it yet. + +## Consequences + +Positive: + +- The data model is settled once for all five requirements; the per-UID + implementation issues get a stable, reviewable contract target instead of + negotiating shapes independently. +- Backend support claims stay falsifiable end to end: capability terms, + per-feature strengths, and constraint disclosures all bind to governed + vocabularies and evidence criteria. +- Retrieval cannot fork the data model: API-408 shapes are projections of + recorded carriers by construction. +- The already-shipped API-405 surface is ratified rather than reworked, + avoiding a breaking manifest change. + +Negative: + +- The contract-model surface in `aces_contracts` grows substantially, and the + base envelope is modeled ahead of the `RUN-30x` carriers that will also + embed it; that model must be reused, not duplicated, when those land. +- Schemas exist before any runtime emits them. Consumers must read manifest + declarations plus evidence criteria — never schema presence — as the + support signal. + +Risks: + +- The `SEM-214` deferral means `participant-context-view-v1` could prove + inadequate when derived-context-view semantics are designed; mitigated by + keeping that carrier reference-and-provenance only. +- The API-407 guarantee-strength scale could be misread as SEM-218 + realization support; mitigated by the explicit boundary rule in this ADR + and by distinct manifest fields. +- A backend could declare `feature_support` entries without the runtime + carriers existing; mitigated because evidence criteria for those terms + reference the published contracts, and conformance fails claims whose + evidence contracts are absent. diff --git a/docs/research/participant-backend-contracts/index.md b/docs/research/participant-backend-contracts/index.md new file mode 100644 index 000000000..5f17bf26c --- /dev/null +++ b/docs/research/participant-backend-contracts/index.md @@ -0,0 +1,59 @@ +# Participant Backend-Facing Contracts Research + +Issue: #76, covering API-405, API-406, API-407, API-408, and API-411. + +Purpose: gather and analyze the design basis for the joint backend-facing +participant contract surface before writing the ADR, formal spec sections, and +normative schema set. The five requirements form one contract surface: API-406 +defines the plain-data shapes, API-405 and API-407 declare backend support for +them, API-408 retrieves what API-406 serializes, and API-411 reports outcomes +against API-406 state shapes. + +## Relationship To Prior Research + +The primary-literature basis for participant semantics and participant runtime +behavior was gathered for issues #71 and #74 and is recorded, with full +citations, in: + +- `specs/formal/participant-semantics/README.md` (primary-source review, + including the knowledge/information-flow, action-language, partial + observability, and time/ordering/causality families) +- `specs/formal/participant-runtime/README.md` (source alignment and primary + reference surface) +- `docs/explain/sdl/lineage.md` (source map) + +These notes do not repeat that review. They cover the question specific to +issue #76: how the established semantics should be carried as portable +plain-data contracts, what the prior art does for the same problem, and what +design criteria follow. + +## Contents + +- [Prior art and design criteria](prior-art-and-design-criteria.md) — how + adjacent systems serialize participant actions, observations, state, + histories, capability declarations, retrieval surfaces, and outcomes; the + formal analysis of contract-surface totality; and the numbered design + criteria the ADR must satisfy. +- [Architecture preflight guardrails](preflight-guardrails.md) — repo-wide + implementation guardrails, required incumbents, cross-cutting gates, extension + boundaries, non-goals, and anti-patterns to keep the later ADR and schema + work aligned with existing authority and runtime surfaces. + +## Standing Constraints + +- ADR-009: schemas, fixtures, and conformance profiles are authoritative; + reference implementations are consumers. Schemas under `contracts/schemas/` + are generated from contract models; generator inputs change, generated + output is never hand-edited. +- ADR-012: external concept authority plus disciplined ACES-native extension; + controlled vocabularies are governed surfaces. +- ADR-022 / ADR-054: the participant semantics and runtime models whose + objects these contracts serialize. The contracts must not weaken, fork, or + reinterpret those semantics. +- ADR-041: participant implementation manifest/provenance already covers the + apparatus identity surface; the backend-facing contracts here must compose + with it, not duplicate it. +- API-405 is already ACTIVE (PR #405): `backend-manifest/v2` + `capabilities.participant_runtime` with governed role/feature vocabularies + and term-level evidence criteria. The joint design ratifies that surface and + extends it for API-407 rather than redesigning it. diff --git a/docs/research/participant-backend-contracts/preflight-guardrails.md b/docs/research/participant-backend-contracts/preflight-guardrails.md new file mode 100644 index 000000000..e20fb4ad8 --- /dev/null +++ b/docs/research/participant-backend-contracts/preflight-guardrails.md @@ -0,0 +1,169 @@ +# Architecture Preflight Guardrails + +Date: 2026-06-11 + +Issue: #76. + +Requirements: API-405, API-406, API-407, API-408, API-411. + +This note is architecture preflight guidance for the joint participant +backend-facing contract surface. It is not an implementation plan and does not +publish the ADR or schemas required by the issue. + +## Architecture Decisions + +- Issue #76 publishes contract shapes over ADR-022 participant semantics and + ADR-054 participant runtime emission points. It does not redefine action, + observation, visibility, attribution, temporal, interaction, lifecycle, or + outcome semantics. +- API-405 is the existing `backend-manifest-v2` + `capabilities.participant_runtime` block. API-407 extends that same block + for feature support and constraints; it must not create a parallel manifest + section, backend profile branch, or processor-side capability surface. +- API-407 support is a per-feature guarantee declaration, not a scalar boolean. + Reuse ADR-054's ordered concern vocabulary: + `unsupported < disclosed_weak < bounded < exact`, with `not_applicable` + outside that order. +- API-406 carriers serialize existing participant episode, behavior, action, + observation, shared-state, attribution, temporal, and outcome surfaces. They + compose with `RuntimeSnapshot.participant_episode_*` and + `RuntimeSnapshot.participant_behavior_history`; they do not replace those + fields with snapshot metadata or backend-native DTOs. +- API-408 retrieval shapes are projections of recorded API-406 carriers keyed + by participant, episode, order point, and view reference. Retrieval must not + invent state that was not recorded, and view semantics that belong to SEM-214 + remain deferred. +- API-411 outcome reports are SEM-215 interpretation records linking + participant-local sources to scenario, workflow, evaluation, evidence, or + reward layers. They are not scores, objective results, episode statuses, or + reward records. +- If the implementation adds a `participant-runtime` schema family, update the + schema generation routing explicitly. Do not let new contract ids fall through + to the current `control-plane` default by accident. + +## Required Incumbents + +Reuse these repo surfaces before adding anything new: + +- Contract authority: ADR-009, `contracts/README.md`, + `contracts/schema-publication-manifest.json`, `ContractModel`, + `schema_bundle()`, `tools/generate_contract_schemas.py`, and the generated + schema drift/publication checks. +- Concept authority: ADR-012, `controlled-vocabularies-v1`, + `concept-families-v1`, `ConceptBindingEntryModel`, + `validate_controlled_vocabulary_scope_values()`, and the manifest concept + binding validators. +- Participant semantics and runtime: ADR-022, ADR-054, + `specs/formal/participant-semantics/`, + `specs/formal/participant-runtime/`, + `ParticipantBehaviorHistoryEventModel`, + `ParticipantEpisodeStateModel`, `ParticipantEpisodeHistoryEventModel`, + participant behavior validators, and participant outcome/temporal/ + attribution validators. +- Backend declarations: `BackendManifestV2Model`, + `ParticipantRuntimeCapabilitiesModel`, + `BACKEND_SUPPORTED_CONTRACT_IDS`, + `ParticipantRuntimeCapabilities`, + `PARTICIPANT_RUNTIME_CAPABILITY_REQUIRED_CONTRACTS`, + `participant_runtime_capability_contract_gaps()`, and + `backend_manifest_payload()`. +- Runtime and retrieval: `RuntimeSnapshot`, `RuntimeSnapshotEnvelopeModel`, + `ControlPlaneStore`, `_snapshot_model()`, + `participant_runtime_state_contract_diagnostics()`, and + `participant_runtime_history_transition_diagnostics()`. +- API/security path: `ControlPlaneSecurityConfig`, `ControlPlaneIdentity`, + `ControlPlaneRole`, control-plane request-size limits, idempotency keys, + request fingerprints, audit events, redacted FastAPI error handling, and + structured `Diagnostic` values. +- Verification: `.ground-control.yaml`, `.gc/plan-rules.md`, + `tools/check_repo_policy.py`, `tools/check_requirement_governance.py`, + `tools/check_schema_publication.py`, `tools/check_generated_schemas.py`, + `tools/check_json_artifacts.py`, and `tools/verify_all.py`. + +## Cross-Cutting Gates + +- Contract shape gate: every published payload must be a closed-world contract + model with generated JSON Schema and valid/invalid fixtures. Direct edits to + `contracts/schemas/` are forbidden; change generator inputs and regenerate. +- Vocabulary gate: every portable role, feature, support concern, lifecycle + value, observation guarantee, outcome layer, and extension term must resolve + through existing enum or controlled-vocabulary authority. Backend-local terms + use the governed `x-:` extension syntax. +- Manifest authority gate: new participant backend contract ids must be added + to the canonical manifest authority lists and evidence-gap checks when they + are claimable by a backend. API-407 support claims must be falsifiable + against published contract evidence. +- Runtime state gate: participant state/history belongs in first-class + snapshot fields and event streams. Shared state, outcomes, or observation + details must not be smuggled through `RuntimeSnapshot.metadata` or generic + `details` maps when a first-class carrier exists. +- Retrieval security gate: any API-408 HTTP read surface must reuse + control-plane authentication, auditor/operator/backend role checks, request + size limits, audit recording, and redacted error envelopes. Visibility + projection and markings must be applied before returning participant-facing + views. +- Secret-handling gate: contracts carry references, digests, markings, and + provenance, not credentials, bearer tokens, raw prompts, hidden answer keys, + private configuration payloads, backend object representations, environment + dumps, or process argv. +- Error-envelope gate: validation and runtime failures use `Diagnostic` or the + existing redacted HTTP error pattern. Do not add a participant-specific + exception hierarchy or leak raw tracebacks. +- Persistence gate: use `ControlPlaneStore` and JSON-like contract payloads for + control-plane durability. Do not introduce a participant-specific durable + store as part of the contract design. + +## Extension Boundary + +The primary extension seam is the published contract id plus schema version. +Adding a participant backend contract should require adding one generated +contract model, fixtures, publication-manifest entry, and conformance validator +registration, not ad hoc branches across runtime, API, and conformance code. + +API-407's seam is the governed feature-support entry: feature term, concern +name, guarantee strength, constraint refs, disclosure/evidence refs, and +optional backend-specific extension term. New concerns should extend that entry +shape and controlled vocabulary, not add more boolean fields. + +API-408's seam is the retrieval projection selector: participant address, +episode id, order point, and view reference. Derived context views should cite +their view ref and provenance while leaving SEM-214 view semantics outside this +issue. + +## Gotchas And Anti-Patterns + +Avoid: + +- hand-editing `contracts/schemas/` or forgetting + `contracts/schema-publication-manifest.json`; +- adding a second schema registry, DTO layer, fixture loader, profile table, + validation stack, exception hierarchy, persistence store, or audit log; +- placing new participant runtime schemas under `control-plane` by generator + fallthrough when a dedicated family is intended; +- treating missing API-407 support as neutral rather than an invalid or + unsupported declaration; +- collapsing `unknown`, `opaque`, `unsupported`, and `not_applicable`; +- treating episode terminal reason, objective result, evaluation score, reward, + or workflow state as participant-local outcome; +- exposing hidden world truth, scoring state, centralized-training state, + private answer keys, canaries, prompts, credentials, or raw configuration as + participant-visible data; +- treating SDL `agents`, participant implementation manifests, backend + participant-runtime capability, and participant episode state as the same + concept; +- inferring causality from timestamps or backend scheduler order; +- using backend-native action names, ATT&CK/CVE/tool labels, OpenC2/CACAO + fields, or RL spaces as ACES action semantics without loss-labeled mapping; +- weakening accepted ADRs in place without the ADR-059 amendment and pin gate. + +## Non-Goals + +- Implementing runtime emission, storage, HTTP endpoints, backend adapters, or + conformance execution. +- Adding SDL authoring syntax. +- Replacing ADR-013 participant episode lifecycle, ADR-022 participant + semantics, ADR-041 participant implementation apparatus identity, or ADR-054 + participant runtime lifecycle. +- Defining SEM-214 derived-view semantics. +- Publishing secret material, hidden truth, or backend-private data as portable + contract payloads. diff --git a/docs/research/participant-backend-contracts/prior-art-and-design-criteria.md b/docs/research/participant-backend-contracts/prior-art-and-design-criteria.md new file mode 100644 index 000000000..9866bb6e2 --- /dev/null +++ b/docs/research/participant-backend-contracts/prior-art-and-design-criteria.md @@ -0,0 +1,213 @@ +# Prior Art And Design Criteria For The Participant Backend-Contract Surface + +Issue #76 — API-405, API-406, API-407, API-408, API-411. + +## 1. The Design Question + +ADR-022 and ADR-054 define what participant actions, observations, state, +histories, interactions, and outcomes *mean*. Issue #76 must decide how those +objects travel as plain data between backends, the processor/runtime layer, and +consumers — and how a backend declares, without ambiguity, which parts of that +surface it supports. The contract surface fails if any of the following hold: + +- a semantic object named by the requirement statements has no schema carrier + (incompleteness); +- a carrier admits a reading the formal specs forbid (unsoundness); +- support claims are scalar or implicit where the runtime model is + vector-valued and disclosure-based (overclaim); +- retrieval returns backend-local objects rather than the governed contracts + (portability collapse). + +## 2. Prior Art: How Adjacent Systems Serialize The Same Concepts + +### 2.1 Action/observation interfaces (Gymnasium, PettingZoo, OpenSpiel) + +Gymnasium serializes the agent boundary as typed spaces plus per-step tuples +(observation, reward, terminated, truncated, info); PettingZoo adds per-agent +keying and agent-set discipline; OpenSpiel serializes information states as +strings/tensors with explicit current-player, chance, and simultaneous-move +structure. Lessons for ACES: + +- spaces/signals are *declared surfaces with identity*, not conventions — + ACES carriers must reference governed space/contract ids, not infer shape; +- per-agent keying is mandatory in multi-participant data: every carrier keys + by `participant_address` and `episode_id` (already the ADR-054 base-envelope + rule); +- none of these ecosystems carries provenance, markings, capability honesty, + or evidence linkage — exactly the gap ACES contracts add. They are interface + precedent, not contract precedent. + +### 2.2 Security event/object schemas (OCSF, STIX 2.1) + +OCSF and STIX are the strongest plain-data precedents in the domain: explicit +schema versioning, registry-governed classification tuples, normalized status +with preserved source labels, raw-data integrity fields, granular markings, +and a governed extension policy. ADR-054 already adopts this pattern for the +runtime base envelope. Lesson: the #76 schema set inherits the base-envelope +discipline wholesale; no carrier may define its own ad hoc identity, +versioning, marking, or extension rules. + +### 2.3 Command/playbook interchange (OpenC2, CACAO) + +OpenC2 separates command/response with correlation ids and actuator profiles; +CACAO separates playbooks, steps, commands, agents/targets, and variables with +explicit success/failure routing. Lesson: cyber-action carriers preserve +source correlation identity and routing facts as references with declared +mapping loss — ADR-054's `CyberActionEnvelope` already fixes this shape; the +schema set publishes it rather than redesigning it. + +### 2.4 Capability and conformance declaration (HLA, FMI, OGC, IETF) + +Declared-capability precedent converges on the same finding from four +directions: HLA federates declare time-regulating/time-constrained roles and +services per the federation agreement; FMI publishes per-FMU capability flags +in a static model description; OGC services declare conformance classes; +IETF/IANA registries govern extension terms. Lessons: + +- capability is declared per named term against a governed registry, never + free text — API-405's governed vocabularies and `x-:` extension + rule follow this and stand; +- a boolean per term is the *floor*, not the model: FMI's experience (flags + that proved too coarse and grew variants) supports ADR-054's move to + per-concern guarantee strengths. API-407's unsupported/constrained/partial + declarations should therefore reuse the ADR-054 guarantee-strength scale + rather than inventing a third support vocabulary; +- conformance-class precedent (OGC) supports binding each declared term to + evidence criteria — the API-405 term-level evidence table generalizes to + API-407 constraints. + +### 2.5 Retrieval surfaces (control planes, replay logs) + +The repo's own control-plane contract (API-403/404, `runtime-snapshot-v1`, +result envelopes) is the governing retrieval precedent: durable store, DTO +parity with published contracts, idempotency and audit. External precedent +(OpenC2 query, HLA object reflection, ROS bag replay) adds one lesson ACES +must keep: retrieval is a *projection* of recorded contracts, never a second +source of truth. API-408 therefore defines retrieval shapes over the API-406 +carriers — status, views, histories — and forbids retrieval-only fields that +do not exist in the recorded contracts. + +### 2.6 Outcome reporting (benchmark harnesses, evaluator contracts) + +Cybench/AutoPenBench-style harnesses reduce outcomes to task success plus +milestones; the repo's evaluator-results contract and SEM-215 forbid exactly +that collapse. Lesson: the API-411 carrier reports participant-local outcomes +as interpretation records — outcome source grounding, interpretation rule +refs, and explicit relationship references to scenario/workflow state — not +as scores. Reward/return remain step signals (ADR-054), not outcomes. + +## 3. Formal Analysis: Required Carriers And Totality + +The five requirement statements name concrete object families. Mapping each +to the normative object that defines it and the carrier that must exist: + +| Requirement clause | Defining surface | Carrier obligation | +| --- | --- | --- | +| API-406 "actions" | ADR-054 lifecycle envelope; SEM-208/211 action contracts | participant action/lifecycle event contract | +| API-406 "observations" | ADR-054 observation envelope; SEM-210 boundaries | participant observation contract | +| API-406 "state snapshots" | `runtime-snapshot-v1` participant surfaces | ratified; extended for new envelope refs | +| API-406 "histories" | behavior/episode history event streams | ratified; event payloads bound to new contracts | +| API-406 "state-change reports" | ADR-054 shared-state record + access | shared-state record/access contract | +| API-405 roles/features | backend-manifest v2 `participant_runtime` | ratified as shipped (PR #405) | +| API-407 unsupported/constrained/partial | ADR-054 capability guarantee vectors; SEM-218 realization split | per-feature support declaration on the manifest block | +| API-408 status/views/histories | control-plane contract; SEM-214 (semantics deferred, wave 3) | retrieval projection contracts over API-406 carriers | +| API-411 outcomes + relationships | SEM-215 interpretation records | participant outcome report contract | + +Totality check: every noun phrase in the five statements resolves to a row; +no row's carrier depends on an undefined semantic object. Two deferrals are +deliberate and must be stated in the ADR: (a) the *semantics* of derived +context views belong to SEM-214 (DRAFT, wave 3) — API-408 defines the carrier +and its provenance/marking discipline only; (b) operation records, step +signals, interaction contexts, joint actions, and time-management contexts are +ADR-054 surfaces whose carriers belong to the RUN-30x implementation issues — +API-406 must reference, not absorb, them. + +Soundness obligations inherited from the formal specs (the carriers must make +these expressible and must not make their violation expressible as valid): + +- closed vocabularies: lifecycle phase, phase realization, admission + disposition, operation state, information guarantee, ordering basis, + isolation guarantee, conflict policy, mapping loss, delivery basis, + capability strength (runtime spec I16); +- base-envelope identity, three-timestamp, marking, and evidence-integrity + rules (PRT-01..PRT-04); +- hidden-truth boundary: no carrier may put hidden state, scoring state, or + centralized-training state in a participant-visible field (I2/SEM-210); +- capability honesty: support claims are per-term and per-concern with + explicit downgrade values; missing is failure, not neutral (I14, I20, I21, + and the capability-meet rules); +- outcome separation: local outcome, episode status, objective success, + workflow state, evaluation result, and reward stay distinct (I10/SEM-215). + +## 4. Design Criteria + +The ADR and schema set must satisfy all of the following. Each criterion +cites its basis. + +1. **One contract family, one base discipline.** All new carriers live in one + `participant-runtime` contract family and embed the ADR-054 base-envelope + fields and rules; no carrier invents local identity, versioning, marking, + or extension semantics. (Basis: §2.2; ADR-009; PRT-01.) +2. **Serialize the spec, do not re-specify it.** Field meanings are defined by + reference to `specs/formal/participant-semantics/` and + `specs/formal/participant-runtime/`; the schema set adds requiredness, + shape, and vocabulary bindings only. Divergence between spec vocabulary and + schema enum values is a defect. (Basis: §3 soundness; PRT-19.) +3. **Ratify API-405; extend, never fork, for API-407.** The shipped + `capabilities.participant_runtime` block is the API-405 surface. API-407 + adds per-feature support declarations on that same block using the ADR-054 + guarantee-strength scale (`unsupported`, `disclosed_weak`, `bounded`, + `exact`) plus constraint disclosures; it does not introduce a second + support vocabulary or a parallel manifest section. (Basis: §2.4; ADR-059 + amendment policy if ratification requires ADR-022/054 amendment.) +4. **API-407 is distinct from realization-support.** Feature-support + declarations cover the participant-feature surface; SEM-218 + explicitness/realization declarations cover cross-domain realization + handling. The manifest carries both without merging them, and the ADR + states the boundary. (Basis: API-407 statement; SEM-218.) +5. **Retrieval is projection.** API-408 carriers are read-shapes over recorded + API-406 contracts keyed by participant/episode/order-point, with visibility + projection and marking enforcement applied before publication; they carry + no retrieval-only state. Derived-context-view retrieval declares its view + ref and provenance but defers view semantics to SEM-214. (Basis: §2.5; + I11/I22.) +6. **Outcomes are interpretation records.** The API-411 carrier grounds every + outcome in its sources (action result, episode terminal state, evidence + refs), names its interpretation rule, and links scenario/workflow state by + reference. No score, reward, or objective-success field appears on it. + (Basis: §2.6; SEM-215; I10.) +7. **Evidence criteria per declared term.** Every declarable role/feature/ + support term binds to required evidence contracts, extending the API-405 + evidence-criteria table; conformance can falsify any declaration. (Basis: + §2.4; ADR-021 falsification-first.) +8. **Governed extension only.** New roles, features, vocabularies, and + support terms enter through the controlled-vocabulary authority with + `x-:` syntax for backend-specific terms. (Basis: ADR-012; + API-405 precedent.) +9. **Versioned, generated, drift-gated.** Carriers are generated from contract + models into `contracts/schemas/`, with schema names and versions following + the existing family conventions and drift checked by the contract-schema + parity gates. (Basis: ADR-009; repo plan rules.) +10. **Scope honesty.** The design issue publishes shapes; emission, + validation, conformance checks, and tests belong to #200–#203, and the + ADR says so. A carrier published here makes no claim that the runtime + produces it yet. (Basis: issue #76 scope; PRT-19 traceability.) + +## 5. Decision Sketch Carried Into The ADR + +- New contract family `participant-runtime/` under `contracts/schemas/`: + participant lifecycle/action events, observation envelopes, shared-state + records and accesses, outcome reports, and the retrieval projections for + status/views/histories. +- Backend-manifest v2 `participant_runtime` block ratified (API-405) and + extended with `feature_support` entries (API-407): per governed feature + term, a guarantee-strength support level, optional constraint refs, and + required disclosure refs for anything below `exact`. +- Control-plane retrieval contracts (API-408) defined as projections of the + recorded carriers; endpoint binding stays in the control-plane contract + family. +- Outcome report contract (API-411) as a SEM-215 interpretation record with + scenario/workflow relationship refs. +- All five requirements' carriers inherit the base envelope and closed + vocabularies from ADR-054 as amended by the 2026-06 remediation (mapping + loss, delivery basis, attribution/outcome refs). diff --git a/specs/formal/runtime-contracts/README.md b/specs/formal/runtime-contracts/README.md index f0cde090d..eb21c98a6 100644 --- a/specs/formal/runtime-contracts/README.md +++ b/specs/formal/runtime-contracts/README.md @@ -11,6 +11,9 @@ This directory holds the formal artifacts for portable runtime result contracts. - backend participant capability declarations - manager-side validation of backend workflow results - manager-side validation of backend evaluator results +- participant backend-facing contract surface (API-405/406/407/408/411): see + [participant-backend-contracts.md](participant-backend-contracts.md), + governed by ADR-060 ## API-405 - Backend Participant Capability Declarations diff --git a/specs/formal/runtime-contracts/participant-backend-contracts.md b/specs/formal/runtime-contracts/participant-backend-contracts.md new file mode 100644 index 000000000..7ea28aa7e --- /dev/null +++ b/specs/formal/runtime-contracts/participant-backend-contracts.md @@ -0,0 +1,205 @@ +# Participant Backend-Facing Contracts + +This document is the issue #76 formal design artifact for the joint +backend-facing participant contract surface: + +- `API-405` - Participant Capability Declaration (ACTIVE; ratified here) +- `API-406` - Backend-Facing Participant Plain-Data Contracts +- `API-407` - Participant Feature Support And Constraint Declaration +- `API-408` - Participant Observation And Retrieval Contracts +- `API-411` - Participant Outcome Reporting Contracts + +It is governed by ADR-060. Field *meanings* are defined by +`specs/formal/participant-semantics/` (ADR-022) and +`specs/formal/participant-runtime/` (ADR-054); this document binds shapes, +requiredness, and vocabularies. The research basis is +`docs/research/participant-backend-contracts/`. + +Scope: this design publishes contract shapes. Runtime emission, endpoint +wiring, semantic validation beyond shape and vocabulary, conformance +execution, and tests against live traces belong to the per-UID implementation +issues (#200-#203). A schema published here is a settled shape, not a claim +that any backend produces it. + +## Shared Base Envelope + +Every carrier in the `participant-runtime` family embeds one shared base +envelope, defined once, with the ADR-054 `BaseEnvelope` fields: identity +(`event_id`, `schema_name`, `schema_version`, `event_type`, +`extension_policy`), optional classification and status +(`event_classification`, `source_status` — nullable only under the +`ClassificationClaim` rule in the participant-runtime spec), scoping +(`participant_address`, `episode_id`, `sequence_number` — null only for +run-scoped records), the three distinct timestamps (`occurred_at`, +`recorded_at`, `ingested_at`) with `clock_authority` and `temporal_context`, +ordering (`ordering_basis` from the closed `OrderingBasis` vocabulary, +`logical_order_ref`, `predecessor_event_refs`), actors and sources +(`actor_ref`, `producer_ref`, `source_system_ref`, `source_record_ref`, +`source_raw_ref`, `source_pipeline`, `raw_data_integrity`), confidence, +provenance and evidence references, and the marking surface +(`marking_definition_refs`, `object_marking_refs`, `markings`, +`granular_markings`, `redaction_policy_ref`, `authorization_scope`). + +No carrier defines local identity, versioning, marking, or extension +semantics. All models are closed-world: unknown fields are rejected. + +## API-406 - Plain-Data Carriers + +| Contract id | Carries | Spec basis | +| --- | --- | --- | +| `participant-lifecycle-event-v1` | Action attempts and lifecycle boundary records: phase, phase realization, admission disposition, action/contract/command refs, actor provenance, action-validity basis, shared-state read/write refs, attribution-edge refs, outcome-interpretation refs, mapping loss (+ detail) | ADR-054 `LifecycleEnvelope`; SEM-208/211/212/215 | +| `participant-observation-envelope-v1` | Participant-visible observations: visibility projection, information guarantee, declared delivery point (`delivery_basis`, `delivery_point_ref`, `delivered_at`), evidence-scoped hidden/centralized state refs, loss descriptor, stochastic context, reconstruction refs | ADR-054 `ObservationEnvelope`; SEM-210 | +| `participant-shared-state-record-v1` | State-change reports: state address/scope/kind, revision, digest, predecessor revisions, conflict policy, visibility projection basis, provenance, value ref, embedded access records | ADR-054 `SharedStateRecord` + `SharedStateAccess`; RUN-307 | + +State snapshots and histories are already carried by `runtime-snapshot-v1`, +`participant-episode-state-envelope-v1`, and the participant episode/behavior +history event streams; those carriers are ratified as the `API-406` snapshot +and history surface and are unchanged by this design. Operation records, step +signals, interaction contexts, joint actions, and time-management contexts +remain ADR-054 surfaces owned by the `RUN-30x` implementation issues; the +carriers above reference them and do not absorb them. + +All enumerated fields use the closed vocabularies of the participant-runtime +spec exactly: `LifecyclePhase`, `PhaseRealization`, `AdmissionDisposition`, +`InformationGuarantee`, `OrderingBasis`, `ConflictPolicy`, `MappingLoss`, +`DeliveryBasis`. Divergence between a schema enum and the spec vocabulary is +a defect (PRT-19). + +Invariants the carriers must keep expressible — and whose violation must not +be expressible as valid data: + +- hidden world truth, scoring state, and centralized-training state appear + only as evidence-scoped references, never as participant-visible payload + (I2, SEM-210); +- values travel by reference or digest; credentials, prompts, hidden answer + keys, and raw command output never appear inline (runtime-spec marking + rules); +- a below-`exact` claim path always exists: every vocabulary carries its + `unknown`/`unsupported` downgrades. + +## API-405 - Capability Declaration (Ratified) + +The `backend-manifest/v2` `capabilities.participant_runtime` block shipped by +PR #405 is the participant capability declaration surface: governed +participant roles, behavior features, and interaction features from +`controlled-vocabularies-v1`, the `x-:` governed extension rule, +and the term-level evidence criteria in +`aces_backend_protocols.capabilities.PARTICIPANT_RUNTIME_CAPABILITY_REQUIRED_CONTRACTS`. +This design ratifies that surface without amendment. See the API-405 section +of `specs/formal/runtime-contracts/README.md`. + +## API-407 - Feature Support And Constraint Declaration + +`capabilities.participant_runtime.feature_support` is a list of per-feature +support declarations: + +```text +ParticipantFeatureSupport = + feature + support_level + constraint_refs + disclosure_refs +``` + +Rules: + +- `feature` resolves to a governed behavior or interaction feature term, or a + governed `x-:` extension. +- `support_level` uses the ADR-054 guarantee-strength scale — + `unsupported < disclosed_weak < bounded < exact` — published as the + `participant-runtime-feature-support-levels` controlled vocabulary. No + second support vocabulary exists. +- Absence of an entry makes no strength claim; it neither implies `exact` nor + counts as `not_applicable`. Consumers needing a strength claim require an + entry. +- A feature listed in the API-405 supported lists must not be declared + `unsupported`; the contradiction fails validation. +- Any `support_level` below `exact` requires at least one disclosure + reference; `constraint_refs` carry the constraint statements for + `constrained`/partial realizations. +- Feature-support declarations cover the participant feature surface only. + SEM-218 explicitness/realization declarations cover cross-domain + realization handling; the manifest carries both, and they are never merged + or inferred from one another. + +## API-408 - Retrieval Projections + +Retrieval contracts are read-shapes over recorded carriers — projections, +never a second source of truth. They live in the `control-plane` family: + +| Contract id | Projects | Notes | +| --- | --- | --- | +| `participant-status-view-v1` | Episode state and lifecycle facts for one participant (+ open operation refs) | embeds the existing episode-state shape | +| `participant-history-view-v1` | Episode and behavior history retrieval | carries `completeness` (`complete`/`truncated`/`filtered`) with a required basis when not complete | +| `participant-context-view-v1` | Derived operational context views | reference-and-provenance only; see below | + +Rules: + +- every view names its `source_snapshot_ref`, `visibility_projection_ref`, + and marking surface; visibility projection and marking/redaction + enforcement run before publication (deny-first intersection per the + participant-runtime spec); +- views carry no retrieval-only state: every field is derivable from recorded + contracts; +- `participant-context-view-v1` carries the governed `view_ref`, the + `derived_from_refs` provenance, and an optional `payload_ref`. The + *semantics* of derived context views — meaning and comparability — belong + to `SEM-214` (wave 3) and are deliberately not claimed here; +- endpoint binding, authentication, role checks, request limits, audit + recording, and error envelopes reuse the existing control-plane contract + (API-403/404) and are implementation scope (#202). + +## API-411 - Outcome Reports + +`participant-outcome-report-v1` is a SEM-215 interpretation record: + +```text +ParticipantOutcomeReport = + BaseEnvelope + outcome_id + interpretation_rule_ref + outcome_sources # >= 1 of {source_kind: action_result | + # episode_status | evidence, source_ref} + state_relationships # {relationship_kind: scenario_state | + # workflow_state | objective_window | + # evaluation_input, target_ref, + # relationship_basis: declared | interpretation_rule} +``` + +Rules: + +- every outcome grounds in its sources: an action result, a terminal + participant-episode status, or evidence — matching the SEM-215 grounding + discipline; +- the interpretation rule is named, always; +- relationships to scenario, workflow, objective, and evaluation state are + explicit references with a declared basis; the report asserts the + relationship, not the downstream result; +- the carrier has no score, reward, or objective-success field. Reward and + return remain ADR-054 step signals; objective and evaluation results remain + their own surfaces (I10). + +## Publication, Generation, And Authority Obligations + +- Carriers are generated from contract models (`aces_contracts`) into + `contracts/schemas/participant-runtime/` and + `contracts/schemas/control-plane/`; generated output is never hand-edited. +- `tools/generate_contract_schemas.py` routes the family explicitly; no + contract id reaches a directory by fallthrough. +- Every contract id is registered in `schema_bundle()`, + `contracts/schema-publication-manifest.json`, and — where claimable by a + backend — `BACKEND_SUPPORTED_CONTRACT_IDS`. Re-binding the API-405 + term-level evidence sets to the new carriers is #201 scope. +- Every contract id has at least one valid and one invalid fixture under + `contracts/fixtures/`. +- New declarable terms enter through `controlled-vocabularies-v1` (ADR-012); + this design adds `participant-runtime-feature-support-levels`. + +## Conformance Obligations For #200-#203 + +The per-UID implementation issues must, for their requirement's carriers: +emit them at the ADR-054 boundary points, validate them semantically (not +just shape), bind the API-405/407 evidence criteria to them, add negative +fixtures per the PRT probe rows, and prove the I2/I10/marking invariants with +leakage and separation tests. This document deliberately stops at the shape +boundary. From 20120e414bc8bfe98dc11c3b5dae4a1494c138bd Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Thu, 11 Jun 2026 19:28:42 +0200 Subject: [PATCH 16/70] Publish participant backend-contract schema family and API-407 manifest extension Add the participant-runtime contract family (participant-lifecycle- event-v1, participant-observation-envelope-v1, participant-shared- state-record-v1, participant-outcome-report-v1) on a shared ADR-054 base envelope model, the control-plane retrieval projections (participant-status-view-v1, participant-history-view-v1, participant-context-view-v1), and the API-407 feature_support extension of capabilities.participant_runtime on the guarantee- strength scale with vocabulary, contradiction, duplicate, and disclosure validators. Wire the surface end to end: schema_bundle registrations, explicit generator routing for the new family, publication-manifest entries, participant-runtime-feature-support-levels controlled vocabulary, BACKEND_SUPPORTED_CONTRACT_IDS additions, regenerated schemas, valid and invalid fixtures per contract, and tests for the new models and validators. Full suite green (2267 passed, 1 skipped). --- .../controlled-vocabularies-v1.json | 24 + .../feature-support-duplicate-feature.json | 215 +++ .../feature-support-missing-disclosure.json | 207 +++ ...eature-support-unguarded-feature-term.json | 207 +++ ...-support-unsupported-declared-feature.json | 209 +++ .../valid/feature-support-bounded.json | 225 +++ .../backend-manifest-v2/valid/stub.json | 7 +- .../valid/reference.json | 24 + .../invalid/empty-derived-from-refs.json | 15 + .../valid/network-posture-context.json | 13 + .../invalid/truncated-without-basis.json | 56 + .../valid/truncated-history.json | 54 + .../invalid/missing-source-snapshot-ref.json | 27 + .../valid/running-episode.json | 24 + .../invalid/unknown-phase.json | 106 ++ .../valid/opaque-llm-execution-attempt.json | 83 + .../valid/opaque-llm-selection.json | 81 + .../missing-visibility-projection.json | 121 ++ .../valid/rl-observation-only.json | 99 ++ .../invalid/empty-outcome-sources.json | 89 + .../invalid/inline-score-field.json | 99 ++ .../valid/exfiltration-outcome.json | 86 + .../invalid/unknown-access-kind.json | 102 ++ .../serialized-service-state-commit.json | 86 + contracts/schema-publication-manifest.json | 28 + .../backend-manifest/backend-manifest-v2.json | 88 +- .../participant-context-view-v1.json | 114 ++ .../participant-history-view-v1.json | 1455 +++++++++++++++++ .../participant-status-view-v1.json | 185 +++ .../participant-lifecycle-event-v1.json | 934 +++++++++++ .../participant-observation-envelope-v1.json | 958 +++++++++++ .../participant-outcome-report-v1.json | 787 +++++++++ .../participant-shared-state-record-v1.json | 925 +++++++++++ .../schemas/profiles/backend-profile-v1.json | 6 +- .../packages/aces_backend_stubs/stubs.py | 4 + .../packages/aces_contracts/contracts.py | 460 ++++++ .../aces_contracts/manifest_authority.py | 4 + .../packages/aces_contracts/versions.py | 7 + .../packages/aces_contracts/vocabulary.py | 9 + .../python/tests/test_backend_manifest.py | 163 +- .../tests/test_controlled_vocabularies.py | 4 + .../test_participant_backend_contracts.py | 225 +++ tools/generate_contract_schemas.py | 13 + 43 files changed, 8624 insertions(+), 4 deletions(-) create mode 100644 contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/feature-support-duplicate-feature.json create mode 100644 contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/feature-support-missing-disclosure.json create mode 100644 contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/feature-support-unguarded-feature-term.json create mode 100644 contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/feature-support-unsupported-declared-feature.json create mode 100644 contracts/fixtures/backend-manifest/backend-manifest-v2/valid/feature-support-bounded.json create mode 100644 contracts/fixtures/control-plane/participant-context-view-v1/invalid/empty-derived-from-refs.json create mode 100644 contracts/fixtures/control-plane/participant-context-view-v1/valid/network-posture-context.json create mode 100644 contracts/fixtures/control-plane/participant-history-view-v1/invalid/truncated-without-basis.json create mode 100644 contracts/fixtures/control-plane/participant-history-view-v1/valid/truncated-history.json create mode 100644 contracts/fixtures/control-plane/participant-status-view-v1/invalid/missing-source-snapshot-ref.json create mode 100644 contracts/fixtures/control-plane/participant-status-view-v1/valid/running-episode.json create mode 100644 contracts/fixtures/participant-runtime/participant-lifecycle-event-v1/invalid/unknown-phase.json create mode 100644 contracts/fixtures/participant-runtime/participant-lifecycle-event-v1/valid/opaque-llm-execution-attempt.json create mode 100644 contracts/fixtures/participant-runtime/participant-lifecycle-event-v1/valid/opaque-llm-selection.json create mode 100644 contracts/fixtures/participant-runtime/participant-observation-envelope-v1/invalid/missing-visibility-projection.json create mode 100644 contracts/fixtures/participant-runtime/participant-observation-envelope-v1/valid/rl-observation-only.json create mode 100644 contracts/fixtures/participant-runtime/participant-outcome-report-v1/invalid/empty-outcome-sources.json create mode 100644 contracts/fixtures/participant-runtime/participant-outcome-report-v1/invalid/inline-score-field.json create mode 100644 contracts/fixtures/participant-runtime/participant-outcome-report-v1/valid/exfiltration-outcome.json create mode 100644 contracts/fixtures/participant-runtime/participant-shared-state-record-v1/invalid/unknown-access-kind.json create mode 100644 contracts/fixtures/participant-runtime/participant-shared-state-record-v1/valid/serialized-service-state-commit.json create mode 100644 contracts/schemas/control-plane/participant-context-view-v1.json create mode 100644 contracts/schemas/control-plane/participant-history-view-v1.json create mode 100644 contracts/schemas/control-plane/participant-status-view-v1.json create mode 100644 contracts/schemas/participant-runtime/participant-lifecycle-event-v1.json create mode 100644 contracts/schemas/participant-runtime/participant-observation-envelope-v1.json create mode 100644 contracts/schemas/participant-runtime/participant-outcome-report-v1.json create mode 100644 contracts/schemas/participant-runtime/participant-shared-state-record-v1.json create mode 100644 implementations/python/tests/test_participant_backend_contracts.py diff --git a/contracts/concept-authority/controlled-vocabularies-v1.json b/contracts/concept-authority/controlled-vocabularies-v1.json index 1a2edb331..496670402 100644 --- a/contracts/concept-authority/controlled-vocabularies-v1.json +++ b/contracts/concept-authority/controlled-vocabularies-v1.json @@ -608,6 +608,30 @@ "description": "Backend supports declared shared-state interaction semantics." } } + }, + "participant-runtime-feature-support-levels": { + "title": "Participant Runtime Feature Support Levels", + "description": "Closed ADR-054 guarantee-strength scale for per-feature participant runtime support declarations.", + "kind": "enumeration", + "extension_policy": "closed", + "terms": { + "bounded": { + "title": "Bounded", + "description": "Backend realizes the feature within explicitly disclosed bounds." + }, + "disclosed_weak": { + "title": "Disclosed Weak", + "description": "Backend realizes the feature with disclosed weak guarantees." + }, + "exact": { + "title": "Exact", + "description": "Backend realizes the feature with exact declared semantics." + }, + "unsupported": { + "title": "Unsupported", + "description": "Backend declares no portable guarantee for the feature." + } + } } } } diff --git a/contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/feature-support-duplicate-feature.json b/contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/feature-support-duplicate-feature.json new file mode 100644 index 000000000..c7e6eb19b --- /dev/null +++ b/contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/feature-support-duplicate-feature.json @@ -0,0 +1,215 @@ +{ + "schema_version": "backend-manifest/v2", + "identity": { + "name": "stub", + "version": "0.2.0" + }, + "supported_contract_versions": [ + "backend-manifest-v2", + "provisioning-plan-v1", + "orchestration-plan-v1", + "evaluation-plan-v1", + "operation-receipt-v1", + "operation-status-v1", + "runtime-snapshot-v1", + "workflow-result-envelope-v1", + "workflow-history-event-stream-v1", + "evaluation-result-envelope-v1", + "evaluation-history-event-stream-v1", + "participant-episode-state-envelope-v1", + "participant-episode-history-event-stream-v1", + "participant-behavior-history-event-stream-v1", + "participant-lifecycle-event-v1", + "participant-observation-envelope-v1", + "participant-shared-state-record-v1", + "participant-outcome-report-v1" + ], + "compatibility": { + "processors": [ + "aces-reference-processor" + ] + }, + "realization_support": [ + { + "domain": "runtime-realization", + "support_mode": "constrained", + "supported_constraint_kinds": [ + "account-feature", + "content-type", + "node-type", + "os-family", + "workflow-feature", + "workflow-state-predicate" + ], + "supported_exact_requirement_kinds": [ + "declared-capability-match" + ], + "disclosure_kinds": [ + "backend-manifest-v2", + "operation-status-v1", + "runtime-snapshot-v1" + ], + "constraints": {} + } + ], + "concept_bindings": [ + { + "scope": "capabilities.provisioner.supported_node_types", + "family": "assets" + }, + { + "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.orchestrator.supported_sections", + "family": "actions-and-events" + }, + { + "scope": "capabilities.evaluator.supported_sections", + "family": "observables" + }, + { + "scope": "capabilities.participant_runtime.supported_participant_roles", + "family": "identities" + }, + { + "scope": "capabilities.participant_runtime.supported_behavior_features", + "family": "actions-and-events" + }, + { + "scope": "capabilities.participant_runtime.supported_interaction_features", + "family": "relationships" + } + ], + "constraints": {}, + "capabilities": { + "provisioner": { + "name": "stub-provisioner", + "supported_node_types": [ + "switch", + "vm" + ], + "supported_os_families": [ + "freebsd", + "linux", + "macos", + "other", + "windows" + ], + "supported_content_types": [ + "dataset", + "directory", + "file" + ], + "supported_account_features": [ + "auth_method", + "disabled", + "groups", + "home", + "mail", + "shell", + "spn" + ], + "max_total_nodes": null, + "supports_acls": true, + "supports_accounts": true, + "constraints": {} + }, + "orchestrator": { + "name": "stub-orchestrator", + "supported_sections": [ + "events", + "injects", + "scripts", + "stories", + "workflows" + ], + "supports_workflows": true, + "supports_condition_refs": true, + "supports_inject_bindings": true, + "supported_workflow_features": [ + "call", + "cancellation", + "compensation", + "decision", + "failure-transitions", + "parallel-barrier", + "retry", + "switch", + "timeouts" + ], + "supported_workflow_state_predicates": [ + "attempt-counts", + "outcome-matching" + ], + "constraints": {} + }, + "evaluator": { + "name": "stub-evaluator", + "supported_sections": [ + "conditions", + "evaluations", + "goals", + "metrics", + "objectives", + "tlos" + ], + "supports_scoring": true, + "supports_objectives": true, + "constraints": {} + }, + "participant_runtime": { + "name": "stub-participant-runtime", + "supported_participant_roles": [ + "blue", + "green", + "red", + "white" + ], + "supported_behavior_features": [ + "action_contracts", + "attribution_support", + "behavior_history", + "effects", + "failure_classes", + "observation_boundaries", + "outcome_interpretation", + "preconditions", + "state_transitions", + "temporal_contracts" + ], + "supported_interaction_features": [ + "contention", + "coordination", + "interference", + "shared_state_change" + ], + "feature_support": [ + { + "feature": "coordination", + "support_level": "bounded", + "constraint_refs": [], + "disclosure_refs": [ + "disclosures.coordination.bounded.v1" + ] + }, + { + "feature": "coordination", + "support_level": "exact", + "constraint_refs": [], + "disclosure_refs": [] + } + ], + "constraints": {} + } + } +} diff --git a/contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/feature-support-missing-disclosure.json b/contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/feature-support-missing-disclosure.json new file mode 100644 index 000000000..3686c00cb --- /dev/null +++ b/contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/feature-support-missing-disclosure.json @@ -0,0 +1,207 @@ +{ + "schema_version": "backend-manifest/v2", + "identity": { + "name": "stub", + "version": "0.2.0" + }, + "supported_contract_versions": [ + "backend-manifest-v2", + "provisioning-plan-v1", + "orchestration-plan-v1", + "evaluation-plan-v1", + "operation-receipt-v1", + "operation-status-v1", + "runtime-snapshot-v1", + "workflow-result-envelope-v1", + "workflow-history-event-stream-v1", + "evaluation-result-envelope-v1", + "evaluation-history-event-stream-v1", + "participant-episode-state-envelope-v1", + "participant-episode-history-event-stream-v1", + "participant-behavior-history-event-stream-v1", + "participant-lifecycle-event-v1", + "participant-observation-envelope-v1", + "participant-shared-state-record-v1", + "participant-outcome-report-v1" + ], + "compatibility": { + "processors": [ + "aces-reference-processor" + ] + }, + "realization_support": [ + { + "domain": "runtime-realization", + "support_mode": "constrained", + "supported_constraint_kinds": [ + "account-feature", + "content-type", + "node-type", + "os-family", + "workflow-feature", + "workflow-state-predicate" + ], + "supported_exact_requirement_kinds": [ + "declared-capability-match" + ], + "disclosure_kinds": [ + "backend-manifest-v2", + "operation-status-v1", + "runtime-snapshot-v1" + ], + "constraints": {} + } + ], + "concept_bindings": [ + { + "scope": "capabilities.provisioner.supported_node_types", + "family": "assets" + }, + { + "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.orchestrator.supported_sections", + "family": "actions-and-events" + }, + { + "scope": "capabilities.evaluator.supported_sections", + "family": "observables" + }, + { + "scope": "capabilities.participant_runtime.supported_participant_roles", + "family": "identities" + }, + { + "scope": "capabilities.participant_runtime.supported_behavior_features", + "family": "actions-and-events" + }, + { + "scope": "capabilities.participant_runtime.supported_interaction_features", + "family": "relationships" + } + ], + "constraints": {}, + "capabilities": { + "provisioner": { + "name": "stub-provisioner", + "supported_node_types": [ + "switch", + "vm" + ], + "supported_os_families": [ + "freebsd", + "linux", + "macos", + "other", + "windows" + ], + "supported_content_types": [ + "dataset", + "directory", + "file" + ], + "supported_account_features": [ + "auth_method", + "disabled", + "groups", + "home", + "mail", + "shell", + "spn" + ], + "max_total_nodes": null, + "supports_acls": true, + "supports_accounts": true, + "constraints": {} + }, + "orchestrator": { + "name": "stub-orchestrator", + "supported_sections": [ + "events", + "injects", + "scripts", + "stories", + "workflows" + ], + "supports_workflows": true, + "supports_condition_refs": true, + "supports_inject_bindings": true, + "supported_workflow_features": [ + "call", + "cancellation", + "compensation", + "decision", + "failure-transitions", + "parallel-barrier", + "retry", + "switch", + "timeouts" + ], + "supported_workflow_state_predicates": [ + "attempt-counts", + "outcome-matching" + ], + "constraints": {} + }, + "evaluator": { + "name": "stub-evaluator", + "supported_sections": [ + "conditions", + "evaluations", + "goals", + "metrics", + "objectives", + "tlos" + ], + "supports_scoring": true, + "supports_objectives": true, + "constraints": {} + }, + "participant_runtime": { + "name": "stub-participant-runtime", + "supported_participant_roles": [ + "blue", + "green", + "red", + "white" + ], + "supported_behavior_features": [ + "action_contracts", + "attribution_support", + "behavior_history", + "effects", + "failure_classes", + "observation_boundaries", + "outcome_interpretation", + "preconditions", + "state_transitions", + "temporal_contracts" + ], + "supported_interaction_features": [ + "contention", + "coordination", + "interference", + "shared_state_change" + ], + "feature_support": [ + { + "feature": "coordination", + "support_level": "disclosed_weak", + "constraint_refs": [], + "disclosure_refs": [] + } + ], + "constraints": {} + } + } +} diff --git a/contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/feature-support-unguarded-feature-term.json b/contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/feature-support-unguarded-feature-term.json new file mode 100644 index 000000000..059afe0e5 --- /dev/null +++ b/contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/feature-support-unguarded-feature-term.json @@ -0,0 +1,207 @@ +{ + "schema_version": "backend-manifest/v2", + "identity": { + "name": "stub", + "version": "0.2.0" + }, + "supported_contract_versions": [ + "backend-manifest-v2", + "provisioning-plan-v1", + "orchestration-plan-v1", + "evaluation-plan-v1", + "operation-receipt-v1", + "operation-status-v1", + "runtime-snapshot-v1", + "workflow-result-envelope-v1", + "workflow-history-event-stream-v1", + "evaluation-result-envelope-v1", + "evaluation-history-event-stream-v1", + "participant-episode-state-envelope-v1", + "participant-episode-history-event-stream-v1", + "participant-behavior-history-event-stream-v1", + "participant-lifecycle-event-v1", + "participant-observation-envelope-v1", + "participant-shared-state-record-v1", + "participant-outcome-report-v1" + ], + "compatibility": { + "processors": [ + "aces-reference-processor" + ] + }, + "realization_support": [ + { + "domain": "runtime-realization", + "support_mode": "constrained", + "supported_constraint_kinds": [ + "account-feature", + "content-type", + "node-type", + "os-family", + "workflow-feature", + "workflow-state-predicate" + ], + "supported_exact_requirement_kinds": [ + "declared-capability-match" + ], + "disclosure_kinds": [ + "backend-manifest-v2", + "operation-status-v1", + "runtime-snapshot-v1" + ], + "constraints": {} + } + ], + "concept_bindings": [ + { + "scope": "capabilities.provisioner.supported_node_types", + "family": "assets" + }, + { + "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.orchestrator.supported_sections", + "family": "actions-and-events" + }, + { + "scope": "capabilities.evaluator.supported_sections", + "family": "observables" + }, + { + "scope": "capabilities.participant_runtime.supported_participant_roles", + "family": "identities" + }, + { + "scope": "capabilities.participant_runtime.supported_behavior_features", + "family": "actions-and-events" + }, + { + "scope": "capabilities.participant_runtime.supported_interaction_features", + "family": "relationships" + } + ], + "constraints": {}, + "capabilities": { + "provisioner": { + "name": "stub-provisioner", + "supported_node_types": [ + "switch", + "vm" + ], + "supported_os_families": [ + "freebsd", + "linux", + "macos", + "other", + "windows" + ], + "supported_content_types": [ + "dataset", + "directory", + "file" + ], + "supported_account_features": [ + "auth_method", + "disabled", + "groups", + "home", + "mail", + "shell", + "spn" + ], + "max_total_nodes": null, + "supports_acls": true, + "supports_accounts": true, + "constraints": {} + }, + "orchestrator": { + "name": "stub-orchestrator", + "supported_sections": [ + "events", + "injects", + "scripts", + "stories", + "workflows" + ], + "supports_workflows": true, + "supports_condition_refs": true, + "supports_inject_bindings": true, + "supported_workflow_features": [ + "call", + "cancellation", + "compensation", + "decision", + "failure-transitions", + "parallel-barrier", + "retry", + "switch", + "timeouts" + ], + "supported_workflow_state_predicates": [ + "attempt-counts", + "outcome-matching" + ], + "constraints": {} + }, + "evaluator": { + "name": "stub-evaluator", + "supported_sections": [ + "conditions", + "evaluations", + "goals", + "metrics", + "objectives", + "tlos" + ], + "supports_scoring": true, + "supports_objectives": true, + "constraints": {} + }, + "participant_runtime": { + "name": "stub-participant-runtime", + "supported_participant_roles": [ + "blue", + "green", + "red", + "white" + ], + "supported_behavior_features": [ + "action_contracts", + "attribution_support", + "behavior_history", + "effects", + "failure_classes", + "observation_boundaries", + "outcome_interpretation", + "preconditions", + "state_transitions", + "temporal_contracts" + ], + "supported_interaction_features": [ + "contention", + "coordination", + "interference", + "shared_state_change" + ], + "feature_support": [ + { + "feature": "custom-feature", + "support_level": "exact", + "constraint_refs": [], + "disclosure_refs": [] + } + ], + "constraints": {} + } + } +} diff --git a/contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/feature-support-unsupported-declared-feature.json b/contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/feature-support-unsupported-declared-feature.json new file mode 100644 index 000000000..3ded4bbfc --- /dev/null +++ b/contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/feature-support-unsupported-declared-feature.json @@ -0,0 +1,209 @@ +{ + "schema_version": "backend-manifest/v2", + "identity": { + "name": "stub", + "version": "0.2.0" + }, + "supported_contract_versions": [ + "backend-manifest-v2", + "provisioning-plan-v1", + "orchestration-plan-v1", + "evaluation-plan-v1", + "operation-receipt-v1", + "operation-status-v1", + "runtime-snapshot-v1", + "workflow-result-envelope-v1", + "workflow-history-event-stream-v1", + "evaluation-result-envelope-v1", + "evaluation-history-event-stream-v1", + "participant-episode-state-envelope-v1", + "participant-episode-history-event-stream-v1", + "participant-behavior-history-event-stream-v1", + "participant-lifecycle-event-v1", + "participant-observation-envelope-v1", + "participant-shared-state-record-v1", + "participant-outcome-report-v1" + ], + "compatibility": { + "processors": [ + "aces-reference-processor" + ] + }, + "realization_support": [ + { + "domain": "runtime-realization", + "support_mode": "constrained", + "supported_constraint_kinds": [ + "account-feature", + "content-type", + "node-type", + "os-family", + "workflow-feature", + "workflow-state-predicate" + ], + "supported_exact_requirement_kinds": [ + "declared-capability-match" + ], + "disclosure_kinds": [ + "backend-manifest-v2", + "operation-status-v1", + "runtime-snapshot-v1" + ], + "constraints": {} + } + ], + "concept_bindings": [ + { + "scope": "capabilities.provisioner.supported_node_types", + "family": "assets" + }, + { + "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.orchestrator.supported_sections", + "family": "actions-and-events" + }, + { + "scope": "capabilities.evaluator.supported_sections", + "family": "observables" + }, + { + "scope": "capabilities.participant_runtime.supported_participant_roles", + "family": "identities" + }, + { + "scope": "capabilities.participant_runtime.supported_behavior_features", + "family": "actions-and-events" + }, + { + "scope": "capabilities.participant_runtime.supported_interaction_features", + "family": "relationships" + } + ], + "constraints": {}, + "capabilities": { + "provisioner": { + "name": "stub-provisioner", + "supported_node_types": [ + "switch", + "vm" + ], + "supported_os_families": [ + "freebsd", + "linux", + "macos", + "other", + "windows" + ], + "supported_content_types": [ + "dataset", + "directory", + "file" + ], + "supported_account_features": [ + "auth_method", + "disabled", + "groups", + "home", + "mail", + "shell", + "spn" + ], + "max_total_nodes": null, + "supports_acls": true, + "supports_accounts": true, + "constraints": {} + }, + "orchestrator": { + "name": "stub-orchestrator", + "supported_sections": [ + "events", + "injects", + "scripts", + "stories", + "workflows" + ], + "supports_workflows": true, + "supports_condition_refs": true, + "supports_inject_bindings": true, + "supported_workflow_features": [ + "call", + "cancellation", + "compensation", + "decision", + "failure-transitions", + "parallel-barrier", + "retry", + "switch", + "timeouts" + ], + "supported_workflow_state_predicates": [ + "attempt-counts", + "outcome-matching" + ], + "constraints": {} + }, + "evaluator": { + "name": "stub-evaluator", + "supported_sections": [ + "conditions", + "evaluations", + "goals", + "metrics", + "objectives", + "tlos" + ], + "supports_scoring": true, + "supports_objectives": true, + "constraints": {} + }, + "participant_runtime": { + "name": "stub-participant-runtime", + "supported_participant_roles": [ + "blue", + "green", + "red", + "white" + ], + "supported_behavior_features": [ + "action_contracts", + "attribution_support", + "behavior_history", + "effects", + "failure_classes", + "observation_boundaries", + "outcome_interpretation", + "preconditions", + "state_transitions", + "temporal_contracts" + ], + "supported_interaction_features": [ + "contention", + "coordination", + "interference", + "shared_state_change" + ], + "feature_support": [ + { + "feature": "behavior_history", + "support_level": "unsupported", + "constraint_refs": [], + "disclosure_refs": [ + "disclosures.behavior-history.unsupported.v1" + ] + } + ], + "constraints": {} + } + } +} diff --git a/contracts/fixtures/backend-manifest/backend-manifest-v2/valid/feature-support-bounded.json b/contracts/fixtures/backend-manifest/backend-manifest-v2/valid/feature-support-bounded.json new file mode 100644 index 000000000..3da8eb510 --- /dev/null +++ b/contracts/fixtures/backend-manifest/backend-manifest-v2/valid/feature-support-bounded.json @@ -0,0 +1,225 @@ +{ + "schema_version": "backend-manifest/v2", + "identity": { + "name": "stub", + "version": "0.2.0" + }, + "supported_contract_versions": [ + "backend-manifest-v2", + "provisioning-plan-v1", + "orchestration-plan-v1", + "evaluation-plan-v1", + "operation-receipt-v1", + "operation-status-v1", + "runtime-snapshot-v1", + "workflow-result-envelope-v1", + "workflow-history-event-stream-v1", + "evaluation-result-envelope-v1", + "evaluation-history-event-stream-v1", + "participant-episode-state-envelope-v1", + "participant-episode-history-event-stream-v1", + "participant-behavior-history-event-stream-v1", + "participant-lifecycle-event-v1", + "participant-observation-envelope-v1", + "participant-shared-state-record-v1", + "participant-outcome-report-v1" + ], + "compatibility": { + "processors": [ + "aces-reference-processor" + ] + }, + "realization_support": [ + { + "domain": "runtime-realization", + "support_mode": "constrained", + "supported_constraint_kinds": [ + "account-feature", + "content-type", + "node-type", + "os-family", + "workflow-feature", + "workflow-state-predicate" + ], + "supported_exact_requirement_kinds": [ + "declared-capability-match" + ], + "disclosure_kinds": [ + "backend-manifest-v2", + "operation-status-v1", + "runtime-snapshot-v1" + ], + "constraints": {} + } + ], + "concept_bindings": [ + { + "scope": "capabilities.provisioner.supported_node_types", + "family": "assets" + }, + { + "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.orchestrator.supported_sections", + "family": "actions-and-events" + }, + { + "scope": "capabilities.evaluator.supported_sections", + "family": "observables" + }, + { + "scope": "capabilities.participant_runtime.supported_participant_roles", + "family": "identities" + }, + { + "scope": "capabilities.participant_runtime.supported_behavior_features", + "family": "actions-and-events" + }, + { + "scope": "capabilities.participant_runtime.supported_interaction_features", + "family": "relationships" + } + ], + "constraints": {}, + "capabilities": { + "provisioner": { + "name": "stub-provisioner", + "supported_node_types": [ + "switch", + "vm" + ], + "supported_os_families": [ + "freebsd", + "linux", + "macos", + "other", + "windows" + ], + "supported_content_types": [ + "dataset", + "directory", + "file" + ], + "supported_account_features": [ + "auth_method", + "disabled", + "groups", + "home", + "mail", + "shell", + "spn" + ], + "max_total_nodes": null, + "supports_acls": true, + "supports_accounts": true, + "constraints": {} + }, + "orchestrator": { + "name": "stub-orchestrator", + "supported_sections": [ + "events", + "injects", + "scripts", + "stories", + "workflows" + ], + "supports_workflows": true, + "supports_condition_refs": true, + "supports_inject_bindings": true, + "supported_workflow_features": [ + "call", + "cancellation", + "compensation", + "decision", + "failure-transitions", + "parallel-barrier", + "retry", + "switch", + "timeouts" + ], + "supported_workflow_state_predicates": [ + "attempt-counts", + "outcome-matching" + ], + "constraints": {} + }, + "evaluator": { + "name": "stub-evaluator", + "supported_sections": [ + "conditions", + "evaluations", + "goals", + "metrics", + "objectives", + "tlos" + ], + "supports_scoring": true, + "supports_objectives": true, + "constraints": {} + }, + "participant_runtime": { + "name": "stub-participant-runtime", + "supported_participant_roles": [ + "blue", + "green", + "red", + "white" + ], + "supported_behavior_features": [ + "action_contracts", + "attribution_support", + "behavior_history", + "effects", + "failure_classes", + "observation_boundaries", + "outcome_interpretation", + "preconditions", + "state_transitions", + "temporal_contracts" + ], + "supported_interaction_features": [ + "contention", + "coordination", + "interference", + "shared_state_change" + ], + "feature_support": [ + { + "feature": "behavior_history", + "support_level": "bounded", + "constraint_refs": [ + "constraints.behavior-history.retention-window" + ], + "disclosure_refs": [ + "disclosures.behavior-history.bounded.v1" + ] + }, + { + "feature": "coordination", + "support_level": "exact", + "constraint_refs": [], + "disclosure_refs": [] + }, + { + "feature": "x-acme:custom-feature", + "support_level": "disclosed_weak", + "constraint_refs": [], + "disclosure_refs": [ + "disclosures.acme.custom-feature.weak.v1" + ] + } + ], + "constraints": {} + } + } +} 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 61439f694..9f544ffe6 100644 --- a/contracts/fixtures/backend-manifest/backend-manifest-v2/valid/stub.json +++ b/contracts/fixtures/backend-manifest/backend-manifest-v2/valid/stub.json @@ -18,7 +18,11 @@ "evaluation-history-event-stream-v1", "participant-episode-state-envelope-v1", "participant-episode-history-event-stream-v1", - "participant-behavior-history-event-stream-v1" + "participant-behavior-history-event-stream-v1", + "participant-lifecycle-event-v1", + "participant-observation-envelope-v1", + "participant-shared-state-record-v1", + "participant-outcome-report-v1" ], "compatibility": { "processors": ["aces-reference-processor"] @@ -111,6 +115,7 @@ "temporal_contracts" ], "supported_interaction_features": ["contention", "coordination", "interference", "shared_state_change"], + "feature_support": [], "constraints": {} } } 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 1a2edb331..496670402 100644 --- a/contracts/fixtures/concept-authority/controlled-vocabularies-v1/valid/reference.json +++ b/contracts/fixtures/concept-authority/controlled-vocabularies-v1/valid/reference.json @@ -608,6 +608,30 @@ "description": "Backend supports declared shared-state interaction semantics." } } + }, + "participant-runtime-feature-support-levels": { + "title": "Participant Runtime Feature Support Levels", + "description": "Closed ADR-054 guarantee-strength scale for per-feature participant runtime support declarations.", + "kind": "enumeration", + "extension_policy": "closed", + "terms": { + "bounded": { + "title": "Bounded", + "description": "Backend realizes the feature within explicitly disclosed bounds." + }, + "disclosed_weak": { + "title": "Disclosed Weak", + "description": "Backend realizes the feature with disclosed weak guarantees." + }, + "exact": { + "title": "Exact", + "description": "Backend realizes the feature with exact declared semantics." + }, + "unsupported": { + "title": "Unsupported", + "description": "Backend declares no portable guarantee for the feature." + } + } } } } diff --git a/contracts/fixtures/control-plane/participant-context-view-v1/invalid/empty-derived-from-refs.json b/contracts/fixtures/control-plane/participant-context-view-v1/invalid/empty-derived-from-refs.json new file mode 100644 index 000000000..59c7ed320 --- /dev/null +++ b/contracts/fixtures/control-plane/participant-context-view-v1/invalid/empty-derived-from-refs.json @@ -0,0 +1,15 @@ +{ + "view_id": "views.context.participants.blue.rl.network-posture.0001", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "generated_at": "2026-05-26T10:21:00Z", + "view_ref": "views.context.network-posture.v1", + "derived_from_refs": [], + "derivation_basis_ref": "rules.context.network-posture.v1", + "payload_ref": "evidence.context.blue.network-posture.tick42", + "visibility_projection_ref": "projections.blue.context.v1", + "marking_definition_refs": [ + "markings.participant_visible.v1" + ], + "redaction_policy_ref": "redaction.blue-observation.v1" +} diff --git a/contracts/fixtures/control-plane/participant-context-view-v1/valid/network-posture-context.json b/contracts/fixtures/control-plane/participant-context-view-v1/valid/network-posture-context.json new file mode 100644 index 000000000..d34c93fed --- /dev/null +++ b/contracts/fixtures/control-plane/participant-context-view-v1/valid/network-posture-context.json @@ -0,0 +1,13 @@ +{ + "view_id": "views.context.participants.blue.rl.network-posture.0001", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "generated_at": "2026-05-26T10:21:00Z", + "view_ref": "views.context.network-posture.v1", + "derived_from_refs": ["snapshots.run-778.tick42", "obs-blue-43"], + "derivation_basis_ref": "rules.context.network-posture.v1", + "payload_ref": "evidence.context.blue.network-posture.tick42", + "visibility_projection_ref": "projections.blue.context.v1", + "marking_definition_refs": ["markings.participant_visible.v1"], + "redaction_policy_ref": "redaction.blue-observation.v1" +} diff --git a/contracts/fixtures/control-plane/participant-history-view-v1/invalid/truncated-without-basis.json b/contracts/fixtures/control-plane/participant-history-view-v1/invalid/truncated-without-basis.json new file mode 100644 index 000000000..8c1f67acf --- /dev/null +++ b/contracts/fixtures/control-plane/participant-history-view-v1/invalid/truncated-without-basis.json @@ -0,0 +1,56 @@ +{ + "view_id": "views.history.participants.blue.rl.0001", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "generated_at": "2026-05-26T10:21:00Z", + "source_snapshot_ref": "snapshots.run-778.tick42", + "episode_history": [ + { + "event_type": "episode_initialized", + "timestamp": "2026-05-26T10:00:00Z", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "sequence_number": 1, + "terminal_reason": null, + "control_action": "initialize", + "details": {} + } + ], + "behavior_history": [ + { + "event_type": "action_attempted", + "timestamp": "2026-05-26T10:20:09Z", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "action_instance_id": "act-blue-42", + "action_contract_address": "contracts.defense.isolate-host.v1", + "observation_boundary_address": null, + "observation_status": null, + "actor_provenance": "participant_observed", + "state_transition_kind": null, + "post_state_digest": null, + "details": {} + }, + { + "event_type": "observation_emitted", + "timestamp": "2026-05-26T10:20:10Z", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "action_instance_id": "act-blue-42", + "action_contract_address": "contracts.defense.isolate-host.v1", + "observation_boundary_address": "projections.blue.local.telemetry.v1", + "observation_status": "terminal", + "actor_provenance": null, + "state_transition_kind": null, + "post_state_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "details": {} + } + ], + "visibility_projection_ref": "projections.blue.history.v1", + "redaction_policy_ref": "redaction.blue-observation.v1", + "completeness": "truncated", + "completeness_basis": null, + "marking_definition_refs": [ + "markings.participant_visible.v1" + ] +} diff --git a/contracts/fixtures/control-plane/participant-history-view-v1/valid/truncated-history.json b/contracts/fixtures/control-plane/participant-history-view-v1/valid/truncated-history.json new file mode 100644 index 000000000..452123587 --- /dev/null +++ b/contracts/fixtures/control-plane/participant-history-view-v1/valid/truncated-history.json @@ -0,0 +1,54 @@ +{ + "view_id": "views.history.participants.blue.rl.0001", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "generated_at": "2026-05-26T10:21:00Z", + "source_snapshot_ref": "snapshots.run-778.tick42", + "episode_history": [ + { + "event_type": "episode_initialized", + "timestamp": "2026-05-26T10:00:00Z", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "sequence_number": 1, + "terminal_reason": null, + "control_action": "initialize", + "details": {} + } + ], + "behavior_history": [ + { + "event_type": "action_attempted", + "timestamp": "2026-05-26T10:20:09Z", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "action_instance_id": "act-blue-42", + "action_contract_address": "contracts.defense.isolate-host.v1", + "observation_boundary_address": null, + "observation_status": null, + "actor_provenance": "participant_observed", + "state_transition_kind": null, + "post_state_digest": null, + "details": {} + }, + { + "event_type": "observation_emitted", + "timestamp": "2026-05-26T10:20:10Z", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "action_instance_id": "act-blue-42", + "action_contract_address": "contracts.defense.isolate-host.v1", + "observation_boundary_address": "projections.blue.local.telemetry.v1", + "observation_status": "terminal", + "actor_provenance": null, + "state_transition_kind": null, + "post_state_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "details": {} + } + ], + "visibility_projection_ref": "projections.blue.history.v1", + "redaction_policy_ref": "redaction.blue-observation.v1", + "completeness": "truncated", + "completeness_basis": "retention.window.30d", + "marking_definition_refs": ["markings.participant_visible.v1"] +} diff --git a/contracts/fixtures/control-plane/participant-status-view-v1/invalid/missing-source-snapshot-ref.json b/contracts/fixtures/control-plane/participant-status-view-v1/invalid/missing-source-snapshot-ref.json new file mode 100644 index 000000000..47e096c00 --- /dev/null +++ b/contracts/fixtures/control-plane/participant-status-view-v1/invalid/missing-source-snapshot-ref.json @@ -0,0 +1,27 @@ +{ + "view_id": "views.status.participants.blue.rl.0001", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "generated_at": "2026-05-26T10:21:00Z", + "episode_state": { + "state_schema_version": "participant-episode-state/v1", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "sequence_number": 1, + "status": "running", + "terminal_reason": null, + "initialized_at": "2026-05-26T10:00:00Z", + "updated_at": "2026-05-26T10:20:10Z", + "terminated_at": null, + "last_control_action": "initialize", + "previous_episode_id": "ep-blue-001" + }, + "open_operation_refs": [ + "op-blue-12" + ], + "visibility_projection_ref": "projections.blue.status.v1", + "marking_definition_refs": [ + "markings.participant_visible.v1" + ], + "redaction_policy_ref": "redaction.blue-observation.v1" +} diff --git a/contracts/fixtures/control-plane/participant-status-view-v1/valid/running-episode.json b/contracts/fixtures/control-plane/participant-status-view-v1/valid/running-episode.json new file mode 100644 index 000000000..ebe58a3d4 --- /dev/null +++ b/contracts/fixtures/control-plane/participant-status-view-v1/valid/running-episode.json @@ -0,0 +1,24 @@ +{ + "view_id": "views.status.participants.blue.rl.0001", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "generated_at": "2026-05-26T10:21:00Z", + "source_snapshot_ref": "snapshots.run-778.tick42", + "episode_state": { + "state_schema_version": "participant-episode-state/v1", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "sequence_number": 1, + "status": "running", + "terminal_reason": null, + "initialized_at": "2026-05-26T10:00:00Z", + "updated_at": "2026-05-26T10:20:10Z", + "terminated_at": null, + "last_control_action": "initialize", + "previous_episode_id": "ep-blue-001" + }, + "open_operation_refs": ["op-blue-12"], + "visibility_projection_ref": "projections.blue.status.v1", + "marking_definition_refs": ["markings.participant_visible.v1"], + "redaction_policy_ref": "redaction.blue-observation.v1" +} diff --git a/contracts/fixtures/participant-runtime/participant-lifecycle-event-v1/invalid/unknown-phase.json b/contracts/fixtures/participant-runtime/participant-lifecycle-event-v1/invalid/unknown-phase.json new file mode 100644 index 000000000..b0c730ecf --- /dev/null +++ b/contracts/fixtures/participant-runtime/participant-lifecycle-event-v1/invalid/unknown-phase.json @@ -0,0 +1,106 @@ +{ + "event_id": "evt-llm-17-exec", + "schema_name": "aces.participant_runtime.lifecycle", + "schema_version": "1.0.0", + "event_type": "execution_attempt", + "extension_policy": "reject_unknown_required", + "event_classification": null, + "source_status": { + "status_id": 1, + "status": "success", + "status_code": "tool_call_completed", + "status_detail": "tool gateway accepted and completed the command", + "source_status_label": "tool_call_completed", + "source_status_mapping": "aces.lifecycle.operation_state.completed" + }, + "participant_address": "participants.red.llm", + "episode_id": "ep-red-004", + "sequence_number": 17, + "occurred_at": "2026-05-26T10:15:01Z", + "recorded_at": "2026-05-26T10:15:02Z", + "ingested_at": "2026-05-26T10:15:03Z", + "clock_authority": "backend.logical_clock.red-range", + "temporal_context": "tick-118", + "ordering_basis": "logical_clock", + "logical_order_ref": "order.red.118.17", + "predecessor_event_refs": [ + "evt-llm-16-selection" + ], + "actor_ref": "participants.red.llm", + "producer_ref": "adapters.llm-tool-runtime.v2", + "source_system_ref": "tool-gateway.red", + "source_record_ref": "gateway-call-992", + "source_raw_ref": "evidence.raw.tool-call-992", + "source_pipeline": { + "product_ref": "products.tool-gateway.red", + "product_version": "2.4.1", + "log_provider": "tool-gateway", + "log_source": "tool-gateway.red", + "log_name": "tool-invocation", + "original_event_uid": "gateway-call-992", + "original_time": "2026-05-26T10:15:01Z", + "processed_time": "2026-05-26T10:15:02Z", + "logged_time": "2026-05-26T10:15:02Z", + "transmit_time": "2026-05-26T10:15:03Z", + "correlation_uid": "corr-tool-call-992", + "sequence": 992 + }, + "raw_data_integrity": { + "raw_data_hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "raw_data_hash_algorithm": "sha256", + "raw_data_size": 4096, + "raw_data_is_truncated": false, + "raw_data_untruncated_size": 4096 + }, + "confidence": 0.82, + "provenance_refs": [ + "provenance.participant_observed" + ], + "evidence_refs": [ + "evidence.tool-call-992-redacted" + ], + "marking_definition_refs": [ + "markings.internal.v1", + "markings.restricted_evidence.v1" + ], + "object_marking_refs": [ + "markings.internal.v1" + ], + "markings": [ + "internal" + ], + "granular_markings": { + "/source_raw_ref": [ + "restricted_evidence" + ] + }, + "redaction_policy_ref": "redaction.no-prompts-or-secrets.v1", + "authorization_scope": "runtime_review", + "phase": "proposal", + "phase_realization": "observed", + "admission_disposition": "admitted", + "operation_ref": null, + "action_ref": "actions.exfiltrate_file", + "action_contract_ref": "contracts.file_access.v1", + "command_ref": "commands.tool.invoke-992", + "actor_provenance": "participant_observed", + "action_validity_basis_ref": null, + "observation_refs": [ + "obs-red-17" + ], + "shared_state_read_refs": [ + "hosts.web01.files.secret-plan@rev3" + ], + "shared_state_write_refs": [ + "evidence.collection.red@rev9" + ], + "emitted_state_update_refs": [ + "state-update-red-17" + ], + "attribution_edge_refs": [], + "outcome_interpretation_refs": [], + "joint_action_set_ref": null, + "source_status_label": "tool_call_completed", + "mapping_loss": "private_apparatus_detail", + "mapping_loss_detail": "selection_private_to_model" +} diff --git a/contracts/fixtures/participant-runtime/participant-lifecycle-event-v1/valid/opaque-llm-execution-attempt.json b/contracts/fixtures/participant-runtime/participant-lifecycle-event-v1/valid/opaque-llm-execution-attempt.json new file mode 100644 index 000000000..d65a5205b --- /dev/null +++ b/contracts/fixtures/participant-runtime/participant-lifecycle-event-v1/valid/opaque-llm-execution-attempt.json @@ -0,0 +1,83 @@ +{ + "event_id": "evt-llm-17-exec", + "schema_name": "aces.participant_runtime.lifecycle", + "schema_version": "1.0.0", + "event_type": "execution_attempt", + "extension_policy": "reject_unknown_required", + "event_classification": null, + "source_status": { + "status_id": 1, + "status": "success", + "status_code": "tool_call_completed", + "status_detail": "tool gateway accepted and completed the command", + "source_status_label": "tool_call_completed", + "source_status_mapping": "aces.lifecycle.operation_state.completed" + }, + "participant_address": "participants.red.llm", + "episode_id": "ep-red-004", + "sequence_number": 17, + "occurred_at": "2026-05-26T10:15:01Z", + "recorded_at": "2026-05-26T10:15:02Z", + "ingested_at": "2026-05-26T10:15:03Z", + "clock_authority": "backend.logical_clock.red-range", + "temporal_context": "tick-118", + "ordering_basis": "logical_clock", + "logical_order_ref": "order.red.118.17", + "predecessor_event_refs": ["evt-llm-16-selection"], + "actor_ref": "participants.red.llm", + "producer_ref": "adapters.llm-tool-runtime.v2", + "source_system_ref": "tool-gateway.red", + "source_record_ref": "gateway-call-992", + "source_raw_ref": "evidence.raw.tool-call-992", + "source_pipeline": { + "product_ref": "products.tool-gateway.red", + "product_version": "2.4.1", + "log_provider": "tool-gateway", + "log_source": "tool-gateway.red", + "log_name": "tool-invocation", + "original_event_uid": "gateway-call-992", + "original_time": "2026-05-26T10:15:01Z", + "processed_time": "2026-05-26T10:15:02Z", + "logged_time": "2026-05-26T10:15:02Z", + "transmit_time": "2026-05-26T10:15:03Z", + "correlation_uid": "corr-tool-call-992", + "sequence": 992 + }, + "raw_data_integrity": { + "raw_data_hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "raw_data_hash_algorithm": "sha256", + "raw_data_size": 4096, + "raw_data_is_truncated": false, + "raw_data_untruncated_size": 4096 + }, + "confidence": 0.82, + "provenance_refs": ["provenance.participant_observed"], + "evidence_refs": ["evidence.tool-call-992-redacted"], + "marking_definition_refs": ["markings.internal.v1", "markings.restricted_evidence.v1"], + "object_marking_refs": ["markings.internal.v1"], + "markings": ["internal"], + "granular_markings": { + "/source_raw_ref": ["restricted_evidence"] + }, + "redaction_policy_ref": "redaction.no-prompts-or-secrets.v1", + "authorization_scope": "runtime_review", + "phase": "execution_attempt", + "phase_realization": "observed", + "admission_disposition": "admitted", + "operation_ref": null, + "action_ref": "actions.exfiltrate_file", + "action_contract_ref": "contracts.file_access.v1", + "command_ref": "commands.tool.invoke-992", + "actor_provenance": "participant_observed", + "action_validity_basis_ref": null, + "observation_refs": ["obs-red-17"], + "shared_state_read_refs": ["hosts.web01.files.secret-plan@rev3"], + "shared_state_write_refs": ["evidence.collection.red@rev9"], + "emitted_state_update_refs": ["state-update-red-17"], + "attribution_edge_refs": [], + "outcome_interpretation_refs": [], + "joint_action_set_ref": null, + "source_status_label": "tool_call_completed", + "mapping_loss": "private_apparatus_detail", + "mapping_loss_detail": "selection_private_to_model" +} diff --git a/contracts/fixtures/participant-runtime/participant-lifecycle-event-v1/valid/opaque-llm-selection.json b/contracts/fixtures/participant-runtime/participant-lifecycle-event-v1/valid/opaque-llm-selection.json new file mode 100644 index 000000000..7ae5cf1e8 --- /dev/null +++ b/contracts/fixtures/participant-runtime/participant-lifecycle-event-v1/valid/opaque-llm-selection.json @@ -0,0 +1,81 @@ +{ + "event_id": "evt-llm-16-selection", + "schema_name": "aces.participant_runtime.lifecycle", + "schema_version": "1.0.0", + "event_type": "selection_or_admission", + "extension_policy": "reject_unknown_required", + "event_classification": null, + "source_status": { + "status_id": 0, + "status": "unknown", + "status_code": "model_private_choice", + "status_detail": "selection existed inside opaque model apparatus", + "source_status_label": "model_private_choice", + "source_status_mapping": "aces.lifecycle.phase_realization.opaque" + }, + "participant_address": "participants.red.llm", + "episode_id": "ep-red-004", + "sequence_number": 16, + "occurred_at": "2026-05-26T10:15:00Z", + "recorded_at": "2026-05-26T10:15:02Z", + "ingested_at": "2026-05-26T10:15:03Z", + "clock_authority": "backend.logical_clock.red-range", + "temporal_context": "tick-118", + "ordering_basis": "logical_clock", + "logical_order_ref": "order.red.118.16", + "predecessor_event_refs": [], + "actor_ref": "participants.red.llm", + "producer_ref": "adapters.llm-tool-runtime.v2", + "source_system_ref": "llm-agent.red", + "source_record_ref": null, + "source_raw_ref": null, + "source_pipeline": { + "product_ref": "products.llm-agent.red", + "product_version": "2.4.1", + "log_provider": null, + "log_source": null, + "log_name": null, + "original_event_uid": null, + "original_time": null, + "processed_time": null, + "logged_time": null, + "transmit_time": "2026-05-26T10:15:03Z", + "correlation_uid": "corr-tool-call-992", + "sequence": 16 + }, + "raw_data_integrity": { + "raw_data_hash": null, + "raw_data_hash_algorithm": null, + "raw_data_size": null, + "raw_data_is_truncated": null, + "raw_data_untruncated_size": null + }, + "confidence": null, + "provenance_refs": ["provenance.apparatus_boundary"], + "evidence_refs": [], + "marking_definition_refs": ["markings.internal.v1"], + "object_marking_refs": ["markings.internal.v1"], + "markings": ["internal"], + "granular_markings": {}, + "redaction_policy_ref": "redaction.no-prompts-or-secrets.v1", + "authorization_scope": "runtime_review", + "phase": "selection_or_admission", + "phase_realization": "opaque", + "admission_disposition": "unknown", + "operation_ref": null, + "action_ref": "actions.exfiltrate_file", + "action_contract_ref": "contracts.file_access.v1", + "command_ref": null, + "actor_provenance": "apparatus_opaque", + "action_validity_basis_ref": null, + "observation_refs": [], + "shared_state_read_refs": [], + "shared_state_write_refs": [], + "emitted_state_update_refs": [], + "attribution_edge_refs": [], + "outcome_interpretation_refs": [], + "joint_action_set_ref": null, + "source_status_label": "model_private_choice", + "mapping_loss": "private_apparatus_detail", + "mapping_loss_detail": "private_policy_trace_not_exposed" +} diff --git a/contracts/fixtures/participant-runtime/participant-observation-envelope-v1/invalid/missing-visibility-projection.json b/contracts/fixtures/participant-runtime/participant-observation-envelope-v1/invalid/missing-visibility-projection.json new file mode 100644 index 000000000..c06be7e7b --- /dev/null +++ b/contracts/fixtures/participant-runtime/participant-observation-envelope-v1/invalid/missing-visibility-projection.json @@ -0,0 +1,121 @@ +{ + "event_id": "obs-blue-43", + "schema_name": "aces.participant_runtime.observation", + "schema_version": "1.0.0", + "event_type": "observation_emission", + "extension_policy": "reject_unknown_required", + "event_classification": { + "category_uid": 4, + "category_name": "findings", + "class_uid": 4001, + "class_name": "detection_finding", + "activity_id": 1, + "activity_name": "telemetry_observed", + "type_uid": 800101, + "type_name": "blue telemetry observation emitted", + "severity_id": 1, + "severity": "informational" + }, + "source_status": { + "status_id": 1, + "status": "success", + "status_code": "observation_emitted", + "status_detail": "simulator emitted the blue local telemetry observation", + "source_status_label": "cyborg_observation", + "source_status_mapping": "aces.observation.emitted" + }, + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "sequence_number": 43, + "occurred_at": "2026-05-26T10:20:10Z", + "recorded_at": "2026-05-26T10:20:10Z", + "ingested_at": "2026-05-26T10:20:11Z", + "clock_authority": "sim.tick", + "temporal_context": "tick-42", + "ordering_basis": "simulation_tick", + "logical_order_ref": "order.sim.42.blue.obs43", + "predecessor_event_refs": [ + "evt-rl-42-exec" + ], + "actor_ref": "participants.blue.rl", + "producer_ref": "adapters.cyborg-blue.v1", + "source_system_ref": "cyborg.sim", + "source_record_ref": "cyborg.obs.42.blue", + "source_raw_ref": "evidence.raw.cyborg.obs.42.blue", + "source_pipeline": { + "product_ref": "products.cyborg.sim", + "product_version": "1.0.0", + "log_provider": "cyborg", + "log_source": "cyborg.sim", + "log_name": "observation-step", + "original_event_uid": "cyborg.obs.42.blue", + "original_time": "2026-05-26T10:20:10Z", + "processed_time": "2026-05-26T10:20:10Z", + "logged_time": "2026-05-26T10:20:10Z", + "transmit_time": "2026-05-26T10:20:11Z", + "correlation_uid": "cyborg.tick42.blue", + "sequence": 42 + }, + "raw_data_integrity": { + "raw_data_hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "raw_data_hash_algorithm": "sha256", + "raw_data_size": 2048, + "raw_data_is_truncated": false, + "raw_data_untruncated_size": 2048 + }, + "confidence": 1.0, + "provenance_refs": [ + "provenance.backend_realized" + ], + "evidence_refs": [ + "evidence.obs-blue-43-redacted" + ], + "marking_definition_refs": [ + "markings.participant_visible.v1", + "markings.restricted_evidence.v1" + ], + "object_marking_refs": [ + "markings.participant_visible.v1" + ], + "markings": [ + "participant_visible" + ], + "granular_markings": { + "/hidden_state_refs": [ + "restricted_evidence" + ] + }, + "redaction_policy_ref": "redaction.blue-observation.v1", + "authorization_scope": "participant:participants.blue.rl", + "observation_ref": "observations.blue.local.telemetry.43", + "phase_ref": "evt-rl-42-exec", + "information_guarantee": "observation_only", + "delivery_basis": "emission_is_delivery", + "delivery_point_ref": "order.sim.42.blue.obs43", + "delivered_at": "2026-05-26T10:20:10Z", + "action_observation_history_ref": "history.blue.ep002.prefix43", + "information_state_ref": null, + "hidden_state_refs": [ + "evidence.hidden-world-state.tick42" + ], + "centralized_state_refs": [ + "evidence.global-training-state.tick42" + ], + "loss_descriptor": { + "kind": "partial_projection", + "fields_redacted": [ + "attacker.intent" + ] + }, + "stochastic_context": { + "seed_ref": "seeds.run-778-blue", + "randomization_policy_ref": "randomization.cyborg.default.v1" + }, + "noise_model_ref": null, + "reconstruction_algorithm_ref": null, + "reconstruction_proof_ref": null, + "belief_support_ref": null, + "redacted_field_refs": [ + "/hidden_state_refs" + ] +} diff --git a/contracts/fixtures/participant-runtime/participant-observation-envelope-v1/valid/rl-observation-only.json b/contracts/fixtures/participant-runtime/participant-observation-envelope-v1/valid/rl-observation-only.json new file mode 100644 index 000000000..2264656fc --- /dev/null +++ b/contracts/fixtures/participant-runtime/participant-observation-envelope-v1/valid/rl-observation-only.json @@ -0,0 +1,99 @@ +{ + "event_id": "obs-blue-43", + "schema_name": "aces.participant_runtime.observation", + "schema_version": "1.0.0", + "event_type": "observation_emission", + "extension_policy": "reject_unknown_required", + "event_classification": { + "category_uid": 4, + "category_name": "findings", + "class_uid": 4001, + "class_name": "detection_finding", + "activity_id": 1, + "activity_name": "telemetry_observed", + "type_uid": 800101, + "type_name": "blue telemetry observation emitted", + "severity_id": 1, + "severity": "informational" + }, + "source_status": { + "status_id": 1, + "status": "success", + "status_code": "observation_emitted", + "status_detail": "simulator emitted the blue local telemetry observation", + "source_status_label": "cyborg_observation", + "source_status_mapping": "aces.observation.emitted" + }, + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "sequence_number": 43, + "occurred_at": "2026-05-26T10:20:10Z", + "recorded_at": "2026-05-26T10:20:10Z", + "ingested_at": "2026-05-26T10:20:11Z", + "clock_authority": "sim.tick", + "temporal_context": "tick-42", + "ordering_basis": "simulation_tick", + "logical_order_ref": "order.sim.42.blue.obs43", + "predecessor_event_refs": ["evt-rl-42-exec"], + "actor_ref": "participants.blue.rl", + "producer_ref": "adapters.cyborg-blue.v1", + "source_system_ref": "cyborg.sim", + "source_record_ref": "cyborg.obs.42.blue", + "source_raw_ref": "evidence.raw.cyborg.obs.42.blue", + "source_pipeline": { + "product_ref": "products.cyborg.sim", + "product_version": "1.0.0", + "log_provider": "cyborg", + "log_source": "cyborg.sim", + "log_name": "observation-step", + "original_event_uid": "cyborg.obs.42.blue", + "original_time": "2026-05-26T10:20:10Z", + "processed_time": "2026-05-26T10:20:10Z", + "logged_time": "2026-05-26T10:20:10Z", + "transmit_time": "2026-05-26T10:20:11Z", + "correlation_uid": "cyborg.tick42.blue", + "sequence": 42 + }, + "raw_data_integrity": { + "raw_data_hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "raw_data_hash_algorithm": "sha256", + "raw_data_size": 2048, + "raw_data_is_truncated": false, + "raw_data_untruncated_size": 2048 + }, + "confidence": 1.0, + "provenance_refs": ["provenance.backend_realized"], + "evidence_refs": ["evidence.obs-blue-43-redacted"], + "marking_definition_refs": ["markings.participant_visible.v1", "markings.restricted_evidence.v1"], + "object_marking_refs": ["markings.participant_visible.v1"], + "markings": ["participant_visible"], + "granular_markings": { + "/hidden_state_refs": ["restricted_evidence"] + }, + "redaction_policy_ref": "redaction.blue-observation.v1", + "authorization_scope": "participant:participants.blue.rl", + "observation_ref": "observations.blue.local.telemetry.43", + "phase_ref": "evt-rl-42-exec", + "visibility_projection_ref": "projections.blue.local.telemetry.v1", + "information_guarantee": "observation_only", + "delivery_basis": "emission_is_delivery", + "delivery_point_ref": "order.sim.42.blue.obs43", + "delivered_at": "2026-05-26T10:20:10Z", + "action_observation_history_ref": "history.blue.ep002.prefix43", + "information_state_ref": null, + "hidden_state_refs": ["evidence.hidden-world-state.tick42"], + "centralized_state_refs": ["evidence.global-training-state.tick42"], + "loss_descriptor": { + "kind": "partial_projection", + "fields_redacted": ["attacker.intent"] + }, + "stochastic_context": { + "seed_ref": "seeds.run-778-blue", + "randomization_policy_ref": "randomization.cyborg.default.v1" + }, + "noise_model_ref": null, + "reconstruction_algorithm_ref": null, + "reconstruction_proof_ref": null, + "belief_support_ref": null, + "redacted_field_refs": ["/hidden_state_refs"] +} diff --git a/contracts/fixtures/participant-runtime/participant-outcome-report-v1/invalid/empty-outcome-sources.json b/contracts/fixtures/participant-runtime/participant-outcome-report-v1/invalid/empty-outcome-sources.json new file mode 100644 index 000000000..cd4021067 --- /dev/null +++ b/contracts/fixtures/participant-runtime/participant-outcome-report-v1/invalid/empty-outcome-sources.json @@ -0,0 +1,89 @@ +{ + "event_id": "outcome-red-17", + "schema_name": "aces.participant_runtime.outcome_report", + "schema_version": "1.0.0", + "event_type": "outcome_report", + "extension_policy": "reject_unknown_required", + "event_classification": null, + "source_status": { + "status_id": 1, + "status": "success", + "status_code": "outcome_interpreted", + "status_detail": "interpretation rule grounded the outcome in recorded sources", + "source_status_label": "outcome_interpreted", + "source_status_mapping": "aces.outcome.interpreted" + }, + "participant_address": "participants.red.llm", + "episode_id": "ep-red-004", + "sequence_number": 19, + "occurred_at": "2026-05-26T10:15:05Z", + "recorded_at": "2026-05-26T10:15:06Z", + "ingested_at": "2026-05-26T10:15:06Z", + "clock_authority": "backend.logical_clock.red-range", + "temporal_context": "tick-118", + "ordering_basis": "logical_clock", + "logical_order_ref": "order.red.118.19", + "predecessor_event_refs": [ + "evt-llm-17-exec" + ], + "actor_ref": "runtime.outcome-interpreter", + "producer_ref": "adapters.llm-tool-runtime.v2", + "source_system_ref": "tool-gateway.red", + "source_record_ref": "gateway-call-992", + "source_raw_ref": null, + "source_pipeline": { + "product_ref": "products.tool-gateway.red", + "product_version": "2.4.1", + "log_provider": "tool-gateway", + "log_source": "tool-gateway.red", + "log_name": "outcome-report", + "original_event_uid": "gateway-call-992", + "original_time": "2026-05-26T10:15:01Z", + "processed_time": "2026-05-26T10:15:06Z", + "logged_time": "2026-05-26T10:15:06Z", + "transmit_time": "2026-05-26T10:15:06Z", + "correlation_uid": "corr-tool-call-992", + "sequence": 993 + }, + "raw_data_integrity": { + "raw_data_hash": null, + "raw_data_hash_algorithm": null, + "raw_data_size": null, + "raw_data_is_truncated": null, + "raw_data_untruncated_size": null + }, + "confidence": 0.9, + "provenance_refs": [ + "provenance.backend_realized" + ], + "evidence_refs": [ + "evidence.tool-call-992-redacted" + ], + "marking_definition_refs": [ + "markings.internal.v1" + ], + "object_marking_refs": [ + "markings.internal.v1" + ], + "markings": [ + "internal" + ], + "granular_markings": {}, + "redaction_policy_ref": "redaction.no-prompts-or-secrets.v1", + "authorization_scope": "runtime_review", + "outcome_id": "outcomes.red.exfiltration.17", + "interpretation_rule_ref": "rules.outcome.exfiltration.v1", + "outcome_sources": [], + "state_relationships": [ + { + "relationship_kind": "objective_window", + "target_ref": "objectives.red.exfiltrate-plan", + "relationship_basis": "interpretation_rule" + }, + { + "relationship_kind": "evaluation_input", + "target_ref": "evaluations.red.exfiltration-check", + "relationship_basis": "declared" + } + ] +} diff --git a/contracts/fixtures/participant-runtime/participant-outcome-report-v1/invalid/inline-score-field.json b/contracts/fixtures/participant-runtime/participant-outcome-report-v1/invalid/inline-score-field.json new file mode 100644 index 000000000..aeec88472 --- /dev/null +++ b/contracts/fixtures/participant-runtime/participant-outcome-report-v1/invalid/inline-score-field.json @@ -0,0 +1,99 @@ +{ + "event_id": "outcome-red-17", + "schema_name": "aces.participant_runtime.outcome_report", + "schema_version": "1.0.0", + "event_type": "outcome_report", + "extension_policy": "reject_unknown_required", + "event_classification": null, + "source_status": { + "status_id": 1, + "status": "success", + "status_code": "outcome_interpreted", + "status_detail": "interpretation rule grounded the outcome in recorded sources", + "source_status_label": "outcome_interpreted", + "source_status_mapping": "aces.outcome.interpreted" + }, + "participant_address": "participants.red.llm", + "episode_id": "ep-red-004", + "sequence_number": 19, + "occurred_at": "2026-05-26T10:15:05Z", + "recorded_at": "2026-05-26T10:15:06Z", + "ingested_at": "2026-05-26T10:15:06Z", + "clock_authority": "backend.logical_clock.red-range", + "temporal_context": "tick-118", + "ordering_basis": "logical_clock", + "logical_order_ref": "order.red.118.19", + "predecessor_event_refs": [ + "evt-llm-17-exec" + ], + "actor_ref": "runtime.outcome-interpreter", + "producer_ref": "adapters.llm-tool-runtime.v2", + "source_system_ref": "tool-gateway.red", + "source_record_ref": "gateway-call-992", + "source_raw_ref": null, + "source_pipeline": { + "product_ref": "products.tool-gateway.red", + "product_version": "2.4.1", + "log_provider": "tool-gateway", + "log_source": "tool-gateway.red", + "log_name": "outcome-report", + "original_event_uid": "gateway-call-992", + "original_time": "2026-05-26T10:15:01Z", + "processed_time": "2026-05-26T10:15:06Z", + "logged_time": "2026-05-26T10:15:06Z", + "transmit_time": "2026-05-26T10:15:06Z", + "correlation_uid": "corr-tool-call-992", + "sequence": 993 + }, + "raw_data_integrity": { + "raw_data_hash": null, + "raw_data_hash_algorithm": null, + "raw_data_size": null, + "raw_data_is_truncated": null, + "raw_data_untruncated_size": null + }, + "confidence": 0.9, + "provenance_refs": [ + "provenance.backend_realized" + ], + "evidence_refs": [ + "evidence.tool-call-992-redacted" + ], + "marking_definition_refs": [ + "markings.internal.v1" + ], + "object_marking_refs": [ + "markings.internal.v1" + ], + "markings": [ + "internal" + ], + "granular_markings": {}, + "redaction_policy_ref": "redaction.no-prompts-or-secrets.v1", + "authorization_scope": "runtime_review", + "outcome_id": "outcomes.red.exfiltration.17", + "interpretation_rule_ref": "rules.outcome.exfiltration.v1", + "outcome_sources": [ + { + "source_kind": "action_result", + "source_ref": "results.red.act-17" + }, + { + "source_kind": "evidence", + "source_ref": "evidence.tool-call-992-redacted" + } + ], + "state_relationships": [ + { + "relationship_kind": "objective_window", + "target_ref": "objectives.red.exfiltrate-plan", + "relationship_basis": "interpretation_rule" + }, + { + "relationship_kind": "evaluation_input", + "target_ref": "evaluations.red.exfiltration-check", + "relationship_basis": "declared" + } + ], + "score": 0.95 +} diff --git a/contracts/fixtures/participant-runtime/participant-outcome-report-v1/valid/exfiltration-outcome.json b/contracts/fixtures/participant-runtime/participant-outcome-report-v1/valid/exfiltration-outcome.json new file mode 100644 index 000000000..aaee4a487 --- /dev/null +++ b/contracts/fixtures/participant-runtime/participant-outcome-report-v1/valid/exfiltration-outcome.json @@ -0,0 +1,86 @@ +{ + "event_id": "outcome-red-17", + "schema_name": "aces.participant_runtime.outcome_report", + "schema_version": "1.0.0", + "event_type": "outcome_report", + "extension_policy": "reject_unknown_required", + "event_classification": null, + "source_status": { + "status_id": 1, + "status": "success", + "status_code": "outcome_interpreted", + "status_detail": "interpretation rule grounded the outcome in recorded sources", + "source_status_label": "outcome_interpreted", + "source_status_mapping": "aces.outcome.interpreted" + }, + "participant_address": "participants.red.llm", + "episode_id": "ep-red-004", + "sequence_number": 19, + "occurred_at": "2026-05-26T10:15:05Z", + "recorded_at": "2026-05-26T10:15:06Z", + "ingested_at": "2026-05-26T10:15:06Z", + "clock_authority": "backend.logical_clock.red-range", + "temporal_context": "tick-118", + "ordering_basis": "logical_clock", + "logical_order_ref": "order.red.118.19", + "predecessor_event_refs": ["evt-llm-17-exec"], + "actor_ref": "runtime.outcome-interpreter", + "producer_ref": "adapters.llm-tool-runtime.v2", + "source_system_ref": "tool-gateway.red", + "source_record_ref": "gateway-call-992", + "source_raw_ref": null, + "source_pipeline": { + "product_ref": "products.tool-gateway.red", + "product_version": "2.4.1", + "log_provider": "tool-gateway", + "log_source": "tool-gateway.red", + "log_name": "outcome-report", + "original_event_uid": "gateway-call-992", + "original_time": "2026-05-26T10:15:01Z", + "processed_time": "2026-05-26T10:15:06Z", + "logged_time": "2026-05-26T10:15:06Z", + "transmit_time": "2026-05-26T10:15:06Z", + "correlation_uid": "corr-tool-call-992", + "sequence": 993 + }, + "raw_data_integrity": { + "raw_data_hash": null, + "raw_data_hash_algorithm": null, + "raw_data_size": null, + "raw_data_is_truncated": null, + "raw_data_untruncated_size": null + }, + "confidence": 0.9, + "provenance_refs": ["provenance.backend_realized"], + "evidence_refs": ["evidence.tool-call-992-redacted"], + "marking_definition_refs": ["markings.internal.v1"], + "object_marking_refs": ["markings.internal.v1"], + "markings": ["internal"], + "granular_markings": {}, + "redaction_policy_ref": "redaction.no-prompts-or-secrets.v1", + "authorization_scope": "runtime_review", + "outcome_id": "outcomes.red.exfiltration.17", + "interpretation_rule_ref": "rules.outcome.exfiltration.v1", + "outcome_sources": [ + { + "source_kind": "action_result", + "source_ref": "results.red.act-17" + }, + { + "source_kind": "evidence", + "source_ref": "evidence.tool-call-992-redacted" + } + ], + "state_relationships": [ + { + "relationship_kind": "objective_window", + "target_ref": "objectives.red.exfiltrate-plan", + "relationship_basis": "interpretation_rule" + }, + { + "relationship_kind": "evaluation_input", + "target_ref": "evaluations.red.exfiltration-check", + "relationship_basis": "declared" + } + ] +} diff --git a/contracts/fixtures/participant-runtime/participant-shared-state-record-v1/invalid/unknown-access-kind.json b/contracts/fixtures/participant-runtime/participant-shared-state-record-v1/invalid/unknown-access-kind.json new file mode 100644 index 000000000..459fd2679 --- /dev/null +++ b/contracts/fixtures/participant-runtime/participant-shared-state-record-v1/invalid/unknown-access-kind.json @@ -0,0 +1,102 @@ +{ + "event_id": "state-web01-http-rev8", + "schema_name": "aces.participant_runtime.shared_state", + "schema_version": "1.0.0", + "event_type": "state_update_commit", + "extension_policy": "reject_unknown_required", + "event_classification": null, + "source_status": { + "status_id": 1, + "status": "success", + "status_code": "state_committed", + "status_detail": "runtime committed the serialized shared-state write", + "source_status_label": "state_committed", + "source_status_mapping": "aces.shared_state.commit" + }, + "participant_address": "participants.red.llm", + "episode_id": "ep-red-004", + "sequence_number": 18, + "occurred_at": "2026-05-26T10:50:00Z", + "recorded_at": "2026-05-26T10:50:01Z", + "ingested_at": "2026-05-26T10:50:01Z", + "clock_authority": "sim.tick", + "temporal_context": "tick-88", + "ordering_basis": "simulation_tick", + "logical_order_ref": "sim.tick.88", + "predecessor_event_refs": [ + "joint-13" + ], + "actor_ref": "participants.red.llm", + "producer_ref": "backend.cyborg-adapter.v1", + "source_system_ref": "cyborg.sim", + "source_record_ref": "sim.tick.88.state", + "source_raw_ref": "evidence.raw.sim.tick88", + "source_pipeline": { + "product_ref": "products.cyborg.sim", + "product_version": "1.0.0", + "log_provider": "cyborg", + "log_source": "cyborg.sim", + "log_name": "shared-state", + "original_event_uid": "sim.tick.88.state", + "original_time": "2026-05-26T10:50:00Z", + "processed_time": "2026-05-26T10:50:01Z", + "logged_time": "2026-05-26T10:50:01Z", + "transmit_time": "2026-05-26T10:50:01Z", + "correlation_uid": "sim.tick.88", + "sequence": 88 + }, + "raw_data_integrity": { + "raw_data_hash": "sha256:7777777777777777777777777777777777777777777777777777777777777777", + "raw_data_hash_algorithm": "sha256", + "raw_data_size": 3072, + "raw_data_is_truncated": false, + "raw_data_untruncated_size": 3072 + }, + "confidence": 1.0, + "provenance_refs": [ + "provenance.backend_realized" + ], + "evidence_refs": [ + "evidence.sim.tick88.state" + ], + "marking_definition_refs": [ + "markings.internal.v1" + ], + "object_marking_refs": [ + "markings.internal.v1" + ], + "markings": [ + "internal" + ], + "granular_markings": {}, + "redaction_policy_ref": "redaction.shared-state.v1", + "authorization_scope": "runtime_review", + "state_address": "hosts.web01.service.http", + "state_scope": "scenario_shared", + "state_kind": "service_configuration", + "revision": "rev8", + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "predecessor_revision_refs": [ + "hosts.web01.service.http@rev7" + ], + "conflict_policy": "serialize", + "visibility_projection_basis": "projections.shared-state.default.v1", + "provenance": "provenance.backend_realized", + "value_ref": "evidence.state.web01-http-rev8", + "accesses": [ + { + "state_address": "hosts.web01.service.http", + "access_kind": "execute", + "read_revision": "rev7", + "write_revision": "rev8", + "read_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "write_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "snapshot_ref": "snapshots.sim.tick88", + "access_purpose": "action_effect_commit", + "atomic_group_ref": "joint-13", + "evidence_refs": [ + "evidence.sim.tick88.joint" + ] + } + ] +} diff --git a/contracts/fixtures/participant-runtime/participant-shared-state-record-v1/valid/serialized-service-state-commit.json b/contracts/fixtures/participant-runtime/participant-shared-state-record-v1/valid/serialized-service-state-commit.json new file mode 100644 index 000000000..826e6252e --- /dev/null +++ b/contracts/fixtures/participant-runtime/participant-shared-state-record-v1/valid/serialized-service-state-commit.json @@ -0,0 +1,86 @@ +{ + "event_id": "state-web01-http-rev8", + "schema_name": "aces.participant_runtime.shared_state", + "schema_version": "1.0.0", + "event_type": "state_update_commit", + "extension_policy": "reject_unknown_required", + "event_classification": null, + "source_status": { + "status_id": 1, + "status": "success", + "status_code": "state_committed", + "status_detail": "runtime committed the serialized shared-state write", + "source_status_label": "state_committed", + "source_status_mapping": "aces.shared_state.commit" + }, + "participant_address": "participants.red.llm", + "episode_id": "ep-red-004", + "sequence_number": 18, + "occurred_at": "2026-05-26T10:50:00Z", + "recorded_at": "2026-05-26T10:50:01Z", + "ingested_at": "2026-05-26T10:50:01Z", + "clock_authority": "sim.tick", + "temporal_context": "tick-88", + "ordering_basis": "simulation_tick", + "logical_order_ref": "sim.tick.88", + "predecessor_event_refs": ["joint-13"], + "actor_ref": "participants.red.llm", + "producer_ref": "backend.cyborg-adapter.v1", + "source_system_ref": "cyborg.sim", + "source_record_ref": "sim.tick.88.state", + "source_raw_ref": "evidence.raw.sim.tick88", + "source_pipeline": { + "product_ref": "products.cyborg.sim", + "product_version": "1.0.0", + "log_provider": "cyborg", + "log_source": "cyborg.sim", + "log_name": "shared-state", + "original_event_uid": "sim.tick.88.state", + "original_time": "2026-05-26T10:50:00Z", + "processed_time": "2026-05-26T10:50:01Z", + "logged_time": "2026-05-26T10:50:01Z", + "transmit_time": "2026-05-26T10:50:01Z", + "correlation_uid": "sim.tick.88", + "sequence": 88 + }, + "raw_data_integrity": { + "raw_data_hash": "sha256:7777777777777777777777777777777777777777777777777777777777777777", + "raw_data_hash_algorithm": "sha256", + "raw_data_size": 3072, + "raw_data_is_truncated": false, + "raw_data_untruncated_size": 3072 + }, + "confidence": 1.0, + "provenance_refs": ["provenance.backend_realized"], + "evidence_refs": ["evidence.sim.tick88.state"], + "marking_definition_refs": ["markings.internal.v1"], + "object_marking_refs": ["markings.internal.v1"], + "markings": ["internal"], + "granular_markings": {}, + "redaction_policy_ref": "redaction.shared-state.v1", + "authorization_scope": "runtime_review", + "state_address": "hosts.web01.service.http", + "state_scope": "scenario_shared", + "state_kind": "service_configuration", + "revision": "rev8", + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "predecessor_revision_refs": ["hosts.web01.service.http@rev7"], + "conflict_policy": "serialize", + "visibility_projection_basis": "projections.shared-state.default.v1", + "provenance": "provenance.backend_realized", + "value_ref": "evidence.state.web01-http-rev8", + "accesses": [ + { + "state_address": "hosts.web01.service.http", + "access_kind": "read_write", + "read_revision": "rev7", + "write_revision": "rev8", + "read_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "write_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "snapshot_ref": "snapshots.sim.tick88", + "access_purpose": "action_effect_commit", + "atomic_group_ref": "joint-13", + "evidence_refs": ["evidence.sim.tick88.joint"] + } + ] +} diff --git a/contracts/schema-publication-manifest.json b/contracts/schema-publication-manifest.json index 8fda9a634..fe6f3b91b 100644 --- a/contracts/schema-publication-manifest.json +++ b/contracts/schema-publication-manifest.json @@ -69,6 +69,10 @@ "contract_id": "participant-behavior-history-event-stream-v1", "schema_path": "contracts/schemas/control-plane/participant-behavior-history-event-stream-v1.json" }, + { + "contract_id": "participant-context-view-v1", + "schema_path": "contracts/schemas/control-plane/participant-context-view-v1.json" + }, { "contract_id": "participant-episode-history-event-stream-v1", "schema_path": "contracts/schemas/control-plane/participant-episode-history-event-stream-v1.json" @@ -77,6 +81,10 @@ "contract_id": "participant-episode-state-envelope-v1", "schema_path": "contracts/schemas/control-plane/participant-episode-state-envelope-v1.json" }, + { + "contract_id": "participant-history-view-v1", + "schema_path": "contracts/schemas/control-plane/participant-history-view-v1.json" + }, { "contract_id": "participant-implementation-manifest-v1", "schema_path": "contracts/schemas/participant-implementation-manifest/participant-implementation-manifest-v1.json" @@ -85,6 +93,26 @@ "contract_id": "participant-implementation-provenance-v1", "schema_path": "contracts/schemas/participant-implementation-provenance/participant-implementation-provenance-v1.json" }, + { + "contract_id": "participant-lifecycle-event-v1", + "schema_path": "contracts/schemas/participant-runtime/participant-lifecycle-event-v1.json" + }, + { + "contract_id": "participant-observation-envelope-v1", + "schema_path": "contracts/schemas/participant-runtime/participant-observation-envelope-v1.json" + }, + { + "contract_id": "participant-outcome-report-v1", + "schema_path": "contracts/schemas/participant-runtime/participant-outcome-report-v1.json" + }, + { + "contract_id": "participant-shared-state-record-v1", + "schema_path": "contracts/schemas/participant-runtime/participant-shared-state-record-v1.json" + }, + { + "contract_id": "participant-status-view-v1", + "schema_path": "contracts/schemas/control-plane/participant-status-view-v1.json" + }, { "contract_id": "processor-manifest-v2", "schema_path": "contracts/schemas/processor-manifest/processor-manifest-v2.json" diff --git a/contracts/schemas/backend-manifest/backend-manifest-v2.json b/contracts/schemas/backend-manifest/backend-manifest-v2.json index 2d92ab738..a68de21d9 100644 --- a/contracts/schemas/backend-manifest/backend-manifest-v2.json +++ b/contracts/schemas/backend-manifest/backend-manifest-v2.json @@ -300,6 +300,81 @@ "title": "OrchestratorCapabilitiesModel", "type": "object" }, + "ParticipantFeatureSupportLevel": { + "description": "ADR-054 guarantee-strength scale for per-feature participant runtime support.", + "enum": [ + "unsupported", + "disclosed_weak", + "bounded", + "exact" + ], + "title": "ParticipantFeatureSupportLevel", + "type": "string" + }, + "ParticipantFeatureSupportModel": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "support_level": { + "enum": [ + "unsupported", + "disclosed_weak", + "bounded" + ] + } + }, + "required": [ + "support_level" + ] + }, + "then": { + "properties": { + "disclosure_refs": { + "minItems": 1 + } + }, + "required": [ + "disclosure_refs" + ] + } + } + ], + "description": "API-407 per-feature participant runtime support declaration.", + "properties": { + "constraint_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Constraint Refs", + "type": "array" + }, + "disclosure_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Disclosure Refs", + "type": "array" + }, + "feature": { + "minLength": 1, + "title": "Feature", + "type": "string" + }, + "support_level": { + "$ref": "#/$defs/ParticipantFeatureSupportLevel" + } + }, + "required": [ + "feature", + "support_level" + ], + "title": "ParticipantFeatureSupportModel", + "type": "object" + }, "ParticipantRuntimeCapabilitiesModel": { "additionalProperties": false, "description": "Participant-episode lifecycle capability block (RUN-311).\n\nA backend that declares this block advertises that it implements\nthe full participant episode control surface on the\n``ParticipantRuntime`` protocol: ``initialize`` / ``reset`` /\n``restart`` / ``terminate`` plus ``status`` / ``results`` /\n``history``. Consumers of the manifest can infer the\n``FULL_REMOTE_CONTROL_PLANE`` conformance profile from this block.\n\nAPI-405 support dimensions live here because they are backend apparatus\nclaims: which participant roles, behavior features, and interaction\nfeatures this participant runtime can actually realize.", @@ -311,6 +386,13 @@ "title": "Constraints", "type": "object" }, + "feature_support": { + "items": { + "$ref": "#/$defs/ParticipantFeatureSupportModel" + }, + "title": "Feature Support", + "type": "array" + }, "name": { "minLength": 1, "title": "Name", @@ -677,7 +759,11 @@ "evaluation-history-event-stream-v1", "participant-episode-state-envelope-v1", "participant-episode-history-event-stream-v1", - "participant-behavior-history-event-stream-v1" + "participant-behavior-history-event-stream-v1", + "participant-lifecycle-event-v1", + "participant-observation-envelope-v1", + "participant-shared-state-record-v1", + "participant-outcome-report-v1" ], "minLength": 1, "type": "string" diff --git a/contracts/schemas/control-plane/participant-context-view-v1.json b/contracts/schemas/control-plane/participant-context-view-v1.json new file mode 100644 index 000000000..c3e5b69a1 --- /dev/null +++ b/contracts/schemas/control-plane/participant-context-view-v1.json @@ -0,0 +1,114 @@ +{ + "$id": "https://aces.dev/schemas/participant-context-view-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "API-408 derived operational context view (reference-and-provenance only).", + "properties": { + "derivation_basis_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Derivation Basis Ref" + }, + "derived_from_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Derived From Refs", + "type": "array" + }, + "episode_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Episode Id" + }, + "generated_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Generated At", + "type": "string" + }, + "marking_definition_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Marking Definition Refs", + "type": "array" + }, + "participant_address": { + "minLength": 1, + "title": "Participant Address", + "type": "string" + }, + "payload_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payload Ref" + }, + "redaction_policy_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Redaction Policy Ref" + }, + "view_id": { + "minLength": 1, + "title": "View Id", + "type": "string" + }, + "view_ref": { + "minLength": 1, + "title": "View Ref", + "type": "string" + }, + "visibility_projection_ref": { + "minLength": 1, + "title": "Visibility Projection Ref", + "type": "string" + } + }, + "required": [ + "view_id", + "participant_address", + "generated_at", + "view_ref", + "derived_from_refs", + "visibility_projection_ref" + ], + "title": "ParticipantContextViewModel", + "type": "object" +} diff --git a/contracts/schemas/control-plane/participant-history-view-v1.json b/contracts/schemas/control-plane/participant-history-view-v1.json new file mode 100644 index 000000000..8f511b4f4 --- /dev/null +++ b/contracts/schemas/control-plane/participant-history-view-v1.json @@ -0,0 +1,1455 @@ +{ + "$defs": { + "OutcomeInterpretationSourceLayer": { + "description": "Semantic layers that may feed a SEM-215 interpretation rule.", + "enum": [ + "participant_action_outcome", + "participant_episode_status", + "objective_result", + "workflow_result", + "evaluation_result", + "evidence_claim", + "reward_signal", + "benchmark_milestone", + "subtask", + "gold_step", + "human_assistance", + "scaffold_variant", + "cost_resource_telemetry", + "privacy_redaction_result" + ], + "title": "OutcomeInterpretationSourceLayer", + "type": "string" + }, + "OutcomeInterpretationTargetLayer": { + "description": "Semantic layers a SEM-215 interpretation rule may explicitly produce.", + "enum": [ + "scenario_meaning", + "objective_result", + "workflow_result", + "evaluation_result", + "evidence_claim", + "reward_signal", + "benchmark_progress", + "privacy_redaction_result" + ], + "title": "OutcomeInterpretationTargetLayer", + "type": "string" + }, + "ParticipantActionEffectResultModel": { + "additionalProperties": false, + "properties": { + "description": { + "minLength": 1, + "title": "Description", + "type": "string" + }, + "diagnostics": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Diagnostics", + "type": "array" + }, + "effect_class": { + "$ref": "#/$defs/ParticipantEffectClass" + }, + "effect_id": { + "minLength": 1, + "title": "Effect Id", + "type": "string" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "target_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Target Refs", + "type": "array" + } + }, + "required": [ + "effect_id", + "effect_class", + "description" + ], + "title": "ParticipantActionEffectResultModel", + "type": "object" + }, + "ParticipantActionPreconditionResultModel": { + "additionalProperties": false, + "properties": { + "action_contract_address": { + "minLength": 1, + "title": "Action Contract Address", + "type": "string" + }, + "diagnostics": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Diagnostics", + "type": "array" + }, + "episode_id": { + "minLength": 1, + "title": "Episode Id", + "type": "string" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "observation_point": { + "minLength": 1, + "title": "Observation Point", + "type": "string" + }, + "participant_address": { + "minLength": 1, + "title": "Participant Address", + "type": "string" + }, + "precondition_class": { + "$ref": "#/$defs/ParticipantPreconditionClass" + }, + "precondition_id": { + "minLength": 1, + "title": "Precondition Id", + "type": "string" + }, + "status": { + "enum": [ + "satisfied", + "unsatisfied", + "unresolved" + ], + "title": "Status", + "type": "string" + }, + "support_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Support Refs", + "type": "array" + } + }, + "required": [ + "precondition_id", + "precondition_class", + "status", + "participant_address", + "episode_id", + "action_contract_address", + "observation_point" + ], + "title": "ParticipantActionPreconditionResultModel", + "type": "object" + }, + "ParticipantActionResultModel": { + "additionalProperties": false, + "properties": { + "action_contract_address": { + "minLength": 1, + "title": "Action Contract Address", + "type": "string" + }, + "action_instance_id": { + "minLength": 1, + "title": "Action Instance Id", + "type": "string" + }, + "diagnostics": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Diagnostics", + "type": "array" + }, + "effects": { + "items": { + "$ref": "#/$defs/ParticipantActionEffectResultModel" + }, + "title": "Effects", + "type": "array" + }, + "episode_id": { + "minLength": 1, + "title": "Episode Id", + "type": "string" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "failure_class": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantFailureClass" + }, + { + "type": "null" + } + ], + "default": null + }, + "observation_point": { + "minLength": 1, + "title": "Observation Point", + "type": "string" + }, + "observations": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Observations", + "type": "array" + }, + "participant_address": { + "minLength": 1, + "title": "Participant Address", + "type": "string" + }, + "preconditions": { + "items": { + "$ref": "#/$defs/ParticipantActionPreconditionResultModel" + }, + "title": "Preconditions", + "type": "array" + }, + "status": { + "enum": [ + "accepted", + "rejected", + "withheld", + "succeeded", + "failed", + "partial_success", + "unknown" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "status", + "participant_address", + "episode_id", + "action_instance_id", + "action_contract_address", + "observation_point" + ], + "title": "ParticipantActionResultModel", + "type": "object" + }, + "ParticipantAdmissionDisposition": { + "description": "RUN-306 selection/admission disposition values.", + "enum": [ + "admitted", + "rejected", + "withheld", + "unknown", + "not_applicable" + ], + "title": "ParticipantAdmissionDisposition", + "type": "string" + }, + "ParticipantAttributionCandidateKind": { + "description": "Portable candidate classes for participant attribution edges.", + "enum": [ + "action", + "state_change", + "detection", + "alert", + "observation", + "evidence", + "downstream_outcome", + "evaluation_result", + "objective_result" + ], + "title": "ParticipantAttributionCandidateKind", + "type": "string" + }, + "ParticipantAttributionCandidateModel": { + "additionalProperties": false, + "properties": { + "candidate_kind": { + "$ref": "#/$defs/ParticipantAttributionCandidateKind" + }, + "description": { + "minLength": 1, + "title": "Description", + "type": "string" + }, + "ref": { + "minLength": 1, + "title": "Ref", + "type": "string" + } + }, + "required": [ + "candidate_kind", + "ref", + "description" + ], + "title": "ParticipantAttributionCandidateModel", + "type": "object" + }, + "ParticipantAttributionEdgeModel": { + "additionalProperties": false, + "properties": { + "cause_candidate": { + "$ref": "#/$defs/ParticipantAttributionCandidateModel" + }, + "confidence": { + "minLength": 1, + "title": "Confidence", + "type": "string" + }, + "edge_id": { + "minLength": 1, + "title": "Edge Id", + "type": "string" + }, + "effect_candidate": { + "$ref": "#/$defs/ParticipantAttributionCandidateModel" + }, + "episode_id": { + "minLength": 1, + "title": "Episode Id", + "type": "string" + }, + "evidence_basis": { + "$ref": "#/$defs/ParticipantAttributionEvidenceBasisModel" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Evidence Refs", + "type": "array" + }, + "interpretation_rule_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Interpretation Rule Ref" + }, + "limitations": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Limitations", + "type": "array" + }, + "observation_point": { + "minLength": 1, + "title": "Observation Point", + "type": "string" + }, + "ordering_basis": { + "$ref": "#/$defs/ParticipantAttributionOrderingBasisModel" + }, + "participant_address": { + "minLength": 1, + "title": "Participant Address", + "type": "string" + }, + "strength": { + "minLength": 1, + "title": "Strength", + "type": "string" + }, + "support_class": { + "$ref": "#/$defs/ParticipantAttributionSupportClass" + } + }, + "required": [ + "edge_id", + "participant_address", + "episode_id", + "observation_point", + "cause_candidate", + "effect_candidate", + "ordering_basis", + "evidence_basis", + "support_class", + "confidence", + "strength", + "limitations", + "evidence_refs" + ], + "title": "ParticipantAttributionEdgeModel", + "type": "object" + }, + "ParticipantAttributionEvidenceBasisModel": { + "additionalProperties": false, + "properties": { + "capture_apparatus": { + "minLength": 1, + "title": "Capture Apparatus", + "type": "string" + }, + "granularity": { + "minLength": 1, + "title": "Granularity", + "type": "string" + }, + "loss_model": { + "minLength": 1, + "title": "Loss Model", + "type": "string" + }, + "observer_effects": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Observer Effects", + "type": "array" + }, + "redaction_policy": { + "minLength": 1, + "title": "Redaction Policy", + "type": "string" + } + }, + "required": [ + "capture_apparatus", + "granularity", + "loss_model", + "redaction_policy", + "observer_effects" + ], + "title": "ParticipantAttributionEvidenceBasisModel", + "type": "object" + }, + "ParticipantAttributionOrderingBasisKind": { + "description": "Explicit ordering bases for attribution edges.", + "enum": [ + "happened_before", + "workflow_order", + "episode_order", + "backend_event_order", + "replay_order", + "ablation_order", + "structural_causal", + "timestamp_adjacency" + ], + "title": "ParticipantAttributionOrderingBasisKind", + "type": "string" + }, + "ParticipantAttributionOrderingBasisModel": { + "additionalProperties": false, + "properties": { + "basis_kind": { + "$ref": "#/$defs/ParticipantAttributionOrderingBasisKind" + }, + "description": { + "minLength": 1, + "title": "Description", + "type": "string" + }, + "ordered_event_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Ordered Event Refs", + "type": "array" + }, + "relation_ref": { + "minLength": 1, + "title": "Relation Ref", + "type": "string" + } + }, + "required": [ + "basis_kind", + "relation_ref", + "description" + ], + "title": "ParticipantAttributionOrderingBasisModel", + "type": "object" + }, + "ParticipantAttributionSupportClass": { + "description": "Evidence-strength classes for participant attribution.", + "enum": [ + "declared_association", + "temporal_support", + "contract_support", + "observation_support", + "counterfactual_support", + "intervention_support", + "replay_support", + "ablation_support", + "structural_causal_support" + ], + "title": "ParticipantAttributionSupportClass", + "type": "string" + }, + "ParticipantBehaviorHistoryEventModel": { + "additionalProperties": false, + "properties": { + "action_contract_address": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Action Contract Address" + }, + "action_instance_id": { + "minLength": 1, + "title": "Action Instance Id", + "type": "string" + }, + "action_result": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantActionResultModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "actor_provenance": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor Provenance" + }, + "admission_disposition": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantAdmissionDisposition" + }, + { + "type": "null" + } + ], + "default": null + }, + "attribution_edges": { + "items": { + "$ref": "#/$defs/ParticipantAttributionEdgeModel" + }, + "title": "Attribution Edges", + "type": "array" + }, + "details": { + "$ref": "#/$defs/ParticipantObservationDetailsModel" + }, + "episode_id": { + "minLength": 1, + "title": "Episode Id", + "type": "string" + }, + "event_type": { + "$ref": "#/$defs/ParticipantBehaviorHistoryEventType" + }, + "interaction_class": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantInteractionClass" + }, + { + "type": "null" + } + ], + "default": null + }, + "interaction_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Interaction Ref" + }, + "joint_action_set_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Joint Action Set Id" + }, + "lifecycle_phase": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantRuntimeLifecyclePhase" + }, + { + "type": "null" + } + ], + "default": null + }, + "observation_boundary_address": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Observation Boundary Address" + }, + "observation_status": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantObservationStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "operation_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Operation Ref" + }, + "operation_state": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantLifecycleOperationState" + }, + { + "type": "null" + } + ], + "default": null + }, + "outcome_interpretations": { + "items": { + "$ref": "#/$defs/ParticipantOutcomeInterpretationRecordModel" + }, + "title": "Outcome Interpretations", + "type": "array" + }, + "participant_address": { + "minLength": 1, + "title": "Participant Address", + "type": "string" + }, + "phase_realization": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantPhaseRealization" + }, + { + "type": "null" + } + ], + "default": null + }, + "post_state_digest": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Post State Digest" + }, + "realized_order": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Realized Order" + }, + "shared_state_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Shared State Refs", + "type": "array" + }, + "state_transition_kind": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "State Transition Kind" + }, + "temporal_contexts": { + "items": { + "$ref": "#/$defs/ParticipantTemporalRuntimeContextModel" + }, + "title": "Temporal Contexts", + "type": "array" + }, + "timestamp": { + "minLength": 1, + "title": "Timestamp", + "type": "string" + } + }, + "required": [ + "event_type", + "timestamp", + "participant_address", + "episode_id", + "action_instance_id" + ], + "title": "ParticipantBehaviorHistoryEventModel", + "type": "object" + }, + "ParticipantBehaviorHistoryEventType": { + "description": "Portable history event kinds for participant behavior semantics.", + "enum": [ + "action_attempted", + "state_transition_recorded", + "observation_emitted" + ], + "title": "ParticipantBehaviorHistoryEventType", + "type": "string" + }, + "ParticipantEffectClass": { + "description": "SEM-211 effect classes for participant action results.", + "enum": [ + "intended_effect", + "side_effect", + "observation_effect", + "visibility_effect", + "detection_effect", + "evidence_effect", + "no_effect", + "unknown_effect" + ], + "title": "ParticipantEffectClass", + "type": "string" + }, + "ParticipantEpisodeHistoryEventModel": { + "additionalProperties": false, + "properties": { + "control_action": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Control Action" + }, + "details": { + "additionalProperties": true, + "title": "Details", + "type": "object" + }, + "episode_id": { + "title": "Episode Id", + "type": "string" + }, + "event_type": { + "title": "Event Type", + "type": "string" + }, + "participant_address": { + "title": "Participant Address", + "type": "string" + }, + "sequence_number": { + "title": "Sequence Number", + "type": "integer" + }, + "terminal_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Terminal Reason" + }, + "timestamp": { + "title": "Timestamp", + "type": "string" + } + }, + "required": [ + "event_type", + "timestamp", + "participant_address", + "episode_id", + "sequence_number" + ], + "title": "ParticipantEpisodeHistoryEventModel", + "type": "object" + }, + "ParticipantFailureClass": { + "description": "SEM-211 portable failure classes for participant action attempts.", + "enum": [ + "precondition_unsatisfied", + "unsupported_action", + "target_unavailable", + "authority_denied", + "resource_exhausted", + "timeout", + "interrupted", + "contention_lost", + "partial_success", + "unsafe_withheld", + "backend_error", + "unknown" + ], + "title": "ParticipantFailureClass", + "type": "string" + }, + "ParticipantInteractionClass": { + "description": "SEM-209 interaction classes for multi-participant behavior.", + "enum": [ + "coordination", + "contention", + "interference", + "shared_state_change" + ], + "title": "ParticipantInteractionClass", + "type": "string" + }, + "ParticipantLifecycleOperationState": { + "description": "RUN-306 operation states for execution-attempt records.", + "enum": [ + "submitted", + "acknowledged", + "running", + "blocked", + "completed", + "partial", + "failed", + "timed_out", + "cancelled", + "unknown", + "unsupported" + ], + "title": "ParticipantLifecycleOperationState", + "type": "string" + }, + "ParticipantObservationDetailsModel": { + "additionalProperties": false, + "properties": { + "disclosed_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Disclosed Refs", + "type": "array" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "visible_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Visible Refs", + "type": "array" + } + }, + "title": "ParticipantObservationDetailsModel", + "type": "object" + }, + "ParticipantObservationStatus": { + "description": "Terminal interpretation of a participant observation event.", + "enum": [ + "terminal", + "orphaned_action" + ], + "title": "ParticipantObservationStatus", + "type": "string" + }, + "ParticipantOutcomeInterpretationRecordModel": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Diagnostics", + "type": "array" + }, + "episode_id": { + "minLength": 1, + "title": "Episode Id", + "type": "string" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Evidence Refs", + "type": "array" + }, + "interpretation_id": { + "minLength": 1, + "title": "Interpretation Id", + "type": "string" + }, + "limitations": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Limitations", + "type": "array" + }, + "observation_point": { + "minLength": 1, + "title": "Observation Point", + "type": "string" + }, + "participant_address": { + "minLength": 1, + "title": "Participant Address", + "type": "string" + }, + "rule_address": { + "minLength": 1, + "title": "Rule Address", + "type": "string" + }, + "source_bindings": { + "items": { + "$ref": "#/$defs/ParticipantOutcomeSourceRecordModel" + }, + "minItems": 1, + "title": "Source Bindings", + "type": "array" + }, + "target_bindings": { + "items": { + "$ref": "#/$defs/ParticipantOutcomeTargetRecordModel" + }, + "minItems": 1, + "title": "Target Bindings", + "type": "array" + } + }, + "required": [ + "interpretation_id", + "rule_address", + "participant_address", + "episode_id", + "observation_point", + "source_bindings", + "target_bindings", + "evidence_refs", + "limitations" + ], + "title": "ParticipantOutcomeInterpretationRecordModel", + "type": "object" + }, + "ParticipantOutcomeSourceRecordModel": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Diagnostics", + "type": "array" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "observed_value": { + "minLength": 1, + "title": "Observed Value", + "type": "string" + }, + "provenance_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Provenance Refs", + "type": "array" + }, + "ref": { + "minLength": 1, + "title": "Ref", + "type": "string" + }, + "source_id": { + "minLength": 1, + "title": "Source Id", + "type": "string" + }, + "source_layer": { + "$ref": "#/$defs/OutcomeInterpretationSourceLayer" + } + }, + "required": [ + "source_id", + "source_layer", + "ref", + "observed_value" + ], + "title": "ParticipantOutcomeSourceRecordModel", + "type": "object" + }, + "ParticipantOutcomeTargetRecordModel": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Diagnostics", + "type": "array" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Evidence Refs", + "type": "array" + }, + "governance_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Governance Ref" + }, + "interpreted_value": { + "minLength": 1, + "title": "Interpreted Value", + "type": "string" + }, + "limitations": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Limitations", + "type": "array" + }, + "ref": { + "minLength": 1, + "title": "Ref", + "type": "string" + }, + "target_id": { + "minLength": 1, + "title": "Target Id", + "type": "string" + }, + "target_layer": { + "$ref": "#/$defs/OutcomeInterpretationTargetLayer" + } + }, + "required": [ + "target_id", + "target_layer", + "ref", + "interpreted_value", + "evidence_refs", + "limitations" + ], + "title": "ParticipantOutcomeTargetRecordModel", + "type": "object" + }, + "ParticipantPhaseRealization": { + "description": "RUN-306 realization modes for an observable lifecycle phase.", + "enum": [ + "observed", + "runtime_mediated", + "externally_supplied", + "opaque", + "unknown", + "not_applicable", + "unsupported" + ], + "title": "ParticipantPhaseRealization", + "type": "string" + }, + "ParticipantPreconditionClass": { + "description": "SEM-211 precondition classes for participant action applicability.", + "enum": [ + "authority", + "capability", + "target", + "knowledge", + "resource", + "temporal", + "interaction", + "realization" + ], + "title": "ParticipantPreconditionClass", + "type": "string" + }, + "ParticipantRuntimeLifecyclePhase": { + "description": "RUN-306 observable participant runtime lifecycle phases.", + "enum": [ + "intent_or_proposal", + "selection_or_admission", + "execution_attempt", + "observation_emission", + "state_update_commit" + ], + "title": "ParticipantRuntimeLifecyclePhase", + "type": "string" + }, + "ParticipantTemporalEventPoint": { + "description": "Named participant event points used by temporal contracts.", + "enum": [ + "submit", + "start", + "end", + "observed", + "effective", + "deadline", + "window_open", + "window_close", + "reset", + "replay" + ], + "title": "ParticipantTemporalEventPoint", + "type": "string" + }, + "ParticipantTemporalRuntimeContextModel": { + "additionalProperties": false, + "properties": { + "backend_disclosure_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Backend Disclosure Refs", + "type": "array" + }, + "clock_authority": { + "minLength": 1, + "title": "Clock Authority", + "type": "string" + }, + "event_points": { + "items": { + "$ref": "#/$defs/ParticipantTemporalEventPoint" + }, + "minItems": 1, + "title": "Event Points", + "type": "array" + }, + "observation_point": { + "minLength": 1, + "title": "Observation Point", + "type": "string" + }, + "replay_boundary": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Replay Boundary" + }, + "reset_boundary": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reset Boundary" + }, + "temporal_contract_id": { + "minLength": 1, + "title": "Temporal Contract Id", + "type": "string" + }, + "time_domain": { + "$ref": "#/$defs/ParticipantTimeDomain" + } + }, + "required": [ + "temporal_contract_id", + "time_domain", + "clock_authority", + "event_points", + "observation_point" + ], + "title": "ParticipantTemporalRuntimeContextModel", + "type": "object" + }, + "ParticipantTimeDomain": { + "description": "Distinct SEM-213 time domains.", + "enum": [ + "episode_step", + "scenario_time", + "simulation_time", + "backend_time", + "wall_clock_time" + ], + "title": "ParticipantTimeDomain", + "type": "string" + } + }, + "$id": "https://aces.dev/schemas/participant-history-view-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "completeness": { + "enum": [ + "truncated", + "filtered" + ] + } + }, + "required": [ + "completeness" + ] + }, + "then": { + "properties": { + "completeness_basis": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "completeness_basis" + ] + } + } + ], + "description": "API-408 retrieval projection of participant episode/behavior history.", + "properties": { + "behavior_history": { + "items": { + "$ref": "#/$defs/ParticipantBehaviorHistoryEventModel" + }, + "title": "Behavior History", + "type": "array" + }, + "completeness": { + "enum": [ + "complete", + "truncated", + "filtered" + ], + "title": "Completeness", + "type": "string" + }, + "completeness_basis": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Completeness Basis" + }, + "episode_history": { + "items": { + "$ref": "#/$defs/ParticipantEpisodeHistoryEventModel" + }, + "title": "Episode History", + "type": "array" + }, + "episode_id": { + "minLength": 1, + "title": "Episode Id", + "type": "string" + }, + "generated_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Generated At", + "type": "string" + }, + "marking_definition_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Marking Definition Refs", + "type": "array" + }, + "participant_address": { + "minLength": 1, + "title": "Participant Address", + "type": "string" + }, + "redaction_policy_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Redaction Policy Ref" + }, + "source_snapshot_ref": { + "minLength": 1, + "title": "Source Snapshot Ref", + "type": "string" + }, + "view_id": { + "minLength": 1, + "title": "View Id", + "type": "string" + }, + "visibility_projection_ref": { + "minLength": 1, + "title": "Visibility Projection Ref", + "type": "string" + } + }, + "required": [ + "view_id", + "participant_address", + "episode_id", + "generated_at", + "source_snapshot_ref", + "visibility_projection_ref", + "completeness" + ], + "title": "ParticipantHistoryViewModel", + "type": "object" +} diff --git a/contracts/schemas/control-plane/participant-status-view-v1.json b/contracts/schemas/control-plane/participant-status-view-v1.json new file mode 100644 index 000000000..a6e5b1ce7 --- /dev/null +++ b/contracts/schemas/control-plane/participant-status-view-v1.json @@ -0,0 +1,185 @@ +{ + "$defs": { + "ParticipantEpisodeStateModel": { + "additionalProperties": false, + "properties": { + "episode_id": { + "title": "Episode Id", + "type": "string" + }, + "initialized_at": { + "title": "Initialized At", + "type": "string" + }, + "last_control_action": { + "title": "Last Control Action", + "type": "string" + }, + "participant_address": { + "title": "Participant Address", + "type": "string" + }, + "previous_episode_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Previous Episode Id" + }, + "sequence_number": { + "title": "Sequence Number", + "type": "integer" + }, + "state_schema_version": { + "const": "participant-episode-state/v1", + "default": "participant-episode-state/v1", + "title": "State Schema Version", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "terminal_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Terminal Reason" + }, + "terminated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Terminated At" + }, + "updated_at": { + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "participant_address", + "episode_id", + "sequence_number", + "status", + "initialized_at", + "updated_at", + "last_control_action" + ], + "title": "ParticipantEpisodeStateModel", + "type": "object" + } + }, + "$id": "https://aces.dev/schemas/participant-status-view-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "API-408 retrieval projection of one participant's episode status.", + "properties": { + "episode_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Episode Id" + }, + "episode_state": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantEpisodeStateModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "generated_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Generated At", + "type": "string" + }, + "marking_definition_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Marking Definition Refs", + "type": "array" + }, + "open_operation_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Open Operation Refs", + "type": "array" + }, + "participant_address": { + "minLength": 1, + "title": "Participant Address", + "type": "string" + }, + "redaction_policy_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Redaction Policy Ref" + }, + "source_snapshot_ref": { + "minLength": 1, + "title": "Source Snapshot Ref", + "type": "string" + }, + "view_id": { + "minLength": 1, + "title": "View Id", + "type": "string" + }, + "visibility_projection_ref": { + "minLength": 1, + "title": "Visibility Projection Ref", + "type": "string" + } + }, + "required": [ + "view_id", + "participant_address", + "generated_at", + "source_snapshot_ref", + "visibility_projection_ref" + ], + "title": "ParticipantStatusViewModel", + "type": "object" +} diff --git a/contracts/schemas/participant-runtime/participant-lifecycle-event-v1.json b/contracts/schemas/participant-runtime/participant-lifecycle-event-v1.json new file mode 100644 index 000000000..f8895c4db --- /dev/null +++ b/contracts/schemas/participant-runtime/participant-lifecycle-event-v1.json @@ -0,0 +1,934 @@ +{ + "$defs": { + "EventClassificationModel": { + "additionalProperties": false, + "description": "ACES-native normalized event classification tuple (ADR-054).", + "properties": { + "activity_id": { + "title": "Activity Id", + "type": "integer" + }, + "activity_name": { + "minLength": 1, + "title": "Activity Name", + "type": "string" + }, + "category_name": { + "minLength": 1, + "title": "Category Name", + "type": "string" + }, + "category_uid": { + "title": "Category Uid", + "type": "integer" + }, + "class_name": { + "minLength": 1, + "title": "Class Name", + "type": "string" + }, + "class_uid": { + "title": "Class Uid", + "type": "integer" + }, + "severity": { + "minLength": 1, + "title": "Severity", + "type": "string" + }, + "severity_id": { + "title": "Severity Id", + "type": "integer" + }, + "type_name": { + "minLength": 1, + "title": "Type Name", + "type": "string" + }, + "type_uid": { + "title": "Type Uid", + "type": "integer" + } + }, + "required": [ + "category_uid", + "category_name", + "class_uid", + "class_name", + "activity_id", + "activity_name", + "type_uid", + "type_name", + "severity_id", + "severity" + ], + "title": "EventClassificationModel", + "type": "object" + }, + "ParticipantAdmissionDisposition": { + "description": "RUN-306 selection/admission disposition values.", + "enum": [ + "admitted", + "rejected", + "withheld", + "unknown", + "not_applicable" + ], + "title": "ParticipantAdmissionDisposition", + "type": "string" + }, + "ParticipantPhaseRealization": { + "description": "RUN-306 realization modes for an observable lifecycle phase.", + "enum": [ + "observed", + "runtime_mediated", + "externally_supplied", + "opaque", + "unknown", + "not_applicable", + "unsupported" + ], + "title": "ParticipantPhaseRealization", + "type": "string" + }, + "ParticipantRuntimeLifecyclePhase": { + "description": "RUN-306 observable participant runtime lifecycle phases.", + "enum": [ + "intent_or_proposal", + "selection_or_admission", + "execution_attempt", + "observation_emission", + "state_update_commit" + ], + "title": "ParticipantRuntimeLifecyclePhase", + "type": "string" + }, + "RawDataIntegrityModel": { + "additionalProperties": false, + "description": "Hash, size, and truncation facts for raw data behind a runtime claim.", + "properties": { + "raw_data_hash": { + "anyOf": [ + { + "minLength": 1, + "pattern": "^(?:sha256:[A-Fa-f0-9]{64}|sha384:[A-Fa-f0-9]{96}|sha512:[A-Fa-f0-9]{128}|blake3:[A-Fa-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Hash" + }, + "raw_data_hash_algorithm": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Hash Algorithm" + }, + "raw_data_is_truncated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Is Truncated" + }, + "raw_data_size": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Size" + }, + "raw_data_untruncated_size": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Untruncated Size" + } + }, + "title": "RawDataIntegrityModel", + "type": "object" + }, + "SourcePipelineModel": { + "additionalProperties": false, + "description": "Source product, identity, and pipeline-time facts for a mapped record.", + "properties": { + "correlation_uid": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Correlation Uid" + }, + "log_name": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log Name" + }, + "log_provider": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log Provider" + }, + "log_source": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log Source" + }, + "logged_time": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Logged Time" + }, + "original_event_uid": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Original Event Uid" + }, + "original_time": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Original Time" + }, + "processed_time": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Processed Time" + }, + "product_ref": { + "minLength": 1, + "title": "Product Ref", + "type": "string" + }, + "product_version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Product Version" + }, + "sequence": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sequence" + }, + "transmit_time": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Transmit Time" + } + }, + "required": [ + "product_ref" + ], + "title": "SourcePipelineModel", + "type": "object" + }, + "SourceStatusModel": { + "additionalProperties": false, + "description": "Normalized source status claim for one participant runtime record.", + "properties": { + "source_status_label": { + "minLength": 1, + "title": "Source Status Label", + "type": "string" + }, + "source_status_mapping": { + "minLength": 1, + "title": "Source Status Mapping", + "type": "string" + }, + "status": { + "minLength": 1, + "title": "Status", + "type": "string" + }, + "status_code": { + "minLength": 1, + "title": "Status Code", + "type": "string" + }, + "status_detail": { + "minLength": 1, + "title": "Status Detail", + "type": "string" + }, + "status_id": { + "title": "Status Id", + "type": "integer" + } + }, + "required": [ + "status_id", + "status", + "status_code", + "status_detail", + "source_status_label", + "source_status_mapping" + ], + "title": "SourceStatusModel", + "type": "object" + } + }, + "$id": "https://aces.dev/schemas/participant-lifecycle-event-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "RUN-306 lifecycle boundary record for one participant action event.", + "properties": { + "action_contract_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Action Contract Ref" + }, + "action_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Action Ref" + }, + "action_validity_basis_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Action Validity Basis Ref" + }, + "actor_provenance": { + "minLength": 1, + "title": "Actor Provenance", + "type": "string" + }, + "actor_ref": { + "minLength": 1, + "title": "Actor Ref", + "type": "string" + }, + "admission_disposition": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantAdmissionDisposition" + }, + { + "type": "null" + } + ], + "default": null + }, + "attribution_edge_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Attribution Edge Refs", + "type": "array" + }, + "authorization_scope": { + "minLength": 1, + "title": "Authorization Scope", + "type": "string" + }, + "clock_authority": { + "minLength": 1, + "title": "Clock Authority", + "type": "string" + }, + "command_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command Ref" + }, + "confidence": { + "anyOf": [ + { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confidence" + }, + "emitted_state_update_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Emitted State Update Refs", + "type": "array" + }, + "episode_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Episode Id" + }, + "event_classification": { + "anyOf": [ + { + "$ref": "#/$defs/EventClassificationModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "event_id": { + "minLength": 1, + "title": "Event Id", + "type": "string" + }, + "event_type": { + "minLength": 1, + "title": "Event Type", + "type": "string" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "extension_policy": { + "minLength": 1, + "title": "Extension Policy", + "type": "string" + }, + "granular_markings": { + "additionalProperties": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "propertyNames": { + "minLength": 1 + }, + "title": "Granular Markings", + "type": "object" + }, + "ingested_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Ingested At", + "type": "string" + }, + "joint_action_set_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Joint Action Set Ref" + }, + "logical_order_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Logical Order Ref" + }, + "mapping_loss": { + "anyOf": [ + { + "enum": [ + "none", + "private_apparatus_detail", + "source_fields_omitted", + "semantics_approximated", + "redacted_by_policy", + "temporal_detail_collapsed", + "unknown", + "unsupported" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mapping Loss" + }, + "mapping_loss_detail": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mapping Loss Detail" + }, + "marking_definition_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Marking Definition Refs", + "type": "array" + }, + "markings": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Markings", + "type": "array" + }, + "object_marking_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Object Marking Refs", + "type": "array" + }, + "observation_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Observation Refs", + "type": "array" + }, + "occurred_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Occurred At", + "type": "string" + }, + "operation_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Operation Ref" + }, + "ordering_basis": { + "enum": [ + "total_order", + "partial_order", + "simultaneous", + "serialized_backend_order", + "simulation_tick", + "control_plane_order", + "logical_clock", + "vector_clock", + "wall_clock_only", + "unknown", + "unsupported" + ], + "title": "Ordering Basis", + "type": "string" + }, + "outcome_interpretation_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Outcome Interpretation Refs", + "type": "array" + }, + "participant_address": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Participant Address" + }, + "phase": { + "$ref": "#/$defs/ParticipantRuntimeLifecyclePhase" + }, + "phase_realization": { + "$ref": "#/$defs/ParticipantPhaseRealization" + }, + "predecessor_event_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Predecessor Event Refs", + "type": "array" + }, + "producer_ref": { + "minLength": 1, + "title": "Producer Ref", + "type": "string" + }, + "provenance_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Provenance Refs", + "type": "array" + }, + "raw_data_integrity": { + "anyOf": [ + { + "$ref": "#/$defs/RawDataIntegrityModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "recorded_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Recorded At", + "type": "string" + }, + "redaction_policy_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Redaction Policy Ref" + }, + "schema_name": { + "minLength": 1, + "title": "Schema Name", + "type": "string" + }, + "schema_version": { + "minLength": 1, + "title": "Schema Version", + "type": "string" + }, + "sequence_number": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sequence Number" + }, + "shared_state_read_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Shared State Read Refs", + "type": "array" + }, + "shared_state_write_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Shared State Write Refs", + "type": "array" + }, + "source_pipeline": { + "anyOf": [ + { + "$ref": "#/$defs/SourcePipelineModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "source_raw_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Raw Ref" + }, + "source_record_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Record Ref" + }, + "source_status": { + "anyOf": [ + { + "$ref": "#/$defs/SourceStatusModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "source_status_label": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Status Label" + }, + "source_system_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source System Ref" + }, + "temporal_context": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Temporal Context" + } + }, + "required": [ + "event_id", + "schema_name", + "schema_version", + "event_type", + "extension_policy", + "occurred_at", + "recorded_at", + "ingested_at", + "clock_authority", + "ordering_basis", + "actor_ref", + "producer_ref", + "authorization_scope", + "phase", + "phase_realization", + "actor_provenance" + ], + "title": "ParticipantLifecycleEventModel", + "type": "object" +} diff --git a/contracts/schemas/participant-runtime/participant-observation-envelope-v1.json b/contracts/schemas/participant-runtime/participant-observation-envelope-v1.json new file mode 100644 index 000000000..19678ada0 --- /dev/null +++ b/contracts/schemas/participant-runtime/participant-observation-envelope-v1.json @@ -0,0 +1,958 @@ +{ + "$defs": { + "EventClassificationModel": { + "additionalProperties": false, + "description": "ACES-native normalized event classification tuple (ADR-054).", + "properties": { + "activity_id": { + "title": "Activity Id", + "type": "integer" + }, + "activity_name": { + "minLength": 1, + "title": "Activity Name", + "type": "string" + }, + "category_name": { + "minLength": 1, + "title": "Category Name", + "type": "string" + }, + "category_uid": { + "title": "Category Uid", + "type": "integer" + }, + "class_name": { + "minLength": 1, + "title": "Class Name", + "type": "string" + }, + "class_uid": { + "title": "Class Uid", + "type": "integer" + }, + "severity": { + "minLength": 1, + "title": "Severity", + "type": "string" + }, + "severity_id": { + "title": "Severity Id", + "type": "integer" + }, + "type_name": { + "minLength": 1, + "title": "Type Name", + "type": "string" + }, + "type_uid": { + "title": "Type Uid", + "type": "integer" + } + }, + "required": [ + "category_uid", + "category_name", + "class_uid", + "class_name", + "activity_id", + "activity_name", + "type_uid", + "type_name", + "severity_id", + "severity" + ], + "title": "EventClassificationModel", + "type": "object" + }, + "ParticipantObservationLossDescriptorModel": { + "additionalProperties": false, + "description": "Declared projection-loss facts for one participant-visible observation.", + "properties": { + "fields_redacted": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Fields Redacted", + "type": "array" + }, + "kind": { + "minLength": 1, + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "ParticipantObservationLossDescriptorModel", + "type": "object" + }, + "ParticipantObservationStochasticContextModel": { + "additionalProperties": false, + "description": "Seed and randomization-policy references behind one observation.", + "properties": { + "randomization_policy_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Randomization Policy Ref" + }, + "seed_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Seed Ref" + } + }, + "title": "ParticipantObservationStochasticContextModel", + "type": "object" + }, + "RawDataIntegrityModel": { + "additionalProperties": false, + "description": "Hash, size, and truncation facts for raw data behind a runtime claim.", + "properties": { + "raw_data_hash": { + "anyOf": [ + { + "minLength": 1, + "pattern": "^(?:sha256:[A-Fa-f0-9]{64}|sha384:[A-Fa-f0-9]{96}|sha512:[A-Fa-f0-9]{128}|blake3:[A-Fa-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Hash" + }, + "raw_data_hash_algorithm": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Hash Algorithm" + }, + "raw_data_is_truncated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Is Truncated" + }, + "raw_data_size": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Size" + }, + "raw_data_untruncated_size": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Untruncated Size" + } + }, + "title": "RawDataIntegrityModel", + "type": "object" + }, + "SourcePipelineModel": { + "additionalProperties": false, + "description": "Source product, identity, and pipeline-time facts for a mapped record.", + "properties": { + "correlation_uid": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Correlation Uid" + }, + "log_name": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log Name" + }, + "log_provider": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log Provider" + }, + "log_source": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log Source" + }, + "logged_time": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Logged Time" + }, + "original_event_uid": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Original Event Uid" + }, + "original_time": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Original Time" + }, + "processed_time": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Processed Time" + }, + "product_ref": { + "minLength": 1, + "title": "Product Ref", + "type": "string" + }, + "product_version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Product Version" + }, + "sequence": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sequence" + }, + "transmit_time": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Transmit Time" + } + }, + "required": [ + "product_ref" + ], + "title": "SourcePipelineModel", + "type": "object" + }, + "SourceStatusModel": { + "additionalProperties": false, + "description": "Normalized source status claim for one participant runtime record.", + "properties": { + "source_status_label": { + "minLength": 1, + "title": "Source Status Label", + "type": "string" + }, + "source_status_mapping": { + "minLength": 1, + "title": "Source Status Mapping", + "type": "string" + }, + "status": { + "minLength": 1, + "title": "Status", + "type": "string" + }, + "status_code": { + "minLength": 1, + "title": "Status Code", + "type": "string" + }, + "status_detail": { + "minLength": 1, + "title": "Status Detail", + "type": "string" + }, + "status_id": { + "title": "Status Id", + "type": "integer" + } + }, + "required": [ + "status_id", + "status", + "status_code", + "status_detail", + "source_status_label", + "source_status_mapping" + ], + "title": "SourceStatusModel", + "type": "object" + } + }, + "$id": "https://aces.dev/schemas/participant-observation-envelope-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "SEM-210 participant-visible observation record with explicit guarantees.", + "properties": { + "action_observation_history_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Action Observation History Ref" + }, + "actor_ref": { + "minLength": 1, + "title": "Actor Ref", + "type": "string" + }, + "authorization_scope": { + "minLength": 1, + "title": "Authorization Scope", + "type": "string" + }, + "belief_support_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Belief Support Ref" + }, + "centralized_state_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Centralized State Refs", + "type": "array" + }, + "clock_authority": { + "minLength": 1, + "title": "Clock Authority", + "type": "string" + }, + "confidence": { + "anyOf": [ + { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confidence" + }, + "delivered_at": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Delivered At" + }, + "delivery_basis": { + "enum": [ + "emission_is_delivery", + "runtime_delivery", + "participant_acknowledgement", + "external_delivery", + "unknown", + "unsupported" + ], + "title": "Delivery Basis", + "type": "string" + }, + "delivery_point_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Delivery Point Ref" + }, + "episode_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Episode Id" + }, + "event_classification": { + "anyOf": [ + { + "$ref": "#/$defs/EventClassificationModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "event_id": { + "minLength": 1, + "title": "Event Id", + "type": "string" + }, + "event_type": { + "minLength": 1, + "title": "Event Type", + "type": "string" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "extension_policy": { + "minLength": 1, + "title": "Extension Policy", + "type": "string" + }, + "granular_markings": { + "additionalProperties": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "propertyNames": { + "minLength": 1 + }, + "title": "Granular Markings", + "type": "object" + }, + "hidden_state_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Hidden State Refs", + "type": "array" + }, + "information_guarantee": { + "enum": [ + "observation_only", + "history_consistent", + "perfect_recall", + "lossy_projection", + "unknown", + "unsupported" + ], + "title": "Information Guarantee", + "type": "string" + }, + "information_state_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Information State Ref" + }, + "ingested_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Ingested At", + "type": "string" + }, + "logical_order_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Logical Order Ref" + }, + "loss_descriptor": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantObservationLossDescriptorModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "marking_definition_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Marking Definition Refs", + "type": "array" + }, + "markings": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Markings", + "type": "array" + }, + "noise_model_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Noise Model Ref" + }, + "object_marking_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Object Marking Refs", + "type": "array" + }, + "observation_ref": { + "minLength": 1, + "title": "Observation Ref", + "type": "string" + }, + "occurred_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Occurred At", + "type": "string" + }, + "ordering_basis": { + "enum": [ + "total_order", + "partial_order", + "simultaneous", + "serialized_backend_order", + "simulation_tick", + "control_plane_order", + "logical_clock", + "vector_clock", + "wall_clock_only", + "unknown", + "unsupported" + ], + "title": "Ordering Basis", + "type": "string" + }, + "participant_address": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Participant Address" + }, + "phase_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Phase Ref" + }, + "predecessor_event_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Predecessor Event Refs", + "type": "array" + }, + "producer_ref": { + "minLength": 1, + "title": "Producer Ref", + "type": "string" + }, + "provenance_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Provenance Refs", + "type": "array" + }, + "raw_data_integrity": { + "anyOf": [ + { + "$ref": "#/$defs/RawDataIntegrityModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "reconstruction_algorithm_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reconstruction Algorithm Ref" + }, + "reconstruction_proof_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reconstruction Proof Ref" + }, + "recorded_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Recorded At", + "type": "string" + }, + "redacted_field_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Redacted Field Refs", + "type": "array" + }, + "redaction_policy_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Redaction Policy Ref" + }, + "schema_name": { + "minLength": 1, + "title": "Schema Name", + "type": "string" + }, + "schema_version": { + "minLength": 1, + "title": "Schema Version", + "type": "string" + }, + "sequence_number": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sequence Number" + }, + "source_pipeline": { + "anyOf": [ + { + "$ref": "#/$defs/SourcePipelineModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "source_raw_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Raw Ref" + }, + "source_record_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Record Ref" + }, + "source_status": { + "anyOf": [ + { + "$ref": "#/$defs/SourceStatusModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "source_system_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source System Ref" + }, + "stochastic_context": { + "anyOf": [ + { + "$ref": "#/$defs/ParticipantObservationStochasticContextModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "temporal_context": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Temporal Context" + }, + "visibility_projection_ref": { + "minLength": 1, + "title": "Visibility Projection Ref", + "type": "string" + } + }, + "required": [ + "event_id", + "schema_name", + "schema_version", + "event_type", + "extension_policy", + "occurred_at", + "recorded_at", + "ingested_at", + "clock_authority", + "ordering_basis", + "actor_ref", + "producer_ref", + "authorization_scope", + "observation_ref", + "visibility_projection_ref", + "information_guarantee", + "delivery_basis" + ], + "title": "ParticipantObservationEnvelopeModel", + "type": "object" +} diff --git a/contracts/schemas/participant-runtime/participant-outcome-report-v1.json b/contracts/schemas/participant-runtime/participant-outcome-report-v1.json new file mode 100644 index 000000000..f4803d8c2 --- /dev/null +++ b/contracts/schemas/participant-runtime/participant-outcome-report-v1.json @@ -0,0 +1,787 @@ +{ + "$defs": { + "EventClassificationModel": { + "additionalProperties": false, + "description": "ACES-native normalized event classification tuple (ADR-054).", + "properties": { + "activity_id": { + "title": "Activity Id", + "type": "integer" + }, + "activity_name": { + "minLength": 1, + "title": "Activity Name", + "type": "string" + }, + "category_name": { + "minLength": 1, + "title": "Category Name", + "type": "string" + }, + "category_uid": { + "title": "Category Uid", + "type": "integer" + }, + "class_name": { + "minLength": 1, + "title": "Class Name", + "type": "string" + }, + "class_uid": { + "title": "Class Uid", + "type": "integer" + }, + "severity": { + "minLength": 1, + "title": "Severity", + "type": "string" + }, + "severity_id": { + "title": "Severity Id", + "type": "integer" + }, + "type_name": { + "minLength": 1, + "title": "Type Name", + "type": "string" + }, + "type_uid": { + "title": "Type Uid", + "type": "integer" + } + }, + "required": [ + "category_uid", + "category_name", + "class_uid", + "class_name", + "activity_id", + "activity_name", + "type_uid", + "type_name", + "severity_id", + "severity" + ], + "title": "EventClassificationModel", + "type": "object" + }, + "ParticipantOutcomeReportSourceModel": { + "additionalProperties": false, + "description": "SEM-215 grounding source for one participant outcome report.", + "properties": { + "source_kind": { + "enum": [ + "action_result", + "episode_status", + "evidence" + ], + "title": "Source Kind", + "type": "string" + }, + "source_ref": { + "minLength": 1, + "title": "Source Ref", + "type": "string" + } + }, + "required": [ + "source_kind", + "source_ref" + ], + "title": "ParticipantOutcomeReportSourceModel", + "type": "object" + }, + "ParticipantOutcomeReportStateRelationshipModel": { + "additionalProperties": false, + "description": "Declared relationship between an outcome report and downstream state.", + "properties": { + "relationship_basis": { + "enum": [ + "declared", + "interpretation_rule" + ], + "title": "Relationship Basis", + "type": "string" + }, + "relationship_kind": { + "enum": [ + "scenario_state", + "workflow_state", + "objective_window", + "evaluation_input" + ], + "title": "Relationship Kind", + "type": "string" + }, + "target_ref": { + "minLength": 1, + "title": "Target Ref", + "type": "string" + } + }, + "required": [ + "relationship_kind", + "target_ref", + "relationship_basis" + ], + "title": "ParticipantOutcomeReportStateRelationshipModel", + "type": "object" + }, + "RawDataIntegrityModel": { + "additionalProperties": false, + "description": "Hash, size, and truncation facts for raw data behind a runtime claim.", + "properties": { + "raw_data_hash": { + "anyOf": [ + { + "minLength": 1, + "pattern": "^(?:sha256:[A-Fa-f0-9]{64}|sha384:[A-Fa-f0-9]{96}|sha512:[A-Fa-f0-9]{128}|blake3:[A-Fa-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Hash" + }, + "raw_data_hash_algorithm": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Hash Algorithm" + }, + "raw_data_is_truncated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Is Truncated" + }, + "raw_data_size": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Size" + }, + "raw_data_untruncated_size": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Untruncated Size" + } + }, + "title": "RawDataIntegrityModel", + "type": "object" + }, + "SourcePipelineModel": { + "additionalProperties": false, + "description": "Source product, identity, and pipeline-time facts for a mapped record.", + "properties": { + "correlation_uid": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Correlation Uid" + }, + "log_name": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log Name" + }, + "log_provider": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log Provider" + }, + "log_source": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log Source" + }, + "logged_time": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Logged Time" + }, + "original_event_uid": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Original Event Uid" + }, + "original_time": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Original Time" + }, + "processed_time": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Processed Time" + }, + "product_ref": { + "minLength": 1, + "title": "Product Ref", + "type": "string" + }, + "product_version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Product Version" + }, + "sequence": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sequence" + }, + "transmit_time": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Transmit Time" + } + }, + "required": [ + "product_ref" + ], + "title": "SourcePipelineModel", + "type": "object" + }, + "SourceStatusModel": { + "additionalProperties": false, + "description": "Normalized source status claim for one participant runtime record.", + "properties": { + "source_status_label": { + "minLength": 1, + "title": "Source Status Label", + "type": "string" + }, + "source_status_mapping": { + "minLength": 1, + "title": "Source Status Mapping", + "type": "string" + }, + "status": { + "minLength": 1, + "title": "Status", + "type": "string" + }, + "status_code": { + "minLength": 1, + "title": "Status Code", + "type": "string" + }, + "status_detail": { + "minLength": 1, + "title": "Status Detail", + "type": "string" + }, + "status_id": { + "title": "Status Id", + "type": "integer" + } + }, + "required": [ + "status_id", + "status", + "status_code", + "status_detail", + "source_status_label", + "source_status_mapping" + ], + "title": "SourceStatusModel", + "type": "object" + } + }, + "$id": "https://aces.dev/schemas/participant-outcome-report-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "SEM-215 outcome interpretation report.\n\nThe carrier deliberately has no score, reward, or objective-success\nfield: reward and return remain ADR-054 step signals, and objective and\nevaluation results remain their own contract surfaces.", + "properties": { + "actor_ref": { + "minLength": 1, + "title": "Actor Ref", + "type": "string" + }, + "authorization_scope": { + "minLength": 1, + "title": "Authorization Scope", + "type": "string" + }, + "clock_authority": { + "minLength": 1, + "title": "Clock Authority", + "type": "string" + }, + "confidence": { + "anyOf": [ + { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confidence" + }, + "episode_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Episode Id" + }, + "event_classification": { + "anyOf": [ + { + "$ref": "#/$defs/EventClassificationModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "event_id": { + "minLength": 1, + "title": "Event Id", + "type": "string" + }, + "event_type": { + "minLength": 1, + "title": "Event Type", + "type": "string" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "extension_policy": { + "minLength": 1, + "title": "Extension Policy", + "type": "string" + }, + "granular_markings": { + "additionalProperties": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "propertyNames": { + "minLength": 1 + }, + "title": "Granular Markings", + "type": "object" + }, + "ingested_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Ingested At", + "type": "string" + }, + "interpretation_rule_ref": { + "minLength": 1, + "title": "Interpretation Rule Ref", + "type": "string" + }, + "logical_order_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Logical Order Ref" + }, + "marking_definition_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Marking Definition Refs", + "type": "array" + }, + "markings": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Markings", + "type": "array" + }, + "object_marking_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Object Marking Refs", + "type": "array" + }, + "occurred_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Occurred At", + "type": "string" + }, + "ordering_basis": { + "enum": [ + "total_order", + "partial_order", + "simultaneous", + "serialized_backend_order", + "simulation_tick", + "control_plane_order", + "logical_clock", + "vector_clock", + "wall_clock_only", + "unknown", + "unsupported" + ], + "title": "Ordering Basis", + "type": "string" + }, + "outcome_id": { + "minLength": 1, + "title": "Outcome Id", + "type": "string" + }, + "outcome_sources": { + "items": { + "$ref": "#/$defs/ParticipantOutcomeReportSourceModel" + }, + "minItems": 1, + "title": "Outcome Sources", + "type": "array" + }, + "participant_address": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Participant Address" + }, + "predecessor_event_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Predecessor Event Refs", + "type": "array" + }, + "producer_ref": { + "minLength": 1, + "title": "Producer Ref", + "type": "string" + }, + "provenance_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Provenance Refs", + "type": "array" + }, + "raw_data_integrity": { + "anyOf": [ + { + "$ref": "#/$defs/RawDataIntegrityModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "recorded_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Recorded At", + "type": "string" + }, + "redaction_policy_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Redaction Policy Ref" + }, + "schema_name": { + "minLength": 1, + "title": "Schema Name", + "type": "string" + }, + "schema_version": { + "minLength": 1, + "title": "Schema Version", + "type": "string" + }, + "sequence_number": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sequence Number" + }, + "source_pipeline": { + "anyOf": [ + { + "$ref": "#/$defs/SourcePipelineModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "source_raw_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Raw Ref" + }, + "source_record_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Record Ref" + }, + "source_status": { + "anyOf": [ + { + "$ref": "#/$defs/SourceStatusModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "source_system_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source System Ref" + }, + "state_relationships": { + "items": { + "$ref": "#/$defs/ParticipantOutcomeReportStateRelationshipModel" + }, + "title": "State Relationships", + "type": "array" + }, + "temporal_context": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Temporal Context" + } + }, + "required": [ + "event_id", + "schema_name", + "schema_version", + "event_type", + "extension_policy", + "occurred_at", + "recorded_at", + "ingested_at", + "clock_authority", + "ordering_basis", + "actor_ref", + "producer_ref", + "authorization_scope", + "outcome_id", + "interpretation_rule_ref", + "outcome_sources" + ], + "title": "ParticipantOutcomeReportModel", + "type": "object" +} diff --git a/contracts/schemas/participant-runtime/participant-shared-state-record-v1.json b/contracts/schemas/participant-runtime/participant-shared-state-record-v1.json new file mode 100644 index 000000000..527638654 --- /dev/null +++ b/contracts/schemas/participant-runtime/participant-shared-state-record-v1.json @@ -0,0 +1,925 @@ +{ + "$defs": { + "EventClassificationModel": { + "additionalProperties": false, + "description": "ACES-native normalized event classification tuple (ADR-054).", + "properties": { + "activity_id": { + "title": "Activity Id", + "type": "integer" + }, + "activity_name": { + "minLength": 1, + "title": "Activity Name", + "type": "string" + }, + "category_name": { + "minLength": 1, + "title": "Category Name", + "type": "string" + }, + "category_uid": { + "title": "Category Uid", + "type": "integer" + }, + "class_name": { + "minLength": 1, + "title": "Class Name", + "type": "string" + }, + "class_uid": { + "title": "Class Uid", + "type": "integer" + }, + "severity": { + "minLength": 1, + "title": "Severity", + "type": "string" + }, + "severity_id": { + "title": "Severity Id", + "type": "integer" + }, + "type_name": { + "minLength": 1, + "title": "Type Name", + "type": "string" + }, + "type_uid": { + "title": "Type Uid", + "type": "integer" + } + }, + "required": [ + "category_uid", + "category_name", + "class_uid", + "class_name", + "activity_id", + "activity_name", + "type_uid", + "type_name", + "severity_id", + "severity" + ], + "title": "EventClassificationModel", + "type": "object" + }, + "ParticipantSharedStateAccessModel": { + "additionalProperties": false, + "description": "RUN-307 read/write access record over one shared-state address.", + "properties": { + "access_kind": { + "enum": [ + "read", + "write", + "read_write" + ], + "title": "Access Kind", + "type": "string" + }, + "access_purpose": { + "minLength": 1, + "title": "Access Purpose", + "type": "string" + }, + "atomic_group_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Atomic Group Ref" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "read_digest": { + "anyOf": [ + { + "minLength": 1, + "pattern": "^(?:sha256:[A-Fa-f0-9]{64}|sha384:[A-Fa-f0-9]{96}|sha512:[A-Fa-f0-9]{128}|blake3:[A-Fa-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Read Digest" + }, + "read_revision": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Read Revision" + }, + "snapshot_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Snapshot Ref" + }, + "state_address": { + "minLength": 1, + "title": "State Address", + "type": "string" + }, + "write_digest": { + "anyOf": [ + { + "minLength": 1, + "pattern": "^(?:sha256:[A-Fa-f0-9]{64}|sha384:[A-Fa-f0-9]{96}|sha512:[A-Fa-f0-9]{128}|blake3:[A-Fa-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Write Digest" + }, + "write_revision": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Write Revision" + } + }, + "required": [ + "state_address", + "access_kind", + "access_purpose" + ], + "title": "ParticipantSharedStateAccessModel", + "type": "object" + }, + "RawDataIntegrityModel": { + "additionalProperties": false, + "description": "Hash, size, and truncation facts for raw data behind a runtime claim.", + "properties": { + "raw_data_hash": { + "anyOf": [ + { + "minLength": 1, + "pattern": "^(?:sha256:[A-Fa-f0-9]{64}|sha384:[A-Fa-f0-9]{96}|sha512:[A-Fa-f0-9]{128}|blake3:[A-Fa-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Hash" + }, + "raw_data_hash_algorithm": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Hash Algorithm" + }, + "raw_data_is_truncated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Is Truncated" + }, + "raw_data_size": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Size" + }, + "raw_data_untruncated_size": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Data Untruncated Size" + } + }, + "title": "RawDataIntegrityModel", + "type": "object" + }, + "SourcePipelineModel": { + "additionalProperties": false, + "description": "Source product, identity, and pipeline-time facts for a mapped record.", + "properties": { + "correlation_uid": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Correlation Uid" + }, + "log_name": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log Name" + }, + "log_provider": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log Provider" + }, + "log_source": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log Source" + }, + "logged_time": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Logged Time" + }, + "original_event_uid": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Original Event Uid" + }, + "original_time": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Original Time" + }, + "processed_time": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Processed Time" + }, + "product_ref": { + "minLength": 1, + "title": "Product Ref", + "type": "string" + }, + "product_version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Product Version" + }, + "sequence": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sequence" + }, + "transmit_time": { + "anyOf": [ + { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Transmit Time" + } + }, + "required": [ + "product_ref" + ], + "title": "SourcePipelineModel", + "type": "object" + }, + "SourceStatusModel": { + "additionalProperties": false, + "description": "Normalized source status claim for one participant runtime record.", + "properties": { + "source_status_label": { + "minLength": 1, + "title": "Source Status Label", + "type": "string" + }, + "source_status_mapping": { + "minLength": 1, + "title": "Source Status Mapping", + "type": "string" + }, + "status": { + "minLength": 1, + "title": "Status", + "type": "string" + }, + "status_code": { + "minLength": 1, + "title": "Status Code", + "type": "string" + }, + "status_detail": { + "minLength": 1, + "title": "Status Detail", + "type": "string" + }, + "status_id": { + "title": "Status Id", + "type": "integer" + } + }, + "required": [ + "status_id", + "status", + "status_code", + "status_detail", + "source_status_label", + "source_status_mapping" + ], + "title": "SourceStatusModel", + "type": "object" + } + }, + "$id": "https://aces.dev/schemas/participant-shared-state-record-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "RUN-307 versioned shared operational state-change report.", + "properties": { + "accesses": { + "items": { + "$ref": "#/$defs/ParticipantSharedStateAccessModel" + }, + "title": "Accesses", + "type": "array" + }, + "actor_ref": { + "minLength": 1, + "title": "Actor Ref", + "type": "string" + }, + "authorization_scope": { + "minLength": 1, + "title": "Authorization Scope", + "type": "string" + }, + "clock_authority": { + "minLength": 1, + "title": "Clock Authority", + "type": "string" + }, + "confidence": { + "anyOf": [ + { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confidence" + }, + "conflict_policy": { + "enum": [ + "coordinate", + "serialize", + "reject", + "retry", + "withhold", + "merge", + "rollback", + "disclose_weak_guarantee", + "unsupported" + ], + "title": "Conflict Policy", + "type": "string" + }, + "digest": { + "anyOf": [ + { + "minLength": 1, + "pattern": "^(?:sha256:[A-Fa-f0-9]{64}|sha384:[A-Fa-f0-9]{96}|sha512:[A-Fa-f0-9]{128}|blake3:[A-Fa-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Digest" + }, + "episode_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Episode Id" + }, + "event_classification": { + "anyOf": [ + { + "$ref": "#/$defs/EventClassificationModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "event_id": { + "minLength": 1, + "title": "Event Id", + "type": "string" + }, + "event_type": { + "minLength": 1, + "title": "Event Type", + "type": "string" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "extension_policy": { + "minLength": 1, + "title": "Extension Policy", + "type": "string" + }, + "granular_markings": { + "additionalProperties": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "propertyNames": { + "minLength": 1 + }, + "title": "Granular Markings", + "type": "object" + }, + "ingested_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Ingested At", + "type": "string" + }, + "logical_order_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Logical Order Ref" + }, + "marking_definition_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Marking Definition Refs", + "type": "array" + }, + "markings": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Markings", + "type": "array" + }, + "object_marking_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Object Marking Refs", + "type": "array" + }, + "occurred_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Occurred At", + "type": "string" + }, + "ordering_basis": { + "enum": [ + "total_order", + "partial_order", + "simultaneous", + "serialized_backend_order", + "simulation_tick", + "control_plane_order", + "logical_clock", + "vector_clock", + "wall_clock_only", + "unknown", + "unsupported" + ], + "title": "Ordering Basis", + "type": "string" + }, + "participant_address": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Participant Address" + }, + "predecessor_event_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Predecessor Event Refs", + "type": "array" + }, + "predecessor_revision_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Predecessor Revision Refs", + "type": "array" + }, + "producer_ref": { + "minLength": 1, + "title": "Producer Ref", + "type": "string" + }, + "provenance": { + "minLength": 1, + "title": "Provenance", + "type": "string" + }, + "provenance_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Provenance Refs", + "type": "array" + }, + "raw_data_integrity": { + "anyOf": [ + { + "$ref": "#/$defs/RawDataIntegrityModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "recorded_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Recorded At", + "type": "string" + }, + "redaction_policy_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Redaction Policy Ref" + }, + "revision": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Revision" + }, + "schema_name": { + "minLength": 1, + "title": "Schema Name", + "type": "string" + }, + "schema_version": { + "minLength": 1, + "title": "Schema Version", + "type": "string" + }, + "sequence_number": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sequence Number" + }, + "source_pipeline": { + "anyOf": [ + { + "$ref": "#/$defs/SourcePipelineModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "source_raw_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Raw Ref" + }, + "source_record_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Record Ref" + }, + "source_status": { + "anyOf": [ + { + "$ref": "#/$defs/SourceStatusModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "source_system_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source System Ref" + }, + "state_address": { + "minLength": 1, + "title": "State Address", + "type": "string" + }, + "state_kind": { + "minLength": 1, + "title": "State Kind", + "type": "string" + }, + "state_scope": { + "minLength": 1, + "title": "State Scope", + "type": "string" + }, + "temporal_context": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Temporal Context" + }, + "value_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Value Ref" + }, + "visibility_projection_basis": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Visibility Projection Basis" + } + }, + "required": [ + "event_id", + "schema_name", + "schema_version", + "event_type", + "extension_policy", + "occurred_at", + "recorded_at", + "ingested_at", + "clock_authority", + "ordering_basis", + "actor_ref", + "producer_ref", + "authorization_scope", + "state_address", + "state_scope", + "state_kind", + "conflict_policy", + "provenance" + ], + "title": "ParticipantSharedStateRecordModel", + "type": "object" +} diff --git a/contracts/schemas/profiles/backend-profile-v1.json b/contracts/schemas/profiles/backend-profile-v1.json index 521aab6fc..200a8a8d2 100644 --- a/contracts/schemas/profiles/backend-profile-v1.json +++ b/contracts/schemas/profiles/backend-profile-v1.json @@ -25,7 +25,11 @@ "evaluation-history-event-stream-v1", "participant-episode-state-envelope-v1", "participant-episode-history-event-stream-v1", - "participant-behavior-history-event-stream-v1" + "participant-behavior-history-event-stream-v1", + "participant-lifecycle-event-v1", + "participant-observation-envelope-v1", + "participant-shared-state-record-v1", + "participant-outcome-report-v1" ], "type": "string" }, diff --git a/implementations/python/packages/aces_backend_stubs/stubs.py b/implementations/python/packages/aces_backend_stubs/stubs.py index 3e53bede5..e2ec36e7b 100644 --- a/implementations/python/packages/aces_backend_stubs/stubs.py +++ b/implementations/python/packages/aces_backend_stubs/stubs.py @@ -78,6 +78,10 @@ def create_stub_manifest( supported_contract_versions.discard("participant-episode-state-envelope-v1") supported_contract_versions.discard("participant-episode-history-event-stream-v1") supported_contract_versions.discard("participant-behavior-history-event-stream-v1") + supported_contract_versions.discard("participant-lifecycle-event-v1") + supported_contract_versions.discard("participant-observation-envelope-v1") + supported_contract_versions.discard("participant-shared-state-record-v1") + supported_contract_versions.discard("participant-outcome-report-v1") concept_bindings = ( ConceptBinding(scope="capabilities.provisioner.supported_node_types", family="assets"), ConceptBinding(scope="capabilities.provisioner.supported_os_families", family="assets"), diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index 14d58b667..930d97bc9 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -79,6 +79,7 @@ from .vocabulary import ( ConceptFamilyId, ConceptProvenanceCategory, + ParticipantFeatureSupportLevel, ProcessorFeature, RealizationSupportMode, WorkflowFeature, @@ -118,6 +119,7 @@ class ContractModel(BaseModel): NonNegativeInteger = Annotated[int, Field(ge=0)] PositiveInteger = Annotated[int, Field(ge=1)] UnitIntervalFloat = Annotated[float, Field(gt=0, le=1)] +ClosedUnitIntervalFloat = Annotated[float, Field(ge=0, le=1)] SemanticProfileId = Annotated[str, Field(pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*-v[0-9]+$")] SemanticAssumptionId = Annotated[str, Field(pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$")] ReferenceModelId = Annotated[str, Field(pattern=r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")] @@ -782,6 +784,355 @@ def _validate_lifecycle_fields(self) -> ParticipantBehaviorHistoryEventModel: return self +ParticipantRuntimeOrderingBasis = Literal[ + "total_order", + "partial_order", + "simultaneous", + "serialized_backend_order", + "simulation_tick", + "control_plane_order", + "logical_clock", + "vector_clock", + "wall_clock_only", + "unknown", + "unsupported", +] +ParticipantRuntimeMappingLoss = Literal[ + "none", + "private_apparatus_detail", + "source_fields_omitted", + "semantics_approximated", + "redacted_by_policy", + "temporal_detail_collapsed", + "unknown", + "unsupported", +] +ParticipantRuntimeInformationGuarantee = Literal[ + "observation_only", + "history_consistent", + "perfect_recall", + "lossy_projection", + "unknown", + "unsupported", +] +ParticipantRuntimeDeliveryBasis = Literal[ + "emission_is_delivery", + "runtime_delivery", + "participant_acknowledgement", + "external_delivery", + "unknown", + "unsupported", +] +ParticipantRuntimeConflictPolicy = Literal[ + "coordinate", + "serialize", + "reject", + "retry", + "withhold", + "merge", + "rollback", + "disclose_weak_guarantee", + "unsupported", +] + + +class EventClassificationModel(ContractModel): + """ACES-native normalized event classification tuple (ADR-054).""" + + category_uid: int + category_name: NonEmptyString + class_uid: int + class_name: NonEmptyString + activity_id: int + activity_name: NonEmptyString + type_uid: int + type_name: NonEmptyString + severity_id: int + severity: NonEmptyString + + +class SourceStatusModel(ContractModel): + """Normalized source status claim for one participant runtime record.""" + + status_id: int + status: NonEmptyString + status_code: NonEmptyString + status_detail: NonEmptyString + source_status_label: NonEmptyString + source_status_mapping: NonEmptyString + + +class SourcePipelineModel(ContractModel): + """Source product, identity, and pipeline-time facts for a mapped record.""" + + product_ref: NonEmptyString + product_version: NonEmptyString | None = None + log_provider: NonEmptyString | None = None + log_source: NonEmptyString | None = None + log_name: NonEmptyString | None = None + original_event_uid: NonEmptyString | None = None + original_time: Rfc3339DateTimeString | None = None + processed_time: Rfc3339DateTimeString | None = None + logged_time: Rfc3339DateTimeString | None = None + transmit_time: Rfc3339DateTimeString | None = None + correlation_uid: NonEmptyString | None = None + sequence: NonNegativeInteger | None = None + + +class RawDataIntegrityModel(ContractModel): + """Hash, size, and truncation facts for raw data behind a runtime claim.""" + + raw_data_hash: PrefixedDigestString | None = None + raw_data_hash_algorithm: NonEmptyString | None = None + raw_data_size: NonNegativeInteger | None = None + raw_data_is_truncated: bool | None = None + raw_data_untruncated_size: NonNegativeInteger | None = None + + +class ParticipantRuntimeBaseEnvelopeModel(ContractModel): + """Shared ADR-054 base envelope for participant-runtime family carriers. + + Every published carrier in the ``participant-runtime`` family embeds this + envelope exactly once: identity, classification and source status, + participant/episode scoping, the three distinct timestamps with clock + authority, ordering, actor/source refs, raw-data integrity, confidence, + provenance/evidence refs, and the marking surface. No carrier redefines + local identity, versioning, marking, or extension semantics. + """ + + event_id: NonEmptyString + schema_name: NonEmptyString + schema_version: NonEmptyString + event_type: NonEmptyString + extension_policy: NonEmptyString + event_classification: EventClassificationModel | None = None + source_status: SourceStatusModel | None = None + participant_address: NonEmptyString | None = None + episode_id: NonEmptyString | None = None + sequence_number: NonNegativeInteger | None = None + occurred_at: Rfc3339DateTimeString + recorded_at: Rfc3339DateTimeString + ingested_at: Rfc3339DateTimeString + clock_authority: NonEmptyString + temporal_context: NonEmptyString | None = None + ordering_basis: ParticipantRuntimeOrderingBasis + logical_order_ref: NonEmptyString | None = None + predecessor_event_refs: list[NonEmptyString] = Field(default_factory=list) + actor_ref: NonEmptyString + producer_ref: NonEmptyString + source_system_ref: NonEmptyString | None = None + source_record_ref: NonEmptyString | None = None + source_raw_ref: NonEmptyString | None = None + source_pipeline: SourcePipelineModel | None = None + raw_data_integrity: RawDataIntegrityModel | None = None + confidence: ClosedUnitIntervalFloat | None = None + provenance_refs: list[NonEmptyString] = Field(default_factory=list) + evidence_refs: list[NonEmptyString] = Field(default_factory=list) + marking_definition_refs: list[NonEmptyString] = Field(default_factory=list) + object_marking_refs: list[NonEmptyString] = Field(default_factory=list) + markings: list[NonEmptyString] = Field(default_factory=list) + granular_markings: dict[NonEmptyString, list[NonEmptyString]] = Field(default_factory=dict) + redaction_policy_ref: NonEmptyString | None = None + authorization_scope: NonEmptyString + + +class ParticipantLifecycleEventModel(ParticipantRuntimeBaseEnvelopeModel): + """RUN-306 lifecycle boundary record for one participant action event.""" + + phase: ParticipantRuntimeLifecyclePhase + phase_realization: ParticipantPhaseRealization + admission_disposition: ParticipantAdmissionDisposition | None = None + operation_ref: NonEmptyString | None = None + action_ref: NonEmptyString | None = None + action_contract_ref: NonEmptyString | None = None + command_ref: NonEmptyString | None = None + actor_provenance: NonEmptyString + action_validity_basis_ref: NonEmptyString | None = None + observation_refs: list[NonEmptyString] = Field(default_factory=list) + shared_state_read_refs: list[NonEmptyString] = Field(default_factory=list) + shared_state_write_refs: list[NonEmptyString] = Field(default_factory=list) + emitted_state_update_refs: list[NonEmptyString] = Field(default_factory=list) + attribution_edge_refs: list[NonEmptyString] = Field(default_factory=list) + outcome_interpretation_refs: list[NonEmptyString] = Field(default_factory=list) + joint_action_set_ref: NonEmptyString | None = None + source_status_label: NonEmptyString | None = None + mapping_loss: ParticipantRuntimeMappingLoss | None = None + mapping_loss_detail: NonEmptyString | None = None + + +class ParticipantObservationLossDescriptorModel(ContractModel): + """Declared projection-loss facts for one participant-visible observation.""" + + kind: NonEmptyString + fields_redacted: list[NonEmptyString] = Field(default_factory=list) + + +class ParticipantObservationStochasticContextModel(ContractModel): + """Seed and randomization-policy references behind one observation.""" + + seed_ref: NonEmptyString | None = None + randomization_policy_ref: NonEmptyString | None = None + + +class ParticipantObservationEnvelopeModel(ParticipantRuntimeBaseEnvelopeModel): + """SEM-210 participant-visible observation record with explicit guarantees.""" + + observation_ref: NonEmptyString + phase_ref: NonEmptyString | None = None + visibility_projection_ref: NonEmptyString + information_guarantee: ParticipantRuntimeInformationGuarantee + delivery_basis: ParticipantRuntimeDeliveryBasis + delivery_point_ref: NonEmptyString | None = None + delivered_at: Rfc3339DateTimeString | None = None + action_observation_history_ref: NonEmptyString | None = None + information_state_ref: NonEmptyString | None = None + hidden_state_refs: list[NonEmptyString] = Field(default_factory=list) + centralized_state_refs: list[NonEmptyString] = Field(default_factory=list) + loss_descriptor: ParticipantObservationLossDescriptorModel | None = None + stochastic_context: ParticipantObservationStochasticContextModel | None = None + noise_model_ref: NonEmptyString | None = None + reconstruction_algorithm_ref: NonEmptyString | None = None + reconstruction_proof_ref: NonEmptyString | None = None + belief_support_ref: NonEmptyString | None = None + redacted_field_refs: list[NonEmptyString] = Field(default_factory=list) + + +class ParticipantSharedStateAccessModel(ContractModel): + """RUN-307 read/write access record over one shared-state address.""" + + state_address: NonEmptyString + access_kind: Literal["read", "write", "read_write"] + read_revision: NonEmptyString | None = None + write_revision: NonEmptyString | None = None + read_digest: PrefixedDigestString | None = None + write_digest: PrefixedDigestString | None = None + snapshot_ref: NonEmptyString | None = None + access_purpose: NonEmptyString + atomic_group_ref: NonEmptyString | None = None + evidence_refs: list[NonEmptyString] = Field(default_factory=list) + + +class ParticipantSharedStateRecordModel(ParticipantRuntimeBaseEnvelopeModel): + """RUN-307 versioned shared operational state-change report.""" + + state_address: NonEmptyString + state_scope: NonEmptyString + state_kind: NonEmptyString + revision: NonEmptyString | None = None + digest: PrefixedDigestString | None = None + predecessor_revision_refs: list[NonEmptyString] = Field(default_factory=list) + conflict_policy: ParticipantRuntimeConflictPolicy + visibility_projection_basis: NonEmptyString | None = None + provenance: NonEmptyString + value_ref: NonEmptyString | None = None + accesses: list[ParticipantSharedStateAccessModel] = Field(default_factory=list) + + +class ParticipantOutcomeReportSourceModel(ContractModel): + """SEM-215 grounding source for one participant outcome report.""" + + source_kind: Literal["action_result", "episode_status", "evidence"] + source_ref: NonEmptyString + + +class ParticipantOutcomeReportStateRelationshipModel(ContractModel): + """Declared relationship between an outcome report and downstream state.""" + + relationship_kind: Literal["scenario_state", "workflow_state", "objective_window", "evaluation_input"] + target_ref: NonEmptyString + relationship_basis: Literal["declared", "interpretation_rule"] + + +class ParticipantOutcomeReportModel(ParticipantRuntimeBaseEnvelopeModel): + """SEM-215 outcome interpretation report. + + The carrier deliberately has no score, reward, or objective-success + field: reward and return remain ADR-054 step signals, and objective and + evaluation results remain their own contract surfaces. + """ + + outcome_id: NonEmptyString + interpretation_rule_ref: NonEmptyString + outcome_sources: list[ParticipantOutcomeReportSourceModel] = Field(min_length=1) + state_relationships: list[ParticipantOutcomeReportStateRelationshipModel] = Field(default_factory=list) + + +class ParticipantStatusViewModel(ContractModel): + """API-408 retrieval projection of one participant's episode status.""" + + view_id: NonEmptyString + participant_address: NonEmptyString + episode_id: NonEmptyString | None = None + generated_at: Rfc3339DateTimeString + source_snapshot_ref: NonEmptyString + episode_state: ParticipantEpisodeStateModel | None = None + open_operation_refs: list[NonEmptyString] = Field(default_factory=list) + visibility_projection_ref: NonEmptyString + marking_definition_refs: list[NonEmptyString] = Field(default_factory=list) + redaction_policy_ref: NonEmptyString | None = None + + +class ParticipantHistoryViewModel(ContractModel): + """API-408 retrieval projection of participant episode/behavior history.""" + + view_id: NonEmptyString + participant_address: NonEmptyString + episode_id: NonEmptyString + generated_at: Rfc3339DateTimeString + source_snapshot_ref: NonEmptyString + episode_history: list[ParticipantEpisodeHistoryEventModel] = Field(default_factory=list) + behavior_history: list[ParticipantBehaviorHistoryEventModel] = Field(default_factory=list) + visibility_projection_ref: NonEmptyString + redaction_policy_ref: NonEmptyString | None = None + completeness: Literal["complete", "truncated", "filtered"] + completeness_basis: NonEmptyString | None = None + marking_definition_refs: list[NonEmptyString] = Field(default_factory=list) + + @model_validator(mode="after") + def _validate_completeness_basis(self) -> ParticipantHistoryViewModel: + if self.completeness != "complete" and self.completeness_basis is None: + raise ValueError("completeness_basis is required when completeness is not 'complete'") + return self + + @classmethod + def __get_pydantic_json_schema__( + cls, + core_schema: CoreSchema, + handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + json_schema = handler(core_schema) + json_schema = handler.resolve_ref_schema(json_schema) + json_schema.setdefault("allOf", []).append( + { + "if": { + "properties": {"completeness": {"enum": ["truncated", "filtered"]}}, + "required": ["completeness"], + }, + "then": { + "required": ["completeness_basis"], + "properties": {"completeness_basis": {"type": "string", "minLength": 1}}, + }, + } + ) + return json_schema + + +class ParticipantContextViewModel(ContractModel): + """API-408 derived operational context view (reference-and-provenance only).""" + + view_id: NonEmptyString + participant_address: NonEmptyString + episode_id: NonEmptyString | None = None + generated_at: Rfc3339DateTimeString + view_ref: NonEmptyString + derived_from_refs: list[NonEmptyString] = Field(min_length=1) + derivation_basis_ref: NonEmptyString | None = None + payload_ref: NonEmptyString | None = None + visibility_projection_ref: NonEmptyString + marking_definition_refs: list[NonEmptyString] = Field(default_factory=list) + redaction_policy_ref: NonEmptyString | None = None + + class PlanOperationModel(ContractModel): action: str address: str @@ -1151,6 +1502,75 @@ def __get_pydantic_json_schema__( return json_schema +_PARTICIPANT_FEATURE_SUPPORT_VOCABULARY_IDS = ( + "participant-runtime-behavior-features", + "participant-runtime-interaction-features", +) + + +def _validate_participant_feature_support_term(feature: str) -> None: + from .controlled_vocabularies import load_controlled_vocabulary_catalog + + catalog = load_controlled_vocabulary_catalog() + for vocabulary_id in _PARTICIPANT_FEATURE_SUPPORT_VOCABULARY_IDS: + definition = catalog.vocabularies[vocabulary_id] + if feature in definition.terms: + return + if definition.extension_pattern is not None and re.fullmatch(definition.extension_pattern, feature): + return + joined = ", ".join(_PARTICIPANT_FEATURE_SUPPORT_VOCABULARY_IDS) + raise ValueError( + f"feature_support feature '{feature}' is not a governed term of {joined} " + "and does not match the governed extension pattern" + ) + + +class ParticipantFeatureSupportModel(ContractModel): + """API-407 per-feature participant runtime support declaration.""" + + feature: NonEmptyString + support_level: ParticipantFeatureSupportLevel + constraint_refs: list[NonEmptyString] = Field(default_factory=list) + disclosure_refs: list[NonEmptyString] = Field(default_factory=list) + + @model_validator(mode="after") + def _validate_feature_support_declaration(self) -> ParticipantFeatureSupportModel: + _validate_participant_feature_support_term(self.feature) + _validate_unique_string_values("constraint_refs", self.constraint_refs) + _validate_unique_string_values("disclosure_refs", self.disclosure_refs) + if self.support_level != ParticipantFeatureSupportLevel.EXACT and not self.disclosure_refs: + raise ValueError( + f"feature_support entry '{self.feature}' declares support_level " + f"'{self.support_level.value}' below 'exact' and must carry at least one disclosure_refs entry" + ) + return self + + @classmethod + def __get_pydantic_json_schema__( + cls, + core_schema: CoreSchema, + handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + json_schema = handler(core_schema) + json_schema = handler.resolve_ref_schema(json_schema) + below_exact = [ + level.value for level in ParticipantFeatureSupportLevel if level != ParticipantFeatureSupportLevel.EXACT + ] + json_schema.setdefault("allOf", []).append( + { + "if": { + "properties": {"support_level": {"enum": below_exact}}, + "required": ["support_level"], + }, + "then": { + "required": ["disclosure_refs"], + "properties": {"disclosure_refs": {"minItems": 1}}, + }, + } + ) + return json_schema + + class ParticipantRuntimeCapabilitiesModel(ContractModel): """Participant-episode lifecycle capability block (RUN-311). @@ -1179,6 +1599,7 @@ class ParticipantRuntimeCapabilitiesModel(ContractModel): min_length=1, json_schema_extra={"uniqueItems": True}, ) + feature_support: list[ParticipantFeatureSupportModel] = Field(default_factory=list) constraints: dict[str, str] = Field(default_factory=dict) @model_validator(mode="after") @@ -1200,6 +1621,19 @@ def _validate_api_405_declarations(self) -> ParticipantRuntimeCapabilitiesModel: ) return self + @model_validator(mode="after") + def _validate_api_407_feature_support(self) -> ParticipantRuntimeCapabilitiesModel: + _validate_unique_string_values("feature_support", [entry.feature for entry in self.feature_support]) + supported_features = set(self.supported_behavior_features) | set(self.supported_interaction_features) + for entry in self.feature_support: + declared_unsupported = entry.support_level == ParticipantFeatureSupportLevel.UNSUPPORTED + if declared_unsupported and entry.feature in supported_features: + raise ValueError( + f"feature_support entry '{entry.feature}' declares support_level 'unsupported' but the " + "feature is declared in supported_behavior_features or supported_interaction_features" + ) + return self + class BackendCapabilitiesV2Model(ContractModel): provisioner: ProvisionerCapabilitiesModel @@ -4427,6 +4861,13 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "ParticipantBehaviorHistoryEventStream", ParticipantBehaviorHistoryEventModel.model_json_schema(), ), + "participant-lifecycle-event-v1": ParticipantLifecycleEventModel.model_json_schema(), + "participant-observation-envelope-v1": ParticipantObservationEnvelopeModel.model_json_schema(), + "participant-shared-state-record-v1": ParticipantSharedStateRecordModel.model_json_schema(), + "participant-outcome-report-v1": ParticipantOutcomeReportModel.model_json_schema(), + "participant-status-view-v1": ParticipantStatusViewModel.model_json_schema(), + "participant-history-view-v1": ParticipantHistoryViewModel.model_json_schema(), + "participant-context-view-v1": ParticipantContextViewModel.model_json_schema(), "operation-receipt-v1": OperationReceiptModel.model_json_schema(), "operation-status-v1": OperationStatusModel.model_json_schema(), } @@ -4512,6 +4953,7 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "EvaluationResultStateModel", "EVALUATION_STATE_SCHEMA_VERSION", "EvaluatorCapabilitiesModel", + "EventClassificationModel", "InstantiationRequestModel", "OPERATION_SCHEMA_VERSION", "OperationReceiptModel", @@ -4529,18 +4971,33 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "ParticipantAttributionEvidenceBasisModel", "ParticipantAttributionOrderingBasisModel", "ParticipantBehaviorHistoryEventModel", + "ParticipantContextViewModel", "ParticipantEpisodeHistoryEventModel", "ParticipantEpisodeStateModel", "ParticipantExposurePolicyModel", + "ParticipantFeatureSupportLevel", + "ParticipantFeatureSupportModel", + "ParticipantHistoryViewModel", "ParticipantImplementationCapabilitiesModel", "ParticipantImplementationCompatibilityModel", "ParticipantImplementationManifestModel", "ParticipantImplementationProvenanceModel", "ParticipantImplementationSelectionModel", + "ParticipantLifecycleEventModel", + "ParticipantObservationEnvelopeModel", + "ParticipantObservationLossDescriptorModel", + "ParticipantObservationStochasticContextModel", "ParticipantOutcomeInterpretationRecordModel", + "ParticipantOutcomeReportModel", + "ParticipantOutcomeReportSourceModel", + "ParticipantOutcomeReportStateRelationshipModel", "ParticipantOutcomeSourceRecordModel", "ParticipantOutcomeTargetRecordModel", + "ParticipantRuntimeBaseEnvelopeModel", "ParticipantRuntimeCapabilitiesModel", + "ParticipantSharedStateAccessModel", + "ParticipantSharedStateRecordModel", + "ParticipantStatusViewModel", "ParticipantTemporalRuntimeContextModel", "PlanOperationModel", "ProcessorFeature", @@ -4550,6 +5007,7 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "ProcessorCapabilitiesV2Model", "ProvisionerCapabilitiesModel", "ProvisioningPlanModel", + "RawDataIntegrityModel", "RealizationSupportDeclarationModel", "RealizationSupportMode", "ReferenceModelCatalogModel", @@ -4565,6 +5023,8 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "SemanticProfileModel", "SemanticProfilePhaseModel", "SnapshotEntryModel", + "SourcePipelineModel", + "SourceStatusModel", "WorkflowCancellationRequestModel", "WORKFLOW_CANCELLATION_REQUEST_SCHEMA_VERSION", "WorkflowExecutionStateModel", diff --git a/implementations/python/packages/aces_contracts/manifest_authority.py b/implementations/python/packages/aces_contracts/manifest_authority.py index c121abc40..635be87e4 100644 --- a/implementations/python/packages/aces_contracts/manifest_authority.py +++ b/implementations/python/packages/aces_contracts/manifest_authority.py @@ -47,6 +47,10 @@ "participant-episode-state-envelope-v1", "participant-episode-history-event-stream-v1", "participant-behavior-history-event-stream-v1", + "participant-lifecycle-event-v1", + "participant-observation-envelope-v1", + "participant-shared-state-record-v1", + "participant-outcome-report-v1", ) PARTICIPANT_IMPLEMENTATION_SUPPORTED_CONTRACT_IDS = ( diff --git a/implementations/python/packages/aces_contracts/versions.py b/implementations/python/packages/aces_contracts/versions.py index 439929961..02d3d1b22 100644 --- a/implementations/python/packages/aces_contracts/versions.py +++ b/implementations/python/packages/aces_contracts/versions.py @@ -16,6 +16,13 @@ OPERATION_SCHEMA_VERSION = "runtime-operation/v1" EVALUATION_STATE_SCHEMA_VERSION = "evaluation-result-state/v1" PARTICIPANT_EPISODE_STATE_SCHEMA_VERSION = "participant-episode-state/v1" +PARTICIPANT_LIFECYCLE_EVENT_V1_SCHEMA_VERSION = "participant-lifecycle-event/v1" +PARTICIPANT_OBSERVATION_ENVELOPE_V1_SCHEMA_VERSION = "participant-observation-envelope/v1" +PARTICIPANT_SHARED_STATE_RECORD_V1_SCHEMA_VERSION = "participant-shared-state-record/v1" +PARTICIPANT_OUTCOME_REPORT_V1_SCHEMA_VERSION = "participant-outcome-report/v1" +PARTICIPANT_STATUS_VIEW_V1_SCHEMA_VERSION = "participant-status-view/v1" +PARTICIPANT_HISTORY_VIEW_V1_SCHEMA_VERSION = "participant-history-view/v1" +PARTICIPANT_CONTEXT_VIEW_V1_SCHEMA_VERSION = "participant-context-view/v1" EXPERIMENT_TASK_SCHEMA_VERSION = "experiment-task/v1" EXPERIMENT_APPARATUS_CONTEXT_SCHEMA_VERSION = "experiment-apparatus-context/v1" EXPERIMENT_RUN_SCHEMA_VERSION = "experiment-run/v1" diff --git a/implementations/python/packages/aces_contracts/vocabulary.py b/implementations/python/packages/aces_contracts/vocabulary.py index 97a6ecdce..a1654305a 100644 --- a/implementations/python/packages/aces_contracts/vocabulary.py +++ b/implementations/python/packages/aces_contracts/vocabulary.py @@ -48,6 +48,15 @@ class RealizationSupportMode(str, Enum): OPEN_REALIZATION = "open-realization" +class ParticipantFeatureSupportLevel(str, Enum): + """ADR-054 guarantee-strength scale for per-feature participant runtime support.""" + + UNSUPPORTED = "unsupported" + DISCLOSED_WEAK = "disclosed_weak" + BOUNDED = "bounded" + EXACT = "exact" + + class ConceptProvenanceCategory(str, Enum): """How a concept family relates to its authority source.""" diff --git a/implementations/python/tests/test_backend_manifest.py b/implementations/python/tests/test_backend_manifest.py index 4e9a3ef5c..c776a01d6 100644 --- a/implementations/python/tests/test_backend_manifest.py +++ b/implementations/python/tests/test_backend_manifest.py @@ -21,7 +21,11 @@ from aces_backend_stubs.stubs import create_stub_manifest from aces_contracts.contracts import BackendManifestV2Model from aces_contracts.manifest_authority import BACKEND_SUPPORTED_CONTRACT_IDS -from aces_contracts.vocabulary import WorkflowFeature, WorkflowStatePredicateFeature +from aces_contracts.vocabulary import ( + ParticipantFeatureSupportLevel, + WorkflowFeature, + WorkflowStatePredicateFeature, +) from pydantic import ValidationError FIXTURES_ROOT = Path(__file__).resolve().parents[3] / "contracts" / "fixtures" @@ -228,6 +232,163 @@ def test_participant_runtime_capability_claims_require_published_contract_eviden assert any("supported_interaction_features.coordination" in gap for gap in gaps) +def _stub_payload_with_feature_support(entries: list[dict]) -> dict: + payload = json.loads((V2_VALID_DIR / "stub.json").read_text(encoding="utf-8")) + payload["capabilities"]["participant_runtime"]["feature_support"] = entries + return payload + + +def test_backend_manifest_v2_accepts_feature_support_declarations(): + """API-407: per-feature support declarations carry governed terms and levels.""" + + payload = json.loads((V2_VALID_DIR / "feature-support-bounded.json").read_text(encoding="utf-8")) + model = BackendManifestV2Model.model_validate(payload) + + assert model.capabilities.participant_runtime is not None + feature_support = model.capabilities.participant_runtime.feature_support + assert [entry.feature for entry in feature_support] == [ + "behavior_history", + "coordination", + "x-acme:custom-feature", + ] + assert feature_support[0].support_level == ParticipantFeatureSupportLevel.BOUNDED + assert feature_support[0].disclosure_refs == ["disclosures.behavior-history.bounded.v1"] + assert feature_support[2].support_level == ParticipantFeatureSupportLevel.DISCLOSED_WEAK + + +def test_backend_manifest_v2_feature_support_defaults_to_empty(): + payload = json.loads((V2_VALID_DIR / "stub.json").read_text(encoding="utf-8")) + model = BackendManifestV2Model.model_validate(payload) + + assert model.capabilities.participant_runtime is not None + assert model.capabilities.participant_runtime.feature_support == [] + + +def test_backend_manifest_v2_accepts_exact_feature_support_without_disclosure(): + payload = _stub_payload_with_feature_support( + [{"feature": "coordination", "support_level": "exact", "constraint_refs": [], "disclosure_refs": []}] + ) + model = BackendManifestV2Model.model_validate(payload) + + assert model.capabilities.participant_runtime is not None + assert model.capabilities.participant_runtime.feature_support[0].support_level == ( + ParticipantFeatureSupportLevel.EXACT + ) + + +def test_backend_manifest_v2_accepts_unsupported_feature_support_for_undeclared_feature(): + payload = _stub_payload_with_feature_support( + [ + { + "feature": "x-acme:custom-feature", + "support_level": "unsupported", + "constraint_refs": [], + "disclosure_refs": ["disclosures.acme.custom-feature.unsupported.v1"], + } + ] + ) + model = BackendManifestV2Model.model_validate(payload) + + assert model.capabilities.participant_runtime is not None + assert model.capabilities.participant_runtime.feature_support[0].support_level == ( + ParticipantFeatureSupportLevel.UNSUPPORTED + ) + + +def test_backend_manifest_v2_rejects_unguarded_feature_support_terms(): + payload = _stub_payload_with_feature_support( + [{"feature": "custom-feature", "support_level": "exact", "constraint_refs": [], "disclosure_refs": []}] + ) + + with pytest.raises(ValidationError, match="not a governed term"): + BackendManifestV2Model.model_validate(payload) + + +def test_backend_manifest_v2_rejects_unknown_feature_support_levels(): + payload = _stub_payload_with_feature_support( + [{"feature": "coordination", "support_level": "partial", "constraint_refs": [], "disclosure_refs": []}] + ) + + with pytest.raises(ValidationError, match="support_level"): + BackendManifestV2Model.model_validate(payload) + + +def test_backend_manifest_v2_rejects_duplicate_feature_support_features(): + payload = _stub_payload_with_feature_support( + [ + { + "feature": "coordination", + "support_level": "bounded", + "constraint_refs": [], + "disclosure_refs": ["disclosures.coordination.bounded.v1"], + }, + {"feature": "coordination", "support_level": "exact", "constraint_refs": [], "disclosure_refs": []}, + ] + ) + + with pytest.raises(ValidationError, match="duplicate"): + BackendManifestV2Model.model_validate(payload) + + +def test_backend_manifest_v2_rejects_unsupported_feature_support_for_declared_feature(): + payload = _stub_payload_with_feature_support( + [ + { + "feature": "behavior_history", + "support_level": "unsupported", + "constraint_refs": [], + "disclosure_refs": ["disclosures.behavior-history.unsupported.v1"], + } + ] + ) + + with pytest.raises(ValidationError, match="declares support_level 'unsupported'"): + BackendManifestV2Model.model_validate(payload) + + +@pytest.mark.parametrize("support_level", ["unsupported", "disclosed_weak", "bounded"]) +def test_backend_manifest_v2_rejects_below_exact_feature_support_without_disclosure(support_level: str): + payload = _stub_payload_with_feature_support( + [ + { + "feature": "x-acme:custom-feature", + "support_level": support_level, + "constraint_refs": [], + "disclosure_refs": [], + } + ] + ) + + with pytest.raises(ValidationError, match="disclosure_refs"): + BackendManifestV2Model.model_validate(payload) + + +def test_backend_manifest_v2_schema_publishes_feature_support_disclosure_rule(): + from aces_contracts.contracts import schema_bundle + + schema = schema_bundle()["backend-manifest-v2"] + feature_support_schema = schema["$defs"]["ParticipantFeatureSupportModel"] + + assert feature_support_schema["additionalProperties"] is False + assert feature_support_schema["properties"]["support_level"]["$ref"] == "#/$defs/ParticipantFeatureSupportLevel" + assert schema["$defs"]["ParticipantFeatureSupportLevel"]["enum"] == [ + "unsupported", + "disclosed_weak", + "bounded", + "exact", + ] + assert { + "if": { + "properties": {"support_level": {"enum": ["unsupported", "disclosed_weak", "bounded"]}}, + "required": ["support_level"], + }, + "then": { + "required": ["disclosure_refs"], + "properties": {"disclosure_refs": {"minItems": 1}}, + }, + } in feature_support_schema["allOf"] + + def test_backend_manifest_v2_requires_manifest_sections(): with pytest.raises(ValidationError): BackendManifestV2Model( diff --git a/implementations/python/tests/test_controlled_vocabularies.py b/implementations/python/tests/test_controlled_vocabularies.py index de3fba6b2..9cee1311e 100644 --- a/implementations/python/tests/test_controlled_vocabularies.py +++ b/implementations/python/tests/test_controlled_vocabularies.py @@ -14,6 +14,7 @@ ) from aces_contracts.vocabulary import ( ConceptProvenanceCategory, + ParticipantFeatureSupportLevel, ProcessorFeature, RealizationSupportMode, WorkflowFeature, @@ -91,6 +92,9 @@ def test_closed_enum_vocabularies_match_python_enums(): assert set(catalog.vocabularies["concept-provenance-categories"].terms) == { category.value for category in ConceptProvenanceCategory } + assert set(catalog.vocabularies["participant-runtime-feature-support-levels"].terms) == { + level.value for level in ParticipantFeatureSupportLevel + } def test_governed_extension_values_are_allowed_for_extensible_vocabularies(): diff --git a/implementations/python/tests/test_participant_backend_contracts.py b/implementations/python/tests/test_participant_backend_contracts.py new file mode 100644 index 000000000..377dfc877 --- /dev/null +++ b/implementations/python/tests/test_participant_backend_contracts.py @@ -0,0 +1,225 @@ +"""API-406/407/408/411 participant backend-facing contract tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from aces_contracts.contracts import ( + ParticipantContextViewModel, + ParticipantHistoryViewModel, + ParticipantLifecycleEventModel, + ParticipantObservationEnvelopeModel, + ParticipantOutcomeReportModel, + ParticipantSharedStateRecordModel, + ParticipantStatusViewModel, + schema_bundle, +) +from jsonschema import Draft202012Validator +from pydantic import ValidationError + +REPO_ROOT = Path(__file__).resolve().parents[3] +FIXTURES_ROOT = REPO_ROOT / "contracts" / "fixtures" + +PARTICIPANT_RUNTIME_FIXTURE_MODELS = { + "participant-lifecycle-event-v1": ParticipantLifecycleEventModel, + "participant-observation-envelope-v1": ParticipantObservationEnvelopeModel, + "participant-shared-state-record-v1": ParticipantSharedStateRecordModel, + "participant-outcome-report-v1": ParticipantOutcomeReportModel, +} +CONTROL_PLANE_VIEW_FIXTURE_MODELS = { + "participant-status-view-v1": ParticipantStatusViewModel, + "participant-history-view-v1": ParticipantHistoryViewModel, + "participant-context-view-v1": ParticipantContextViewModel, +} +FIXTURE_ROOTS_BY_CONTRACT = { + **{contract_id: FIXTURES_ROOT / "participant-runtime" for contract_id in PARTICIPANT_RUNTIME_FIXTURE_MODELS}, + **{contract_id: FIXTURES_ROOT / "control-plane" for contract_id in CONTROL_PLANE_VIEW_FIXTURE_MODELS}, +} +ALL_FIXTURE_MODELS = {**PARTICIPANT_RUNTIME_FIXTURE_MODELS, **CONTROL_PLANE_VIEW_FIXTURE_MODELS} + +_ORDERING_BASIS_TERMS = [ + "total_order", + "partial_order", + "simultaneous", + "serialized_backend_order", + "simulation_tick", + "control_plane_order", + "logical_clock", + "vector_clock", + "wall_clock_only", + "unknown", + "unsupported", +] + + +def _valid_fixture(contract_id: str) -> dict: + fixture_dir = FIXTURE_ROOTS_BY_CONTRACT[contract_id] / contract_id / "valid" + path = sorted(fixture_dir.glob("*.json"))[0] + return json.loads(path.read_text(encoding="utf-8")) + + +def test_participant_backend_contracts_are_published_closed_world(): + generated = schema_bundle() + for contract_id in ALL_FIXTURE_MODELS: + assert contract_id in generated + schema = generated[contract_id] + assert schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" + assert schema["$id"] == f"https://aces.dev/schemas/{contract_id}.json" + assert schema["additionalProperties"] is False + + +def test_participant_runtime_carriers_embed_one_shared_base_envelope(): + generated = schema_bundle() + base_envelope_fields = { + "event_id", + "schema_name", + "schema_version", + "event_type", + "extension_policy", + "event_classification", + "source_status", + "participant_address", + "episode_id", + "sequence_number", + "occurred_at", + "recorded_at", + "ingested_at", + "clock_authority", + "temporal_context", + "ordering_basis", + "logical_order_ref", + "predecessor_event_refs", + "actor_ref", + "producer_ref", + "source_system_ref", + "source_record_ref", + "source_raw_ref", + "source_pipeline", + "raw_data_integrity", + "confidence", + "provenance_refs", + "evidence_refs", + "marking_definition_refs", + "object_marking_refs", + "markings", + "granular_markings", + "redaction_policy_ref", + "authorization_scope", + } + for contract_id in PARTICIPANT_RUNTIME_FIXTURE_MODELS: + schema = generated[contract_id] + assert base_envelope_fields <= set(schema["properties"]), contract_id + assert schema["properties"]["ordering_basis"]["enum"] == _ORDERING_BASIS_TERMS + substructures = ( + "EventClassificationModel", + "SourceStatusModel", + "SourcePipelineModel", + "RawDataIntegrityModel", + ) + for substructure in substructures: + assert substructure in schema["$defs"], (contract_id, substructure) + + +def test_participant_outcome_report_publishes_no_score_or_reward_surface(): + schema = schema_bundle()["participant-outcome-report-v1"] + forbidden = {"score", "reward", "objective_success", "max_score", "return_value"} + assert not forbidden & set(schema["properties"]) + source_schema = schema["$defs"]["ParticipantOutcomeReportSourceModel"] + assert source_schema["properties"]["source_kind"]["enum"] == ["action_result", "episode_status", "evidence"] + assert schema["properties"]["outcome_sources"]["minItems"] == 1 + + +def test_participant_history_view_schema_requires_completeness_basis_when_not_complete(): + schema = schema_bundle()["participant-history-view-v1"] + assert { + "if": { + "properties": {"completeness": {"enum": ["truncated", "filtered"]}}, + "required": ["completeness"], + }, + "then": { + "required": ["completeness_basis"], + "properties": {"completeness_basis": {"type": "string", "minLength": 1}}, + }, + } in schema["allOf"] + + +def test_participant_views_reuse_published_episode_shapes(): + generated = schema_bundle() + status_schema = generated["participant-status-view-v1"] + history_schema = generated["participant-history-view-v1"] + + assert "ParticipantEpisodeStateModel" in status_schema["$defs"] + assert history_schema["properties"]["episode_history"]["items"]["$ref"] == ( + "#/$defs/ParticipantEpisodeHistoryEventModel" + ) + assert history_schema["properties"]["behavior_history"]["items"]["$ref"] == ( + "#/$defs/ParticipantBehaviorHistoryEventModel" + ) + context_schema = generated["participant-context-view-v1"] + assert context_schema["properties"]["derived_from_refs"]["minItems"] == 1 + + +def test_participant_backend_contract_valid_fixtures_pass_schema_and_model_validation(): + generated = schema_bundle() + for contract_id, model_cls in ALL_FIXTURE_MODELS.items(): + fixture_root = FIXTURE_ROOTS_BY_CONTRACT[contract_id] / contract_id + validator = Draft202012Validator(generated[contract_id]) + valid_paths = sorted((fixture_root / "valid").glob("*.json")) + assert valid_paths, f"{contract_id} must publish at least one valid fixture" + for path in valid_paths: + payload = json.loads(path.read_text(encoding="utf-8")) + validator.validate(payload) + model_cls.model_validate(payload) + + +def test_participant_backend_contract_invalid_fixtures_fail_schema_and_model_validation(): + generated = schema_bundle() + for contract_id, model_cls in ALL_FIXTURE_MODELS.items(): + fixture_root = FIXTURE_ROOTS_BY_CONTRACT[contract_id] / contract_id + validator = Draft202012Validator(generated[contract_id]) + invalid_paths = sorted((fixture_root / "invalid").glob("*.json")) + assert invalid_paths, f"{contract_id} must publish at least one invalid fixture" + for path in invalid_paths: + payload = json.loads(path.read_text(encoding="utf-8")) + assert list(validator.iter_errors(payload)), path + with pytest.raises(ValidationError): + model_cls.model_validate(payload) + + +def test_participant_history_view_requires_completeness_basis_unless_complete(): + payload = _valid_fixture("participant-history-view-v1") + payload["completeness"] = "filtered" + payload["completeness_basis"] = None + + with pytest.raises(ValidationError, match="completeness_basis"): + ParticipantHistoryViewModel.model_validate(payload) + + payload["completeness"] = "complete" + model = ParticipantHistoryViewModel.model_validate(payload) + assert model.completeness_basis is None + + +def test_participant_lifecycle_event_rejects_unknown_mapping_loss(): + payload = _valid_fixture("participant-lifecycle-event-v1") + payload["mapping_loss"] = "collapsed" + + with pytest.raises(ValidationError, match="mapping_loss"): + ParticipantLifecycleEventModel.model_validate(payload) + + +def test_participant_observation_envelope_rejects_unknown_information_guarantee(): + payload = _valid_fixture("participant-observation-envelope-v1") + payload["information_guarantee"] = "total_recall" + + with pytest.raises(ValidationError, match="information_guarantee"): + ParticipantObservationEnvelopeModel.model_validate(payload) + + +def test_participant_shared_state_record_rejects_unknown_conflict_policy(): + payload = _valid_fixture("participant-shared-state-record-v1") + payload["conflict_policy"] = "last_write_wins" + + with pytest.raises(ValidationError, match="conflict_policy"): + ParticipantSharedStateRecordModel.model_validate(payload) diff --git a/tools/generate_contract_schemas.py b/tools/generate_contract_schemas.py index 2a1ecd8b5..7d40c7f7c 100644 --- a/tools/generate_contract_schemas.py +++ b/tools/generate_contract_schemas.py @@ -35,6 +35,19 @@ def _schema_output_path(schemas_dir: Path, name: str) -> Path: return schemas_dir / "profiles" / f"{name}.json" if name.startswith("backend-profile-v"): return schemas_dir / "profiles" / f"{name}.json" + if name in { + "participant-lifecycle-event-v1", + "participant-observation-envelope-v1", + "participant-shared-state-record-v1", + "participant-outcome-report-v1", + }: + return schemas_dir / "participant-runtime" / f"{name}.json" + if name in { + "participant-status-view-v1", + "participant-history-view-v1", + "participant-context-view-v1", + }: + return schemas_dir / "control-plane" / f"{name}.json" if name.startswith("experiment-"): return schemas_dir / "experiment-core" / f"{name}.json" if name.endswith("-plan-v1"): From c9a718714c39bae791370efba9774c868b57b702 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Thu, 11 Jun 2026 21:22:52 +0200 Subject: [PATCH 17/70] Fix review findings: context-view provenance and view-scope binding Add the required source_snapshot_ref to participant-context-view-v1 (model, regenerated schema, fixture) so every API-408 retrieval view names its source snapshot as the formal spec requires. Bind embedded participant-scoped records to the view scope: the status view rejects an episode_state whose participant_address or episode_id does not match the view, and the history view rejects episode/behavior events outside the view's participant and episode. Cross-field scope equality is model-level, so the negative cases are model tests rather than schema fixtures. --- .../valid/network-posture-context.json | 10 ++++- .../participant-context-view-v1.json | 6 +++ .../packages/aces_contracts/contracts.py | 37 +++++++++++++++++ .../test_participant_backend_contracts.py | 40 +++++++++++++++++++ 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/contracts/fixtures/control-plane/participant-context-view-v1/valid/network-posture-context.json b/contracts/fixtures/control-plane/participant-context-view-v1/valid/network-posture-context.json index d34c93fed..0f8d526f1 100644 --- a/contracts/fixtures/control-plane/participant-context-view-v1/valid/network-posture-context.json +++ b/contracts/fixtures/control-plane/participant-context-view-v1/valid/network-posture-context.json @@ -3,11 +3,17 @@ "participant_address": "participants.blue.rl", "episode_id": "ep-blue-002", "generated_at": "2026-05-26T10:21:00Z", + "source_snapshot_ref": "snapshots.run-778.tick42", "view_ref": "views.context.network-posture.v1", - "derived_from_refs": ["snapshots.run-778.tick42", "obs-blue-43"], + "derived_from_refs": [ + "snapshots.run-778.tick42", + "obs-blue-43" + ], "derivation_basis_ref": "rules.context.network-posture.v1", "payload_ref": "evidence.context.blue.network-posture.tick42", "visibility_projection_ref": "projections.blue.context.v1", - "marking_definition_refs": ["markings.participant_visible.v1"], + "marking_definition_refs": [ + "markings.participant_visible.v1" + ], "redaction_policy_ref": "redaction.blue-observation.v1" } diff --git a/contracts/schemas/control-plane/participant-context-view-v1.json b/contracts/schemas/control-plane/participant-context-view-v1.json index c3e5b69a1..27d1747ad 100644 --- a/contracts/schemas/control-plane/participant-context-view-v1.json +++ b/contracts/schemas/control-plane/participant-context-view-v1.json @@ -85,6 +85,11 @@ "default": null, "title": "Redaction Policy Ref" }, + "source_snapshot_ref": { + "minLength": 1, + "title": "Source Snapshot Ref", + "type": "string" + }, "view_id": { "minLength": 1, "title": "View Id", @@ -105,6 +110,7 @@ "view_id", "participant_address", "generated_at", + "source_snapshot_ref", "view_ref", "derived_from_refs", "visibility_projection_ref" diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index 930d97bc9..997066b68 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -1071,6 +1071,22 @@ class ParticipantStatusViewModel(ContractModel): marking_definition_refs: list[NonEmptyString] = Field(default_factory=list) redaction_policy_ref: NonEmptyString | None = None + @model_validator(mode="after") + def _validate_embedded_record_scope(self) -> ParticipantStatusViewModel: + if self.episode_state is not None: + if self.episode_state.participant_address != self.participant_address: + raise ValueError( + "episode_state.participant_address " + f"'{self.episode_state.participant_address}' does not match the " + f"view participant_address '{self.participant_address}'" + ) + if self.episode_id is not None and self.episode_state.episode_id != self.episode_id: + raise ValueError( + f"episode_state.episode_id '{self.episode_state.episode_id}' " + f"does not match the view episode_id '{self.episode_id}'" + ) + return self + class ParticipantHistoryViewModel(ContractModel): """API-408 retrieval projection of participant episode/behavior history.""" @@ -1094,6 +1110,26 @@ def _validate_completeness_basis(self) -> ParticipantHistoryViewModel: raise ValueError("completeness_basis is required when completeness is not 'complete'") return self + @model_validator(mode="after") + def _validate_embedded_record_scope(self) -> ParticipantHistoryViewModel: + for field_name, events in ( + ("episode_history", self.episode_history), + ("behavior_history", self.behavior_history), + ): + for index, event in enumerate(events): + if event.participant_address != self.participant_address: + raise ValueError( + f"{field_name}[{index}].participant_address " + f"'{event.participant_address}' does not match the " + f"view participant_address '{self.participant_address}'" + ) + if event.episode_id != self.episode_id: + raise ValueError( + f"{field_name}[{index}].episode_id '{event.episode_id}' " + f"does not match the view episode_id '{self.episode_id}'" + ) + return self + @classmethod def __get_pydantic_json_schema__( cls, @@ -1124,6 +1160,7 @@ class ParticipantContextViewModel(ContractModel): participant_address: NonEmptyString episode_id: NonEmptyString | None = None generated_at: Rfc3339DateTimeString + source_snapshot_ref: NonEmptyString view_ref: NonEmptyString derived_from_refs: list[NonEmptyString] = Field(min_length=1) derivation_basis_ref: NonEmptyString | None = None diff --git a/implementations/python/tests/test_participant_backend_contracts.py b/implementations/python/tests/test_participant_backend_contracts.py index 377dfc877..3b4c9e20b 100644 --- a/implementations/python/tests/test_participant_backend_contracts.py +++ b/implementations/python/tests/test_participant_backend_contracts.py @@ -201,6 +201,46 @@ def test_participant_history_view_requires_completeness_basis_unless_complete(): assert model.completeness_basis is None +def test_participant_status_view_rejects_episode_state_for_another_participant(): + payload = _valid_fixture("participant-status-view-v1") + payload["episode_state"]["participant_address"] = "participants.red.llm" + + with pytest.raises(ValidationError, match="participant_address"): + ParticipantStatusViewModel.model_validate(payload) + + +def test_participant_status_view_rejects_episode_state_for_another_episode(): + payload = _valid_fixture("participant-status-view-v1") + payload["episode_state"]["episode_id"] = "ep-blue-009" + + with pytest.raises(ValidationError, match="episode_id"): + ParticipantStatusViewModel.model_validate(payload) + + +def test_participant_history_view_rejects_events_for_another_participant(): + payload = _valid_fixture("participant-history-view-v1") + payload["episode_history"][0]["participant_address"] = "participants.red.llm" + + with pytest.raises(ValidationError, match="participant_address"): + ParticipantHistoryViewModel.model_validate(payload) + + +def test_participant_history_view_rejects_events_for_another_episode(): + payload = _valid_fixture("participant-history-view-v1") + payload["behavior_history"][0]["episode_id"] = "ep-blue-001" + + with pytest.raises(ValidationError, match="episode_id"): + ParticipantHistoryViewModel.model_validate(payload) + + +def test_participant_context_view_requires_source_snapshot_ref(): + payload = _valid_fixture("participant-context-view-v1") + payload.pop("source_snapshot_ref") + + with pytest.raises(ValidationError, match="source_snapshot_ref"): + ParticipantContextViewModel.model_validate(payload) + + def test_participant_lifecycle_event_rejects_unknown_mapping_loss(): payload = _valid_fixture("participant-lifecycle-event-v1") payload["mapping_loss"] = "collapsed" From d0387a23f95e9dfbbcf9fe6474bd75284ac14f8c Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Thu, 11 Jun 2026 21:33:19 +0200 Subject: [PATCH 18/70] Make API-408 view scope binding structural via projected nested types Replace the embedded recorded-contract models in the status and history views with scope-projected variants (ParticipantStatusViewEpisodeStateModel, ParticipantHistoryViewEpisodeEventModel, ParticipantHistoryViewBehaviorEventModel) that remove participant_address and episode_id: the view carries scope once at the top level and a nested record for another participant or episode is unrepresentable, enforced by closed-world validation at the published schema boundary rather than only in Python model validators. Add invalid fixtures for scope restatement in episode_state, episode_history, and behavior_history; a status-view rule requiring episode_id when episode_state is embedded; a field-parity drift gate binding each projected type to its recorded source contract; and the scope-projection rule in the formal spec section. Regenerated the two control-plane view schemas. Full suite green (2272 passed). --- .../behavior-event-restates-scope.json | 51 +++++++ .../invalid/episode-event-restates-scope.json | 51 +++++++ .../valid/truncated-history.json | 10 +- .../invalid/episode-state-restates-scope.json | 27 ++++ .../valid/running-episode.json | 10 +- .../participant-history-view-v1.json | 124 ++++++++---------- .../participant-status-view-v1.json | 17 +-- .../packages/aces_contracts/contracts.py | 124 +++++++++++++----- .../test_participant_backend_contracts.py | 49 +++++-- .../participant-backend-contracts.md | 13 +- 10 files changed, 334 insertions(+), 142 deletions(-) create mode 100644 contracts/fixtures/control-plane/participant-history-view-v1/invalid/behavior-event-restates-scope.json create mode 100644 contracts/fixtures/control-plane/participant-history-view-v1/invalid/episode-event-restates-scope.json create mode 100644 contracts/fixtures/control-plane/participant-status-view-v1/invalid/episode-state-restates-scope.json diff --git a/contracts/fixtures/control-plane/participant-history-view-v1/invalid/behavior-event-restates-scope.json b/contracts/fixtures/control-plane/participant-history-view-v1/invalid/behavior-event-restates-scope.json new file mode 100644 index 000000000..a30ea9ba3 --- /dev/null +++ b/contracts/fixtures/control-plane/participant-history-view-v1/invalid/behavior-event-restates-scope.json @@ -0,0 +1,51 @@ +{ + "view_id": "views.history.participants.blue.rl.0001", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "generated_at": "2026-05-26T10:21:00Z", + "source_snapshot_ref": "snapshots.run-778.tick42", + "episode_history": [ + { + "event_type": "episode_initialized", + "timestamp": "2026-05-26T10:00:00Z", + "sequence_number": 1, + "terminal_reason": null, + "control_action": "initialize", + "details": {} + } + ], + "behavior_history": [ + { + "event_type": "action_attempted", + "timestamp": "2026-05-26T10:20:09Z", + "action_instance_id": "act-blue-42", + "action_contract_address": "contracts.defense.isolate-host.v1", + "observation_boundary_address": null, + "observation_status": null, + "actor_provenance": "participant_observed", + "state_transition_kind": null, + "post_state_digest": null, + "details": {}, + "episode_id": "ep-blue-001" + }, + { + "event_type": "observation_emitted", + "timestamp": "2026-05-26T10:20:10Z", + "action_instance_id": "act-blue-42", + "action_contract_address": "contracts.defense.isolate-host.v1", + "observation_boundary_address": "projections.blue.local.telemetry.v1", + "observation_status": "terminal", + "actor_provenance": null, + "state_transition_kind": null, + "post_state_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "details": {} + } + ], + "visibility_projection_ref": "projections.blue.history.v1", + "redaction_policy_ref": "redaction.blue-observation.v1", + "completeness": "truncated", + "completeness_basis": "retention.window.30d", + "marking_definition_refs": [ + "markings.participant_visible.v1" + ] +} diff --git a/contracts/fixtures/control-plane/participant-history-view-v1/invalid/episode-event-restates-scope.json b/contracts/fixtures/control-plane/participant-history-view-v1/invalid/episode-event-restates-scope.json new file mode 100644 index 000000000..4bdcc74b7 --- /dev/null +++ b/contracts/fixtures/control-plane/participant-history-view-v1/invalid/episode-event-restates-scope.json @@ -0,0 +1,51 @@ +{ + "view_id": "views.history.participants.blue.rl.0001", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "generated_at": "2026-05-26T10:21:00Z", + "source_snapshot_ref": "snapshots.run-778.tick42", + "episode_history": [ + { + "event_type": "episode_initialized", + "timestamp": "2026-05-26T10:00:00Z", + "sequence_number": 1, + "terminal_reason": null, + "control_action": "initialize", + "details": {}, + "participant_address": "participants.red.llm" + } + ], + "behavior_history": [ + { + "event_type": "action_attempted", + "timestamp": "2026-05-26T10:20:09Z", + "action_instance_id": "act-blue-42", + "action_contract_address": "contracts.defense.isolate-host.v1", + "observation_boundary_address": null, + "observation_status": null, + "actor_provenance": "participant_observed", + "state_transition_kind": null, + "post_state_digest": null, + "details": {} + }, + { + "event_type": "observation_emitted", + "timestamp": "2026-05-26T10:20:10Z", + "action_instance_id": "act-blue-42", + "action_contract_address": "contracts.defense.isolate-host.v1", + "observation_boundary_address": "projections.blue.local.telemetry.v1", + "observation_status": "terminal", + "actor_provenance": null, + "state_transition_kind": null, + "post_state_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "details": {} + } + ], + "visibility_projection_ref": "projections.blue.history.v1", + "redaction_policy_ref": "redaction.blue-observation.v1", + "completeness": "truncated", + "completeness_basis": "retention.window.30d", + "marking_definition_refs": [ + "markings.participant_visible.v1" + ] +} diff --git a/contracts/fixtures/control-plane/participant-history-view-v1/valid/truncated-history.json b/contracts/fixtures/control-plane/participant-history-view-v1/valid/truncated-history.json index 452123587..840c9e96c 100644 --- a/contracts/fixtures/control-plane/participant-history-view-v1/valid/truncated-history.json +++ b/contracts/fixtures/control-plane/participant-history-view-v1/valid/truncated-history.json @@ -8,8 +8,6 @@ { "event_type": "episode_initialized", "timestamp": "2026-05-26T10:00:00Z", - "participant_address": "participants.blue.rl", - "episode_id": "ep-blue-002", "sequence_number": 1, "terminal_reason": null, "control_action": "initialize", @@ -20,8 +18,6 @@ { "event_type": "action_attempted", "timestamp": "2026-05-26T10:20:09Z", - "participant_address": "participants.blue.rl", - "episode_id": "ep-blue-002", "action_instance_id": "act-blue-42", "action_contract_address": "contracts.defense.isolate-host.v1", "observation_boundary_address": null, @@ -34,8 +30,6 @@ { "event_type": "observation_emitted", "timestamp": "2026-05-26T10:20:10Z", - "participant_address": "participants.blue.rl", - "episode_id": "ep-blue-002", "action_instance_id": "act-blue-42", "action_contract_address": "contracts.defense.isolate-host.v1", "observation_boundary_address": "projections.blue.local.telemetry.v1", @@ -50,5 +44,7 @@ "redaction_policy_ref": "redaction.blue-observation.v1", "completeness": "truncated", "completeness_basis": "retention.window.30d", - "marking_definition_refs": ["markings.participant_visible.v1"] + "marking_definition_refs": [ + "markings.participant_visible.v1" + ] } diff --git a/contracts/fixtures/control-plane/participant-status-view-v1/invalid/episode-state-restates-scope.json b/contracts/fixtures/control-plane/participant-status-view-v1/invalid/episode-state-restates-scope.json new file mode 100644 index 000000000..16ea1d7d1 --- /dev/null +++ b/contracts/fixtures/control-plane/participant-status-view-v1/invalid/episode-state-restates-scope.json @@ -0,0 +1,27 @@ +{ + "view_id": "views.status.participants.blue.rl.0001", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-002", + "generated_at": "2026-05-26T10:21:00Z", + "source_snapshot_ref": "snapshots.run-778.tick42", + "episode_state": { + "state_schema_version": "participant-episode-state/v1", + "sequence_number": 1, + "status": "running", + "terminal_reason": null, + "initialized_at": "2026-05-26T10:00:00Z", + "updated_at": "2026-05-26T10:20:10Z", + "terminated_at": null, + "last_control_action": "initialize", + "previous_episode_id": "ep-blue-001", + "participant_address": "participants.red.llm" + }, + "open_operation_refs": [ + "op-blue-12" + ], + "visibility_projection_ref": "projections.blue.status.v1", + "marking_definition_refs": [ + "markings.participant_visible.v1" + ], + "redaction_policy_ref": "redaction.blue-observation.v1" +} diff --git a/contracts/fixtures/control-plane/participant-status-view-v1/valid/running-episode.json b/contracts/fixtures/control-plane/participant-status-view-v1/valid/running-episode.json index ebe58a3d4..66a6dd066 100644 --- a/contracts/fixtures/control-plane/participant-status-view-v1/valid/running-episode.json +++ b/contracts/fixtures/control-plane/participant-status-view-v1/valid/running-episode.json @@ -6,8 +6,6 @@ "source_snapshot_ref": "snapshots.run-778.tick42", "episode_state": { "state_schema_version": "participant-episode-state/v1", - "participant_address": "participants.blue.rl", - "episode_id": "ep-blue-002", "sequence_number": 1, "status": "running", "terminal_reason": null, @@ -17,8 +15,12 @@ "last_control_action": "initialize", "previous_episode_id": "ep-blue-001" }, - "open_operation_refs": ["op-blue-12"], + "open_operation_refs": [ + "op-blue-12" + ], "visibility_projection_ref": "projections.blue.status.v1", - "marking_definition_refs": ["markings.participant_visible.v1"], + "marking_definition_refs": [ + "markings.participant_visible.v1" + ], "redaction_policy_ref": "redaction.blue-observation.v1" } diff --git a/contracts/schemas/control-plane/participant-history-view-v1.json b/contracts/schemas/control-plane/participant-history-view-v1.json index 8f511b4f4..7fb10cb17 100644 --- a/contracts/schemas/control-plane/participant-history-view-v1.json +++ b/contracts/schemas/control-plane/participant-history-view-v1.json @@ -522,8 +522,53 @@ "title": "ParticipantAttributionSupportClass", "type": "string" }, - "ParticipantBehaviorHistoryEventModel": { + "ParticipantBehaviorHistoryEventType": { + "description": "Portable history event kinds for participant behavior semantics.", + "enum": [ + "action_attempted", + "state_transition_recorded", + "observation_emitted" + ], + "title": "ParticipantBehaviorHistoryEventType", + "type": "string" + }, + "ParticipantEffectClass": { + "description": "SEM-211 effect classes for participant action results.", + "enum": [ + "intended_effect", + "side_effect", + "observation_effect", + "visibility_effect", + "detection_effect", + "evidence_effect", + "no_effect", + "unknown_effect" + ], + "title": "ParticipantEffectClass", + "type": "string" + }, + "ParticipantFailureClass": { + "description": "SEM-211 portable failure classes for participant action attempts.", + "enum": [ + "precondition_unsatisfied", + "unsupported_action", + "target_unavailable", + "authority_denied", + "resource_exhausted", + "timeout", + "interrupted", + "contention_lost", + "partial_success", + "unsafe_withheld", + "backend_error", + "unknown" + ], + "title": "ParticipantFailureClass", + "type": "string" + }, + "ParticipantHistoryViewBehaviorEventModel": { "additionalProperties": false, + "description": "Scope-projected behavior history event embedded in API-408 history views.", "properties": { "action_contract_address": { "anyOf": [ @@ -588,11 +633,6 @@ "details": { "$ref": "#/$defs/ParticipantObservationDetailsModel" }, - "episode_id": { - "minLength": 1, - "title": "Episode Id", - "type": "string" - }, "event_type": { "$ref": "#/$defs/ParticipantBehaviorHistoryEventType" }, @@ -699,11 +739,6 @@ "title": "Outcome Interpretations", "type": "array" }, - "participant_address": { - "minLength": 1, - "title": "Participant Address", - "type": "string" - }, "phase_realization": { "anyOf": [ { @@ -778,40 +813,14 @@ "required": [ "event_type", "timestamp", - "participant_address", - "episode_id", "action_instance_id" ], - "title": "ParticipantBehaviorHistoryEventModel", + "title": "ParticipantHistoryViewBehaviorEventModel", "type": "object" }, - "ParticipantBehaviorHistoryEventType": { - "description": "Portable history event kinds for participant behavior semantics.", - "enum": [ - "action_attempted", - "state_transition_recorded", - "observation_emitted" - ], - "title": "ParticipantBehaviorHistoryEventType", - "type": "string" - }, - "ParticipantEffectClass": { - "description": "SEM-211 effect classes for participant action results.", - "enum": [ - "intended_effect", - "side_effect", - "observation_effect", - "visibility_effect", - "detection_effect", - "evidence_effect", - "no_effect", - "unknown_effect" - ], - "title": "ParticipantEffectClass", - "type": "string" - }, - "ParticipantEpisodeHistoryEventModel": { + "ParticipantHistoryViewEpisodeEventModel": { "additionalProperties": false, + "description": "Scope-projected episode history event embedded in API-408 history views.", "properties": { "control_action": { "anyOf": [ @@ -830,18 +839,10 @@ "title": "Details", "type": "object" }, - "episode_id": { - "title": "Episode Id", - "type": "string" - }, "event_type": { "title": "Event Type", "type": "string" }, - "participant_address": { - "title": "Participant Address", - "type": "string" - }, "sequence_number": { "title": "Sequence Number", "type": "integer" @@ -866,32 +867,11 @@ "required": [ "event_type", "timestamp", - "participant_address", - "episode_id", "sequence_number" ], - "title": "ParticipantEpisodeHistoryEventModel", + "title": "ParticipantHistoryViewEpisodeEventModel", "type": "object" }, - "ParticipantFailureClass": { - "description": "SEM-211 portable failure classes for participant action attempts.", - "enum": [ - "precondition_unsatisfied", - "unsupported_action", - "target_unavailable", - "authority_denied", - "resource_exhausted", - "timeout", - "interrupted", - "contention_lost", - "partial_success", - "unsafe_withheld", - "backend_error", - "unknown" - ], - "title": "ParticipantFailureClass", - "type": "string" - }, "ParticipantInteractionClass": { "description": "SEM-209 interaction classes for multi-participant behavior.", "enum": [ @@ -1353,7 +1333,7 @@ "properties": { "behavior_history": { "items": { - "$ref": "#/$defs/ParticipantBehaviorHistoryEventModel" + "$ref": "#/$defs/ParticipantHistoryViewBehaviorEventModel" }, "title": "Behavior History", "type": "array" @@ -1382,7 +1362,7 @@ }, "episode_history": { "items": { - "$ref": "#/$defs/ParticipantEpisodeHistoryEventModel" + "$ref": "#/$defs/ParticipantHistoryViewEpisodeEventModel" }, "title": "Episode History", "type": "array" diff --git a/contracts/schemas/control-plane/participant-status-view-v1.json b/contracts/schemas/control-plane/participant-status-view-v1.json index a6e5b1ce7..e5f47935a 100644 --- a/contracts/schemas/control-plane/participant-status-view-v1.json +++ b/contracts/schemas/control-plane/participant-status-view-v1.json @@ -1,12 +1,9 @@ { "$defs": { - "ParticipantEpisodeStateModel": { + "ParticipantStatusViewEpisodeStateModel": { "additionalProperties": false, + "description": "Scope-projected episode state embedded in API-408 status views.\n\nThe view carries `participant_address` and `episode_id` once at the top\nlevel; the embedded record cannot restate them, so a nested record scoped\nto another participant or episode is structurally unrepresentable.", "properties": { - "episode_id": { - "title": "Episode Id", - "type": "string" - }, "initialized_at": { "title": "Initialized At", "type": "string" @@ -15,10 +12,6 @@ "title": "Last Control Action", "type": "string" }, - "participant_address": { - "title": "Participant Address", - "type": "string" - }, "previous_episode_id": { "anyOf": [ { @@ -75,15 +68,13 @@ } }, "required": [ - "participant_address", - "episode_id", "sequence_number", "status", "initialized_at", "updated_at", "last_control_action" ], - "title": "ParticipantEpisodeStateModel", + "title": "ParticipantStatusViewEpisodeStateModel", "type": "object" } }, @@ -108,7 +99,7 @@ "episode_state": { "anyOf": [ { - "$ref": "#/$defs/ParticipantEpisodeStateModel" + "$ref": "#/$defs/ParticipantStatusViewEpisodeStateModel" }, { "type": "null" diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index 997066b68..57374b9c2 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -1057,6 +1057,84 @@ class ParticipantOutcomeReportModel(ParticipantRuntimeBaseEnvelopeModel): state_relationships: list[ParticipantOutcomeReportStateRelationshipModel] = Field(default_factory=list) +class ParticipantStatusViewEpisodeStateModel(ContractModel): + """Scope-projected episode state embedded in API-408 status views. + + The view carries `participant_address` and `episode_id` once at the top + level; the embedded record cannot restate them, so a nested record scoped + to another participant or episode is structurally unrepresentable. + """ + + state_schema_version: Literal[PARTICIPANT_EPISODE_STATE_SCHEMA_VERSION] = PARTICIPANT_EPISODE_STATE_SCHEMA_VERSION + sequence_number: int + status: str + terminal_reason: str | None = None + initialized_at: str + updated_at: str + terminated_at: str | None = None + last_control_action: str + previous_episode_id: str | None = None + + +class ParticipantHistoryViewEpisodeEventModel(ContractModel): + """Scope-projected episode history event embedded in API-408 history views.""" + + event_type: str + timestamp: str + sequence_number: int + terminal_reason: str | None = None + control_action: str | None = None + details: dict[str, Any] = Field(default_factory=dict) + + +class ParticipantHistoryViewBehaviorEventModel(ContractModel): + """Scope-projected behavior history event embedded in API-408 history views.""" + + event_type: ParticipantBehaviorHistoryEventType + timestamp: NonEmptyString + action_instance_id: NonEmptyString + action_contract_address: NonEmptyString | None = None + observation_boundary_address: NonEmptyString | None = None + observation_status: ParticipantObservationStatus | None = None + actor_provenance: NonEmptyString | None = None + lifecycle_phase: ParticipantRuntimeLifecyclePhase | None = None + phase_realization: ParticipantPhaseRealization | None = None + admission_disposition: ParticipantAdmissionDisposition | None = None + operation_ref: NonEmptyString | None = None + operation_state: ParticipantLifecycleOperationState | None = None + state_transition_kind: NonEmptyString | None = None + post_state_digest: NonEmptyString | None = None + joint_action_set_id: NonEmptyString | None = None + realized_order: StrictInt | None = Field(default=None, ge=0) + interaction_class: ParticipantInteractionClass | None = None + interaction_ref: NonEmptyString | None = None + shared_state_refs: list[NonEmptyString] = Field(default_factory=list) + action_result: ParticipantActionResultModel | None = None + attribution_edges: list[ParticipantAttributionEdgeModel] = Field(default_factory=list) + outcome_interpretations: list[ParticipantOutcomeInterpretationRecordModel] = Field(default_factory=list) + temporal_contexts: list[ParticipantTemporalRuntimeContextModel] = Field(default_factory=list) + details: ParticipantObservationDetailsModel = Field(default_factory=ParticipantObservationDetailsModel) + + @model_validator(mode="after") + def _validate_lifecycle_fields(self) -> ParticipantHistoryViewBehaviorEventModel: + messages = participant_lifecycle_field_violation_messages( + event_type=self.event_type, + lifecycle_phase=self.lifecycle_phase, + phase_realization=self.phase_realization, + admission_disposition=self.admission_disposition, + operation_ref=self.operation_ref, + operation_state=self.operation_state, + ) + if messages: + raise ValueError(messages[0]) + return self + + +VIEW_SCOPE_PROJECTED_FIELDS: tuple[str, ...] = ("participant_address", "episode_id") +"""Recorded-contract fields carried once at the view level and removed from +records embedded in API-408 retrieval projections.""" + + class ParticipantStatusViewModel(ContractModel): """API-408 retrieval projection of one participant's episode status.""" @@ -1065,26 +1143,16 @@ class ParticipantStatusViewModel(ContractModel): episode_id: NonEmptyString | None = None generated_at: Rfc3339DateTimeString source_snapshot_ref: NonEmptyString - episode_state: ParticipantEpisodeStateModel | None = None + episode_state: ParticipantStatusViewEpisodeStateModel | None = None open_operation_refs: list[NonEmptyString] = Field(default_factory=list) visibility_projection_ref: NonEmptyString marking_definition_refs: list[NonEmptyString] = Field(default_factory=list) redaction_policy_ref: NonEmptyString | None = None @model_validator(mode="after") - def _validate_embedded_record_scope(self) -> ParticipantStatusViewModel: - if self.episode_state is not None: - if self.episode_state.participant_address != self.participant_address: - raise ValueError( - "episode_state.participant_address " - f"'{self.episode_state.participant_address}' does not match the " - f"view participant_address '{self.participant_address}'" - ) - if self.episode_id is not None and self.episode_state.episode_id != self.episode_id: - raise ValueError( - f"episode_state.episode_id '{self.episode_state.episode_id}' " - f"does not match the view episode_id '{self.episode_id}'" - ) + def _validate_episode_scope(self) -> ParticipantStatusViewModel: + if self.episode_state is not None and self.episode_id is None: + raise ValueError("episode_id is required when episode_state is embedded") return self @@ -1096,8 +1164,8 @@ class ParticipantHistoryViewModel(ContractModel): episode_id: NonEmptyString generated_at: Rfc3339DateTimeString source_snapshot_ref: NonEmptyString - episode_history: list[ParticipantEpisodeHistoryEventModel] = Field(default_factory=list) - behavior_history: list[ParticipantBehaviorHistoryEventModel] = Field(default_factory=list) + episode_history: list[ParticipantHistoryViewEpisodeEventModel] = Field(default_factory=list) + behavior_history: list[ParticipantHistoryViewBehaviorEventModel] = Field(default_factory=list) visibility_projection_ref: NonEmptyString redaction_policy_ref: NonEmptyString | None = None completeness: Literal["complete", "truncated", "filtered"] @@ -1110,26 +1178,6 @@ def _validate_completeness_basis(self) -> ParticipantHistoryViewModel: raise ValueError("completeness_basis is required when completeness is not 'complete'") return self - @model_validator(mode="after") - def _validate_embedded_record_scope(self) -> ParticipantHistoryViewModel: - for field_name, events in ( - ("episode_history", self.episode_history), - ("behavior_history", self.behavior_history), - ): - for index, event in enumerate(events): - if event.participant_address != self.participant_address: - raise ValueError( - f"{field_name}[{index}].participant_address " - f"'{event.participant_address}' does not match the " - f"view participant_address '{self.participant_address}'" - ) - if event.episode_id != self.episode_id: - raise ValueError( - f"{field_name}[{index}].episode_id '{event.episode_id}' " - f"does not match the view episode_id '{self.episode_id}'" - ) - return self - @classmethod def __get_pydantic_json_schema__( cls, @@ -5014,6 +5062,8 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "ParticipantExposurePolicyModel", "ParticipantFeatureSupportLevel", "ParticipantFeatureSupportModel", + "ParticipantHistoryViewBehaviorEventModel", + "ParticipantHistoryViewEpisodeEventModel", "ParticipantHistoryViewModel", "ParticipantImplementationCapabilitiesModel", "ParticipantImplementationCompatibilityModel", @@ -5034,8 +5084,10 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "ParticipantRuntimeCapabilitiesModel", "ParticipantSharedStateAccessModel", "ParticipantSharedStateRecordModel", + "ParticipantStatusViewEpisodeStateModel", "ParticipantStatusViewModel", "ParticipantTemporalRuntimeContextModel", + "VIEW_SCOPE_PROJECTED_FIELDS", "PlanOperationModel", "ProcessorFeature", "PROCESSOR_MANIFEST_V2_SCHEMA_VERSION", diff --git a/implementations/python/tests/test_participant_backend_contracts.py b/implementations/python/tests/test_participant_backend_contracts.py index 3b4c9e20b..fa11052b0 100644 --- a/implementations/python/tests/test_participant_backend_contracts.py +++ b/implementations/python/tests/test_participant_backend_contracts.py @@ -7,12 +7,19 @@ import pytest from aces_contracts.contracts import ( + VIEW_SCOPE_PROJECTED_FIELDS, + ParticipantBehaviorHistoryEventModel, ParticipantContextViewModel, + ParticipantEpisodeHistoryEventModel, + ParticipantEpisodeStateModel, + ParticipantHistoryViewBehaviorEventModel, + ParticipantHistoryViewEpisodeEventModel, ParticipantHistoryViewModel, ParticipantLifecycleEventModel, ParticipantObservationEnvelopeModel, ParticipantOutcomeReportModel, ParticipantSharedStateRecordModel, + ParticipantStatusViewEpisodeStateModel, ParticipantStatusViewModel, schema_bundle, ) @@ -150,12 +157,12 @@ def test_participant_views_reuse_published_episode_shapes(): status_schema = generated["participant-status-view-v1"] history_schema = generated["participant-history-view-v1"] - assert "ParticipantEpisodeStateModel" in status_schema["$defs"] + assert "ParticipantStatusViewEpisodeStateModel" in status_schema["$defs"] assert history_schema["properties"]["episode_history"]["items"]["$ref"] == ( - "#/$defs/ParticipantEpisodeHistoryEventModel" + "#/$defs/ParticipantHistoryViewEpisodeEventModel" ) assert history_schema["properties"]["behavior_history"]["items"]["$ref"] == ( - "#/$defs/ParticipantBehaviorHistoryEventModel" + "#/$defs/ParticipantHistoryViewBehaviorEventModel" ) context_schema = generated["participant-context-view-v1"] assert context_schema["properties"]["derived_from_refs"]["minItems"] == 1 @@ -201,7 +208,33 @@ def test_participant_history_view_requires_completeness_basis_unless_complete(): assert model.completeness_basis is None -def test_participant_status_view_rejects_episode_state_for_another_participant(): +def _projected_field_parity(source_cls, projected_cls): + source_fields = dict(source_cls.model_fields) + for field_name in VIEW_SCOPE_PROJECTED_FIELDS: + assert field_name in source_fields, f"{source_cls.__name__} no longer carries {field_name}" + source_fields.pop(field_name) + projected_fields = projected_cls.model_fields + assert set(projected_fields) == set(source_fields), ( + f"{projected_cls.__name__} drifted from {source_cls.__name__}: " + f"missing={set(source_fields) - set(projected_fields)} " + f"extra={set(projected_fields) - set(source_fields)}" + ) + for field_name, source_field in source_fields.items(): + assert projected_fields[field_name].annotation == source_field.annotation, ( + f"{projected_cls.__name__}.{field_name} type drifted from {source_cls.__name__}" + ) + assert projected_fields[field_name].is_required() == source_field.is_required(), ( + f"{projected_cls.__name__}.{field_name} requiredness drifted from {source_cls.__name__}" + ) + + +def test_view_projected_models_track_recorded_contract_shapes(): + _projected_field_parity(ParticipantEpisodeStateModel, ParticipantStatusViewEpisodeStateModel) + _projected_field_parity(ParticipantEpisodeHistoryEventModel, ParticipantHistoryViewEpisodeEventModel) + _projected_field_parity(ParticipantBehaviorHistoryEventModel, ParticipantHistoryViewBehaviorEventModel) + + +def test_participant_status_view_rejects_episode_state_restating_scope(): payload = _valid_fixture("participant-status-view-v1") payload["episode_state"]["participant_address"] = "participants.red.llm" @@ -209,23 +242,21 @@ def test_participant_status_view_rejects_episode_state_for_another_participant() ParticipantStatusViewModel.model_validate(payload) -def test_participant_status_view_rejects_episode_state_for_another_episode(): +def test_participant_status_view_requires_episode_id_when_state_embedded(): payload = _valid_fixture("participant-status-view-v1") - payload["episode_state"]["episode_id"] = "ep-blue-009" + payload["episode_id"] = None with pytest.raises(ValidationError, match="episode_id"): ParticipantStatusViewModel.model_validate(payload) -def test_participant_history_view_rejects_events_for_another_participant(): +def test_participant_history_view_rejects_events_restating_scope(): payload = _valid_fixture("participant-history-view-v1") payload["episode_history"][0]["participant_address"] = "participants.red.llm" with pytest.raises(ValidationError, match="participant_address"): ParticipantHistoryViewModel.model_validate(payload) - -def test_participant_history_view_rejects_events_for_another_episode(): payload = _valid_fixture("participant-history-view-v1") payload["behavior_history"][0]["episode_id"] = "ep-blue-001" diff --git a/specs/formal/runtime-contracts/participant-backend-contracts.md b/specs/formal/runtime-contracts/participant-backend-contracts.md index 7ea28aa7e..3a00b8925 100644 --- a/specs/formal/runtime-contracts/participant-backend-contracts.md +++ b/specs/formal/runtime-contracts/participant-backend-contracts.md @@ -129,10 +129,21 @@ never a second source of truth. They live in the `control-plane` family: | Contract id | Projects | Notes | | --- | --- | --- | -| `participant-status-view-v1` | Episode state and lifecycle facts for one participant (+ open operation refs) | embeds the existing episode-state shape | +| `participant-status-view-v1` | Episode state and lifecycle facts for one participant (+ open operation refs) | embeds the scope-projected episode-state shape | | `participant-history-view-v1` | Episode and behavior history retrieval | carries `completeness` (`complete`/`truncated`/`filtered`) with a required basis when not complete | | `participant-context-view-v1` | Derived operational context views | reference-and-provenance only; see below | +Views are one-participant projections, and the contract makes that structural: +`participant_address` and `episode_id` are carried exactly once, at the view +level, and the embedded episode-state and history-event records are +scope-projected variants of the recorded contracts with those fields removed. +A nested record scoped to another participant or episode is therefore +unrepresentable in a valid view payload — closed-world validation rejects any +embedded record that restates scope — rather than merely forbidden by prose. +The projected record shapes must track their recorded source contracts +field-for-field apart from the removed scope fields; divergence is a defect +(PRT-19). + Rules: - every view names its `source_snapshot_ref`, `visibility_projection_ref`, From faf142c9a019897dbf85ee577ec602f5499faf0b Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Thu, 11 Jun 2026 21:39:42 +0200 Subject: [PATCH 19/70] Bind nested recorded-record scope recursively in history views Behavior events cite recorded semantic records (action results and their precondition results, attribution edges, outcome interpretations) that carry their own participant_address/episode_id as recorded contracts. Add a recursive scope validator on participant-history-view-v1 requiring every nested scope field to equal the view scope, with negative tests for mismatched action results, precondition results, attribution edges, and outcome interpretations, and record the recursive rule as a semantic-gate obligation in the formal spec section. --- .../packages/aces_contracts/contracts.py | 37 +++++ .../test_participant_backend_contracts.py | 131 ++++++++++++++++++ .../participant-backend-contracts.md | 10 ++ 3 files changed, 178 insertions(+) diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index 57374b9c2..531aa4ae1 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -1178,6 +1178,43 @@ def _validate_completeness_basis(self) -> ParticipantHistoryViewModel: raise ValueError("completeness_basis is required when completeness is not 'complete'") return self + @model_validator(mode="after") + def _validate_nested_record_scope(self) -> ParticipantHistoryViewModel: + """Recursively bind nested recorded-contract scope to the view scope. + + The direct embedded event shapes are scope-projected, but behavior + events cite recorded semantic records (action results and their + preconditions, attribution edges, outcome interpretations) that carry + their own `participant_address`/`episode_id`. A one-participant view + must not smuggle records scoped to another participant or episode + through those subrecords. + """ + + def _walk(node: object, path: str) -> None: + if isinstance(node, dict): + for key, value in node.items(): + if key == "participant_address" and isinstance(value, str) and value != self.participant_address: + raise ValueError( + f"{path}.{key} '{value}' does not match the view " + f"participant_address '{self.participant_address}'" + ) + if key == "episode_id" and isinstance(value, str) and value != self.episode_id: + raise ValueError( + f"{path}.{key} '{value}' does not match the view episode_id '{self.episode_id}'" + ) + _walk(value, f"{path}.{key}") + elif isinstance(node, list): + for index, item in enumerate(node): + _walk(item, f"{path}[{index}]") + + for field_name, events in ( + ("episode_history", self.episode_history), + ("behavior_history", self.behavior_history), + ): + for index, event in enumerate(events): + _walk(event.model_dump(mode="python"), f"{field_name}[{index}]") + return self + @classmethod def __get_pydantic_json_schema__( cls, diff --git a/implementations/python/tests/test_participant_backend_contracts.py b/implementations/python/tests/test_participant_backend_contracts.py index fa11052b0..41f83797f 100644 --- a/implementations/python/tests/test_participant_backend_contracts.py +++ b/implementations/python/tests/test_participant_backend_contracts.py @@ -264,6 +264,137 @@ def test_participant_history_view_rejects_events_restating_scope(): ParticipantHistoryViewModel.model_validate(payload) +def _scoped_action_result(participant_address: str, episode_id: str) -> dict: + return { + "status": "succeeded", + "participant_address": participant_address, + "episode_id": episode_id, + "action_instance_id": "act-blue-42", + "action_contract_address": "contracts.defense.isolate-host.v1", + "observation_point": "order.sim.42.blue", + "preconditions": [ + { + "precondition_id": "pre-1", + "precondition_class": "authority", + "status": "satisfied", + "participant_address": participant_address, + "episode_id": episode_id, + "action_contract_address": "contracts.defense.isolate-host.v1", + "observation_point": "order.sim.42.blue", + } + ], + } + + +def _history_payload_with_action_result(participant_address: str, episode_id: str) -> dict: + payload = _valid_fixture("participant-history-view-v1") + event = payload["behavior_history"][0] + event["event_type"] = "action_attempted" + event["action_result"] = _scoped_action_result(participant_address, episode_id) + return payload + + +def test_participant_history_view_accepts_in_scope_nested_records(): + payload = _history_payload_with_action_result("participants.blue.rl", "ep-blue-002") + ParticipantHistoryViewModel.model_validate(payload) + + +def test_participant_history_view_rejects_nested_action_result_for_another_participant(): + payload = _history_payload_with_action_result("participants.red.llm", "ep-blue-002") + + with pytest.raises(ValidationError, match="participant_address"): + ParticipantHistoryViewModel.model_validate(payload) + + +def test_participant_history_view_rejects_nested_precondition_for_another_episode(): + payload = _history_payload_with_action_result("participants.blue.rl", "ep-blue-002") + payload["behavior_history"][0]["action_result"]["preconditions"][0]["episode_id"] = "ep-blue-001" + + with pytest.raises(ValidationError, match="episode_id"): + ParticipantHistoryViewModel.model_validate(payload) + + +def test_participant_history_view_rejects_nested_attribution_edge_out_of_scope(): + payload = _valid_fixture("participant-history-view-v1") + event = payload["behavior_history"][-1] + event["attribution_edges"] = [ + { + "edge_id": "edge-1", + "participant_address": "participants.red.llm", + "episode_id": "ep-blue-002", + "observation_point": "order.sim.42.blue", + "cause_candidate": { + "candidate_kind": "action", + "ref": "act-blue-42", + "description": "isolate host attempt", + }, + "effect_candidate": { + "candidate_kind": "observation", + "ref": "obs-blue-43", + "description": "telemetry delta", + }, + "ordering_basis": { + "basis_kind": "backend_event_order", + "relation_ref": "order.sim.42", + "description": "simulation tick order", + "ordered_event_refs": ["act-blue-42", "obs-blue-43"], + }, + "evidence_basis": { + "capture_apparatus": "cyborg.sim", + "granularity": "event", + "loss_model": "none", + "redaction_policy": "redaction.blue-observation.v1", + "observer_effects": ["none_declared"], + }, + "support_class": "observation_support", + "confidence": "high", + "strength": "supported", + "limitations": ["single-run evidence"], + "evidence_refs": ["evidence.obs-blue-43-redacted"], + } + ] + + with pytest.raises(ValidationError, match="participant_address"): + ParticipantHistoryViewModel.model_validate(payload) + + +def test_participant_history_view_rejects_nested_outcome_interpretation_out_of_scope(): + payload = _valid_fixture("participant-history-view-v1") + event = payload["behavior_history"][-1] + event["outcome_interpretations"] = [ + { + "interpretation_id": "interp-1", + "rule_address": "rules.outcome.blue.v1", + "participant_address": "participants.blue.rl", + "episode_id": "ep-blue-001", + "observation_point": "order.sim.42.blue", + "source_bindings": [ + { + "source_id": "src-1", + "source_layer": "participant_action_outcome", + "ref": "act-blue-42", + "observed_value": "succeeded", + } + ], + "target_bindings": [ + { + "target_id": "tgt-1", + "target_layer": "objective_result", + "ref": "objectives.contain-intrusion", + "interpreted_value": "progressed", + "evidence_refs": ["evidence.obs-blue-43-redacted"], + "limitations": ["partial coverage"], + } + ], + "evidence_refs": ["evidence.obs-blue-43-redacted"], + "limitations": ["single-run evidence"], + } + ] + + with pytest.raises(ValidationError, match="episode_id"): + ParticipantHistoryViewModel.model_validate(payload) + + def test_participant_context_view_requires_source_snapshot_ref(): payload = _valid_fixture("participant-context-view-v1") payload.pop("source_snapshot_ref") diff --git a/specs/formal/runtime-contracts/participant-backend-contracts.md b/specs/formal/runtime-contracts/participant-backend-contracts.md index 3a00b8925..ebebdf118 100644 --- a/specs/formal/runtime-contracts/participant-backend-contracts.md +++ b/specs/formal/runtime-contracts/participant-backend-contracts.md @@ -144,6 +144,16 @@ The projected record shapes must track their recorded source contracts field-for-field apart from the removed scope fields; divergence is a defect (PRT-19). +The scope binding is recursive. Behavior events cite recorded semantic +records — action results and their precondition results, attribution edges, +outcome interpretations — that legitimately carry their own +`participant_address`/`episode_id` because they are recorded contracts, not +view projections. A valid history view requires every such nested scope field +to equal the view scope; a one-participant view cannot smuggle another +participant's or episode's records through nested semantic payloads. This is +a semantic-gate obligation (model-level validation with negative tests), not +a schema-shape rule. + Rules: - every view names its `source_snapshot_ref`, `visibility_projection_ref`, From 2b77b83d3c6ebc8dd621781ebebb92b4ed5c7e92 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 12 Jun 2026 12:02:02 +0200 Subject: [PATCH 20/70] Tighten ADR corpus hygiene --- changelog.d/482.changed.md | 1 + docs/decisions/adrs/README.md | 51 ++++++++++++++++++- docs/decisions/adrs/TEMPLATE.md | 29 +++++++++++ ...r-055-experiment-core-contract-boundary.md | 6 +++ docs/decisions/adrs/adr-index.yaml | 4 ++ docs/index.md | 19 +++++++ .../python/tests/test_repo_policy_tools.py | 42 +++++++++++++++ tools/policy/repo_policy.py | 39 ++++++++++++++ tools/policy/requirement_order.yaml | 2 + 9 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 changelog.d/482.changed.md create mode 100644 docs/decisions/adrs/TEMPLATE.md diff --git a/changelog.d/482.changed.md b/changelog.d/482.changed.md new file mode 100644 index 000000000..1f567ef93 --- /dev/null +++ b/changelog.d/482.changed.md @@ -0,0 +1 @@ +ADR corpus policy now validates the canonical ADR template's required sections. diff --git a/docs/decisions/adrs/README.md b/docs/decisions/adrs/README.md index 1c69e9cc4..11077b177 100644 --- a/docs/decisions/adrs/README.md +++ b/docs/decisions/adrs/README.md @@ -12,8 +12,12 @@ Each ADR includes: - **Status**: `proposed`, `accepted`, `deprecated`, or `superseded by ADR-XXX` - **Context**: The problem or situation driving the decision - **Decision**: What we chose and why +- **Alternatives Considered**: Credible rejected options and why they were not + chosen - **Consequences**: Trade-offs (positive, negative, risks) +Use [`TEMPLATE.md`](TEMPLATE.md) when drafting a new ADR. + ## Principles - An **accepted** ADR's content is **pinned** and citable. Its acceptance (or @@ -29,7 +33,9 @@ Each ADR includes: - `proposed` ADRs are still being decided and may change freely; `superseded`/`deprecated` ADRs leave the pinned set (the citable decision has moved to the replacing ADR). -- ADRs are **numbered sequentially** and never reused. +- ADRs are **numbered sequentially** in landing order and never reused. The ADR + date records when the decision was made; it may differ from the landing order + when a decision is backfilled or merged later. - ADRs are **versioned with code** and live in the repo. ## Index @@ -37,6 +43,48 @@ Each ADR includes: ```{toctree} :hidden: +TEMPLATE +adr-000-use-adrs +adr-001-scenario-description-language +adr-002-declarative-sdl-objectives +adr-003-workflows-targetable-subobjects-and-enum-variables +adr-004-sdl-runtime-layer +adr-005-control-flow-primitives +adr-006-workflow-control-language-redesign +adr-007-lightweight-formal-methods-policy +adr-008-processor-layer-and-execution-artifact-boundaries +adr-009-normative-artifact-authority-and-repository-structure +adr-010-repository-realignment-order-and-compatibility-policy +adr-011-narrow-end-to-end-mvp-validation +adr-012-shared-concept-authority-and-aces-extension-discipline +adr-013-participant-episode-lifecycle-boundaries +adr-014-nox-as-canonical-verification-graph +adr-015-sdl-processor-layering-and-source-file-size-cap +adr-016-semantic-layer-scope-and-coverage-model +adr-017-conversation-surface-hardening +adr-018-classification-based-assurance-policy +adr-019-normative-authority-boundary-manifest +adr-020-declarative-participant-framing-boundaries +adr-021-falsification-first-claim-evidence-gate +adr-022-participant-behavior-and-interaction-semantics +adr-023-container-image-build-provenance-surface +adr-024-local-identity-inventory-surface +adr-025-container-network-realization-surface +adr-026-application-http-surface-inventory +adr-027-container-init-reaper-runtime-surface +adr-028-container-seccomp-security-options-surface +adr-029-database-logical-state-runtime-surface +adr-030-process-scoped-linux-capability-policy +adr-031-ssh-server-configuration-surface +adr-032-directory-domain-identity-runtime-surface +adr-033-scenario-delivery-boundary-for-runtime-node-state +adr-034-runtime-software-component-inventory +adr-035-service-manager-unit-state-runtime-surface +adr-036-sdl-processor-runtime-module-boundaries +adr-037-runtime-file-service-and-filesystem-presence-semantics +adr-038-runtime-mail-service-logical-state +adr-039-dns-service-runtime-inventory +adr-040-security-monitoring-manager-runtime-inventory adr-041-participant-implementation-manifest-and-provenance adr-042-network-sensor-runtime-monitoring adr-043-runtime-service-listener-surface @@ -56,6 +104,7 @@ adr-056-runtime-observed-values-and-credential-posture adr-057-runtime-secret-name-classifier-boundaries adr-058-datastore-node-engine-provenance-and-endpoints adr-059-adr-amendment-policy-and-pin-gate +adr-060-participant-backend-facing-contract-surface ``` | ADR | Title | Status | Date | diff --git a/docs/decisions/adrs/TEMPLATE.md b/docs/decisions/adrs/TEMPLATE.md new file mode 100644 index 000000000..513adac39 --- /dev/null +++ b/docs/decisions/adrs/TEMPLATE.md @@ -0,0 +1,29 @@ +# ADR-NNN: Title + +## Status + +proposed + +## Date + +YYYY-MM-DD + +## Context + +Describe the problem, constraints, and forces that make an architectural +decision necessary. + +## Decision + +State the chosen option and the reasons it is the best fit for the current +system. + +## Alternatives Considered + +List the credible options that were rejected, including the main reason each +was not chosen. + +## Consequences + +Record the positive outcomes, negative costs, and risks introduced by the +decision. diff --git a/docs/decisions/adrs/adr-055-experiment-core-contract-boundary.md b/docs/decisions/adrs/adr-055-experiment-core-contract-boundary.md index 0f4c81abf..1b59b7927 100644 --- a/docs/decisions/adrs/adr-055-experiment-core-contract-boundary.md +++ b/docs/decisions/adrs/adr-055-experiment-core-contract-boundary.md @@ -287,3 +287,9 @@ and redaction patterns. - If later APIs need draft or partial experiment records, they must introduce an explicit draft lifecycle surface rather than weakening these archival contracts. + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-06-12 | #482 | Recorded that the ADR's decision date is 2026-05-26 and it landed with experiment-core PR #422 on 2026-06-05. | diff --git a/docs/decisions/adrs/adr-index.yaml b/docs/decisions/adrs/adr-index.yaml index e44046033..ff6914021 100644 --- a/docs/decisions/adrs/adr-index.yaml +++ b/docs/decisions/adrs/adr-index.yaml @@ -195,6 +195,10 @@ adrs: - id: ADR-055 path: docs/decisions/adrs/adr-055-experiment-core-contract-boundary.md pin: 2e6666f2e066700df3dbf38d0592a2f24e062ad4f94d695113824f2ba73ff695 + amendments: + - date: 2026-06-12 + ref: "#482" + summary: "Recorded that the ADR's decision date is 2026-05-26 and it landed with experiment-core PR #422 on 2026-06-05." - id: ADR-056 path: docs/decisions/adrs/adr-056-runtime-observed-values-and-credential-posture.md pin: 3aa602de53f7607cf531b4a9da3f58192eb87ad21ddd92846fd2ae2a7526a4c3 diff --git a/docs/index.md b/docs/index.md index d0b4cf254..dff575f85 100644 --- a/docs/index.md +++ b/docs/index.md @@ -136,7 +136,26 @@ decisions/adrs/adr-037-runtime-file-service-and-filesystem-presence-semantics decisions/adrs/adr-038-runtime-mail-service-logical-state decisions/adrs/adr-039-dns-service-runtime-inventory decisions/adrs/adr-040-security-monitoring-manager-runtime-inventory +decisions/adrs/adr-041-participant-implementation-manifest-and-provenance +decisions/adrs/adr-042-network-sensor-runtime-monitoring +decisions/adrs/adr-043-runtime-service-listener-surface +decisions/adrs/adr-044-network-detection-engine-runtime-inventory +decisions/adrs/adr-045-security-monitoring-detection-definition-semantics +decisions/adrs/adr-046-app-authorization-runtime-inventory +decisions/adrs/adr-047-scheduled-job-runtime-inventory +decisions/adrs/adr-048-datastore-service-runtime-inventory +decisions/adrs/adr-049-platform-application-runtime-inventory +decisions/adrs/adr-050-forwarding-agent-runtime-inventory +decisions/adrs/adr-051-orchestration-authority-runtime-inventory +decisions/adrs/adr-052-typed-runtime-relationship-subtypes +decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios +decisions/adrs/adr-054-participant-runtime-observable-lifecycle decisions/adrs/adr-055-experiment-core-contract-boundary +decisions/adrs/adr-056-runtime-observed-values-and-credential-posture +decisions/adrs/adr-057-runtime-secret-name-classifier-boundaries +decisions/adrs/adr-058-datastore-node-engine-provenance-and-endpoints +decisions/adrs/adr-059-adr-amendment-policy-and-pin-gate +decisions/adrs/adr-060-participant-backend-facing-contract-surface decisions/sem-213-temporal-participant-preflight ``` diff --git a/implementations/python/tests/test_repo_policy_tools.py b/implementations/python/tests/test_repo_policy_tools.py index f0dae842d..2b6f24640 100644 --- a/implementations/python/tests/test_repo_policy_tools.py +++ b/implementations/python/tests/test_repo_policy_tools.py @@ -83,6 +83,22 @@ def setup_policy_repo(tmp_path: Path) -> Path: "| --- | --- | --- | --- |\n" "| [001](adr-001-example.md) | Example ADR | Accepted | 2026-04-05 |\n", ) + write_text( + adr_dir / "TEMPLATE.md", + "# ADR-NNN: Title\n\n" + "## Status\n\n" + "proposed\n\n" + "## Date\n\n" + "YYYY-MM-DD\n\n" + "## Context\n\n" + "What problem or situation is driving this decision?\n\n" + "## Decision\n\n" + "What did we choose, and why?\n\n" + "## Alternatives Considered\n\n" + "Which credible options were rejected, and why?\n\n" + "## Consequences\n\n" + "What are the positive, negative, and risk trade-offs?\n", + ) for package in ( "aces_sdl", "aces_processor", @@ -236,6 +252,32 @@ def test_adr_index_accepts_legacy_inline_status_and_date_fields(tmp_path: Path) assert failures == [] +def test_adr_template_requires_alternatives_considered(tmp_path: Path) -> None: + repo_root = setup_policy_repo(tmp_path) + write_text( + repo_root / "docs" / "decisions" / "adrs" / "TEMPLATE.md", + "# ADR-NNN: Title\n\n" + "## Status\n\n" + "proposed\n\n" + "## Date\n\n" + "YYYY-MM-DD\n\n" + "## Context\n\n" + "Context.\n\n" + "## Decision\n\n" + "Decision.\n\n" + "## Consequences\n\n" + "Consequences.\n", + ) + + failures = evaluate_repo_policy( + repo_root, + ["docs/decisions/adrs/TEMPLATE.md"], + structural_runner=structural_runner_stub, + ) + + assert [failure.rule_id for failure in failures] == ["adr-template-section-missing"] + + # ── ADR-015: SDL→processor layering rule ──────────────────────────────── diff --git a/tools/policy/repo_policy.py b/tools/policy/repo_policy.py index 25ffd07ce..e143b7acf 100644 --- a/tools/policy/repo_policy.py +++ b/tools/policy/repo_policy.py @@ -104,6 +104,7 @@ def structural_runner(input_document: dict) -> list[PolicyFailure]: failures.extend(_check_module_boundaries(repo_root, policy, changed, check_set=check_set)) if check_set == "full": + failures.extend(_check_adr_template(repo_root, changed)) failures.extend(_check_adr_index(repo_root, policy, changed)) if "CHANGELOG.md" in changed: @@ -947,6 +948,44 @@ def _check_changelog_versioned(repo_root: Path) -> list[PolicyFailure]: r"^\| \[(\d{3})\]\(([^)]+)\) \| (.+?) \| (.+?) \| (\d{4}-\d{2}-\d{2}) \|$", re.MULTILINE, ) +ADR_TEMPLATE_PATH = "docs/decisions/adrs/TEMPLATE.md" +ADR_TEMPLATE_REQUIRED_SECTIONS = ( + "Status", + "Date", + "Context", + "Decision", + "Alternatives Considered", + "Consequences", +) +ADR_TEMPLATE_HEADING_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) + + +def _check_adr_template(repo_root: Path, changed: list[str]) -> list[PolicyFailure]: + if not any(path.startswith("docs/decisions/adrs/") or path == "tools/policy/repo_policy.py" for path in changed): + return [] + + template = repo_root / ADR_TEMPLATE_PATH + if not template.exists(): + return [ + PolicyFailure( + "adr-template-missing", + "ADR template is missing", + ADR_TEMPLATE_PATH, + ) + ] + + text = template.read_text(encoding="utf-8") + headings = {match.group(1).strip() for match in ADR_TEMPLATE_HEADING_RE.finditer(text)} + missing = [section for section in ADR_TEMPLATE_REQUIRED_SECTIONS if section not in headings] + if missing: + return [ + PolicyFailure( + "adr-template-section-missing", + f"ADR template is missing required section(s): {', '.join(missing)}", + ADR_TEMPLATE_PATH, + ) + ] + return [] def _check_adr_index(repo_root: Path, policy: dict, changed: list[str]) -> list[PolicyFailure]: diff --git a/tools/policy/requirement_order.yaml b/tools/policy/requirement_order.yaml index aa5408932..9ce1842b1 100644 --- a/tools/policy/requirement_order.yaml +++ b/tools/policy/requirement_order.yaml @@ -181,6 +181,8 @@ ownership: - CHANGELOG.md decision-record-governance: - docs/decisions/adrs + - docs/index.md + - implementations/python/tests experiment-core: - contracts/README.md - contracts/schema-publication-manifest.json From 07df441abe06da03cecff452d3148e83cfd81245 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 13 Jun 2026 02:53:00 +0200 Subject: [PATCH 21/70] Add FM classification assurance gate --- changelog.d/483.changed.md | 4 + docs/decisions/adrs/TEMPLATE.md | 6 + .../reference/fm-classification-ledger.yaml | 437 +++++++++++++++++ .../reference/shared-semantic-integrity.md | 7 + .../python/tests/test_assurance_policy.py | 232 ++++++++- tools/check_assurance_policy.py | 443 ++++++++++++++++++ 6 files changed, 1128 insertions(+), 1 deletion(-) create mode 100644 changelog.d/483.changed.md create mode 100644 docs/explain/reference/fm-classification-ledger.yaml diff --git a/changelog.d/483.changed.md b/changelog.d/483.changed.md new file mode 100644 index 000000000..be98c40d7 --- /dev/null +++ b/changelog.d/483.changed.md @@ -0,0 +1,4 @@ +### Changed + +- Added an auditable FM classification ledger and policy gate for new ADR + classification fields. diff --git a/docs/decisions/adrs/TEMPLATE.md b/docs/decisions/adrs/TEMPLATE.md index 513adac39..b6cdfae17 100644 --- a/docs/decisions/adrs/TEMPLATE.md +++ b/docs/decisions/adrs/TEMPLATE.md @@ -8,6 +8,12 @@ proposed YYYY-MM-DD +## Classification + +Classification: FM +Required artifacts: +Waivers: + ## Context Describe the problem, constraints, and forces that make an architectural diff --git a/docs/explain/reference/fm-classification-ledger.yaml b/docs/explain/reference/fm-classification-ledger.yaml new file mode 100644 index 000000000..fd8bce2a9 --- /dev/null +++ b/docs/explain/reference/fm-classification-ledger.yaml @@ -0,0 +1,437 @@ +ledger: per-change-fm-classification +policy_ref: specs/formal/assurance-policy.yaml +entries: + - adr: ADR-023 + surface: Container image build provenance surface + fm_level: FM1 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-023-container-image-build-provenance-surface.md + - kind: unit_tests + path: implementations/python/tests/test_scenarios.py + waived_artifacts: [] + - adr: ADR-024 + surface: Local identity inventory surface + fm_level: FM1 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-024-local-identity-inventory-surface.md + - kind: unit_tests + path: implementations/python/tests/test_sdl_models.py + waived_artifacts: [] + - adr: ADR-025 + surface: Container network realization surface + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-025-container-network-realization-surface.md + - kind: unit_tests + path: implementations/python/tests/test_sdl_validator.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_sdl_validator.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-026 + surface: Application HTTP surface inventory + fm_level: FM1 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-026-application-http-surface-inventory.md + - kind: unit_tests + path: implementations/python/tests/test_sdl_validator.py + waived_artifacts: [] + - adr: ADR-027 + surface: Container init and reaper runtime surface + fm_level: FM1 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-027-container-init-reaper-runtime-surface.md + - kind: unit_tests + path: implementations/python/tests/test_sdl_validator.py + waived_artifacts: [] + - adr: ADR-028 + surface: Container seccomp and security options surface + fm_level: FM1 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-028-container-seccomp-security-options-surface.md + - kind: unit_tests + path: implementations/python/tests/test_sdl_validator.py + waived_artifacts: [] + - adr: ADR-029 + surface: Database logical-state runtime surface + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-029-database-logical-state-runtime-surface.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_datastore.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-030 + surface: Process-scoped Linux capability policy + fm_level: FM1 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-030-process-scoped-linux-capability-policy.md + - kind: unit_tests + path: implementations/python/tests/test_sdl_validator.py + waived_artifacts: [] + - adr: ADR-031 + surface: SSH server configuration surface + fm_level: FM1 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-031-ssh-server-configuration-surface.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_ssh_server.py + waived_artifacts: [] + - adr: ADR-032 + surface: Directory and domain identity runtime surface + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-032-directory-domain-identity-runtime-surface.md + - kind: unit_tests + path: implementations/python/tests/test_sdl_validator.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-033 + surface: Scenario delivery boundary for runtime node state + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-033-scenario-delivery-boundary-for-runtime-node-state.md + - kind: unit_tests + path: implementations/python/tests/test_sdl_validator.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-034 + surface: Runtime software component inventory + fm_level: FM1 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-034-runtime-software-component-inventory.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_models.py + waived_artifacts: [] + - adr: ADR-035 + surface: Service-manager unit state runtime surface + fm_level: FM3 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-035-service-manager-unit-state-runtime-surface.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_service_units.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - kind: abstract_state_machine_model + rationale: Historical backfill records current evidence; no abstract state-machine artifact was recorded for this accepted ADR. + - adr: ADR-036 + surface: SDL, processor, and runtime module boundaries + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-036-sdl-processor-runtime-module-boundaries.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_contract_boundaries.py + - kind: typed_ir_or_contract_coverage + path: tools/policy/adr_policy.yaml + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-037 + surface: Runtime file-service and filesystem presence semantics + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-037-runtime-file-service-and-filesystem-presence-semantics.md + - kind: unit_tests + path: implementations/python/tests/test_sdl_validator.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-038 + surface: Runtime mail-service logical state + fm_level: FM3 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-038-runtime-mail-service-logical-state.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_mail_service.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - kind: abstract_state_machine_model + rationale: Historical backfill records current evidence; no abstract state-machine artifact was recorded for this accepted ADR. + - adr: ADR-039 + surface: DNS service runtime inventory + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-039-dns-service-runtime-inventory.md + - kind: unit_tests + path: implementations/python/tests/test_sdl_validator.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-040 + surface: Security-monitoring manager runtime inventory + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-040-security-monitoring-manager-runtime-inventory.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_security_monitoring.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-041 + surface: Participant implementation manifest and provenance surface + fm_level: FM1 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-041-participant-implementation-manifest-and-provenance.md + - kind: unit_tests + path: implementations/python/tests/test_participant_implementation_manifest.py + waived_artifacts: [] + - adr: ADR-042 + surface: Network sensor runtime monitoring posture + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-042-network-sensor-runtime-monitoring.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_network_sensor.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-043 + surface: Generic runtime service listener surface + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-043-runtime-service-listener-surface.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_service_listeners.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-044 + surface: Network detection engine runtime inventory + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-044-network-detection-engine-runtime-inventory.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_network_detection.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-045 + surface: Security-monitoring detection definition semantics + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-045-security-monitoring-detection-definition-semantics.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_security_monitoring.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-046 + surface: Application-internal authorization runtime inventory + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-046-app-authorization-runtime-inventory.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_app_authorization.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-047 + surface: Scheduled-job runtime inventory + fm_level: FM3 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-047-scheduled-job-runtime-inventory.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_scheduled_job.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - kind: abstract_state_machine_model + rationale: Historical backfill records current evidence; no abstract state-machine artifact was recorded for this accepted ADR. + - adr: ADR-048 + surface: Datastore service runtime inventory + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_datastore.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-049 + surface: Platform application runtime inventory + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-049-platform-application-runtime-inventory.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_platform_application.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-050 + surface: Forwarding agent runtime inventory + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-050-forwarding-agent-runtime-inventory.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_forwarding_agent.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-051 + surface: Orchestration authority runtime inventory + fm_level: FM3 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-051-orchestration-authority-runtime-inventory.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_orchestration.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - kind: abstract_state_machine_model + rationale: Historical backfill records current evidence; no abstract state-machine artifact was recorded for this accepted ADR. + - adr: ADR-052 + surface: Typed runtime relationship subtypes + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_family_invariants.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-053 + surface: SDL module composition for inventory-backed scenarios + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md + - kind: unit_tests + path: implementations/python/tests/test_sdl_module_registry.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-054 + surface: Participant runtime observable lifecycle + fm_level: FM3 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-054-participant-runtime-observable-lifecycle.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_control_plane.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - kind: abstract_state_machine_model + rationale: Historical backfill records current evidence; no abstract state-machine artifact was recorded for this accepted ADR. + - adr: ADR-055 + surface: Experiment core contract boundary + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-055-experiment-core-contract-boundary.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_contracts.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-056 + surface: Runtime observed values and credential posture + fm_level: FM1 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-056-runtime-observed-values-and-credential-posture.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_observed_values.py + waived_artifacts: [] + - adr: ADR-057 + surface: Runtime scenario value realizability and explicit redaction + fm_level: FM1 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-057-runtime-secret-name-classifier-boundaries.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_observed_values.py + waived_artifacts: [] + - adr: ADR-058 + surface: Datastore node engine provenance and endpoints + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-058-datastore-node-engine-provenance-and-endpoints.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_datastore.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/tests/test_runtime_contracts.py + waived_artifacts: + - kind: property_based_or_differential_tests + rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. diff --git a/docs/explain/reference/shared-semantic-integrity.md b/docs/explain/reference/shared-semantic-integrity.md index 846cb09f2..6f2ef11be 100644 --- a/docs/explain/reference/shared-semantic-integrity.md +++ b/docs/explain/reference/shared-semantic-integrity.md @@ -12,6 +12,13 @@ ADR-016 governs; every `SEM-2xx` implementation PR moves its construct family's row toward `active` there. The structural gate `tools/check_semantic_coverage.py` validates that table on every `nox` policy run. +Per-change FM classifications for semantic and runtime-surface ADRs are +recorded in the sibling +[`fm-classification-ledger.yaml`](fm-classification-ledger.yaml) file. The +assurance-policy gate validates that ledger against +`specs/formal/assurance-policy.yaml`, including the ADR-023 through ADR-058 +runtime-surface backfill. + `SEM-200` is broader than concept authority alone. It covers whether scenario constructs keep the same meaning across authoring, validation, instantiation, compilation, planning, execution, live observation, and post-run experiment diff --git a/implementations/python/tests/test_assurance_policy.py b/implementations/python/tests/test_assurance_policy.py index bc8d87e55..b30fc6070 100644 --- a/implementations/python/tests/test_assurance_policy.py +++ b/implementations/python/tests/test_assurance_policy.py @@ -10,12 +10,15 @@ sys.path.insert(0, str(REPO_ROOT)) from tools.check_assurance_policy import ( # noqa: E402 + ADR_CLASSIFICATION_REQUIRED_FROM, ADR_POLICY_RELATIVE_PATH, ADR_REF, ADR_REFS, + ADR_TEMPLATE_RELATIVE_PATH, ASSURANCE_POLICY_RELATIVE_PATH, CANONICAL_LEVEL_IDS, CODING_STANDARDS_RELATIVE_PATH, + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, FORMAL_OVERVIEW_RELATIVE_PATH, REQUIRED_CHANGE_CATEGORIES, REQUIREMENT_REF, @@ -99,6 +102,44 @@ | FM3 | Stateful / Control Semantics | FM2 + abstract state-machine model | """ +_GOOD_ADR_TEMPLATE = """# ADR-NNN: Title + +## Status + +proposed + +## Date + +YYYY-MM-DD + +## Classification + +Classification: FM +Required artifacts: +Waivers: + +## Context + +Describe the problem. + +## Decision + +State the decision. + +## Alternatives Considered + +List rejected options. + +## Consequences + +Record outcomes. +""" + +_GOOD_EMPTY_LEDGER = """ledger: per-change-fm-classification +policy_ref: specs/formal/assurance-policy.yaml +entries: [] +""" + def _seed_repo( tmp_path: Path, @@ -107,8 +148,10 @@ def _seed_repo( adr_body: str | None = _GOOD_ADR, coding_standards_body: str | None = _GOOD_CODING_STANDARDS, formal_overview_body: str | None = _GOOD_FORMAL_OVERVIEW, + adr_template_body: str | None = _GOOD_ADR_TEMPLATE, + ledger_body: str | None = _GOOD_EMPTY_LEDGER, ) -> Path: - """Seed a temp repo skeleton with the policy YAML and the three referencing docs.""" + """Seed a temp repo skeleton with the policy YAML and referencing docs.""" policy_path = tmp_path / ASSURANCE_POLICY_RELATIVE_PATH policy_path.parent.mkdir(parents=True, exist_ok=True) policy_path.write_text(policy_body, encoding="utf-8") @@ -128,6 +171,16 @@ def _seed_repo( fo_path.parent.mkdir(parents=True, exist_ok=True) fo_path.write_text(formal_overview_body, encoding="utf-8") + if adr_template_body is not None: + template_path = tmp_path / ADR_TEMPLATE_RELATIVE_PATH + template_path.parent.mkdir(parents=True, exist_ok=True) + template_path.write_text(adr_template_body, encoding="utf-8") + + if ledger_body is not None: + ledger_path = tmp_path / FM_CLASSIFICATION_LEDGER_RELATIVE_PATH + ledger_path.parent.mkdir(parents=True, exist_ok=True) + ledger_path.write_text(ledger_body, encoding="utf-8") + return tmp_path @@ -137,6 +190,84 @@ def _flagged(failures, marker: str) -> bool: return any(f.rule_id == marker or needle in f.render().lower() for f in failures) +def _write(path: Path, text: str = "placeholder\n") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def _new_adr_body( + *, + classification: str | None = "FM1", + artifacts: str | None = "unit_tests", + waivers: str | None = "none", +) -> str: + fields: list[str] = [] + if classification is not None: + fields.append(f"Classification: {classification}") + if artifacts is not None: + fields.append(f"Required artifacts: {artifacts}") + if waivers is not None: + fields.append(f"Waivers: {waivers}") + classification_section = "\n".join(fields) + return f"""# ADR-{ADR_CLASSIFICATION_REQUIRED_FROM:03d}: New Semantic Surface + +## Status + +proposed + +## Date + +2026-06-12 + +## Classification + +{classification_section} + +## Context + +This proposed ADR adds a semantic runtime surface. + +## Decision + +Record a semantic decision. + +## Alternatives Considered + +None. + +## Consequences + +The new decision is auditable. +""" + + +def _ledger_entry( + adr: str = "ADR-023", + *, + fm_level: str = "FM1", + include_unit_test: bool = True, +) -> str: + delivered = [ + f" - kind: invariant_list\n path: docs/decisions/adrs/adr-{adr[-3:]}-surface.md", + ] + if include_unit_test: + delivered.append( + " - kind: unit_tests\n path: implementations/python/tests/test_assurance_policy_surface.py" + ) + return f""" - adr: {adr} + surface: Example runtime surface + fm_level: {fm_level} + delivered_artifacts: +{chr(10).join(delivered)} + waived_artifacts: [] +""" + + +def _seed_runtime_surface(repo: Path, adr: str = "ADR-023") -> None: + _write(repo / f"docs/decisions/adrs/adr-{adr[-3:]}-surface.md", f"# {adr}: Surface\n") + _write(repo / "implementations/python/tests/test_assurance_policy_surface.py") + + # ----------------------------------------------------------------------------- # # Positive case -- the canonical YAML against canonical doc stubs is clean. # # ----------------------------------------------------------------------------- # @@ -795,6 +926,105 @@ def test_fm4_with_artifacts_not_superset_of_fm3_is_flagged(tmp_path: Path) -> No assert _flagged(failures, "FM4") +# ----------------------------------------------------------------------------- # +# ADR classification and per-change FM ledger. # +# ----------------------------------------------------------------------------- # + + +def test_new_adr_without_classification_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path) + _write( + repo / f"docs/decisions/adrs/adr-{ADR_CLASSIFICATION_REQUIRED_FROM:03d}-new-semantic-surface.md", + _new_adr_body(classification=None), + ) + + failures = evaluate_assurance_policy(repo) + + assert _flagged(failures, "adr-classification-missing") + + +def test_new_adr_with_unknown_fm_level_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path) + _write( + repo / f"docs/decisions/adrs/adr-{ADR_CLASSIFICATION_REQUIRED_FROM:03d}-new-semantic-surface.md", + _new_adr_body(classification="FM9"), + ) + + failures = evaluate_assurance_policy(repo) + + assert _flagged(failures, "adr-classification-level") + assert _flagged(failures, "FM9") + + +def test_new_adr_missing_required_artifacts_line_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path) + _write( + repo / f"docs/decisions/adrs/adr-{ADR_CLASSIFICATION_REQUIRED_FROM:03d}-new-semantic-surface.md", + _new_adr_body(artifacts=None), + ) + + failures = evaluate_assurance_policy(repo) + + assert _flagged(failures, "adr-classification-artifacts") + + +def test_adr_template_missing_classification_field_is_flagged(tmp_path: Path) -> None: + template = _GOOD_ADR_TEMPLATE.replace("Classification: FM\n", "", 1) + + failures = evaluate_assurance_policy(_seed_repo(tmp_path, adr_template_body=template)) + + assert _flagged(failures, "adr-template-classification") + + +def test_ledger_missing_existing_runtime_surface_entry_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo( + tmp_path, + ledger_body=( + f"ledger: per-change-fm-classification\npolicy_ref: {ASSURANCE_POLICY_RELATIVE_PATH}\nentries:\n" + f"{_ledger_entry('ADR-023')}" + ), + ) + _seed_runtime_surface(repo, "ADR-023") + _seed_runtime_surface(repo, "ADR-024") + + failures = evaluate_assurance_policy(repo) + + assert _flagged(failures, "fm-classification-ledger-coverage") + assert _flagged(failures, "ADR-024") + + +def test_ledger_fm_level_must_resolve_to_policy_yaml(tmp_path: Path) -> None: + repo = _seed_repo( + tmp_path, + ledger_body=( + f"ledger: per-change-fm-classification\npolicy_ref: {ASSURANCE_POLICY_RELATIVE_PATH}\nentries:\n" + f"{_ledger_entry(fm_level='FM9')}" + ), + ) + _seed_runtime_surface(repo) + + failures = evaluate_assurance_policy(repo) + + assert _flagged(failures, "fm-classification-ledger-level") + assert _flagged(failures, "FM9") + + +def test_ledger_required_artifacts_must_be_delivered_or_waived(tmp_path: Path) -> None: + repo = _seed_repo( + tmp_path, + ledger_body=( + f"ledger: per-change-fm-classification\npolicy_ref: {ASSURANCE_POLICY_RELATIVE_PATH}\nentries:\n" + f"{_ledger_entry(include_unit_test=False)}" + ), + ) + _seed_runtime_surface(repo) + + failures = evaluate_assurance_policy(repo) + + assert _flagged(failures, "fm-classification-ledger-artifacts") + assert _flagged(failures, "unit_tests") + + # ----------------------------------------------------------------------------- # # Real-repo invariant -- the actual checked-in YAML and docs must be clean. # # ----------------------------------------------------------------------------- # diff --git a/tools/check_assurance_policy.py b/tools/check_assurance_policy.py index 28d019d9b..62eebf30a 100644 --- a/tools/check_assurance_policy.py +++ b/tools/check_assurance_policy.py @@ -23,6 +23,7 @@ from __future__ import annotations import argparse +import re import sys from collections.abc import Iterable from pathlib import Path @@ -49,11 +50,19 @@ ADR_POLICY_RELATIVE_PATH = "docs/decisions/adrs/adr-007-lightweight-formal-methods-policy.md" CODING_STANDARDS_RELATIVE_PATH = "docs/explain/reference/coding-standards.md" FORMAL_OVERVIEW_RELATIVE_PATH = "docs/specs/formal.md" +ADR_TEMPLATE_RELATIVE_PATH = "docs/decisions/adrs/TEMPLATE.md" +ADR_DIRECTORY_RELATIVE_PATH = "docs/decisions/adrs" +FM_CLASSIFICATION_LEDGER_RELATIVE_PATH = "docs/explain/reference/fm-classification-ledger.yaml" # The baseline canonical level ids. The YAML MAY add more levels (e.g. FM4), # but these four are the floor and must always be present. CANONICAL_LEVEL_IDS: tuple[str, ...] = ("FM0", "FM1", "FM2", "FM3") +# ADR-060 existed before this gate. ADR-061 and later ADRs must carry the +# explicit per-change FM classification record. +ADR_CLASSIFICATION_REQUIRED_FROM = 61 +FM_LEDGER_RUNTIME_ADR_RANGE = range(23, 59) + # The four words in the ASR-505 statement -- the validator pins each to a # specific level so reordering does not silently keep passing. REQUIRED_CHANGE_CATEGORIES: tuple[str, ...] = ("structural", "semantic", "graph", "stateful") @@ -111,6 +120,11 @@ "prohibited_artifacts", ) _FM0_PROHIBITED_ARTIFACTS: frozenset[str] = frozenset({"TLA+", "Alloy"}) +_ADR_FILE_RE = re.compile(r"^adr-(\d{3})-.+\.md$") +_ADR_CLASSIFICATION_RE = re.compile(r"^Classification:\s*(FM\d+)\s*$", re.MULTILINE) +_ADR_REQUIRED_ARTIFACTS_RE = re.compile(r"^Required artifacts:\s*(.+?)\s*$", re.MULTILINE) +_ADR_WAIVERS_RE = re.compile(r"^Waivers:\s*(.+?)\s*$", re.MULTILINE) +_LEDGER_VALUE = "per-change-fm-classification" # Human-readable phrasings for each YAML artifact slug. The drift guard # requires the union of required artifacts (across all levels in the YAML) to @@ -499,6 +513,431 @@ def _required_artifact_union(levels: list[Any]) -> set[str]: return union +def _level_ids(levels: list[Any]) -> set[str]: + """Return valid FM level ids from the canonical YAML.""" + return {level["id"] for level in levels if isinstance(level, dict) and isinstance(level.get("id"), str)} + + +def _required_artifacts_by_level(levels: list[Any]) -> dict[str, set[str]]: + """Return YAML-required artifact kinds per FM level.""" + by_level: dict[str, set[str]] = {} + for level in levels: + if isinstance(level, dict) and isinstance(level.get("id"), str): + by_level[level["id"]] = set(_level_sequence(level, "required_artifacts")) + return by_level + + +def _is_filled_field(value: str | None) -> bool: + """Return true when a template field has been replaced with a real value.""" + if value is None: + return False + normalized = value.strip().lower() + if not normalized: + return False + if normalized in {"tbd", "todo", "none yet", "n/a"}: + return False + return "<" not in value and "..." not in value + + +def _adr_number(path: Path) -> int | None: + match = _ADR_FILE_RE.match(path.name) + if not match: + return None + return int(match.group(1)) + + +def _adr_id_from_number(number: int) -> str: + return f"ADR-{number:03d}" + + +def _find_adr_path(repo_root: Path, adr_id: str) -> Path | None: + suffix = adr_id.removeprefix("ADR-") + if not suffix.isdigit(): + return None + adr_dir = repo_root / ADR_DIRECTORY_RELATIVE_PATH + matches = sorted(adr_dir.glob(f"adr-{suffix}-*.md")) + return matches[0] if matches else None + + +def _resolve_repo_path(repo_root: Path, relative_path: str) -> Path | None: + root = repo_root.resolve() + candidate = (root / relative_path).resolve() + if candidate == root or root in candidate.parents: + return candidate + return None + + +def _check_adr_template_classification(repo_root: Path) -> list[PolicyFailure]: + template_path = repo_root / ADR_TEMPLATE_RELATIVE_PATH + if not template_path.is_file(): + return [_fail("adr-template-classification", "ADR template is missing", ADR_TEMPLATE_RELATIVE_PATH)] + text = template_path.read_text(encoding="utf-8") + required_snippets = ( + "## Classification", + "Classification: FM", + "Required artifacts:", + "Waivers:", + ) + missing = [snippet for snippet in required_snippets if snippet not in text] + if missing: + return [ + _fail( + "adr-template-classification", + "ADR template is missing FM classification field(s): " + ", ".join(missing), + ADR_TEMPLATE_RELATIVE_PATH, + ) + ] + return [] + + +def _check_new_adr_classifications(repo_root: Path, level_ids: set[str]) -> list[PolicyFailure]: + adr_dir = repo_root / ADR_DIRECTORY_RELATIVE_PATH + if not adr_dir.is_dir(): + return [] + failures: list[PolicyFailure] = [] + for adr_path in sorted(adr_dir.glob("adr-*.md")): + number = _adr_number(adr_path) + if number is None or number < ADR_CLASSIFICATION_REQUIRED_FROM: + continue + rel_path = adr_path.relative_to(repo_root).as_posix() + text = adr_path.read_text(encoding="utf-8") + classification_matches = _ADR_CLASSIFICATION_RE.findall(text) + if len(classification_matches) != 1: + failures.append( + _fail( + "adr-classification-missing", + f"{_adr_id_from_number(number)} must contain exactly one 'Classification: FM' field", + rel_path, + ) + ) + continue + level_id = classification_matches[0] + if level_id not in level_ids: + failures.append( + _fail( + "adr-classification-level", + f"{_adr_id_from_number(number)} classification level {level_id} " + f"is not defined in {ASSURANCE_POLICY_RELATIVE_PATH}", + rel_path, + ) + ) + artifacts_match = _ADR_REQUIRED_ARTIFACTS_RE.search(text) + if not _is_filled_field(artifacts_match.group(1) if artifacts_match else None): + failures.append( + _fail( + "adr-classification-artifacts", + f"{_adr_id_from_number(number)} must name required artifacts delivered or waived", + rel_path, + ) + ) + waivers_match = _ADR_WAIVERS_RE.search(text) + if not _is_filled_field(waivers_match.group(1) if waivers_match else None): + failures.append( + _fail( + "adr-classification-waivers", + f"{_adr_id_from_number(number)} must name waivers or explicitly say none", + rel_path, + ) + ) + return failures + + +def _runtime_adr_ids_present(repo_root: Path) -> set[str]: + adr_dir = repo_root / ADR_DIRECTORY_RELATIVE_PATH + if not adr_dir.is_dir(): + return set() + present: set[str] = set() + for path in adr_dir.glob("adr-*.md"): + number = _adr_number(path) + if number in FM_LEDGER_RUNTIME_ADR_RANGE: + present.add(_adr_id_from_number(number)) + return present + + +def _check_ledger_artifact( + repo_root: Path, + entry_id: str, + artifact: Any, + valid_artifact_kinds: set[str], + index: int, +) -> tuple[str | None, list[PolicyFailure]]: + path = FM_CLASSIFICATION_LEDGER_RELATIVE_PATH + if not isinstance(artifact, dict): + return None, [ + _fail( + "fm-classification-ledger-artifact", + f"{entry_id} delivered_artifacts[{index}] must be a mapping", + path, + ) + ] + kind = artifact.get("kind") + artifact_path = artifact.get("path") + failures: list[PolicyFailure] = [] + if not isinstance(kind, str) or kind not in valid_artifact_kinds: + failures.append( + _fail( + "fm-classification-ledger-artifact", + f"{entry_id} delivered_artifacts[{index}].kind must be one of " + f"{sorted(valid_artifact_kinds)}; got {kind!r}", + path, + ) + ) + if not isinstance(artifact_path, str) or not artifact_path.strip(): + failures.append( + _fail( + "fm-classification-ledger-artifact", + f"{entry_id} delivered_artifacts[{index}].path must be a non-empty repo-relative path", + path, + ) + ) + else: + resolved = _resolve_repo_path(repo_root, artifact_path) + if resolved is None: + failures.append( + _fail( + "fm-classification-ledger-artifact", + f"{entry_id} delivered artifact path escapes the repo root: {artifact_path}", + path, + ) + ) + elif not resolved.exists(): + failures.append( + _fail( + "fm-classification-ledger-artifact", + f"{entry_id} delivered artifact path does not exist: {artifact_path}", + path, + ) + ) + return kind if isinstance(kind, str) else None, failures + + +def _check_ledger_waiver( + entry_id: str, + waiver: Any, + valid_artifact_kinds: set[str], + index: int, +) -> tuple[str | None, list[PolicyFailure]]: + path = FM_CLASSIFICATION_LEDGER_RELATIVE_PATH + if not isinstance(waiver, dict): + return None, [ + _fail( + "fm-classification-ledger-waiver", + f"{entry_id} waived_artifacts[{index}] must be a mapping", + path, + ) + ] + kind = waiver.get("kind") + rationale = waiver.get("rationale") + failures: list[PolicyFailure] = [] + if not isinstance(kind, str) or kind not in valid_artifact_kinds: + failures.append( + _fail( + "fm-classification-ledger-waiver", + f"{entry_id} waived_artifacts[{index}].kind must be one of " + f"{sorted(valid_artifact_kinds)}; got {kind!r}", + path, + ) + ) + if not isinstance(rationale, str) or not rationale.strip(): + failures.append( + _fail( + "fm-classification-ledger-waiver", + f"{entry_id} waived_artifacts[{index}].rationale must be non-empty", + path, + ) + ) + return kind if isinstance(kind, str) else None, failures + + +def _check_fm_classification_ledger(repo_root: Path, levels: list[Any]) -> list[PolicyFailure]: + ledger_path = repo_root / FM_CLASSIFICATION_LEDGER_RELATIVE_PATH + if not ledger_path.is_file(): + return [ + _fail( + "fm-classification-ledger-missing", + f"FM classification ledger not found: {FM_CLASSIFICATION_LEDGER_RELATIVE_PATH}", + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, + ) + ] + try: + raw = yaml.safe_load(ledger_path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + return [ + _fail( + "fm-classification-ledger-parse", + f"failed to parse {FM_CLASSIFICATION_LEDGER_RELATIVE_PATH}: {exc}", + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, + ) + ] + if not isinstance(raw, dict): + return [ + _fail( + "fm-classification-ledger-shape", + f"{FM_CLASSIFICATION_LEDGER_RELATIVE_PATH} must be a YAML mapping at the top level", + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, + ) + ] + + failures: list[PolicyFailure] = [] + if raw.get("ledger") != _LEDGER_VALUE: + failures.append( + _fail( + "fm-classification-ledger-field", + f"ledger field must be {_LEDGER_VALUE!r}", + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, + ) + ) + if raw.get("policy_ref") != ASSURANCE_POLICY_RELATIVE_PATH: + failures.append( + _fail( + "fm-classification-ledger-policy-ref", + f"policy_ref must be {ASSURANCE_POLICY_RELATIVE_PATH}", + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, + ) + ) + + entries = raw.get("entries") + if not isinstance(entries, list): + return failures + [ + _fail( + "fm-classification-ledger-field", + "entries must be a YAML list", + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, + ) + ] + + level_ids = _level_ids(levels) + required_by_level = _required_artifacts_by_level(levels) + valid_artifact_kinds = _required_artifact_union(levels) + seen: set[str] = set() + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + failures.append( + _fail( + "fm-classification-ledger-entry", + f"entries[{index}] must be a mapping", + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, + ) + ) + continue + adr = entry.get("adr") + entry_id = adr if isinstance(adr, str) else f"entries[{index}]" + if not isinstance(adr, str) or not re.fullmatch(r"ADR-\d{3}", adr): + failures.append( + _fail( + "fm-classification-ledger-entry", + f"entries[{index}].adr must be an ADR-NNN id", + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, + ) + ) + elif adr in seen: + failures.append( + _fail( + "fm-classification-ledger-duplicate", + f"ledger contains duplicate entry for {adr}", + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, + ) + ) + else: + seen.add(adr) + if _find_adr_path(repo_root, adr) is None: + failures.append( + _fail( + "fm-classification-ledger-entry", + f"{adr} does not resolve to an ADR file under {ADR_DIRECTORY_RELATIVE_PATH}", + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, + ) + ) + surface = entry.get("surface") + if not isinstance(surface, str) or not surface.strip(): + failures.append( + _fail( + "fm-classification-ledger-entry", + f"{entry_id}.surface must be non-empty", + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, + ) + ) + fm_level = entry.get("fm_level") + if not isinstance(fm_level, str) or fm_level not in level_ids: + failures.append( + _fail( + "fm-classification-ledger-level", + f"{entry_id}.fm_level {fm_level!r} is not defined in {ASSURANCE_POLICY_RELATIVE_PATH}", + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, + ) + ) + required = set() + else: + required = required_by_level.get(fm_level, set()) + + delivered = entry.get("delivered_artifacts") + if not isinstance(delivered, list): + failures.append( + _fail( + "fm-classification-ledger-artifact", + f"{entry_id}.delivered_artifacts must be a YAML list", + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, + ) + ) + delivered = [] + delivered_kinds: set[str] = set() + for artifact_index, artifact in enumerate(delivered): + kind, artifact_failures = _check_ledger_artifact( + repo_root, + entry_id, + artifact, + valid_artifact_kinds, + artifact_index, + ) + if kind is not None: + delivered_kinds.add(kind) + failures.extend(artifact_failures) + + waived = entry.get("waived_artifacts", []) + if not isinstance(waived, list): + failures.append( + _fail( + "fm-classification-ledger-waiver", + f"{entry_id}.waived_artifacts must be a YAML list when present", + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, + ) + ) + waived = [] + waived_kinds: set[str] = set() + for waiver_index, waiver in enumerate(waived): + kind, waiver_failures = _check_ledger_waiver( + entry_id, + waiver, + valid_artifact_kinds, + waiver_index, + ) + if kind is not None: + waived_kinds.add(kind) + failures.extend(waiver_failures) + + missing_required = sorted(required - delivered_kinds - waived_kinds) + if missing_required: + failures.append( + _fail( + "fm-classification-ledger-artifacts", + f"{entry_id} ({fm_level}) must deliver or waive required artifact kind(s): {missing_required}", + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, + ) + ) + + missing = sorted(_runtime_adr_ids_present(repo_root) - seen) + if missing: + failures.append( + _fail( + "fm-classification-ledger-coverage", + f"ledger must cover existing runtime-surface ADRs {FM_LEDGER_RUNTIME_ADR_RANGE.start:03d}-" + f"{FM_LEDGER_RUNTIME_ADR_RANGE.stop - 1:03d}; missing: {missing}", + FM_CLASSIFICATION_LEDGER_RELATIVE_PATH, + ) + ) + return failures + + def _check_artifact_keyword_drift( repo_root: Path, doc_rel: str, @@ -717,6 +1156,10 @@ def evaluate_assurance_policy(repo_root: Path) -> list[PolicyFailure]: ) ) + failures.extend(_check_adr_template_classification(repo_root)) + failures.extend(_check_new_adr_classifications(repo_root, _level_ids(levels))) + failures.extend(_check_fm_classification_ledger(repo_root, levels)) + return failures From 1250236f9c23376e0734b252f66f79e60833cf75 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 13 Jun 2026 07:11:00 +0200 Subject: [PATCH 22/70] Fix inventory capture secret boundary --- .../aces-asset-inventory-capture/SKILL.md | 42 +++++--- .../capture-container-evidence-template.sh | 66 +++++++++---- .../aces-asset-inventory-capture/SKILL.md | 42 +++++--- .../capture-container-evidence-template.sh | 66 +++++++++---- changelog.d/516.fixed.md | 1 + docs/aces/inventory/ad-preflight.md | 8 +- .../inventory/asset-inventory-methodology.md | 31 ++++-- docs/aces/inventory/index.md | 1 + .../issue-516-redaction-boundary-preflight.md | 96 +++++++++++++++++++ docs/aces/inventory/kali-preflight.md | 19 ++-- docs/aces/inventory/webapp-preflight.md | 13 +-- .../tests/test_agent_inventory_skill.py | 47 +++++++++ 12 files changed, 343 insertions(+), 89 deletions(-) create mode 100644 changelog.d/516.fixed.md create mode 100644 docs/aces/inventory/issue-516-redaction-boundary-preflight.md diff --git a/.claude/skills/aces-asset-inventory-capture/SKILL.md b/.claude/skills/aces-asset-inventory-capture/SKILL.md index ad297e0e7..f008fbbd9 100644 --- a/.claude/skills/aces-asset-inventory-capture/SKILL.md +++ b/.claude/skills/aces-asset-inventory-capture/SKILL.md @@ -20,8 +20,12 @@ For Docker/Compose/container-image captures, start by copying `scripts/capture-container-evidence-template.sh` and `scripts/normalize-syft-cyclonedx.jq` into the target bundle as runnable capture resources, then tailor the copied script for asset-specific source paths, -filesystem manifests, and redaction names. Keep every evidence-affecting -normalization in the script or a referenced jq file. +filesystem manifests, and any explicit operator/out-of-scenario withholding +rules. Set `CAPTURE_BOUNDARY=scenario-target` only after confirming the script +is pointed at participant-discoverable target state, and set +`OPERATOR_SECRET_NAME_REGEX` only for material classified outside that boundary. +Keep every evidence-affecting normalization in the script or a referenced jq +file. ## Inputs @@ -47,10 +51,10 @@ Produce a bundle that validates with the current APTL reference ledger tooling: - `capture-evidence.sh` or equivalent committed capture commands, derived from the template for Docker/Compose assets when applicable; - `mapping-ledger.yaml` using the current reference ledger schema; -- `evidence/` with raw or redacted evidence files; +- `evidence/` with source evidence files; - `evidence/capture-limits.txt` with one first-class limit for every skipped required step, declined useful-optional step, contamination boundary, or - redaction that changes what was captured; + operator/out-of-scenario withholding that changes what was captured; - `evidence/captured-at-utc.txt`; - `evidence/evidence-sha256sums.txt` covering committed evidence files. @@ -117,13 +121,26 @@ No `needs_gap_triage` row may remain at review time. intact and separate filesystem provenance is captured or the omission is recorded in `capture-limits.txt`. -5. Hash and redact before committing. - - Follow ADR-029 redaction discipline. Do not place credentials, bearer - tokens, private keys, generated service secrets, cookies, session ids, or - operator-only values in evidence, logs, argv, tracebacks, issue comments, or - `mapping-ledger.yaml`. Participant-visible fixture secrets may be retained - only through the repo-approved secret-fixture classification. +5. Preserve scenario-target secrets and withhold only operator material. + + Follow ADR-057 as the inventory boundary. Scenario-target secrets are + capture facts and must not be redacted from source inventory bundles when a + participant or in-range agent could discover them. This includes passwords, + hashes, private keys, tokens, generated range secrets, service config + secrets, and security product state required to realize or inspect the + target. The Wazuh/OpenSearch Security `internal_users.yml` bcrypt hashes + from APTL #341 are the canonical example: preserve them unredacted in the + source evidence bundle because they are target configuration facts. + + Operator/out-of-scenario secrets remain outside the inventory boundary. + Host SSH keys, cloud or CI tokens, maintainer credentials, local + control-plane secrets, workstation secrets, and accidental adjacent + environment material must not be captured as scenario facts. Exclude them + or record a first-class `capture-limits.txt` entry describing the withheld + scope. ADR-029 still governs operator-secret handling in logs, argv, + tracebacks, issue comments, and other non-evidence surfaces. + Sanitized/public exports are separate derived views; do not replace source + evidence with `` placeholders as the default safety mechanism. 6. Build the ledger fact-by-fact. @@ -200,7 +217,8 @@ Before returning: - every evidence file is referenced from `mapping-ledger.yaml`; - `capture-limits.txt` records skipped methodology steps as first-class limits; -- no raw secrets or operator-only values are committed; +- scenario-target secrets are preserved as source capture facts, while + operator/out-of-scenario material is excluded or recorded as a capture limit; - `aptl aces-inventory validate ` passes; - `aptl aces-inventory gaps ` has no unresolved `needs_gap_triage`; diff --git a/.claude/skills/aces-asset-inventory-capture/scripts/capture-container-evidence-template.sh b/.claude/skills/aces-asset-inventory-capture/scripts/capture-container-evidence-template.sh index 0cb709102..3ceed4133 100755 --- a/.claude/skills/aces-asset-inventory-capture/scripts/capture-container-evidence-template.sh +++ b/.claude/skills/aces-asset-inventory-capture/scripts/capture-container-evidence-template.sh @@ -10,7 +10,8 @@ CONTAINER="${CONTAINER:-}" COMPOSE_FILE="${COMPOSE_FILE:-$ROOT/docker-compose.yml}" COMPOSE_SERVICE="${COMPOSE_SERVICE:-}" COMPOSE_PROFILES="${COMPOSE_PROFILES:-}" -SECRET_NAME_REGEX="${SECRET_NAME_REGEX:-(token|secret|password|credential|cookie|session|private_key|api_key|jwt|flag_key)}" +CAPTURE_BOUNDARY="${CAPTURE_BOUNDARY:-}" +OPERATOR_SECRET_NAME_REGEX="${OPERATOR_SECRET_NAME_REGEX:-}" TRIVY_IMAGE="${TRIVY_IMAGE:-aquasec/trivy@sha256:be1190afcb28352bfddc4ddeb71470835d16462af68d310f9f4bca710961a41e}" SYFT_IMAGE="${SYFT_IMAGE:-anchore/syft@sha256:86fde6445b483d902fe011dd9f68c4987dd94e07da1e9edc004e3c2422650de6}" @@ -26,12 +27,25 @@ require() { } } +require_capture_boundary() { + if [[ "$CAPTURE_BOUNDARY" != "scenario-target" ]]; then + printf '%s\n' \ + "Set CAPTURE_BOUNDARY=scenario-target after confirming this capture is scoped to participant-discoverable target state." \ + "Use OPERATOR_SECRET_NAME_REGEX only for operator/out-of-scenario material that must be withheld." >&2 + exit 2 + fi +} + record_limit() { printf -- '- %s\n' "$*" >> "$OUT/capture-limits.txt" } redact_text_stream() { - awk -v secret_re="$SECRET_NAME_REGEX" ' + if [[ -z "$OPERATOR_SECRET_NAME_REGEX" ]]; then + cat + return + fi + awk -v secret_re="$OPERATOR_SECRET_NAME_REGEX" ' { for (i = 1; i <= NF; i++) { token = $i @@ -57,7 +71,7 @@ redact_text_stream() { redact_env_jq=' def redact_env($secret_re): - if contains("=") then + if (($secret_re | length) > 0) and contains("=") then capture("^(?[^=]+)=(?.*)$") as $m | if ($m.name | test($secret_re; "i")) then "\($m.name)=" @@ -69,29 +83,41 @@ redact_env_jq=' end; def redact_sensitive_keys($secret_re): - walk( - if type == "object" then - with_entries( - if (.key | test($secret_re; "i")) then - .value = "" - else - . - end - ) - else - . - end - ); + if ($secret_re | length) == 0 then + . + else + walk( + if type == "object" then + with_entries( + if (.key | test($secret_re; "i")) then + .value = "" + else + . + end + ) + else + . + end + ) + end; ' require docker require jq require sha256sum +require_capture_boundary mkdir -p "$OUT" : > "$OUT/capture-limits.txt" date -u +"%Y-%m-%dT%H:%M:%SZ" > "$OUT/captured-at-utc.txt" +# With CAPTURE_BOUNDARY=scenario-target, this template preserves scenario-target values by default. +# Configure OPERATOR_SECRET_NAME_REGEX only after classifying a value as +# operator/out-of-scenario material rather than target evidence. +if [[ -n "$OPERATOR_SECRET_NAME_REGEX" ]]; then + record_limit "Operator/out-of-scenario values matching OPERATOR_SECRET_NAME_REGEX were withheld by the capture template; ledger must describe the boundary and evidence impact" +fi + docker version --format json | jq . > "$OUT/docker-version.json" if compose_version="$(docker compose version --format json 2>/dev/null)"; then printf '%s\n' "$compose_version" | jq . > "$OUT/docker-compose-version.json" @@ -106,7 +132,7 @@ if [[ -n "$COMPOSE_SERVICE" && -f "$COMPOSE_FILE" ]]; then docker compose -f "$COMPOSE_FILE" config --format json fi | jq \ --arg service "$COMPOSE_SERVICE" \ - --arg secret_re "$SECRET_NAME_REGEX" ' + --arg secret_re "$OPERATOR_SECRET_NAME_REGEX" ' if ((.services // {}) | has($service) | not) then error("compose service not found: " + $service) else @@ -115,7 +141,7 @@ if [[ -n "$COMPOSE_SERVICE" && -f "$COMPOSE_FILE" ]]; then | .environment = ( (.environment // {}) | with_entries( - if (.key | test($secret_re; "i")) then + if (($secret_re | length) > 0 and (.key | test($secret_re; "i"))) then .value = ("") else . @@ -129,7 +155,7 @@ fi if [[ -n "$CONTAINER" ]]; then docker inspect "$CONTAINER" \ - | jq --arg secret_re "$SECRET_NAME_REGEX" \ + | jq --arg secret_re "$OPERATOR_SECRET_NAME_REGEX" \ "$redact_env_jq .[].Config.Env |= ((. // []) | map(redact_env(\$secret_re))) | redact_sensitive_keys(\$secret_re)" \ @@ -162,7 +188,7 @@ else fi docker image inspect "$IMAGE" \ - | jq --arg secret_re "$SECRET_NAME_REGEX" "$redact_env_jq redact_sensitive_keys(\$secret_re)" \ + | jq --arg secret_re "$OPERATOR_SECRET_NAME_REGEX" "$redact_env_jq redact_sensitive_keys(\$secret_re)" \ > "$OUT/docker-inspect.image.json" docker history --no-trunc "$IMAGE" | redact_text_stream > "$OUT/docker-history.image.txt" diff --git a/.codex-skills/aces-asset-inventory-capture/SKILL.md b/.codex-skills/aces-asset-inventory-capture/SKILL.md index ad297e0e7..f008fbbd9 100644 --- a/.codex-skills/aces-asset-inventory-capture/SKILL.md +++ b/.codex-skills/aces-asset-inventory-capture/SKILL.md @@ -20,8 +20,12 @@ For Docker/Compose/container-image captures, start by copying `scripts/capture-container-evidence-template.sh` and `scripts/normalize-syft-cyclonedx.jq` into the target bundle as runnable capture resources, then tailor the copied script for asset-specific source paths, -filesystem manifests, and redaction names. Keep every evidence-affecting -normalization in the script or a referenced jq file. +filesystem manifests, and any explicit operator/out-of-scenario withholding +rules. Set `CAPTURE_BOUNDARY=scenario-target` only after confirming the script +is pointed at participant-discoverable target state, and set +`OPERATOR_SECRET_NAME_REGEX` only for material classified outside that boundary. +Keep every evidence-affecting normalization in the script or a referenced jq +file. ## Inputs @@ -47,10 +51,10 @@ Produce a bundle that validates with the current APTL reference ledger tooling: - `capture-evidence.sh` or equivalent committed capture commands, derived from the template for Docker/Compose assets when applicable; - `mapping-ledger.yaml` using the current reference ledger schema; -- `evidence/` with raw or redacted evidence files; +- `evidence/` with source evidence files; - `evidence/capture-limits.txt` with one first-class limit for every skipped required step, declined useful-optional step, contamination boundary, or - redaction that changes what was captured; + operator/out-of-scenario withholding that changes what was captured; - `evidence/captured-at-utc.txt`; - `evidence/evidence-sha256sums.txt` covering committed evidence files. @@ -117,13 +121,26 @@ No `needs_gap_triage` row may remain at review time. intact and separate filesystem provenance is captured or the omission is recorded in `capture-limits.txt`. -5. Hash and redact before committing. - - Follow ADR-029 redaction discipline. Do not place credentials, bearer - tokens, private keys, generated service secrets, cookies, session ids, or - operator-only values in evidence, logs, argv, tracebacks, issue comments, or - `mapping-ledger.yaml`. Participant-visible fixture secrets may be retained - only through the repo-approved secret-fixture classification. +5. Preserve scenario-target secrets and withhold only operator material. + + Follow ADR-057 as the inventory boundary. Scenario-target secrets are + capture facts and must not be redacted from source inventory bundles when a + participant or in-range agent could discover them. This includes passwords, + hashes, private keys, tokens, generated range secrets, service config + secrets, and security product state required to realize or inspect the + target. The Wazuh/OpenSearch Security `internal_users.yml` bcrypt hashes + from APTL #341 are the canonical example: preserve them unredacted in the + source evidence bundle because they are target configuration facts. + + Operator/out-of-scenario secrets remain outside the inventory boundary. + Host SSH keys, cloud or CI tokens, maintainer credentials, local + control-plane secrets, workstation secrets, and accidental adjacent + environment material must not be captured as scenario facts. Exclude them + or record a first-class `capture-limits.txt` entry describing the withheld + scope. ADR-029 still governs operator-secret handling in logs, argv, + tracebacks, issue comments, and other non-evidence surfaces. + Sanitized/public exports are separate derived views; do not replace source + evidence with `` placeholders as the default safety mechanism. 6. Build the ledger fact-by-fact. @@ -200,7 +217,8 @@ Before returning: - every evidence file is referenced from `mapping-ledger.yaml`; - `capture-limits.txt` records skipped methodology steps as first-class limits; -- no raw secrets or operator-only values are committed; +- scenario-target secrets are preserved as source capture facts, while + operator/out-of-scenario material is excluded or recorded as a capture limit; - `aptl aces-inventory validate ` passes; - `aptl aces-inventory gaps ` has no unresolved `needs_gap_triage`; diff --git a/.codex-skills/aces-asset-inventory-capture/scripts/capture-container-evidence-template.sh b/.codex-skills/aces-asset-inventory-capture/scripts/capture-container-evidence-template.sh index 0cb709102..3ceed4133 100755 --- a/.codex-skills/aces-asset-inventory-capture/scripts/capture-container-evidence-template.sh +++ b/.codex-skills/aces-asset-inventory-capture/scripts/capture-container-evidence-template.sh @@ -10,7 +10,8 @@ CONTAINER="${CONTAINER:-}" COMPOSE_FILE="${COMPOSE_FILE:-$ROOT/docker-compose.yml}" COMPOSE_SERVICE="${COMPOSE_SERVICE:-}" COMPOSE_PROFILES="${COMPOSE_PROFILES:-}" -SECRET_NAME_REGEX="${SECRET_NAME_REGEX:-(token|secret|password|credential|cookie|session|private_key|api_key|jwt|flag_key)}" +CAPTURE_BOUNDARY="${CAPTURE_BOUNDARY:-}" +OPERATOR_SECRET_NAME_REGEX="${OPERATOR_SECRET_NAME_REGEX:-}" TRIVY_IMAGE="${TRIVY_IMAGE:-aquasec/trivy@sha256:be1190afcb28352bfddc4ddeb71470835d16462af68d310f9f4bca710961a41e}" SYFT_IMAGE="${SYFT_IMAGE:-anchore/syft@sha256:86fde6445b483d902fe011dd9f68c4987dd94e07da1e9edc004e3c2422650de6}" @@ -26,12 +27,25 @@ require() { } } +require_capture_boundary() { + if [[ "$CAPTURE_BOUNDARY" != "scenario-target" ]]; then + printf '%s\n' \ + "Set CAPTURE_BOUNDARY=scenario-target after confirming this capture is scoped to participant-discoverable target state." \ + "Use OPERATOR_SECRET_NAME_REGEX only for operator/out-of-scenario material that must be withheld." >&2 + exit 2 + fi +} + record_limit() { printf -- '- %s\n' "$*" >> "$OUT/capture-limits.txt" } redact_text_stream() { - awk -v secret_re="$SECRET_NAME_REGEX" ' + if [[ -z "$OPERATOR_SECRET_NAME_REGEX" ]]; then + cat + return + fi + awk -v secret_re="$OPERATOR_SECRET_NAME_REGEX" ' { for (i = 1; i <= NF; i++) { token = $i @@ -57,7 +71,7 @@ redact_text_stream() { redact_env_jq=' def redact_env($secret_re): - if contains("=") then + if (($secret_re | length) > 0) and contains("=") then capture("^(?[^=]+)=(?.*)$") as $m | if ($m.name | test($secret_re; "i")) then "\($m.name)=" @@ -69,29 +83,41 @@ redact_env_jq=' end; def redact_sensitive_keys($secret_re): - walk( - if type == "object" then - with_entries( - if (.key | test($secret_re; "i")) then - .value = "" - else - . - end - ) - else - . - end - ); + if ($secret_re | length) == 0 then + . + else + walk( + if type == "object" then + with_entries( + if (.key | test($secret_re; "i")) then + .value = "" + else + . + end + ) + else + . + end + ) + end; ' require docker require jq require sha256sum +require_capture_boundary mkdir -p "$OUT" : > "$OUT/capture-limits.txt" date -u +"%Y-%m-%dT%H:%M:%SZ" > "$OUT/captured-at-utc.txt" +# With CAPTURE_BOUNDARY=scenario-target, this template preserves scenario-target values by default. +# Configure OPERATOR_SECRET_NAME_REGEX only after classifying a value as +# operator/out-of-scenario material rather than target evidence. +if [[ -n "$OPERATOR_SECRET_NAME_REGEX" ]]; then + record_limit "Operator/out-of-scenario values matching OPERATOR_SECRET_NAME_REGEX were withheld by the capture template; ledger must describe the boundary and evidence impact" +fi + docker version --format json | jq . > "$OUT/docker-version.json" if compose_version="$(docker compose version --format json 2>/dev/null)"; then printf '%s\n' "$compose_version" | jq . > "$OUT/docker-compose-version.json" @@ -106,7 +132,7 @@ if [[ -n "$COMPOSE_SERVICE" && -f "$COMPOSE_FILE" ]]; then docker compose -f "$COMPOSE_FILE" config --format json fi | jq \ --arg service "$COMPOSE_SERVICE" \ - --arg secret_re "$SECRET_NAME_REGEX" ' + --arg secret_re "$OPERATOR_SECRET_NAME_REGEX" ' if ((.services // {}) | has($service) | not) then error("compose service not found: " + $service) else @@ -115,7 +141,7 @@ if [[ -n "$COMPOSE_SERVICE" && -f "$COMPOSE_FILE" ]]; then | .environment = ( (.environment // {}) | with_entries( - if (.key | test($secret_re; "i")) then + if (($secret_re | length) > 0 and (.key | test($secret_re; "i"))) then .value = ("") else . @@ -129,7 +155,7 @@ fi if [[ -n "$CONTAINER" ]]; then docker inspect "$CONTAINER" \ - | jq --arg secret_re "$SECRET_NAME_REGEX" \ + | jq --arg secret_re "$OPERATOR_SECRET_NAME_REGEX" \ "$redact_env_jq .[].Config.Env |= ((. // []) | map(redact_env(\$secret_re))) | redact_sensitive_keys(\$secret_re)" \ @@ -162,7 +188,7 @@ else fi docker image inspect "$IMAGE" \ - | jq --arg secret_re "$SECRET_NAME_REGEX" "$redact_env_jq redact_sensitive_keys(\$secret_re)" \ + | jq --arg secret_re "$OPERATOR_SECRET_NAME_REGEX" "$redact_env_jq redact_sensitive_keys(\$secret_re)" \ > "$OUT/docker-inspect.image.json" docker history --no-trunc "$IMAGE" | redact_text_stream > "$OUT/docker-history.image.txt" diff --git a/changelog.d/516.fixed.md b/changelog.d/516.fixed.md new file mode 100644 index 000000000..be6a1da2b --- /dev/null +++ b/changelog.d/516.fixed.md @@ -0,0 +1 @@ +Corrected the ACES asset-inventory capture guidance so participant-discoverable scenario-target secrets are preserved in source evidence bundles, while operator/out-of-scenario secrets remain withheld or recorded as capture limits. diff --git a/docs/aces/inventory/ad-preflight.md b/docs/aces/inventory/ad-preflight.md index e14beff93..88add3ca3 100644 --- a/docs/aces/inventory/ad-preflight.md +++ b/docs/aces/inventory/ad-preflight.md @@ -19,8 +19,12 @@ the issue phase marker, but the tool did not create a repo-local note file. - Reuse `docs/aces/inventory/asset-inventory-methodology.md`, `src/aptl/core/aces_inventory.py`, `src/aptl/cli/aces_inventory.py`, the existing webapp and db inventory bundles, and `docs/aces/parity-inventory.yaml`. -- Redact AD administrator credentials, generated flags, Kerberos/Samba secret - material, Wazuh client keys, and private key contents from committed evidence. +- Historical note: issue #516 supersedes the original blanket-redaction rule + for source inventory bundles. Preserve AD administrator credentials, + generated flags, Kerberos/Samba secret material, Wazuh client keys, and + private key contents when they are participant-discoverable scenario-target + facts; withhold only operator/out-of-scenario material and record that + boundary in `capture-limits.txt`. - Keep legacy `aptl.core.sdl` and `scenarios/*.yaml` functional until the downstream APTL ADR-035 cutover PR. This issue does not change backend runtime behavior or flip default scenario selection. diff --git a/docs/aces/inventory/asset-inventory-methodology.md b/docs/aces/inventory/asset-inventory-methodology.md index 6311b9b03..9c5d0dd6a 100644 --- a/docs/aces/inventory/asset-inventory-methodology.md +++ b/docs/aces/inventory/asset-inventory-methodology.md @@ -46,8 +46,8 @@ The practical reproducibility standard is therefore: - every captured fact has either an ACES specification mapping or an ACES issue for the expressivity gap; - the capture can be rerun with non-commercial tooling on a local lab; -- limits, time-sensitive scanner output, mutable tags, redactions, and - skipped steps are recorded plainly. +- limits, time-sensitive scanner output, mutable tags, operator/out-of-scenario + withholding, and skipped steps are recorded plainly. This follows the computational reproducibility framing in Peng (2011), Goodman, Fanelli, and Ioannidis (2016), and Boettiger (2015): preserve @@ -122,11 +122,24 @@ instead of pretending a local build recipe exists. Preserve container inspect, network inspect, volume inspect, process list, listeners, mounts, OS release, users/groups, working directory, - command/entrypoint, exposed ports, restart policy, resource limits, and - bind mounts. Redact secret values before evidence enters git unless the - scenario explicitly requires a participant-visible secret fixture to be - specified; in that case, store it through the repo's approved secret-fixture - mechanism rather than leaking live local secrets. + command/entrypoint, exposed ports, restart policy, resource limits, bind + mounts, and scenario-target secrets. Participant-discoverable target + credentials, password hashes, private keys, bearer tokens, generated service + secrets, config secrets, and other credential-shaped values are capture + facts and must not be redacted from source inventory bundles. For example, + Wazuh/OpenSearch Security `internal_users.yml` bcrypt hashes are target + configuration facts; replacing them with `` corrupts the + inventory and prevents later ACES/APTL mapping from seeing the realized + system. + + Operator/out-of-scenario secrets remain outside the capture boundary: + host SSH keys, cloud or CI tokens, maintainer credentials, local + control-plane secrets, workstation secrets, unrelated prior-run + transcripts, and accidental adjacent-environment material are not scenario + facts. Exclude them, or record the withheld scope and reason as a + first-class `capture-limits.txt` entry. Sanitized publication/export + bundles are separate derived views; they must not replace the authoritative + source inventory bundle. 6. Capture package and dependency inventory. @@ -283,8 +296,8 @@ The pass proved that the method can capture an upstream-image asset with: surfaces, and runtime process/listener baseline; - OS package inventory and Go module manifests visible in the image; - CycloneDX SBOM and Trivy vulnerability output; -- evidence checksums and pytest validation for redaction, digest identity, - SBOM structure, and severity-count consistency. +- evidence checksums and pytest validation for capture-boundary handling, + digest identity, SBOM structure, and severity-count consistency. This proof pass is not yet a full maximal-discovery pass because it mostly uses host-side Docker evidence plus a container runtime baseline. The next diff --git a/docs/aces/inventory/index.md b/docs/aces/inventory/index.md index 5b37bb9fb..9ddccd92a 100644 --- a/docs/aces/inventory/index.md +++ b/docs/aces/inventory/index.md @@ -33,6 +33,7 @@ methodology owner. asset-inventory-methodology methodology-assurance-report scn010-expressivity-gap-analysis +issue-516-redaction-boundary-preflight webapp-preflight kali-preflight ad-preflight diff --git a/docs/aces/inventory/issue-516-redaction-boundary-preflight.md b/docs/aces/inventory/issue-516-redaction-boundary-preflight.md new file mode 100644 index 000000000..0ab289671 --- /dev/null +++ b/docs/aces/inventory/issue-516-redaction-boundary-preflight.md @@ -0,0 +1,96 @@ +# Issue 516 Inventory Redaction Boundary Preflight + +This note is architecture guidance for the issue 516 documentation and +workflow cleanup. It is not an implementation plan and does not replace the +canonical methodology, skill, or runtime ADRs. + +## Architecture Decisions + +- ADR-057 is the controlling secret-boundary decision for scenario inventory. + A scenario-target value that a participant or in-range agent can discover is + scenario content, even when it is a password, token, private key, bcrypt + hash, generated service secret, config secret, or other credential-shaped + value. +- Source inventory bundles are authoritative capture artifacts, not sanitized + publication views. Sanitized exports may redact scenario secrets later, but + that is a separate projection concern and must not change the source bundle. +- Operator and out-of-scenario secrets remain outside the inventory boundary: + host SSH keys, cloud or CI tokens, maintainer credentials, local control-plane + secrets, unrelated prior-run transcripts, and other values that are not facts + of the described target system must not be captured as scenario facts. +- APTL #341 would have preserved the Wazuh indexer OpenSearch Security + `internal_users.yml` bcrypt hashes in the source evidence bundle because they + are target configuration facts inside the participant range. + +## Cross-Cutting Concerns To Reuse + +- Inventory authority: `docs/aces/inventory/asset-inventory-methodology.md` + remains the canonical capture workflow and inclusion rule. +- Agent workflow: `.codex-skills/aces-asset-inventory-capture/SKILL.md` must + continue to operationalize the methodology without defining a second ledger + schema or secret taxonomy. +- Template workflow: + `.codex-skills/aces-asset-inventory-capture/scripts/capture-container-evidence-template.sh` + is the reusable Docker/Compose capture entry point. It must not keep + name-based blanket redaction as the default for scenario-target evidence. +- Runtime SDL validation: `runtime_values.enforce_observed_value_redaction()` + is the canonical helper for explicit `redacted` and `operator_secret` + omission. `name_indicates_secret()` is advisory only and must not be copied + into capture-time source evidence policy. +- Ledger accountability: `mapping-ledger.yaml`, `capture-limits.txt`, evidence + checksums, and the downstream `aptl aces-inventory validate/gaps/schema` + commands remain the evidence accountability surface. + +## Security And Validation Layers + +- **Secret-handling surface:** scenario-target secrets pass through unredacted + when they are participant-discoverable facts. Operator/out-of-scenario + secrets are excluded or explicitly recorded as capture limits; they are not + represented as scenario facts. +- **Schema and model validators:** no SDL schema or validator change is + required for this issue. Existing explicit-redaction validators already + reject raw values only when the author marks a field `redacted` or + `operator_secret`. +- **Config and environment shapes:** do not introduce a second env parser, + secret classifier, or config schema. Capture scripts should parameterize the + scenario-vs-operator boundary instead of relying on credential-shaped names. +- **OS/process exposure:** capture commands should avoid putting operator + secrets in argv, logs, tracebacks, or issue comments. Scenario-target secrets + that are read from target files or target runtime state may appear in source + evidence, but tooling should still avoid leaking unrelated host/operator + material through command construction. +- **Error envelopes and observability:** CLI/test/helper failures should report + narrow diagnostics and avoid dumping host-side command payloads that could + contain operator secrets. + +## Extensibility Seam + +The reusable seam is an explicit capture-boundary parameter: target scenario +content versus operator/out-of-scenario material. Future sanitized publication, +teaching, or external-release workflows should consume the source bundle and +emit a redacted projection instead of changing capture semantics or adding a +second source-artifact schema. + +## Gotchas And Anti-Patterns + +- Do not replace blanket redaction with blanket publication of all host-side + material. The boundary is participant-discoverable target state. +- Do not require `secret_fixture` for generated scenario credentials, hashes, + keys, or tokens that are needed to realize or inspect the range. ADR-057 made + `secret_fixture` an author classification, not a bypass for name-based + omission. +- Do not update generated schemas under `contracts/schemas/` directly. +- Do not edit accepted ADRs to clean up stale historical language unless the + change follows ADR-059 with an amendment record and pin update, or a new ADR + supersedes the old one. +- Do not create a new inventory validator, secret taxonomy, exception + hierarchy, or logging/redaction utility for this issue. + +## Non-Goals + +- No change to SDL runtime model semantics. +- No new source evidence schema. +- No implementation of a sanitized export/publication pipeline. +- No change to downstream APTL runtime behavior. +- No broad rewrite of historical preflight notes beyond correcting or marking + stale guidance that could be reused as current capture policy. diff --git a/docs/aces/inventory/kali-preflight.md b/docs/aces/inventory/kali-preflight.md index e6f852edf..da8134bab 100644 --- a/docs/aces/inventory/kali-preflight.md +++ b/docs/aces/inventory/kali-preflight.md @@ -73,11 +73,13 @@ asset-inventorying methodology. - **Inventory ledger:** every captured fact needs an existing `AcesSurface` mapping, caveat, or linked ACES issue in `mapping-ledger.yaml`. No `needs_gap_triage` rows should remain at review. -- **Secret classification:** ADR-029 is canonical. Operator secrets, private - SSH keys, bearer tokens, cookies, generated service config, and arbitrary - prior run transcripts must not be committed unredacted. Intentional target - fixture credentials may be encoded only when they are participant-visible - scenario facts with an explicit `secret_fixture`-style classification. +- **Secret classification:** ADR-057 is canonical for scenario-target values, + with ADR-029 still governing operator/control-plane handling. Preserve + participant-discoverable target credentials, keys, tokens, generated service + config, and hashes as source evidence. Withhold only operator secrets, + private host SSH keys, control-plane bearer tokens, cookies, and arbitrary + prior-run transcripts that are not facts of the Kali target; record the + boundary in `capture-limits.txt`. - **Kali capture data:** ADR-033 capture surfaces can contain full argv, hidden PTY input, raw pcap bytes, and prior experiment data. A non-empty `kali_captures` volume is evidence to account for, but it is not safe to @@ -88,9 +90,10 @@ asset-inventorying methodology. `find_placeholder_env_values`. Do not add ACES-specific environment parsing for Kali. Compose `VICTIM_IP` and SSH `APTL_*` session env vars are different surfaces and must not be collapsed. -- **OS/process exposure:** inventory capture commands must not place private - keys, passwords, hashes, tokens, or replayable IDs in argv, logs, or - exception text. Store command provenance, not secret-bearing command lines. +- **OS/process exposure:** inventory capture commands must not place + operator/out-of-scenario private keys, passwords, hashes, tokens, or + replayable IDs in argv, logs, or exception text. Store command provenance, + not operator-secret command lines. - **Runtime isolation:** preserve ADR-033 non-contamination. No Wazuh agent, rsyslog forwarding, or red-to-SIEM pipe belongs in Kali evidence or future fixes unless a later ADR explicitly overrides ADR-033. diff --git a/docs/aces/inventory/webapp-preflight.md b/docs/aces/inventory/webapp-preflight.md index 4d8255bc1..e7548fe5f 100644 --- a/docs/aces/inventory/webapp-preflight.md +++ b/docs/aces/inventory/webapp-preflight.md @@ -48,15 +48,16 @@ methodology. - ACES SDL authority: sibling `../aces-sdl` parser/model documentation and the closed ACES #354 runtime-surface gap. Do not parse `techvault.sdl.yaml` with `aptl.core.sdl`. -- Secret and evidence safety: ADR-029, `aptl.utils.redaction`, the existing - test pattern that blocks raw secret assignments, and redacted evidence files. +- Secret and evidence safety: ADR-057, ADR-029, `aptl.utils.redaction`, the + existing test pattern for explicit redaction classifications, and source + evidence files that preserve participant-discoverable target facts. ## Security And Validation Gates -- Evidence committed to git must redact operator/control-plane secrets. The - webapp's intentional participant-visible fixture secrets are allowed only - when they are scenario facts already present in checked-in source or Compose - configuration. +- Evidence committed to git must preserve participant-discoverable + scenario-target facts, including target credentials and config secrets. Only + operator/control-plane or other out-of-scenario secrets are withheld, with + the boundary recorded in `capture-limits.txt`. - Runtime evidence must cite the command/source that produced each claim: Docker inspect/history/network/volume/top, in-container runtime baseline, package manifests, filesystem hashes, and scanner output when available. diff --git a/implementations/python/tests/test_agent_inventory_skill.py b/implementations/python/tests/test_agent_inventory_skill.py index 18029ea06..3dbe92c60 100644 --- a/implementations/python/tests/test_agent_inventory_skill.py +++ b/implementations/python/tests/test_agent_inventory_skill.py @@ -11,6 +11,10 @@ CLAUDE_SKILL_DIR / "SKILL.md", CODEX_SKILL_DIR / "SKILL.md", ) +TEMPLATE_PATHS = ( + CLAUDE_SKILL_DIR / "scripts" / "capture-container-evidence-template.sh", + CODEX_SKILL_DIR / "scripts" / "capture-container-evidence-template.sh", +) GAP_SKILL_PATHS = ( GAP_CLAUDE_SKILL_DIR / "SKILL.md", GAP_CODEX_SKILL_DIR / "SKILL.md", @@ -121,6 +125,49 @@ def test_asset_inventory_skill_blocks_known_agent_failure_modes() -> None: assert not missing +def test_asset_inventory_skill_preserves_scenario_target_secrets() -> None: + skills = [path.read_text(encoding="utf-8") for path in SKILL_PATHS] + required_terms = ( + "scenario-target secrets", + "capture facts", + "must not be redacted", + "operator/out-of-scenario", + "internal_users.yml", + "bcrypt hashes", + ) + stale_terms = ( + "Hash and redact before committing", + "Participant-visible fixture secrets may be retained only", + "Do not place credentials, bearer tokens, private keys, generated service secrets", + ) + + missing = [ + (path, term) + for path, skill in zip(SKILL_PATHS, skills, strict=True) + for term in required_terms + if term not in skill + ] + stale = [ + (path, term) for path, skill in zip(SKILL_PATHS, skills, strict=True) for term in stale_terms if term in skill + ] + + assert not missing + assert not stale + + +def test_asset_inventory_container_template_preserves_target_values_by_default() -> None: + claude_template = TEMPLATE_PATHS[0].read_text(encoding="utf-8") + codex_template = TEMPLATE_PATHS[1].read_text(encoding="utf-8") + + assert claude_template == codex_template + assert 'CAPTURE_BOUNDARY="${CAPTURE_BOUNDARY:-}"' in claude_template + assert "CAPTURE_BOUNDARY=scenario-target" in claude_template + assert 'OPERATOR_SECRET_NAME_REGEX="${OPERATOR_SECRET_NAME_REGEX:-}"' in claude_template + assert 'SECRET_NAME_REGEX="${SECRET_NAME_REGEX:-' not in claude_template + assert "preserves scenario-target values by default" in claude_template + assert "operator/out-of-scenario" in claude_template + + def test_gap_remediation_skill_is_cross_agent_and_discoverable_by_codex() -> None: claude_skill = (GAP_CLAUDE_SKILL_DIR / "SKILL.md").read_text(encoding="utf-8") codex_skill = (GAP_CODEX_SKILL_DIR / "SKILL.md").read_text(encoding="utf-8") From 22420552c61c10e1c438d0e5240f331e97af85c4 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 13 Jun 2026 07:50:05 +0200 Subject: [PATCH 23/70] Accept participant ADR dependency chain --- docs/decisions/adrs/README.md | 10 +++++----- ...-declarative-participant-framing-boundaries.md | 2 +- ...021-falsification-first-claim-evidence-gate.md | 2 +- ...ticipant-behavior-and-interaction-semantics.md | 12 ++++++------ ...23-container-image-build-provenance-surface.md | 9 +++++---- ...54-participant-runtime-observable-lifecycle.md | 7 ++++--- docs/decisions/adrs/adr-index.yaml | 15 +++++++++++++++ 7 files changed, 37 insertions(+), 20 deletions(-) diff --git a/docs/decisions/adrs/README.md b/docs/decisions/adrs/README.md index 11077b177..8a7035741 100644 --- a/docs/decisions/adrs/README.md +++ b/docs/decisions/adrs/README.md @@ -129,10 +129,10 @@ adr-060-participant-backend-facing-contract-surface | [017](adr-017-conversation-surface-hardening.md) | Conversation Surface Hardening | accepted | 2026-05-17 | | [018](adr-018-classification-based-assurance-policy.md) | Canonical Mapping for the Classification-Based Assurance Policy | accepted | 2026-05-17 | | [019](adr-019-normative-authority-boundary-manifest.md) | Canonical Manifest for the Normative Artifact Authority Boundary | accepted | 2026-05-17 | -| [020](adr-020-declarative-participant-framing-boundaries.md) | Declarative Participant Framing Boundaries | proposed | 2026-05-18 | -| [021](adr-021-falsification-first-claim-evidence-gate.md) | Falsification-First Claim Evidence Gate | proposed | 2026-05-18 | -| [022](adr-022-participant-behavior-and-interaction-semantics.md) | Participant Behavior and Interaction Semantics | proposed | 2026-05-18 | -| [023](adr-023-container-image-build-provenance-surface.md) | Container Image Build Provenance Surface | proposed | 2026-05-21 | +| [020](adr-020-declarative-participant-framing-boundaries.md) | Declarative Participant Framing Boundaries | accepted | 2026-05-18 | +| [021](adr-021-falsification-first-claim-evidence-gate.md) | Falsification-First Claim Evidence Gate | accepted | 2026-05-18 | +| [022](adr-022-participant-behavior-and-interaction-semantics.md) | Participant Behavior and Interaction Semantics | accepted | 2026-05-18 | +| [023](adr-023-container-image-build-provenance-surface.md) | Container Image Build Provenance Surface | accepted | 2026-05-21 | | [024](adr-024-local-identity-inventory-surface.md) | Local Identity Inventory Surface | accepted | 2026-05-21 | | [025](adr-025-container-network-realization-surface.md) | Container Network Realization Surface | accepted | 2026-05-21 | | [026](adr-026-application-http-surface-inventory.md) | Application HTTP Surface Inventory | accepted | 2026-05-22 | @@ -163,7 +163,7 @@ adr-060-participant-backend-facing-contract-surface | [051](adr-051-orchestration-authority-runtime-inventory.md) | Orchestration Authority Runtime Inventory | accepted | 2026-05-30 | | [052](adr-052-typed-runtime-relationship-subtypes.md) | Typed Runtime Relationship Subtypes | accepted | 2026-05-30 | | [053](adr-053-sdl-module-composition-for-inventory-backed-scenarios.md) | SDL Module Composition for Inventory-Backed Scenarios | accepted | 2026-06-03 | -| [054](adr-054-participant-runtime-observable-lifecycle.md) | Participant Runtime Observable Lifecycle | proposed | 2026-06-05 | +| [054](adr-054-participant-runtime-observable-lifecycle.md) | Participant Runtime Observable Lifecycle | accepted | 2026-06-05 | | [055](adr-055-experiment-core-contract-boundary.md) | Experiment Core Contract Boundary | accepted | 2026-05-26 | | [056](adr-056-runtime-observed-values-and-credential-posture.md) | Runtime Observed Values and Credential Posture | accepted | 2026-06-05 | | [057](adr-057-runtime-secret-name-classifier-boundaries.md) | Runtime Scenario Value Realizability and Explicit Redaction | accepted | 2026-06-06 | diff --git a/docs/decisions/adrs/adr-020-declarative-participant-framing-boundaries.md b/docs/decisions/adrs/adr-020-declarative-participant-framing-boundaries.md index 12512acef..2c0782df1 100644 --- a/docs/decisions/adrs/adr-020-declarative-participant-framing-boundaries.md +++ b/docs/decisions/adrs/adr-020-declarative-participant-framing-boundaries.md @@ -2,7 +2,7 @@ ## Status -proposed +accepted ## Date diff --git a/docs/decisions/adrs/adr-021-falsification-first-claim-evidence-gate.md b/docs/decisions/adrs/adr-021-falsification-first-claim-evidence-gate.md index 6b011e51a..b9101b18c 100644 --- a/docs/decisions/adrs/adr-021-falsification-first-claim-evidence-gate.md +++ b/docs/decisions/adrs/adr-021-falsification-first-claim-evidence-gate.md @@ -2,7 +2,7 @@ ## Status -proposed +accepted ## Date diff --git a/docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md b/docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md index 19cf56991..1ceb60b4d 100644 --- a/docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md +++ b/docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md @@ -2,7 +2,7 @@ ## Status -proposed +accepted ## Date @@ -287,18 +287,18 @@ contracts, invariants, model/state-machine coverage, negative fixtures, backend-conformance fixtures, mapping-loss tests, leakage tests, and run/study provenance checks. -## Cross-Issue Dependencies And Deferred Evidence +## Cross-Issue Boundaries And Evidence Ownership The critical review for issue #71 identified several concerns that are broader -than participant semantics. This ADR records them as dependencies or deferrals -instead of claiming that SEM-208 through SEM-215 solve them alone. +than participant semantics. This ADR records the ownership boundary for those +concerns instead of claiming that SEM-208 through SEM-215 solve them alone. | Concern | Owning issue(s) | ADR-022 obligation | | ------- | --------------- | ------------------ | | Scenario/run/study provenance and comparability | #87, #89, #105, #106 | Require participant-semantics artifacts to name the provenance fields they consume or emit; do not define the whole study model here | | Observability and evidence-capture adequacy | #88, #127, #128, #170, #273 | Require observation/evidence contracts to disclose capture basis, loss, latency, redaction, and observer effects | -| Benchmark hidden truth, gold standards, canaries, holdouts, and adjudication assets | #125, #328, #333, #166 | Treat these as information-boundary objects in participant views; defer asset lifecycle, corpus governance, and assurance protocols | -| Trajectories, demonstrations, replay, and participant datasets | #124 and its spawned trajectory issues | Require participant histories and outcomes to be compatible with replay/evidence needs; defer corpus/dataset semantics | +| Benchmark hidden truth, gold standards, canaries, holdouts, and adjudication assets | #125, #328, #333, #166 | Treat these as information-boundary objects in participant views; leave asset lifecycle, corpus governance, and assurance protocols with their owning issues | +| Trajectories, demonstrations, replay, and participant datasets | #124 and its spawned trajectory issues | Require participant histories and outcomes to be compatible with replay/evidence needs; leave corpus/dataset semantics with their owning issues | | Fidelity, backend realization, and sim/emulation transfer disclosure | #100, #165, #177, #239, #335 | Require action/observation contracts to state realization profiles and weakened guarantees | | Machine-checkable semantic validation and evidence gates | #162, #168 | Require participant-specific invariants and negative fixtures; broader validation evidence remains under the falsification gates | | DSL language adequacy and author/reviewer evaluation | #346 | Treat expressiveness, ambiguity, usability, maintainability, and domain-expert reviewability as evidence claims outside #71 | diff --git a/docs/decisions/adrs/adr-023-container-image-build-provenance-surface.md b/docs/decisions/adrs/adr-023-container-image-build-provenance-surface.md index 52ec33503..977972842 100644 --- a/docs/decisions/adrs/adr-023-container-image-build-provenance-surface.md +++ b/docs/decisions/adrs/adr-023-container-image-build-provenance-surface.md @@ -2,7 +2,7 @@ ## Status -proposed +accepted ## Date @@ -134,9 +134,10 @@ sections or overloading `runtime`. ## Non-Goals -- Implementing issue #364. -- Updating `examples/scenarios/techvault.sdl.yaml`. -- Building or inspecting images. +- Building or inspecting images; issue #364 shipped the declarative + `Source.build` authoring surface only. +- Adding further TechVault scenario parity beyond the source build-provenance + examples already used to validate issue #364. - Defining a backend build executor, Docker Compose authoring format, registry distribution service, or OCI module packaging policy. - Adding archival run-provenance contracts beyond the SDL expressivity needed diff --git a/docs/decisions/adrs/adr-054-participant-runtime-observable-lifecycle.md b/docs/decisions/adrs/adr-054-participant-runtime-observable-lifecycle.md index d1bcfb019..ef2367029 100644 --- a/docs/decisions/adrs/adr-054-participant-runtime-observable-lifecycle.md +++ b/docs/decisions/adrs/adr-054-participant-runtime-observable-lifecycle.md @@ -2,7 +2,7 @@ ## Status -proposed +accepted ## Date @@ -648,8 +648,9 @@ plane. ## Non-Goals -- Implementing participant runtime contracts, schemas, APIs, backends, or - storage. +- Completing every participant runtime contract, schema, API, backend, or + storage surface beyond the subsets already published by the RUN-305 and + backend-facing contract work. - Adding new SDL participant syntax. - Replacing ADR-013 participant episode lifecycle semantics. - Replacing ADR-022 participant behavior and interaction semantics. diff --git a/docs/decisions/adrs/adr-index.yaml b/docs/decisions/adrs/adr-index.yaml index ff6914021..44db36265 100644 --- a/docs/decisions/adrs/adr-index.yaml +++ b/docs/decisions/adrs/adr-index.yaml @@ -64,6 +64,18 @@ adrs: - id: ADR-019 path: docs/decisions/adrs/adr-019-normative-authority-boundary-manifest.md pin: 66b4ea3eb2482d6f8855462f4921c9cd7853ddb4c9adfaa21f311a07422f248b + - id: ADR-020 + path: docs/decisions/adrs/adr-020-declarative-participant-framing-boundaries.md + pin: 4a5d146fc0ffd6da97af94e9f2d257a893de0ab7f63c709339d27f33178609d0 + - id: ADR-021 + path: docs/decisions/adrs/adr-021-falsification-first-claim-evidence-gate.md + pin: 3e6053efd262abdbc37c8804bb066466b4c968dc647bfc605c16b563f6639642 + - id: ADR-022 + path: docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md + pin: 87557c92a82c413c0e9042a239735ad78b24e73516c58be2249e97159c5dea38 + - id: ADR-023 + path: docs/decisions/adrs/adr-023-container-image-build-provenance-surface.md + pin: 55aea7a6726bc8942f9df35f83f9d50c3a2e4ffaff091ea47c581f8ef5525133 - id: ADR-024 path: docs/decisions/adrs/adr-024-local-identity-inventory-surface.md pin: 74f1a3df1a7c0f8dc6f44ff006abe88b20b9444fa93a736ac20fca1ca800e665 @@ -192,6 +204,9 @@ adrs: - id: ADR-053 path: docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md pin: e6eae89e31205274087e6c4e775744fba945091cddef1aa5dad3daad47921993 + - id: ADR-054 + path: docs/decisions/adrs/adr-054-participant-runtime-observable-lifecycle.md + pin: a20312d7eb9a82a2611cb16bbe9590f1e43389d4ed1ea4341ae773edb8529086 - id: ADR-055 path: docs/decisions/adrs/adr-055-experiment-core-contract-boundary.md pin: 2e6666f2e066700df3dbf38d0592a2f24e062ad4f94d695113824f2ba73ff695 From 8a544164ac78f347ec830184ab4b2858a7379234 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 13 Jun 2026 08:09:10 +0200 Subject: [PATCH 24/70] Record ADR acceptance amendments --- ...larative-participant-framing-boundaries.md | 6 ++++++ ...falsification-first-claim-evidence-gate.md | 6 ++++++ ...pant-behavior-and-interaction-semantics.md | 6 ++++++ ...ontainer-image-build-provenance-surface.md | 6 ++++++ ...articipant-runtime-observable-lifecycle.md | 6 ++++++ docs/decisions/adrs/adr-index.yaml | 20 +++++++++++++++++++ 6 files changed, 50 insertions(+) diff --git a/docs/decisions/adrs/adr-020-declarative-participant-framing-boundaries.md b/docs/decisions/adrs/adr-020-declarative-participant-framing-boundaries.md index 2c0782df1..ee120e84d 100644 --- a/docs/decisions/adrs/adr-020-declarative-participant-framing-boundaries.md +++ b/docs/decisions/adrs/adr-020-declarative-participant-framing-boundaries.md @@ -171,3 +171,9 @@ field names; the `agents` section heading remains the publishing surface. - Introduce new control-plane authentication, authorization, logging, or persistence machinery. - Move schema authority away from the existing generated-contract pipeline. + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-06-13 | #484 | Recorded acceptance of the implemented participant framing authoring surface. | diff --git a/docs/decisions/adrs/adr-021-falsification-first-claim-evidence-gate.md b/docs/decisions/adrs/adr-021-falsification-first-claim-evidence-gate.md index b9101b18c..576b72efd 100644 --- a/docs/decisions/adrs/adr-021-falsification-first-claim-evidence-gate.md +++ b/docs/decisions/adrs/adr-021-falsification-first-claim-evidence-gate.md @@ -100,3 +100,9 @@ The first claim protocols are tracked as GitHub issues: - ASR-530: Claim Falsification And Evidence Gate - Issue #162: Claim falsification and evidence gate + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-06-13 | #484 | Recorded acceptance of the falsification-first claim evidence gate and its ASR-530/#162 linkage. | diff --git a/docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md b/docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md index 1ceb60b4d..085df562f 100644 --- a/docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md +++ b/docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md @@ -352,3 +352,9 @@ concerns as unverified claims. - [IEEE HLA 1516 family](https://standards.ieee.org/ieee/1516/3744/) - [Do Software Languages Engineers Evaluate their Languages?](https://arxiv.org/abs/1109.6794) - [SISO Cyber DEM](https://cdn.ymaws.com/www.sisostandards.org/resource/resmgr/standards_products/siso-std-025-2023_cyberdem.pdf) + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-06-13 | #484 | Recorded acceptance-time ownership-boundary wording for cross-issue evidence concerns. | diff --git a/docs/decisions/adrs/adr-023-container-image-build-provenance-surface.md b/docs/decisions/adrs/adr-023-container-image-build-provenance-surface.md index 977972842..47dfa18f0 100644 --- a/docs/decisions/adrs/adr-023-container-image-build-provenance-surface.md +++ b/docs/decisions/adrs/adr-023-container-image-build-provenance-surface.md @@ -170,3 +170,9 @@ sections or overloading `runtime`. values enter diagnostics. - Overfitting to Dockerfile syntax could make future OCI/SBOM/provenance inputs look like incompatible second-class artifacts. + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-06-13 | #484 | Recorded acceptance-time non-goal wording after the issue #364 Source.build surface shipped. | diff --git a/docs/decisions/adrs/adr-054-participant-runtime-observable-lifecycle.md b/docs/decisions/adrs/adr-054-participant-runtime-observable-lifecycle.md index ef2367029..7606be471 100644 --- a/docs/decisions/adrs/adr-054-participant-runtime-observable-lifecycle.md +++ b/docs/decisions/adrs/adr-054-participant-runtime-observable-lifecycle.md @@ -663,3 +663,9 @@ plane. - Defining a full archival study-management system beyond the runtime fields needed to preserve participant history, shared-state evidence, and benchmark reproducibility claims. + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-06-13 | #484 | Recorded acceptance-time non-goal wording for already-published RUN-305 and backend-facing contract subsets. | diff --git a/docs/decisions/adrs/adr-index.yaml b/docs/decisions/adrs/adr-index.yaml index 44db36265..b4e981806 100644 --- a/docs/decisions/adrs/adr-index.yaml +++ b/docs/decisions/adrs/adr-index.yaml @@ -67,15 +67,31 @@ adrs: - id: ADR-020 path: docs/decisions/adrs/adr-020-declarative-participant-framing-boundaries.md pin: 4a5d146fc0ffd6da97af94e9f2d257a893de0ab7f63c709339d27f33178609d0 + amendments: + - date: 2026-06-13 + ref: "#484" + summary: "Recorded acceptance of the implemented participant framing authoring surface." - id: ADR-021 path: docs/decisions/adrs/adr-021-falsification-first-claim-evidence-gate.md pin: 3e6053efd262abdbc37c8804bb066466b4c968dc647bfc605c16b563f6639642 + amendments: + - date: 2026-06-13 + ref: "#484" + summary: "Recorded acceptance of the falsification-first claim evidence gate and its ASR-530/#162 linkage." - id: ADR-022 path: docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md pin: 87557c92a82c413c0e9042a239735ad78b24e73516c58be2249e97159c5dea38 + amendments: + - date: 2026-06-13 + ref: "#484" + summary: "Recorded acceptance-time ownership-boundary wording for cross-issue evidence concerns." - id: ADR-023 path: docs/decisions/adrs/adr-023-container-image-build-provenance-surface.md pin: 55aea7a6726bc8942f9df35f83f9d50c3a2e4ffaff091ea47c581f8ef5525133 + amendments: + - date: 2026-06-13 + ref: "#484" + summary: "Recorded acceptance-time non-goal wording after the issue #364 Source.build surface shipped." - id: ADR-024 path: docs/decisions/adrs/adr-024-local-identity-inventory-surface.md pin: 74f1a3df1a7c0f8dc6f44ff006abe88b20b9444fa93a736ac20fca1ca800e665 @@ -207,6 +223,10 @@ adrs: - id: ADR-054 path: docs/decisions/adrs/adr-054-participant-runtime-observable-lifecycle.md pin: a20312d7eb9a82a2611cb16bbe9590f1e43389d4ed1ea4341ae773edb8529086 + amendments: + - date: 2026-06-13 + ref: "#484" + summary: "Recorded acceptance-time non-goal wording for already-published RUN-305 and backend-facing contract subsets." - id: ADR-055 path: docs/decisions/adrs/adr-055-experiment-core-contract-boundary.md pin: 2e6666f2e066700df3dbf38d0592a2f24e062ad4f94d695113824f2ba73ff695 From 8e72c99748945d7c44debaa0381f20e693639163 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 13 Jun 2026 09:18:18 +0200 Subject: [PATCH 25/70] Add semantic invariant regression tests --- changelog.d/488.fixed.md | 1 + .../python/tests/test_semantics_assessment.py | 146 ++++++++++ .../python/tests/test_semantics_objectives.py | 264 ++++++++++++++++++ .../formal/assessment/pipeline-consistency.md | 3 + specs/formal/composition-readiness.md | 10 +- specs/formal/objectives/window-consistency.md | 13 + 6 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 changelog.d/488.fixed.md diff --git a/changelog.d/488.fixed.md b/changelog.d/488.fixed.md new file mode 100644 index 000000000..7d44ccb80 --- /dev/null +++ b/changelog.d/488.fixed.md @@ -0,0 +1 @@ +Added named semantic regression tests for composition-readiness and objective-window invariants. diff --git a/implementations/python/tests/test_semantics_assessment.py b/implementations/python/tests/test_semantics_assessment.py index 566fd2e4a..02e4cad8e 100644 --- a/implementations/python/tests/test_semantics_assessment.py +++ b/implementations/python/tests/test_semantics_assessment.py @@ -8,11 +8,14 @@ from __future__ import annotations +from pathlib import Path from types import SimpleNamespace +from aces.core.sdl.parser import parse_sdl_file from aces.core.semantics.assessment import ( ASSESSMENT_DEPENDENCY_ROLES, AssessmentDependencyRole, + AssessmentPipelineAnalysis, AssessmentResourceKind, analyze_assessment_pipeline, partition_assessment_dependencies, @@ -42,6 +45,83 @@ def _is_var(value: object) -> bool: return isinstance(value, str) and value.startswith("${") and value.endswith("}") +def _write_assessment_scenario(path: Path, *, namespace: str = "") -> None: + prefix = f"{namespace}." if namespace else "" + path.write_text( + f""" +name: {namespace or "assessment"} +version: 1.0.0 +conditions: + {prefix}health: + command: /bin/true + interval: 15 +metrics: + {prefix}health-metric: + type: conditional + condition: {prefix}health + max-score: 7 + {prefix}manual-metric: + type: manual + max-score: 5 +evaluations: + {prefix}readiness: + metrics: [{prefix}health-metric, {prefix}manual-metric] + min-score: + absolute: 10 +tlos: + {prefix}ready-tlo: + evaluation: {prefix}readiness +goals: + {prefix}ready-goal: + tlos: [{prefix}ready-tlo] +""", + encoding="utf-8", + ) + + +def _write_importing_root(path: Path, imported_name: str, *, namespace: str) -> None: + path.write_text( + f""" +name: root +imports: + - path: {imported_name} + namespace: {namespace} + version: 1.0.0 +""", + encoding="utf-8", + ) + + +def _assessment_analysis_from_file(path: Path) -> AssessmentPipelineAnalysis: + scenario = parse_sdl_file(path) + return analyze_assessment_pipeline( + conditions_by_name=scenario.conditions, + metrics_by_name=scenario.metrics, + evaluations_by_name=scenario.evaluations, + tlos_by_name=scenario.tlos, + goals_by_name=scenario.goals, + ) + + +def _assessment_reference_signature(analysis: AssessmentPipelineAnalysis) -> tuple[tuple[object, ...], ...]: + return tuple( + ( + ref.raw, + ref.source_kind, + ref.source_name, + ref.target_kind, + ref.target_name, + ref.dependency_roles, + ref.namespace_path, + ) + for ref in analysis.references + ) + + +def _strip_shared(name: str) -> str: + return name.removeprefix("shared.") + + class TestAssessmentPipelineSemantics: def test_well_formed_pipeline_normalizes_references_and_dependencies(self) -> None: analysis = analyze_assessment_pipeline( @@ -204,6 +284,72 @@ def test_issue_iteration_follows_pipeline_order(self) -> None: "goal.tlo-undeclared", ] + def test_composition_ready_invariant_layout_variation_preserves_normalized_references_and_aggregation( + self, tmp_path: Path + ) -> None: + flat = tmp_path / "flat.yaml" + imported = tmp_path / "assessment-module.yaml" + root = tmp_path / "root.yaml" + _write_assessment_scenario(flat, namespace="shared") + _write_assessment_scenario(imported) + _write_importing_root(root, imported.name, namespace="shared") + + flat_analysis = _assessment_analysis_from_file(flat) + imported_analysis = _assessment_analysis_from_file(root) + + assert not flat_analysis.has_issues + assert not imported_analysis.has_issues + assert _assessment_reference_signature(imported_analysis) == _assessment_reference_signature(flat_analysis) + assert ( + imported_analysis.evaluation_metric_totals + == flat_analysis.evaluation_metric_totals + == {"shared.readiness": 12} + ) + + def test_composition_ready_invariant_module_expansion_occurs_before_assessment_analysis( + self, tmp_path: Path + ) -> None: + imported = tmp_path / "assessment-module.yaml" + root = tmp_path / "root.yaml" + _write_assessment_scenario(imported) + _write_importing_root(root, imported.name, namespace="shared") + + analysis = _assessment_analysis_from_file(root) + + assert not analysis.has_issues + assert [ref.raw for ref in analysis.references] == [ + "shared.health", + "shared.health-metric", + "shared.manual-metric", + "shared.readiness", + "shared.ready-tlo", + ] + + def test_composition_ready_invariant_namespace_extends_identity_without_changing_kinds_roles_or_aggregation( + self, tmp_path: Path + ) -> None: + plain = tmp_path / "plain.yaml" + namespaced = tmp_path / "namespaced.yaml" + _write_assessment_scenario(plain) + _write_assessment_scenario(namespaced, namespace="shared") + + plain_analysis = _assessment_analysis_from_file(plain) + namespaced_analysis = _assessment_analysis_from_file(namespaced) + + assert [ + (ref.source_kind, ref.target_kind, ref.dependency_roles, ref.namespace_path) + for ref in namespaced_analysis.references + ] == [ + (ref.source_kind, ref.target_kind, ref.dependency_roles, ref.namespace_path) + for ref in plain_analysis.references + ] + assert [ + (_strip_shared(ref.source_name), _strip_shared(ref.target_name)) for ref in namespaced_analysis.references + ] == [(ref.source_name, ref.target_name) for ref in plain_analysis.references] + assert list(namespaced_analysis.evaluation_metric_totals.values()) == list( + plain_analysis.evaluation_metric_totals.values() + ) + class TestAssessmentDependencyPartition: def test_partition_returns_ordering_and_refresh_copies(self) -> None: diff --git a/implementations/python/tests/test_semantics_objectives.py b/implementations/python/tests/test_semantics_objectives.py index b1eadc742..5b0e82e5c 100644 --- a/implementations/python/tests/test_semantics_objectives.py +++ b/implementations/python/tests/test_semantics_objectives.py @@ -2,11 +2,13 @@ from __future__ import annotations +from pathlib import Path from types import SimpleNamespace from hypothesis import given from hypothesis import strategies as st +from aces.core.sdl.parser import parse_sdl_file from aces.core.semantics.assessment import AssessmentResourceKind from aces.core.semantics.objective_semantics import ( OBJECTIVE_ACTOR_DEPENDENCY_ROLES, @@ -31,6 +33,103 @@ def _workflow(*step_names: str) -> SimpleNamespace: return SimpleNamespace(steps={name: object() for name in step_names}) +def _window_analysis( + *, + story_refs: list[str] | None = None, + script_refs: list[str] | None = None, + event_refs: list[str] | None = None, + workflow_refs: list[str] | None = None, + step_refs: list[str] | None = None, + stories_by_name: dict[str, object] | None = None, + scripts_by_name: dict[str, object] | None = None, + events_by_name: dict[str, object] | None = None, + workflows_by_name: dict[str, object] | None = None, +): + return analyze_objective_window( + story_refs=list(story_refs or []), + script_refs=list(script_refs or []), + event_refs=list(event_refs or []), + workflow_refs=list(workflow_refs or []), + step_refs=list(step_refs or []), + stories_by_name=stories_by_name or {}, + scripts_by_name=scripts_by_name or {}, + events_by_name=events_by_name or {}, + workflows_by_name=workflows_by_name or {}, + ) + + +def _window_issue_codes(analysis) -> set[str]: + return {issue.code for issue in analysis.issues} + + +def _write_objective_window_scenario(path: Path, *, namespace: str = "") -> None: + prefix = f"{namespace}." if namespace else "" + path.write_text( + f""" +name: {namespace or "window"} +version: 1.0.0 +conditions: + {prefix}health: + command: /bin/true + interval: 15 +entities: + {prefix}blue: + role: blue +stories: + {prefix}intro: + scripts: [{prefix}timeline] +scripts: + {prefix}timeline: + start-time: 0 + end-time: 60 + speed: 1 + events: + {prefix}kickoff: 0 +events: + {prefix}kickoff: {{}} +objectives: + {prefix}observe: + entity: {prefix}blue + success: + conditions: [{prefix}health] + window: + stories: [{prefix}intro] + scripts: [{prefix}timeline] + events: [{prefix}kickoff] + workflows: [{prefix}flow] + steps: [{prefix}flow.start] +workflows: + {prefix}flow: + start: start + steps: + start: + type: objective + objective: {prefix}observe + on-success: finish + finish: + type: end +""", + encoding="utf-8", + ) + + +def _write_importing_root(path: Path, imported_name: str, *, namespace: str) -> None: + path.write_text( + f""" +name: root +imports: + - path: {imported_name} + namespace: {namespace} + version: 1.0.0 +""", + encoding="utf-8", + ) + + +def _strip_shared(name: str) -> str: + return name.removeprefix("shared.") + + class TestObjectiveWindowSemantics: def test_window_analysis_normalizes_references_and_reachability(self): analysis = analyze_objective_window( @@ -92,6 +191,171 @@ def test_window_analysis_reports_fail_closed_issues(self): "step-unbound", } + def test_window_invariant_story_refs_must_resolve(self) -> None: + analysis = _window_analysis(story_refs=["missing-story"]) + + assert _window_issue_codes(analysis) == {"story-unbound"} + + def test_window_invariant_script_refs_must_resolve(self) -> None: + analysis = _window_analysis(script_refs=["missing-script"]) + + assert _window_issue_codes(analysis) == {"script-unbound"} + + def test_window_invariant_event_refs_must_resolve(self) -> None: + analysis = _window_analysis(event_refs=["missing-event"]) + + assert _window_issue_codes(analysis) == {"event-unbound"} + + def test_window_invariant_steps_must_use_workflow_step_syntax(self) -> None: + analysis = _window_analysis( + workflow_refs=["flow"], + step_refs=["bad-step-ref"], + workflows_by_name={"flow": _workflow("start")}, + ) + + assert _window_issue_codes(analysis) == {"step-invalid-format"} + + def test_window_invariant_steps_require_workflow_window(self) -> None: + analysis = _window_analysis( + step_refs=["flow.start"], + workflows_by_name={"flow": _workflow("start")}, + ) + + assert "step-requires-workflow-window" in _window_issue_codes(analysis) + + def test_window_invariant_workflow_refs_must_resolve(self) -> None: + analysis = _window_analysis(workflow_refs=["missing-flow"]) + + assert _window_issue_codes(analysis) == {"workflow-unbound"} + + def test_window_invariant_step_workflow_must_resolve(self) -> None: + analysis = _window_analysis( + workflow_refs=["flow"], + step_refs=["missing-flow.start"], + workflows_by_name={"flow": _workflow("start")}, + ) + + assert _window_issue_codes(analysis) == {"step-workflow-unbound"} + + def test_window_invariant_step_name_must_resolve_within_workflow(self) -> None: + analysis = _window_analysis( + workflow_refs=["flow"], + step_refs=["flow.missing"], + workflows_by_name={"flow": _workflow("start")}, + ) + + assert _window_issue_codes(analysis) == {"step-unbound"} + + def test_window_invariant_step_workflow_must_be_inside_workflow_window(self) -> None: + analysis = _window_analysis( + workflow_refs=["flow"], + step_refs=["other.done"], + workflows_by_name={"flow": _workflow("start"), "other": _workflow("done")}, + ) + + assert _window_issue_codes(analysis) == {"step-workflow-outside-window"} + + def test_window_invariant_explicit_scripts_must_be_inside_story_window(self) -> None: + analysis = _window_analysis( + story_refs=["intro"], + script_refs=["side"], + stories_by_name={"intro": SimpleNamespace(scripts=["main"])}, + scripts_by_name={"main": SimpleNamespace(events={}), "side": SimpleNamespace(events={})}, + ) + + assert _window_issue_codes(analysis) == {"script-outside-window-stories"} + + def test_window_invariant_events_must_be_inside_reachable_script_window(self) -> None: + analysis = _window_analysis( + script_refs=["timeline"], + event_refs=["cleanup"], + scripts_by_name={"timeline": SimpleNamespace(events={"kickoff": 10})}, + events_by_name={"cleanup": SimpleNamespace()}, + ) + + assert _window_issue_codes(analysis) == {"event-outside-window-scripts"} + + def test_composition_ready_invariant_imported_window_analysis_uses_expanded_canonical_identities( + self, tmp_path: Path + ) -> None: + imported = tmp_path / "window-module.yaml" + root = tmp_path / "root.yaml" + _write_objective_window_scenario(imported) + _write_importing_root(root, imported.name, namespace="shared") + scenario = parse_sdl_file(root) + + analysis = _analyze( + scenario.objectives, + entity_names=set(scenario.entities), + conditions_by_name=scenario.conditions, + stories_by_name=scenario.stories, + scripts_by_name=scenario.scripts, + events_by_name=scenario.events, + workflows_by_name=scenario.workflows, + ) + + assert not analysis.has_issues + assert {ref.canonical_name for ref in analysis.references_of_kind(ObjectiveReferenceKind.WINDOW)} == { + "shared.intro", + "shared.timeline", + "shared.kickoff", + "shared.flow", + "shared.flow.start", + } + window_step = [ + ref + for ref in analysis.references_of_kind(ObjectiveReferenceKind.WINDOW) + if ref.window_reference_kind == ObjectiveWindowReferenceKind.WORKFLOW_STEP + ][0] + assert window_step.workflow_name == "shared.flow" + assert window_step.step_name == "start" + + def test_composition_ready_invariant_namespace_extends_window_identity_without_changing_kind_roles_or_ownership( + self, tmp_path: Path + ) -> None: + plain = tmp_path / "plain.yaml" + namespaced = tmp_path / "namespaced.yaml" + _write_objective_window_scenario(plain) + _write_objective_window_scenario(namespaced, namespace="shared") + plain_scenario = parse_sdl_file(plain) + namespaced_scenario = parse_sdl_file(namespaced) + + plain_window = plain_scenario.objectives["observe"].window + namespaced_window = namespaced_scenario.objectives["shared.observe"].window + plain_analysis = analyze_objective_window( + story_refs=plain_window.stories, + script_refs=plain_window.scripts, + event_refs=plain_window.events, + workflow_refs=plain_window.workflows, + step_refs=plain_window.steps, + stories_by_name=plain_scenario.stories, + scripts_by_name=plain_scenario.scripts, + events_by_name=plain_scenario.events, + workflows_by_name=plain_scenario.workflows, + ) + namespaced_analysis = analyze_objective_window( + story_refs=namespaced_window.stories, + script_refs=namespaced_window.scripts, + event_refs=namespaced_window.events, + workflow_refs=namespaced_window.workflows, + step_refs=namespaced_window.steps, + stories_by_name=namespaced_scenario.stories, + scripts_by_name=namespaced_scenario.scripts, + events_by_name=namespaced_scenario.events, + workflows_by_name=namespaced_scenario.workflows, + ) + + assert [ + (ref.reference_kind, ref.dependency_roles, ref.step_name, _strip_shared(ref.workflow_name or "")) + for ref in namespaced_analysis.references + ] == [ + (ref.reference_kind, ref.dependency_roles, ref.step_name, ref.workflow_name or "") + for ref in plain_analysis.references + ] + assert [_strip_shared(ref.canonical_name) for ref in namespaced_analysis.references] == [ + ref.canonical_name for ref in plain_analysis.references + ] + @given(st.lists(st.sampled_from(["flow.start", "flow.branch"]), max_size=12)) def test_workflow_step_normalization_is_stable(self, step_refs: list[str]): analysis = analyze_objective_window( diff --git a/specs/formal/assessment/pipeline-consistency.md b/specs/formal/assessment/pipeline-consistency.md index cd5878809..57c36902b 100644 --- a/specs/formal/assessment/pipeline-consistency.md +++ b/specs/formal/assessment/pipeline-consistency.md @@ -110,4 +110,7 @@ addresses. - `implementations/python/packages/aces_processor/semantics/planner.py` - differential and cross-stage tests: - `implementations/python/tests/test_semantics_assessment.py` + (`test_composition_ready_invariant_layout_variation_preserves_normalized_references_and_aggregation`, + `test_composition_ready_invariant_module_expansion_occurs_before_assessment_analysis`, + `test_composition_ready_invariant_namespace_extends_identity_without_changing_kinds_roles_or_aggregation`) - `implementations/python/tests/test_fm2_semantics.py` diff --git a/specs/formal/composition-readiness.md b/specs/formal/composition-readiness.md index c529217b3..124ca3590 100644 --- a/specs/formal/composition-readiness.md +++ b/specs/formal/composition-readiness.md @@ -29,9 +29,17 @@ It does lock the semantic preconditions those features must respect. ## Current Implementation Hooks - objective/window references already carry a namespace-extensible path slot in - `implementations/python/packages/aces_processor/semantics/objectives.py` + `implementations/python/packages/aces_sdl/semantics/objectives.py` - planner identity handling is already defined in terms of canonical compiled addresses in `implementations/python/packages/aces_processor/semantics/planner.py` +- named regression tests pin the layout/namespace invariants: + - assessment pipeline: `implementations/python/tests/test_semantics_assessment.py` + (`test_composition_ready_invariant_layout_variation_preserves_normalized_references_and_aggregation`, + `test_composition_ready_invariant_module_expansion_occurs_before_assessment_analysis`, + `test_composition_ready_invariant_namespace_extends_identity_without_changing_kinds_roles_or_aggregation`) + - objective windows: `implementations/python/tests/test_semantics_objectives.py` + (`test_composition_ready_invariant_imported_window_analysis_uses_expanded_canonical_identities`, + `test_composition_ready_invariant_namespace_extends_window_identity_without_changing_kind_roles_or_ownership`) These hooks are intended to let module/import work land later without redefining FM2 semantics. diff --git a/specs/formal/objectives/window-consistency.md b/specs/formal/objectives/window-consistency.md index 720ac9409..5039c7241 100644 --- a/specs/formal/objectives/window-consistency.md +++ b/specs/formal/objectives/window-consistency.md @@ -75,4 +75,17 @@ shape before compiler/planner semantics run. Each resolved reference carries: - `implementations/python/packages/aces_processor/models.py` - differential and property tests: - `implementations/python/tests/test_semantics_objectives.py` + (`test_window_invariant_story_refs_must_resolve`, + `test_window_invariant_script_refs_must_resolve`, + `test_window_invariant_event_refs_must_resolve`, + `test_window_invariant_steps_must_use_workflow_step_syntax`, + `test_window_invariant_steps_require_workflow_window`, + `test_window_invariant_workflow_refs_must_resolve`, + `test_window_invariant_step_workflow_must_resolve`, + `test_window_invariant_step_name_must_resolve_within_workflow`, + `test_window_invariant_step_workflow_must_be_inside_workflow_window`, + `test_window_invariant_explicit_scripts_must_be_inside_story_window`, + `test_window_invariant_events_must_be_inside_reachable_script_window`, + `test_composition_ready_invariant_imported_window_analysis_uses_expanded_canonical_identities`, + `test_composition_ready_invariant_namespace_extends_window_identity_without_changing_kind_roles_or_ownership`) - `implementations/python/tests/test_fm2_semantics.py` From 4ff6d2b823c2e5c25396bb982138525b3d205fd6 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 13 Jun 2026 09:22:30 +0200 Subject: [PATCH 26/70] Add assurance fulfillment gate for classified formal subsystems Per-subsystem fulfillment map (specs/formal/assurance-fulfillment.yaml) enforced by tools/check_assurance_policy.py: every classified specs/formal/ must deliver or explicitly waive (ISO date + tracking ref) each verification artifact kind its FM level requires. Addresses review FM-1 -- an FM3 domain could previously be classified with no executable artifacts while CI stayed green. Anchors ASR-505; gaps recorded as waivers are tracked under #521. --- changelog.d/485.added.md | 1 + ...sue-485-assurance-fulfillment-preflight.md | 131 ++++ docs/specs/formal.md | 6 + .../python/tests/test_assurance_policy.py | 636 ++++++++++++++++++ specs/formal/README.md | 5 + specs/formal/assurance-fulfillment.yaml | 267 ++++++++ tools/check_assurance_policy.py | 416 +++++++++++- 7 files changed, 1461 insertions(+), 1 deletion(-) create mode 100644 changelog.d/485.added.md create mode 100644 docs/decisions/issue-485-assurance-fulfillment-preflight.md create mode 100644 specs/formal/assurance-fulfillment.yaml diff --git a/changelog.d/485.added.md b/changelog.d/485.added.md new file mode 100644 index 000000000..6d207bb83 --- /dev/null +++ b/changelog.d/485.added.md @@ -0,0 +1 @@ +Added an assurance fulfillment gate (`specs/formal/assurance-fulfillment.yaml`, enforced by `tools/check_assurance_policy.py` via `nox -s policy`): every classified formal-spec subsystem must deliver — or explicitly waive with an ISO date and tracking reference — each verification artifact kind required by its FM level, so a subsystem can no longer be classified FM3 with no executable artifacts while CI stays green. diff --git a/docs/decisions/issue-485-assurance-fulfillment-preflight.md b/docs/decisions/issue-485-assurance-fulfillment-preflight.md new file mode 100644 index 000000000..0d2e3e01f --- /dev/null +++ b/docs/decisions/issue-485-assurance-fulfillment-preflight.md @@ -0,0 +1,131 @@ +# Issue 485 Assurance Fulfillment Preflight + +Date: 2026-06-13 + +Issue: #485. + +Requirement: none. The issue title, body, and acceptance criteria are the +contract. + +This note records architecture preflight guardrails for adding the assurance +fulfillment gate. It is guidance for the implementation and does not implement +the checker, the fulfillment map, or the tests. + +## Binding Sources + +- ADR-007 defines the FM0-FM3 assurance ladder and required artifact kinds. +- ADR-018 establishes `specs/formal/assurance-policy.yaml` and + `tools/check_assurance_policy.py` as the canonical machine-readable policy + seam. +- `docs/explain/reference/fm-classification-ledger.yaml` is a per-change ADR + classification ledger. It is related evidence, not the subsystem fulfillment + map this issue requires. +- `specs/formal//` is the formal-domain artifact tree whose classified + subsystems need delivered-or-waived fulfillment records. + +## Architecture Decisions + +- Fulfillment is a classified-formal-subsystem concern, not a per-change ADR + concern. Keep it separate from `fm-classification-ledger.yaml` even if the + checker reuses similar validation helpers. +- Add one machine-readable fulfillment surface next to + `assurance-policy.yaml`, preferably `specs/formal/assurance-fulfillment.yaml` + unless extending `assurance-policy.yaml` keeps the shape clearer. If a sibling + file is used, it must carry `policy_ref: specs/formal/assurance-policy.yaml` + and ADR-007 / ADR-018 references. +- Make the "classified subsystem" set explicit and data-driven. The artifact + should distinguish the subsystem registry (`id`, `path`, `fm_level`) from the + fulfillment map keyed by subsystem id; the checker can then fail when a + classified subsystem has no fulfillment entry without hard-coding the current + list in Python. +- Derive required artifact kinds from `assurance-policy.yaml` by FM level. + Do not duplicate the FM3 artifact list in the fulfillment schema, tests, or + docs. +- Treat status as a per-required-artifact-kind value: + `delivered` requires at least one concrete non-empty repo-relative path, and + `waived` requires an ISO date plus at least one tracking reference. +- The current issue contract names these classified subsystems: delivered + `workflows`, `planner`, `assessment`, `objectives`, `experiment-core`; + waived `participant-runtime`, `participant-semantics`, and `realization`. + `runtime-contracts` is also a formal domain; do not silently include or omit + it. Either include it if the chosen classified-subsystem registry treats it as + classified, or make the registry's boundary explicit. + +## Required Incumbents + +- Reuse `tools/check_assurance_policy.py`; do not add a second policy CLI or + nox session. +- Reuse `tools.policy.common.PolicyFailure`, `failures_to_json`, + `load_exceptions`, `apply_exceptions`, and `safe_repo_path`. +- Extend `implementations/python/tests/test_assurance_policy.py` using the + existing temp-repo seeding and mutation style. +- Keep `nox -s policy` as the workflow entrypoint through the existing + `noxfile.py` policy session. +- Keep ADR-007, ADR-018, `docs/specs/formal.md`, and + `docs/explain/reference/coding-standards.md` as policy consumers; do not + weaken accepted ADR text in place without the ADR-059 amendment and pin gate. + +## Cross-Cutting Layers + +- YAML/config parser: use `yaml.safe_load` and explicit mapping/list/string + shape checks. Never evaluate YAML content or coerce malformed fields into + empty structures that let checks pass. +- Repo-path security: every path from the fulfillment file must be + repo-relative, non-escaping, and resolved through `safe_repo_path` or the same + invariant. Absolute paths, `..`, and symlink escapes must fail. +- Artifact existence: a delivered artifact must point to concrete evidence that + exists and is non-empty. A domain README can satisfy an artifact kind only + when the map deliberately names that README for that kind; it must not be a + blanket substitute for missing executable or model artifacts. +- Waiver authority: fulfillment waivers are first-class records in the + fulfillment map. `tools/policy/exceptions.yaml` remains only for policy-gate + exceptions and must not become the FM gap tracker. +- Error envelope: all failures should be `PolicyFailure` instances and respect + the existing `--json` output. Do not add a new exception hierarchy or raw + traceback output. +- Workflow gate: the new validation must run through the existing assurance + policy checker so `nox -s policy` and `verify` fail on invisible gaps. +- Secret and host exposure: this design should not read secrets, environment + bindings, auth tokens, or network state. Tracking refs are issue identifiers, + not credentials or live GitHub lookups, and no token should appear in process + argv, logs, JSON output, or policy failure messages. + +## Extension Boundary + +The extension seam is the policy YAML plus the classified-subsystem registry: + +- adding an FM4 derives its required artifacts from `assurance-policy.yaml`; +- adding an artifact kind requires one policy-level YAML addition and + fulfillment entries for classified subsystems at levels that inherit it; +- adding a formal subsystem requires one registry entry plus one fulfillment + map entry, and omission of either side should fail the gate; +- adding more waiver evidence should extend a list of tracking refs or + evidence refs, not add ad hoc waiver fields. + +## Gotchas And Anti-Patterns + +Avoid: + +- conflating ADR-level classification fulfillment with formal-domain + subsystem fulfillment; +- hard-coding the current subsystem list or FM3 artifact list in Python when it + can be derived from YAML; +- using `tools/policy/exceptions.yaml` to hide known FM gaps; +- marking participant-runtime or participant-semantics delivered just because + a README contains design prose while the issue calls out missing executable + abstract models; +- accepting empty files, empty directories, absolute paths, parent traversal, + or paths outside the repo as delivered evidence; +- adding a new nox session, new policy failure type, duplicate YAML parser, + duplicate waiver schema, or duplicate test harness; +- editing accepted ADR-018 in place for this issue without following ADR-059. + +## Non-Goals + +- Implementing the fulfillment checker, tests, or YAML contents in this + preflight note. +- Delivering the missing participant-runtime, participant-semantics, or + realization formal artifacts. +- Reclassifying FM levels or changing ADR-007's required artifacts. +- Adding API, auth, persistence, logging, schema-generation, or runtime + behavior. diff --git a/docs/specs/formal.md b/docs/specs/formal.md index eab1d6017..fb6626a33 100644 --- a/docs/specs/formal.md +++ b/docs/specs/formal.md @@ -31,6 +31,12 @@ The canonical mapping is which is the structural source of truth and gates CI via `tools/check_assurance_policy.py`. +Whether each classified domain above actually fulfills the artifact kinds its FM +level requires — delivered, or explicitly waived with an ISO date and tracking +reference — is recorded per subsystem in +[`specs/formal/assurance-fulfillment.yaml`](../../specs/formal/assurance-fulfillment.yaml) +and gated by the same checker. + | Level | Scope | Required artifacts | |-------|-------|--------------------| | FM0 | Structural (parsing, schema, local validation) | Unit tests | diff --git a/implementations/python/tests/test_assurance_policy.py b/implementations/python/tests/test_assurance_policy.py index b30fc6070..13bff40b3 100644 --- a/implementations/python/tests/test_assurance_policy.py +++ b/implementations/python/tests/test_assurance_policy.py @@ -1,9 +1,11 @@ from __future__ import annotations +import datetime import sys from pathlib import Path import pytest +import yaml REPO_ROOT = Path(__file__).resolve().parents[3] if str(REPO_ROOT) not in sys.path: @@ -15,6 +17,7 @@ ADR_REF, ADR_REFS, ADR_TEMPLATE_RELATIVE_PATH, + ASSURANCE_FULFILLMENT_RELATIVE_PATH, ASSURANCE_POLICY_RELATIVE_PATH, CANONICAL_LEVEL_IDS, CODING_STANDARDS_RELATIVE_PATH, @@ -140,6 +143,19 @@ entries: [] """ +# A temp repo seeded by `_seed_repo` has no `specs/formal//` directories, +# so the classified-subsystem registry and the fulfillment map are both empty and +# the gate is clean. Tests that exercise the fulfillment check seed their own +# domain directories and pass an explicit `fulfillment_body`. +_GOOD_EMPTY_FULFILLMENT = """fulfillment: classification-based-assurance-fulfillment +policy_ref: specs/formal/assurance-policy.yaml +adr_refs: + - ADR-007 + - ADR-018 +subsystems: [] +entries: [] +""" + def _seed_repo( tmp_path: Path, @@ -150,6 +166,7 @@ def _seed_repo( formal_overview_body: str | None = _GOOD_FORMAL_OVERVIEW, adr_template_body: str | None = _GOOD_ADR_TEMPLATE, ledger_body: str | None = _GOOD_EMPTY_LEDGER, + fulfillment_body: str | None = _GOOD_EMPTY_FULFILLMENT, ) -> Path: """Seed a temp repo skeleton with the policy YAML and referencing docs.""" policy_path = tmp_path / ASSURANCE_POLICY_RELATIVE_PATH @@ -181,6 +198,11 @@ def _seed_repo( ledger_path.parent.mkdir(parents=True, exist_ok=True) ledger_path.write_text(ledger_body, encoding="utf-8") + if fulfillment_body is not None: + fulfillment_path = tmp_path / ASSURANCE_FULFILLMENT_RELATIVE_PATH + fulfillment_path.parent.mkdir(parents=True, exist_ok=True) + fulfillment_path.write_text(fulfillment_body, encoding="utf-8") + return tmp_path @@ -1033,3 +1055,617 @@ def test_ledger_required_artifacts_must_be_delivered_or_waived(tmp_path: Path) - def test_real_repo_assurance_policy_is_clean() -> None: failures = evaluate_assurance_policy(REPO_ROOT) assert failures == [] + + +# --------------------------------------------------------------------------- # +# Assurance fulfillment map (issue #485). Per classified formal-spec # +# subsystem, every required artifact kind for its FM level must be delivered # +# (a concrete non-empty repo path) or waived (ISO date + tracking ref). # +# --------------------------------------------------------------------------- # + + +def _seed_classified_domain(repo: Path, domain: str) -> None: + """Create specs/formal//README.md so the coverage scan classifies it.""" + _write(repo / "specs" / "formal" / domain / "README.md", f"# {domain}\n\nInvariants under study.\n") + + +def _fulfillment_body( + *, + subsystems: list, + entries: list, + fulfillment: str = "classification-based-assurance-fulfillment", + policy_ref: str = ASSURANCE_POLICY_RELATIVE_PATH, + adr_refs=("ADR-007", "ADR-018"), +) -> str: + return yaml.safe_dump( + { + "fulfillment": fulfillment, + "policy_ref": policy_ref, + "adr_refs": list(adr_refs), + "subsystems": subsystems, + "entries": entries, + }, + sort_keys=False, + ) + + +def _reg(sub_id: str, path: str, fm_level: str = "FM1") -> dict: + return {"id": sub_id, "path": path, "fm_level": fm_level} + + +def _entry(subsystem: str, *, delivered=(), waived=()) -> dict: + return { + "subsystem": subsystem, + "delivered_artifacts": [{"kind": k, "path": p} for k, p in delivered], + "waived_artifacts": [dict(w) for w in waived], + } + + +def _seed_demo_repo(tmp_path: Path) -> Path: + """Repo with one classified FM1 domain 'demo' and a real unit-test file.""" + repo = _seed_repo(tmp_path, fulfillment_body=None) + _seed_classified_domain(repo, "demo") + _write(repo / "implementations/python/tests/test_demo.py", "def test_x():\n assert True\n") + return repo + + +_DEMO_DELIVERED = ( + ("invariant_list", "specs/formal/demo/README.md"), + ("unit_tests", "implementations/python/tests/test_demo.py"), +) + + +def _write_fulfillment(repo: Path, body: str) -> None: + (repo / ASSURANCE_FULFILLMENT_RELATIVE_PATH).write_text(body, encoding="utf-8") + + +def test_good_single_domain_fulfillment_is_clean(tmp_path: Path) -> None: + repo = _seed_demo_repo(tmp_path) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM1")], + entries=[_entry("demo", delivered=_DEMO_DELIVERED)], + ), + ) + assert evaluate_assurance_policy(repo) == [] + + +def test_fulfillment_missing_file_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path, fulfillment_body=None) + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-missing") + + +def test_fulfillment_unparseable_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path, fulfillment_body="fulfillment: [unclosed\n") + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-parse") + + +def test_fulfillment_non_mapping_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path, fulfillment_body="- a\n- b\n") + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-shape") + + +def test_fulfillment_wrong_marker_field_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path, fulfillment_body=_fulfillment_body(subsystems=[], entries=[], fulfillment="wrong")) + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-field") + + +def test_fulfillment_wrong_policy_ref_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path, fulfillment_body=_fulfillment_body(subsystems=[], entries=[], policy_ref="wrong.yaml")) + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-field") + + +def test_fulfillment_missing_adr_ref_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path, fulfillment_body=_fulfillment_body(subsystems=[], entries=[], adr_refs=("ADR-007",))) + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-field") + + +def test_classified_subsystem_absent_from_registry_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path, fulfillment_body=None) + _seed_classified_domain(repo, "demo") + _write_fulfillment(repo, _fulfillment_body(subsystems=[], entries=[])) + failures = evaluate_assurance_policy(repo) + assert _flagged(failures, "assurance-fulfillment-coverage") + assert _flagged(failures, "demo") + + +def test_registry_subsystem_without_entry_is_flagged(tmp_path: Path) -> None: + repo = _seed_demo_repo(tmp_path) + _write_fulfillment(repo, _fulfillment_body(subsystems=[_reg("demo", "specs/formal/demo")], entries=[])) + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-entry-missing") + + +def test_entry_for_unknown_subsystem_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path, fulfillment_body=None) + _write_fulfillment(repo, _fulfillment_body(subsystems=[], entries=[_entry("ghost")])) + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-entry-unknown") + + +def test_registry_fm_level_must_resolve_to_policy_yaml(tmp_path: Path) -> None: + repo = _seed_demo_repo(tmp_path) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM9")], + entries=[_entry("demo", delivered=_DEMO_DELIVERED)], + ), + ) + failures = evaluate_assurance_policy(repo) + assert _flagged(failures, "assurance-fulfillment-level") + assert _flagged(failures, "FM9") + + +def test_registry_path_must_exist_as_formal_domain(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path, fulfillment_body=None) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM1")], + entries=[_entry("demo")], + ), + ) + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-subsystem") + + +def test_registry_path_escaping_repo_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path, fulfillment_body=None) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "../outside", "FM1")], + entries=[_entry("demo")], + ), + ) + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-subsystem") + + +def test_registry_path_outside_specs_formal_is_flagged(tmp_path: Path) -> None: + # A directory that exists and carries a README but lives outside + # specs/formal// is NOT a classified formal subsystem; the registry + # must reject it rather than accept any repo dir that happens to hold a README. + repo = _seed_demo_repo(tmp_path) + _write(repo / "docs" / "notes" / "README.md", "# not a formal domain\n") + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[ + _reg("demo", "specs/formal/demo", "FM1"), + _reg("rogue", "docs/notes", "FM1"), + ], + entries=[ + _entry("demo", delivered=_DEMO_DELIVERED), + _entry("rogue", delivered=_DEMO_DELIVERED), + ], + ), + ) + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-subsystem") + + +def test_registry_nested_specs_formal_path_is_flagged(tmp_path: Path) -> None: + # Only immediate specs/formal// directories are classified subsystems; + # a deeper nested path under a domain is not a registry-valid domain path. + repo = _seed_demo_repo(tmp_path) + _write(repo / "specs" / "formal" / "demo" / "sub" / "README.md", "# nested\n") + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo/sub", "FM1")], + entries=[_entry("demo", delivered=_DEMO_DELIVERED)], + ), + ) + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-subsystem") + + +def test_registry_duplicate_path_alias_is_flagged(tmp_path: Path) -> None: + # Two registry entries pointing at the same formal-domain directory is an + # alias that would let one domain be double-counted; it must be rejected. + repo = _seed_demo_repo(tmp_path) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[ + _reg("demo", "specs/formal/demo", "FM1"), + _reg("demo-alias", "specs/formal/demo", "FM1"), + ], + entries=[ + _entry("demo", delivered=_DEMO_DELIVERED), + _entry("demo-alias", delivered=_DEMO_DELIVERED), + ], + ), + ) + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-subsystem") + + +def test_registry_duplicate_subsystem_id_is_flagged(tmp_path: Path) -> None: + # Two registry entries sharing the same `id` (each pointing at a distinct + # classified domain) is ambiguous: only the first would bind in the registry + # map, silently shadowing the second. The `seen_ids` duplicate-id check must + # reject it; without this case that check would be dead to the suite. + repo = _seed_demo_repo(tmp_path) + _seed_classified_domain(repo, "other") + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[ + _reg("demo", "specs/formal/demo", "FM1"), + _reg("demo", "specs/formal/other", "FM1"), + ], + entries=[_entry("demo", delivered=_DEMO_DELIVERED)], + ), + ) + failures = evaluate_assurance_policy(repo) + assert _flagged(failures, "assurance-fulfillment-subsystem") + assert any("duplicate subsystem id" in failure.message for failure in failures), ( + f"expected a duplicate-subsystem-id failure, got {[failure.render() for failure in failures]}" + ) + + +def test_delivered_path_missing_is_flagged(tmp_path: Path) -> None: + repo = _seed_demo_repo(tmp_path) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM1")], + entries=[ + _entry( + "demo", + delivered=( + ("invariant_list", "specs/formal/demo/README.md"), + ("unit_tests", "implementations/python/tests/test_missing.py"), + ), + ) + ], + ), + ) + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-artifact") + + +def test_delivered_path_escaping_repo_is_flagged(tmp_path: Path) -> None: + # A delivered-artifact path that escapes the repo root must be rejected by + # the `safe_repo_path` boundary, NOT by the "does not exist" branch. The + # escape target is a real file OUTSIDE the repo root, so a naive + # `Path(path).is_file()` check would accept it and silently count the kind + # as delivered; only a genuine escape guard rejects it. This mirrors the + # subsystem-path escape test for the delivered-artifact code path. + repo = tmp_path / "repo" + repo.mkdir() + _seed_demo_repo(repo) + outside = tmp_path / "outside.py" + outside.write_text("def test_x():\n assert True\n", encoding="utf-8") + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM1")], + entries=[ + _entry( + "demo", + delivered=( + ("invariant_list", "specs/formal/demo/README.md"), + ("unit_tests", "../outside.py"), + ), + ) + ], + ), + ) + failures = evaluate_assurance_policy(repo) + assert _flagged(failures, "assurance-fulfillment-artifact") + assert any("escapes the repo root" in failure.message for failure in failures), ( + f"expected a delivered-path escape failure, got {[failure.render() for failure in failures]}" + ) + # The escaping path must not count as delivered, so the required kind is left + # unsatisfied -- proving the escape was rejected, not merely accepted-but-noted. + assert _flagged(failures, "assurance-fulfillment-artifacts") + assert _flagged(failures, "unit_tests") + + +def test_delivered_empty_path_is_flagged(tmp_path: Path) -> None: + repo = _seed_demo_repo(tmp_path) + (repo / "specs/formal/demo/empty.md").write_text("", encoding="utf-8") + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM1")], + entries=[ + _entry( + "demo", + delivered=( + ("invariant_list", "specs/formal/demo/empty.md"), + ("unit_tests", "implementations/python/tests/test_demo.py"), + ), + ) + ], + ), + ) + failures = evaluate_assurance_policy(repo) + assert _flagged(failures, "assurance-fulfillment-artifact") + assert _flagged(failures, "empty") + + +def test_delivered_unknown_kind_is_flagged(tmp_path: Path) -> None: + repo = _seed_demo_repo(tmp_path) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM1")], + entries=[ + _entry( + "demo", + delivered=( + ("invariant_list", "specs/formal/demo/README.md"), + ("unit_tests", "implementations/python/tests/test_demo.py"), + ("bogus_kind", "specs/formal/demo/README.md"), + ), + ) + ], + ), + ) + failures = evaluate_assurance_policy(repo) + assert _flagged(failures, "assurance-fulfillment-artifact") + assert _flagged(failures, "bogus_kind") + + +def test_waiver_missing_date_is_flagged(tmp_path: Path) -> None: + repo = _seed_demo_repo(tmp_path) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM1")], + entries=[ + _entry( + "demo", + delivered=(("invariant_list", "specs/formal/demo/README.md"),), + waived=({"kind": "unit_tests", "tracking": ["#1"], "rationale": "pending"},), + ) + ], + ), + ) + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-waiver") + + +def test_waiver_missing_tracking_is_flagged(tmp_path: Path) -> None: + repo = _seed_demo_repo(tmp_path) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM1")], + entries=[ + _entry( + "demo", + delivered=(("invariant_list", "specs/formal/demo/README.md"),), + waived=({"kind": "unit_tests", "date": "2026-06-13", "rationale": "pending"},), + ) + ], + ), + ) + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-waiver") + + +def test_waiver_empty_tracking_list_is_flagged(tmp_path: Path) -> None: + # An explicitly empty `tracking: []` list (distinct from an absent key) must + # be rejected: the contract requires at least one tracking reference. Without + # this case, weakening the guard from "≥1 ref" to "key present" would go + # undetected. + repo = _seed_demo_repo(tmp_path) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM1")], + entries=[ + _entry( + "demo", + delivered=(("invariant_list", "specs/formal/demo/README.md"),), + waived=({"kind": "unit_tests", "date": "2026-06-13", "tracking": [], "rationale": "pending"},), + ) + ], + ), + ) + failures = evaluate_assurance_policy(repo) + assert _flagged(failures, "assurance-fulfillment-waiver") + # The empty-tracking waiver does not count, so the required kind stays unmet. + assert _flagged(failures, "assurance-fulfillment-artifacts") + assert _flagged(failures, "unit_tests") + + +def test_waiver_missing_rationale_is_flagged(tmp_path: Path) -> None: + # A waiver that supplies a valid date and tracking ref but omits the + # rationale must be rejected: an unjustified waiver may not silently satisfy + # a required artifact kind. Without this case the `rationale_ok` guard in + # `_check_fulfillment_waived` could be removed undetected. + repo = _seed_demo_repo(tmp_path) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM1")], + entries=[ + _entry( + "demo", + delivered=(("invariant_list", "specs/formal/demo/README.md"),), + waived=({"kind": "unit_tests", "date": "2026-06-13", "tracking": ["#1"]},), + ) + ], + ), + ) + failures = evaluate_assurance_policy(repo) + assert _flagged(failures, "assurance-fulfillment-waiver") + # The unjustified waiver does not count, so the required kind is unsatisfied. + assert _flagged(failures, "assurance-fulfillment-artifacts") + assert _flagged(failures, "unit_tests") + + +def test_required_kind_neither_delivered_nor_waived_is_flagged(tmp_path: Path) -> None: + repo = _seed_demo_repo(tmp_path) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM1")], + entries=[_entry("demo", delivered=(("invariant_list", "specs/formal/demo/README.md"),))], + ), + ) + failures = evaluate_assurance_policy(repo) + assert _flagged(failures, "assurance-fulfillment-artifacts") + assert _flagged(failures, "unit_tests") + + +def test_dated_tracked_waiver_satisfies_required_kind(tmp_path: Path) -> None: + repo = _seed_demo_repo(tmp_path) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM1")], + entries=[ + _entry( + "demo", + delivered=(("invariant_list", "specs/formal/demo/README.md"),), + waived=( + { + "kind": "unit_tests", + "date": "2026-06-13", + "tracking": ["#1"], + "rationale": "executable tests pending", + }, + ), + ) + ], + ), + ) + assert evaluate_assurance_policy(repo) == [] + + +def test_native_yaml_date_waiver_is_accepted(tmp_path: Path) -> None: + # An unquoted YAML date (`date: 2026-06-13`) parses to datetime.date, not a + # string; the gate must accept it rather than force every author to quote. + repo = _seed_demo_repo(tmp_path) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM1")], + entries=[ + _entry( + "demo", + delivered=(("invariant_list", "specs/formal/demo/README.md"),), + waived=( + { + "kind": "unit_tests", + "date": datetime.date(2026, 6, 13), + "tracking": ["#1"], + "rationale": "executable tests pending", + }, + ), + ) + ], + ), + ) + assert evaluate_assurance_policy(repo) == [] + + +def test_native_yaml_timestamp_waiver_is_flagged(tmp_path: Path) -> None: + # A YAML timestamp scalar (`date: 2026-06-13 10:30:00`) parses to + # datetime.datetime, a subclass of datetime.date. The waiver contract requires + # a date-only YYYY-MM-DD value, so a wall-clock timestamp must be rejected + # rather than slip through the date subclass check. + repo = _seed_demo_repo(tmp_path) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM1")], + entries=[ + _entry( + "demo", + delivered=(("invariant_list", "specs/formal/demo/README.md"),), + waived=( + { + "kind": "unit_tests", + "date": datetime.datetime(2026, 6, 13, 10, 30, 0), + "tracking": ["#1"], + "rationale": "executable tests pending", + }, + ), + ) + ], + ), + ) + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-waiver") + + +def test_waiver_with_impossible_calendar_date_is_flagged(tmp_path: Path) -> None: + # A string date that matches the YYYY-MM-DD shape but is not a real calendar + # date (month 13, day 45) must be rejected -- a shape match alone must not + # satisfy the waiver-date contract. + repo = _seed_demo_repo(tmp_path) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM1")], + entries=[ + _entry( + "demo", + delivered=(("invariant_list", "specs/formal/demo/README.md"),), + waived=( + { + "kind": "unit_tests", + "date": "2025-13-45", + "tracking": ["#1"], + "rationale": "executable tests pending", + }, + ), + ) + ], + ), + ) + failures = evaluate_assurance_policy(repo) + # The impossible date fails the waiver-date contract, so the waiver does not + # count and the required kind is left unsatisfied. + assert _flagged(failures, "assurance-fulfillment-waiver") + assert _flagged(failures, "assurance-fulfillment-artifacts") + + +def test_waiver_with_invalid_day_of_month_is_flagged(tmp_path: Path) -> None: + # 2025-02-30 is shape-valid but not a real date; it must be rejected. + repo = _seed_demo_repo(tmp_path) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("demo", "specs/formal/demo", "FM1")], + entries=[ + _entry( + "demo", + delivered=(("invariant_list", "specs/formal/demo/README.md"),), + waived=( + { + "kind": "unit_tests", + "date": "2025-02-30", + "tracking": ["#1"], + "rationale": "executable tests pending", + }, + ), + ) + ], + ), + ) + assert _flagged(evaluate_assurance_policy(repo), "assurance-fulfillment-waiver") + + +def test_fm3_domain_requires_abstract_state_machine_model(tmp_path: Path) -> None: + # An FM3 domain that does not deliver or waive abstract_state_machine_model + # (an FM3-only required kind derived from the policy YAML) fails. + repo = _seed_repo(tmp_path, fulfillment_body=None) + _seed_classified_domain(repo, "stateful") + _write(repo / "implementations/python/tests/test_stateful.py", "def test_x():\n assert True\n") + _write(repo / "contracts/schemas/stateful-v1.json", "{}\n") + delivered = ( + ("invariant_list", "specs/formal/stateful/README.md"), + ("unit_tests", "implementations/python/tests/test_stateful.py"), + ("typed_ir_or_contract_coverage", "contracts/schemas/stateful-v1.json"), + ("property_based_or_differential_tests", "implementations/python/tests/test_stateful.py"), + ) + _write_fulfillment( + repo, + _fulfillment_body( + subsystems=[_reg("stateful", "specs/formal/stateful", "FM3")], + entries=[_entry("stateful", delivered=delivered)], + ), + ) + failures = evaluate_assurance_policy(repo) + assert _flagged(failures, "assurance-fulfillment-artifacts") + assert _flagged(failures, "abstract_state_machine_model") diff --git a/specs/formal/README.md b/specs/formal/README.md index 335d9bf37..e717d3aae 100644 --- a/specs/formal/README.md +++ b/specs/formal/README.md @@ -33,3 +33,8 @@ The canonical mapping from change classification to verification artifacts lives in `specs/formal/assurance-policy.yaml`, governed by `docs/decisions/adrs/adr-018-classification-based-assurance-policy.md` and gated by `tools/check_assurance_policy.py` (`nox -s policy`). + +Per-subsystem **fulfillment** — whether each classified domain above actually +delivers (or explicitly, with an ISO date and tracking reference, waives) the +artifact kinds its FM level requires — is recorded in +`specs/formal/assurance-fulfillment.yaml` and gated by the same checker. diff --git a/specs/formal/assurance-fulfillment.yaml b/specs/formal/assurance-fulfillment.yaml new file mode 100644 index 000000000..1afd394f1 --- /dev/null +++ b/specs/formal/assurance-fulfillment.yaml @@ -0,0 +1,267 @@ +# Classification-Based Assurance Fulfillment Map (ASR-505, review FM-1, issue #485) +# +# Companion to specs/formal/assurance-policy.yaml. The policy file defines the +# FM0-FM3 ladder and the required artifact kinds per level (its self-description). +# This file records, per classified formal-spec subsystem under specs/formal/, +# whether each required artifact kind for that subsystem's FM level is actually +# DELIVERED (a concrete, non-empty repo path) or explicitly WAIVED (an ISO date +# plus at least one tracking reference). It makes ADR-018's "the policy cannot rot +# silently" claim true for fulfillment, not just description: a subsystem can no +# longer be classified FM3 forever with no artifacts and a green CI. +# +# Required reading: +# docs/decisions/adrs/adr-007-lightweight-formal-methods-policy.md -- policy +# docs/decisions/adrs/adr-018-classification-based-assurance-policy.md -- seam +# specs/formal/assurance-policy.yaml -- the FM ladder and required artifacts +# +# Gated by tools/check_assurance_policy.py (nox -s policy / verify). Required +# artifact kinds are DERIVED from assurance-policy.yaml by FM level -- do not +# duplicate them here. Distinct from docs/explain/reference/fm-classification- +# ledger.yaml, which is the per-change ADR classification record. + +fulfillment: classification-based-assurance-fulfillment + +policy_ref: specs/formal/assurance-policy.yaml + +adr_refs: + - ADR-007 + - ADR-018 + +# Registry of classified formal-spec subsystems. Every immediate subdirectory of +# specs/formal/ carrying a README.md must appear here (the checker fails on any +# that is absent), and each must carry exactly one fulfillment entry below. +subsystems: + - id: workflows + path: specs/formal/workflows + fm_level: FM3 + - id: planner + path: specs/formal/planner + fm_level: FM2 + - id: assessment + path: specs/formal/assessment + fm_level: FM2 + - id: objectives + path: specs/formal/objectives + fm_level: FM2 + - id: experiment-core + path: specs/formal/experiment-core + fm_level: FM2 + - id: runtime-contracts + path: specs/formal/runtime-contracts + fm_level: FM2 + - id: participant-runtime + path: specs/formal/participant-runtime + fm_level: FM3 + - id: participant-semantics + path: specs/formal/participant-semantics + fm_level: FM3 + - id: realization + path: specs/formal/realization + fm_level: FM2 + +# Fulfillment, keyed by subsystem id. delivered_artifacts name a concrete, +# non-empty repo path that is the named kind of evidence. waived_artifacts record +# a known gap with an ISO date and the tracking issue(s) under which it will be +# closed. +entries: + - subsystem: workflows + delivered_artifacts: + - kind: invariant_list + path: specs/formal/workflows/README.md + - kind: abstract_state_machine_model + path: specs/formal/workflows/state-machine.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_models.py + - kind: typed_ir_or_contract_coverage + path: contracts/schemas/control-plane/workflow-result-envelope-v1.json + waived_artifacts: + - kind: property_based_or_differential_tests + date: 2026-06-13 + tracking: + - "#521" + rationale: >- + No property-based or differential test yet exercises the workflow + state-machine / result-envelope semantics; tracked for delivery. + + - subsystem: planner + delivered_artifacts: + - kind: invariant_list + path: specs/formal/planner/dependency-ordering.md + - kind: unit_tests + path: implementations/python/tests/test_semantics_planner.py + - kind: typed_ir_or_contract_coverage + path: contracts/schemas/plans/orchestration-plan-v1.json + - kind: property_based_or_differential_tests + path: implementations/python/tests/test_semantics_planner.py + waived_artifacts: [] + + - subsystem: assessment + delivered_artifacts: + - kind: invariant_list + path: specs/formal/assessment/pipeline-consistency.md + - kind: unit_tests + path: implementations/python/tests/test_semantics_assessment.py + - kind: typed_ir_or_contract_coverage + path: contracts/schemas/control-plane/evaluation-result-envelope-v1.json + - kind: property_based_or_differential_tests + path: implementations/python/tests/test_fm2_semantics.py + waived_artifacts: [] + + - subsystem: objectives + delivered_artifacts: + - kind: invariant_list + path: specs/formal/objectives/window-consistency.md + - kind: unit_tests + path: implementations/python/tests/test_semantics_objectives.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/packages/aces_sdl/objectives.py + - kind: property_based_or_differential_tests + path: implementations/python/tests/test_semantics_objectives.py + waived_artifacts: [] + + - subsystem: experiment-core + delivered_artifacts: + - kind: invariant_list + path: specs/formal/experiment-core/README.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_contracts.py + - kind: typed_ir_or_contract_coverage + path: contracts/schemas/experiment-core/experiment-task-v1.json + waived_artifacts: + - kind: property_based_or_differential_tests + date: 2026-06-13 + tracking: + - "#521" + rationale: >- + Contract validation in test_runtime_contracts.py is example-based; no + property-based or differential coverage of the EXP-701-705 contracts + yet; tracked for delivery. + + - subsystem: runtime-contracts + delivered_artifacts: + - kind: invariant_list + path: specs/formal/runtime-contracts/README.md + - kind: unit_tests + path: implementations/python/tests/test_runtime_contracts.py + - kind: typed_ir_or_contract_coverage + path: contracts/schemas/control-plane/workflow-result-envelope-v1.json + waived_artifacts: + - kind: property_based_or_differential_tests + date: 2026-06-13 + tracking: + - "#521" + rationale: >- + Portable result/evaluator envelope validation is example-based; no + property-based or differential coverage yet; tracked for delivery. + + - subsystem: participant-runtime + # Design coverage only (RUN-305-308): the README is the documented invariant + # list, but the executable runtime, typed contracts, differential tests, and + # abstract state machine do not yet exist. Per the issue finding, those gaps + # are recorded as dated, tracked waivers rather than left invisible. + delivered_artifacts: + - kind: invariant_list + path: specs/formal/participant-runtime/README.md + waived_artifacts: + - kind: unit_tests + date: 2026-06-13 + tracking: + - "#486" + - "#194" + - "#195" + rationale: >- + Participant-runtime is at design coverage, not implementation + coverage; no executable runtime tests yet. + - kind: typed_ir_or_contract_coverage + date: 2026-06-13 + tracking: + - "#486" + - "#194" + - "#195" + rationale: >- + No typed runtime contract realizes the RUN-305-308 runtime model yet. + - kind: property_based_or_differential_tests + date: 2026-06-13 + tracking: + - "#486" + - "#194" + - "#195" + rationale: >- + No property-based or differential coverage of the runtime model yet. + - kind: abstract_state_machine_model + date: 2026-06-13 + tracking: + - "#486" + - "#194" + - "#195" + rationale: >- + The README defines the design but no executable abstract state-machine + model exists; #486 tracks the executable invariant oracle. + + - subsystem: participant-semantics + # Design coverage only (SEM-208-215): same posture as participant-runtime. + delivered_artifacts: + - kind: invariant_list + path: specs/formal/participant-semantics/README.md + waived_artifacts: + - kind: unit_tests + date: 2026-06-13 + tracking: + - "#487" + rationale: >- + Participant-semantics is at design coverage; no executable semantic + tests realize SEM-208-215 yet. + - kind: typed_ir_or_contract_coverage + date: 2026-06-13 + tracking: + - "#487" + rationale: >- + No typed contract realizes the SEM-208-215 semantic model yet. + - kind: property_based_or_differential_tests + date: 2026-06-13 + tracking: + - "#487" + rationale: >- + No property-based or differential coverage of the semantic model yet. + - kind: abstract_state_machine_model + date: 2026-06-13 + tracking: + - "#487" + rationale: >- + No executable abstract model exists; #487 tracks the executable + invariant oracle (I1-I18) for the abstract model. + + - subsystem: realization + # SEM-218 realization enforcement is in flight across #489/#490/#491; the + # explicitness/realization semantic boundary is documented, but the + # cross-stage enforcement and its tests are not yet delivered. + delivered_artifacts: + - kind: invariant_list + path: specs/formal/realization/explicitness-and-realization.md + waived_artifacts: + - kind: unit_tests + date: 2026-06-13 + tracking: + - "#489" + - "#490" + - "#491" + rationale: >- + Realization enforcement (classifier, compiler emission, runtime gate) + is under construction; no executable enforcement tests yet. + - kind: typed_ir_or_contract_coverage + date: 2026-06-13 + tracking: + - "#489" + - "#490" + - "#491" + rationale: >- + Typed realization-support emission and provenance fields land with + #490/#491; not yet delivered. + - kind: property_based_or_differential_tests + date: 2026-06-13 + tracking: + - "#489" + - "#490" + - "#491" + rationale: >- + No property-based or differential coverage of the realization gate yet. diff --git a/tools/check_assurance_policy.py b/tools/check_assurance_policy.py index 62eebf30a..90bb5ea33 100644 --- a/tools/check_assurance_policy.py +++ b/tools/check_assurance_policy.py @@ -26,6 +26,8 @@ import re import sys from collections.abc import Iterable +from datetime import date as date_cls +from datetime import datetime as datetime_cls from pathlib import Path from typing import Any @@ -35,7 +37,7 @@ import yaml -from tools.policy.common import PolicyFailure, apply_exceptions, failures_to_json, load_exceptions +from tools.policy.common import PolicyFailure, apply_exceptions, failures_to_json, load_exceptions, safe_repo_path # --------------------------------------------------------------------------- # # Canonical paths and baseline policy invariants. Test code imports these # @@ -53,6 +55,13 @@ ADR_TEMPLATE_RELATIVE_PATH = "docs/decisions/adrs/TEMPLATE.md" ADR_DIRECTORY_RELATIVE_PATH = "docs/decisions/adrs" FM_CLASSIFICATION_LEDGER_RELATIVE_PATH = "docs/explain/reference/fm-classification-ledger.yaml" +# Per-classified-formal-subsystem fulfillment map (issue #485). Distinct from the +# per-change ADR ledger above: this records, for each classified subsystem under +# `specs/formal//`, whether the required artifact kinds for its FM level +# are delivered (a concrete non-empty repo path) or explicitly waived (ISO date + +# tracking reference). +ASSURANCE_FULFILLMENT_RELATIVE_PATH = "specs/formal/assurance-fulfillment.yaml" +FORMAL_DOMAINS_RELATIVE_PATH = "specs/formal" # The baseline canonical level ids. The YAML MAY add more levels (e.g. FM4), # but these four are the floor and must always be present. @@ -125,6 +134,43 @@ _ADR_REQUIRED_ARTIFACTS_RE = re.compile(r"^Required artifacts:\s*(.+?)\s*$", re.MULTILINE) _ADR_WAIVERS_RE = re.compile(r"^Waivers:\s*(.+?)\s*$", re.MULTILINE) _LEDGER_VALUE = "per-change-fm-classification" +_FULFILLMENT_VALUE = "classification-based-assurance-fulfillment" +_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _is_iso_date_waiver(value: Any) -> bool: + """Return True only when ``value`` is a date-only ``YYYY-MM-DD`` value. + + Two shapes are accepted, mirroring how ``yaml.safe_load`` parses a waiver's + ``date`` field: + + * A native YAML *date* scalar (``date: 2026-06-13``), which parses to a + ``datetime.date``. A native YAML *timestamp* scalar + (``date: 2026-06-13 10:30:00``) parses to a ``datetime.datetime`` -- which + is a *subclass* of ``date`` -- and must be rejected, because the contract + requires a date-only ``YYYY-MM-DD`` value, not a wall-clock timestamp. + * A quoted string, which must parse as a real ``YYYY-MM-DD`` calendar date. + The regex pins the textual shape (rejecting the broader forms that + ``date.fromisoformat`` accepts on Python >= 3.11, e.g. ``20250101`` or + ``2025-W01-1``); ``date.fromisoformat`` then enforces calendar validity, so + impossible dates such as ``2025-13-45`` or ``2025-02-30`` are rejected + rather than merely shape-matched. + """ + if isinstance(value, datetime_cls): + # datetime is a date subclass; a timestamp scalar is not a date-only value. + return False + if isinstance(value, date_cls): + return True + if not isinstance(value, str): + return False + if not _ISO_DATE_RE.match(value): + return False + try: + date_cls.fromisoformat(value) + except ValueError: + return False + return True + # Human-readable phrasings for each YAML artifact slug. The drift guard # requires the union of required artifacts (across all levels in the YAML) to @@ -938,6 +984,373 @@ def _check_fm_classification_ledger(repo_root: Path, levels: list[Any]) -> list[ return failures +def _classified_formal_domains(repo_root: Path) -> list[str]: + """Return repo-relative paths of classified formal-spec subsystems. + + A classified subsystem is an immediate subdirectory of ``specs/formal/`` that + carries a ``README.md`` -- the domain-marker convention documented in + ``specs/formal/README.md`` ("Each domain directory should include a short + README"). This is the independent source of truth the fulfillment registry is + checked against, so a new ``specs/formal//`` that forgets a registry + entry fails the gate instead of being silently un-tracked. + """ + formal_dir = repo_root / FORMAL_DOMAINS_RELATIVE_PATH + if not formal_dir.is_dir(): + return [] + domains: list[str] = [] + for child in sorted(formal_dir.iterdir()): + if child.is_dir() and (child / "README.md").is_file(): + domains.append(child.relative_to(repo_root).as_posix()) + return domains + + +def _check_fulfillment_delivered( + repo_root: Path, + entry_id: str, + delivered: Any, + valid_artifact_kinds: set[str], +) -> tuple[set[str], list[PolicyFailure]]: + """Validate an entry's ``delivered_artifacts`` and return the satisfied kinds. + + A kind counts as delivered only when its kind is valid AND its path resolves + inside the repo to an existing, non-empty file. + """ + path = ASSURANCE_FULFILLMENT_RELATIVE_PATH + failures: list[PolicyFailure] = [] + kinds: set[str] = set() + if delivered is None: + return kinds, failures # absent list is fine -- required kinds may all be waived. + if not isinstance(delivered, list): + return kinds, [ + _fail("assurance-fulfillment-artifact", f"{entry_id}.delivered_artifacts must be a YAML list", path) + ] + for index, artifact in enumerate(delivered): + if not isinstance(artifact, dict): + failures.append( + _fail( + "assurance-fulfillment-artifact", f"{entry_id}.delivered_artifacts[{index}] must be a mapping", path + ) + ) + continue + kind = artifact.get("kind") + artifact_path = artifact.get("path") + kind_ok = isinstance(kind, str) and kind in valid_artifact_kinds + if not kind_ok: + failures.append( + _fail( + "assurance-fulfillment-artifact", + f"{entry_id}.delivered_artifacts[{index}].kind must be one of " + f"{sorted(valid_artifact_kinds)}; got {kind!r}", + path, + ) + ) + path_ok = False + if not isinstance(artifact_path, str) or not artifact_path.strip(): + failures.append( + _fail( + "assurance-fulfillment-artifact", + f"{entry_id}.delivered_artifacts[{index}].path must be a non-empty repo-relative path", + path, + ) + ) + else: + resolved = safe_repo_path(repo_root, artifact_path) + if resolved is None: + failures.append( + _fail( + "assurance-fulfillment-artifact", + f"{entry_id} delivered artifact path escapes the repo root: {artifact_path}", + path, + ) + ) + elif not resolved.is_file(): + failures.append( + _fail( + "assurance-fulfillment-artifact", + f"{entry_id} delivered artifact path does not exist: {artifact_path}", + path, + ) + ) + elif resolved.stat().st_size == 0: + failures.append( + _fail( + "assurance-fulfillment-artifact", + f"{entry_id} delivered artifact path is empty: {artifact_path}", + path, + ) + ) + else: + path_ok = True + if kind_ok and path_ok: + kinds.add(kind) + return kinds, failures + + +def _check_fulfillment_waived( + entry_id: str, + waived: Any, + valid_artifact_kinds: set[str], +) -> tuple[set[str], list[PolicyFailure]]: + """Validate an entry's ``waived_artifacts`` and return the waived kinds. + + A waiver counts only when it names a valid kind, an ISO ``date``, at least one + ``tracking`` reference, and a non-empty ``rationale``. + """ + path = ASSURANCE_FULFILLMENT_RELATIVE_PATH + failures: list[PolicyFailure] = [] + kinds: set[str] = set() + if waived is None: + return kinds, failures + if not isinstance(waived, list): + return kinds, [ + _fail("assurance-fulfillment-waiver", f"{entry_id}.waived_artifacts must be a YAML list when present", path) + ] + for index, waiver in enumerate(waived): + if not isinstance(waiver, dict): + failures.append( + _fail("assurance-fulfillment-waiver", f"{entry_id}.waived_artifacts[{index}] must be a mapping", path) + ) + continue + kind = waiver.get("kind") + waiver_date = waiver.get("date") + tracking = waiver.get("tracking") + rationale = waiver.get("rationale") + kind_ok = isinstance(kind, str) and kind in valid_artifact_kinds + if not kind_ok: + failures.append( + _fail( + "assurance-fulfillment-waiver", + f"{entry_id}.waived_artifacts[{index}].kind must be one of " + f"{sorted(valid_artifact_kinds)}; got {kind!r}", + path, + ) + ) + date_ok = _is_iso_date_waiver(waiver_date) + if not date_ok: + failures.append( + _fail( + "assurance-fulfillment-waiver", + f"{entry_id} waiver for {kind!r} must carry an ISO date (YYYY-MM-DD); got {waiver_date!r}", + path, + ) + ) + tracking_ok = isinstance(tracking, list) and any(isinstance(ref, str) and ref.strip() for ref in tracking) + if not tracking_ok: + failures.append( + _fail( + "assurance-fulfillment-waiver", + f"{entry_id} waiver for {kind!r} must name at least one tracking reference", + path, + ) + ) + rationale_ok = isinstance(rationale, str) and bool(rationale.strip()) + if not rationale_ok: + failures.append( + _fail( + "assurance-fulfillment-waiver", + f"{entry_id} waiver for {kind!r} must carry a non-empty rationale", + path, + ) + ) + if kind_ok and date_ok and tracking_ok and rationale_ok: + kinds.add(kind) + return kinds, failures + + +def _check_assurance_fulfillment(repo_root: Path, levels: list[Any]) -> list[PolicyFailure]: + """Validate the per-subsystem assurance fulfillment map (issue #485). + + Every classified formal-spec subsystem must appear in the registry; every + registry subsystem must have a fulfillment entry (and vice-versa); and every + required artifact kind for the subsystem's FM level -- derived from + ``assurance-policy.yaml`` -- must be delivered (non-empty repo path) or waived + (ISO date + tracking reference). + """ + path = ASSURANCE_FULFILLMENT_RELATIVE_PATH + fulfillment_path = repo_root / path + if not fulfillment_path.is_file(): + return [_fail("assurance-fulfillment-missing", f"assurance fulfillment map not found: {path}", path)] + try: + raw = yaml.safe_load(fulfillment_path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + return [_fail("assurance-fulfillment-parse", f"failed to parse {path}: {exc}", path)] + if not isinstance(raw, dict): + return [_fail("assurance-fulfillment-shape", f"{path} must be a YAML mapping at the top level", path)] + + failures: list[PolicyFailure] = [] + if raw.get("fulfillment") != _FULFILLMENT_VALUE: + failures.append(_fail("assurance-fulfillment-field", f"fulfillment field must be {_FULFILLMENT_VALUE!r}", path)) + if raw.get("policy_ref") != ASSURANCE_POLICY_RELATIVE_PATH: + failures.append( + _fail("assurance-fulfillment-field", f"policy_ref must be {ASSURANCE_POLICY_RELATIVE_PATH}", path) + ) + adr_refs = raw.get("adr_refs") + if not isinstance(adr_refs, list): + failures.append(_fail("assurance-fulfillment-field", "adr_refs must be a YAML list", path)) + else: + adr_strs = _refs_sequence(adr_refs) + for required_adr in ADR_REFS: + if required_adr not in adr_strs: + failures.append(_fail("assurance-fulfillment-field", f"adr_refs must include {required_adr}", path)) + + level_ids = _level_ids(levels) + required_by_level = _required_artifacts_by_level(levels) + valid_artifact_kinds = _required_artifact_union(levels) + + # --- subsystem registry: {id, path, fm_level} --- + # The registry's path boundary is the classified formal-domain set itself -- + # immediate `specs/formal//` subdirectories carrying a README.md (per + # the issue #485 preflight and the specs/formal/README.md domain-marker + # convention). A registry entry pointing anywhere else + # (some other repo dir that merely happens to hold a README) is rejected, so + # the fulfillment surface cannot drift outside the declared ownership + # boundary. + formal_domain_paths = set(_classified_formal_domains(repo_root)) + subsystems = raw.get("subsystems") + if not isinstance(subsystems, list): + failures.append(_fail("assurance-fulfillment-field", "subsystems must be a YAML list", path)) + subsystems = [] + registry: dict[str, dict] = {} + registered_paths: set[str] = set() + seen_ids: set[str] = set() + for index, sub in enumerate(subsystems): + if not isinstance(sub, dict): + failures.append(_fail("assurance-fulfillment-subsystem", f"subsystems[{index}] must be a mapping", path)) + continue + sub_id = sub.get("id") + sub_path = sub.get("path") + fm_level = sub.get("fm_level") + ident = sub_id if isinstance(sub_id, str) and sub_id.strip() else f"subsystems[{index}]" + if not isinstance(sub_id, str) or not sub_id.strip(): + failures.append( + _fail("assurance-fulfillment-subsystem", f"subsystems[{index}].id must be a non-empty string", path) + ) + sub_id = None + elif sub_id in seen_ids: + failures.append(_fail("assurance-fulfillment-subsystem", f"duplicate subsystem id: {sub_id}", path)) + else: + seen_ids.add(sub_id) + resolved_rel: str | None = None + if not isinstance(sub_path, str) or not sub_path.strip(): + failures.append( + _fail("assurance-fulfillment-subsystem", f"{ident}.path must be a non-empty repo-relative path", path) + ) + else: + resolved = safe_repo_path(repo_root, sub_path) + if resolved is None: + failures.append( + _fail("assurance-fulfillment-subsystem", f"{ident}.path escapes the repo root: {sub_path}", path) + ) + else: + candidate_rel = resolved.relative_to(repo_root.resolve()).as_posix() + if candidate_rel not in formal_domain_paths: + failures.append( + _fail( + "assurance-fulfillment-subsystem", + f"{ident}.path is not a classified formal-spec domain " + f"(must be an immediate specs/formal// directory containing README.md): {sub_path}", + path, + ) + ) + elif candidate_rel in registered_paths: + failures.append( + _fail( + "assurance-fulfillment-subsystem", + f"{ident}.path duplicates another registry entry's path: {sub_path}", + path, + ) + ) + else: + resolved_rel = candidate_rel + registered_paths.add(resolved_rel) + if not isinstance(fm_level, str) or fm_level not in level_ids: + failures.append( + _fail( + "assurance-fulfillment-level", + f"{ident}.fm_level {fm_level!r} is not defined in {ASSURANCE_POLICY_RELATIVE_PATH}", + path, + ) + ) + fm_level = None + if isinstance(sub_id, str) and sub_id.strip() and sub_id not in registry: + registry[sub_id] = {"path": resolved_rel, "fm_level": fm_level} + + # --- coverage: every classified domain dir must be registered --- + for domain_rel in _classified_formal_domains(repo_root): + if domain_rel not in registered_paths: + failures.append( + _fail( + "assurance-fulfillment-coverage", + f"classified formal subsystem is absent from the fulfillment registry: {domain_rel}", + path, + ) + ) + + # --- fulfillment entries keyed by subsystem id --- + entries = raw.get("entries") + if not isinstance(entries, list): + failures.append(_fail("assurance-fulfillment-field", "entries must be a YAML list", path)) + entries = [] + entry_subsystems: set[str] = set() + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + failures.append(_fail("assurance-fulfillment-entry", f"entries[{index}] must be a mapping", path)) + continue + sub_id = entry.get("subsystem") + if not isinstance(sub_id, str) or not sub_id.strip(): + failures.append( + _fail("assurance-fulfillment-entry", f"entries[{index}].subsystem must be a non-empty string", path) + ) + continue + if sub_id in entry_subsystems: + failures.append( + _fail("assurance-fulfillment-entry", f"duplicate fulfillment entry for subsystem: {sub_id}", path) + ) + continue + entry_subsystems.add(sub_id) + if sub_id not in registry: + failures.append( + _fail( + "assurance-fulfillment-entry-unknown", + f"fulfillment entry references subsystem {sub_id!r} not in the registry", + path, + ) + ) + continue + fm_level = registry[sub_id]["fm_level"] + required = required_by_level.get(fm_level, set()) if fm_level else set() + delivered_kinds, deliver_failures = _check_fulfillment_delivered( + repo_root, sub_id, entry.get("delivered_artifacts"), valid_artifact_kinds + ) + failures.extend(deliver_failures) + waived_kinds, waiver_failures = _check_fulfillment_waived( + sub_id, entry.get("waived_artifacts"), valid_artifact_kinds + ) + failures.extend(waiver_failures) + missing_required = sorted(required - delivered_kinds - waived_kinds) + if missing_required: + failures.append( + _fail( + "assurance-fulfillment-artifacts", + f"{sub_id} ({fm_level}) must deliver or waive required artifact kind(s): {missing_required}", + path, + ) + ) + + # --- every registry subsystem must have a fulfillment entry --- + for sub_id in registry: + if sub_id not in entry_subsystems: + failures.append( + _fail( + "assurance-fulfillment-entry-missing", + f"classified subsystem {sub_id!r} is in the registry but has no fulfillment entry", + path, + ) + ) + + return failures + + def _check_artifact_keyword_drift( repo_root: Path, doc_rel: str, @@ -1159,6 +1572,7 @@ def evaluate_assurance_policy(repo_root: Path) -> list[PolicyFailure]: failures.extend(_check_adr_template_classification(repo_root)) failures.extend(_check_new_adr_classifications(repo_root, _level_ids(levels))) failures.extend(_check_fm_classification_ledger(repo_root, levels)) + failures.extend(_check_assurance_fulfillment(repo_root, levels)) return failures From 798566e2ce9b702daa8996b606adce6d7b14cdf1 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 13 Jun 2026 18:55:27 +0200 Subject: [PATCH 27/70] Add participant semantics invariant oracle --- changelog.d/487.added.md | 1 + .../reference/shared-semantic-integrity.md | 12 +- ..._participant_semantics_invariant_oracle.py | 850 ++++++++++++++++++ specs/formal/participant-semantics/README.md | 66 +- 4 files changed, 917 insertions(+), 12 deletions(-) create mode 100644 changelog.d/487.added.md create mode 100644 implementations/python/tests/test_participant_semantics_invariant_oracle.py diff --git a/changelog.d/487.added.md b/changelog.d/487.added.md new file mode 100644 index 000000000..29ca6f392 --- /dev/null +++ b/changelog.d/487.added.md @@ -0,0 +1 @@ +Added an executable participant-semantics invariant oracle covering I1-I18 with property-based valid progressions and targeted rejecting mutations. diff --git a/docs/explain/reference/shared-semantic-integrity.md b/docs/explain/reference/shared-semantic-integrity.md index 6f2ef11be..eaa433c69 100644 --- a/docs/explain/reference/shared-semantic-integrity.md +++ b/docs/explain/reference/shared-semantic-integrity.md @@ -234,13 +234,13 @@ so they are tracked by their own requirements, not here. | Concept authority, controlled vocabularies, reference models, and semantic profiles (meta-layer) | GOV-920 | authoring, validation, compilation, planning, execution | `specs/concept-authority/concept-authority.md`, `specs/concept-authority/semantic-profiles.md`, `implementations/python/packages/aces_contracts/semantic_profiles.py`, `implementations/python/packages/aces_contracts/controlled_vocabularies.py`, `implementations/python/packages/aces_contracts/reference_models.py`, `docs/explain/reference/shared-concept-model.md`, `implementations/python/tests/test_concept_authority.py`, `implementations/python/tests/test_semantic_profiles.py` | active | | Participant episode lifecycle boundaries (initialization, reset, completion, timeout, truncation, interruption) | RUN-311, SEM-222 | execution, observation | `docs/decisions/adrs/adr-013-participant-episode-lifecycle-boundaries.md`, `implementations/python/tests/test_run_311_participant_episode_lifecycle.py` | partial | | Declarative participant framing (identity, role, starting conditions, authority anchors, operating scope) | ACT-601 | authoring, validation | `implementations/python/packages/aces_sdl/agents.py`, `implementations/python/packages/aces_sdl/validator.py`, `docs/decisions/adrs/adr-020-declarative-participant-framing-boundaries.md`, `implementations/python/tests/test_sdl_models.py`, `implementations/python/tests/test_sdl_validator.py` | active | -| Participant behavior semantics (actions, observations, state transitions) | ACT-602, SEM-208 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/tests/test_sem_208_participant_behavior.py` | partial | -| Multi-participant interaction and participant-local histories | SEM-209, SEM-226 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_conformance/conformance.py`, `implementations/python/tests/test_sem_208_participant_behavior.py`, `implementations/python/tests/test_runtime_conformance.py` | partial | -| Visibility and information-boundary semantics | SEM-210 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/tests/test_sem_208_participant_behavior.py` | active | -| Participant preconditions, effects, failure, causality, and attribution semantics | SEM-211, SEM-212 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_action_semantics.py`, `implementations/python/packages/aces_sdl/participant_attribution_semantics.py`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_conformance/conformance.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/tests/test_sem_211_participant_action_semantics.py`, `implementations/python/tests/test_sem_212_participant_attribution_semantics.py`, `implementations/python/tests/test_runtime_conformance.py` | partial | -| Participant temporal, tool/affordance, and decision-surface semantics | SEM-213, SEM-219, SEM-220 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md` | partial | +| Participant behavior semantics (actions, observations, state transitions) | ACT-602, SEM-208 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/tests/test_sem_208_participant_behavior.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | +| Multi-participant interaction and participant-local histories | SEM-209, SEM-226 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_conformance/conformance.py`, `implementations/python/tests/test_sem_208_participant_behavior.py`, `implementations/python/tests/test_runtime_conformance.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | +| Visibility and information-boundary semantics | SEM-210 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/tests/test_sem_208_participant_behavior.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | active | +| Participant preconditions, effects, failure, causality, and attribution semantics | SEM-211, SEM-212 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_action_semantics.py`, `implementations/python/packages/aces_sdl/participant_attribution_semantics.py`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_conformance/conformance.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/tests/test_sem_211_participant_action_semantics.py`, `implementations/python/tests/test_sem_212_participant_attribution_semantics.py`, `implementations/python/tests/test_runtime_conformance.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | +| Participant temporal, tool/affordance, and decision-surface semantics | SEM-213, SEM-219, SEM-220 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | | Participant reference trajectories, demonstrations, budgets, and quota/exhaustion semantics | SEM-221, SEM-223 | — | — | planned | -| Participant outcome interpretation | SEM-215 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_outcome_semantics.py`, `implementations/python/packages/aces_sdl/semantics/participant_outcome.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/tests/test_sem_215_participant_outcome_interpretation.py` | active | +| Participant outcome interpretation | SEM-215 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_outcome_semantics.py`, `implementations/python/packages/aces_sdl/semantics/participant_outcome.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/tests/test_sem_215_participant_outcome_interpretation.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | active | | Derived operational context views (portable meaning and comparability) | SEM-214 | — | — | planned | | Evidence, evaluation, view-boundary, and observability-plane semantics | SEM-216, SEM-224, SEM-225 | — | — | planned | | External knowledge bindings semantics | SEM-217 | — | — | planned | diff --git a/implementations/python/tests/test_participant_semantics_invariant_oracle.py b/implementations/python/tests/test_participant_semantics_invariant_oracle.py new file mode 100644 index 000000000..26635a167 --- /dev/null +++ b/implementations/python/tests/test_participant_semantics_invariant_oracle.py @@ -0,0 +1,850 @@ +"""Executable invariant oracle for the participant-semantics abstract model. + +The oracle intentionally stays test-local. It executes the published invariants +from ``specs/formal/participant-semantics/README.md`` without introducing a +runtime participant-semantics subsystem. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, replace +from pathlib import Path +from typing import TypeVar + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +SPEC_PATH = Path(__file__).resolve().parents[3] / "specs/formal/participant-semantics/README.md" + +IMPLEMENTATION_TYPES = ( + "human", + "ai_agent", + "script", + "playbook", + "simulated_actor", + "human_control_proxy", +) +FAIL_CLOSED_CLASSES = frozenset({"rejected", "withheld", "unknown", "unsafe_withheld"}) +INTERACTION_CLASSES = frozenset({"coordination", "contention", "interference", "shared_state_change"}) +ORDERING_BASES = frozenset({"happened_before", "workflow", "episode", "backend_event_order"}) +ATTRIBUTION_SUPPORT = frozenset( + { + "declared_association", + "temporal_support", + "contract_support", + "observation_support", + "counterfactual_support", + "intervention_support", + } +) +MAPPING_RELATIONS = frozenset({"exact", "narrower", "broader", "approximate", "lossy", "advisory"}) +PRESERVATION_STATES = frozenset({"preserved", "weakened", "simulated", "unavailable"}) +_T = TypeVar("_T") + + +@dataclass(frozen=True) +class Participant: + participant_id: str + implementation_type: str + semantic_profile: str + + +@dataclass(frozen=True) +class PreconditionResult: + precondition_id: str + resolved: bool + satisfied: bool + + +@dataclass(frozen=True) +class ActionAttempt: + action_id: str + participant_id: str + contract_id: str + preconditions: tuple[PreconditionResult, ...] + executed: bool + failure_class: str | None + declared_effect_classes: frozenset[str] + actual_effect_classes: frozenset[str] + declared_side_effect_classes: frozenset[str] + actual_side_effect_classes: frozenset[str] + interaction_classes: frozenset[str] + provenance_interactions: frozenset[str] + time_domain: str + clock_authority: str + + +@dataclass(frozen=True) +class ObservationApparatus: + capture_basis: str + capture_granularity: str + loss_model: str + redaction_policy: str + observer_effects: str + + +@dataclass(frozen=True) +class Observation: + observation_id: str + participant_id: str + source: str + capture_basis: str + visibility_basis: str + latency_domain: str + certainty: str + loss_disclosure: str + evidence_relationship: str + evidence_refs: frozenset[str] + visible_refs: frozenset[str] + disclosure_rule_refs: frozenset[str] + apparatus: ObservationApparatus + inferred_from_archival_evidence: bool + explicit_view_rule: bool + + +@dataclass(frozen=True) +class AttributionEdge: + edge_id: str + cause_action_id: str + effect_ref: str + ordering_basis: str + evidence_strength: str + evidence_refs: frozenset[str] + + +@dataclass(frozen=True) +class OutcomeRecord: + action_status: str + episode_terminal_reason: str + objective_success: bool + workflow_state: str + evaluation_result: str + reward: int + interpretation_rules: frozenset[str] + collapsed_layers: frozenset[tuple[str, str]] + + +@dataclass(frozen=True) +class BackendRealization: + declared_guarantees: frozenset[str] + unrealized_guarantees: frozenset[str] + disclosed_weakened_guarantees: frozenset[str] + capability_validation_failed: bool + + +@dataclass(frozen=True) +class FidelityClaim: + semantic_portability_claimed: bool + fidelity_equivalence_claimed: bool + portability_claim_implies_fidelity: bool + preservation_profile: dict[str, str] + + +@dataclass(frozen=True) +class ExternalMapping: + vocabulary: str + relation: str | None + + +@dataclass(frozen=True) +class RunStudyProvenance: + repeated_run_claim: bool + scenario_version: str + action_contract_versions: frozenset[str] + participant_implementation_version: str + backend_version: str + reset_strategy: str + random_seed: str + scaffold_disclosure: str + environment_fingerprints: frozenset[str] + + +@dataclass(frozen=True) +class VersionedContent: + content_id: str + source: str + semantic_version: str + freshness_basis: str + lifecycle_state: str + + +@dataclass(frozen=True) +class BoundaryObject: + object_id: str + object_class: str + exposure_recorded: bool + exposed_to_participant: bool + + +@dataclass(frozen=True) +class LanguageEvaluation: + concrete_syntax_declared: bool + ambiguity_review: bool + maintainability_review: bool + domain_expert_review: bool + consistency_review: bool + + +@dataclass(frozen=True) +class ParticipantProgression: + episode_id: str + participants: tuple[Participant, ...] + actions: tuple[ActionAttempt, ...] + observations: tuple[Observation, ...] + attribution_edges: tuple[AttributionEdge, ...] + outcomes: tuple[OutcomeRecord, ...] + backend_realization: BackendRealization + fidelity_claim: FidelityClaim + external_mappings: tuple[ExternalMapping, ...] + provenance: RunStudyProvenance + versioned_content: tuple[VersionedContent, ...] + boundary_objects: tuple[BoundaryObject, ...] + language_evaluation: LanguageEvaluation + hidden_truth_refs: frozenset[str] + explicit_disclosure_refs: frozenset[str] + + +@dataclass(frozen=True) +class Invariant: + invariant_id: str + spec_section: str + sem_refs: tuple[str, ...] + predicate: Callable[[ParticipantProgression], bool] + mutate: Callable[[ParticipantProgression], ParticipantProgression] + + +def _replace_tuple_item(items: tuple[_T, ...], index: int, value: _T) -> tuple[_T, ...]: + return items[:index] + (value,) + items[index + 1 :] + + +def _replace_first_participant(state: ParticipantProgression, **changes: object) -> ParticipantProgression: + participant = replace(state.participants[0], **changes) + return replace(state, participants=_replace_tuple_item(state.participants, 0, participant)) + + +def _replace_first_action(state: ParticipantProgression, **changes: object) -> ParticipantProgression: + action = replace(state.actions[0], **changes) + return replace(state, actions=_replace_tuple_item(state.actions, 0, action)) + + +def _replace_first_observation(state: ParticipantProgression, **changes: object) -> ParticipantProgression: + observation = replace(state.observations[0], **changes) + return replace(state, observations=_replace_tuple_item(state.observations, 0, observation)) + + +def _replace_first_attribution(state: ParticipantProgression, **changes: object) -> ParticipantProgression: + edge = replace(state.attribution_edges[0], **changes) + return replace(state, attribution_edges=_replace_tuple_item(state.attribution_edges, 0, edge)) + + +def _replace_first_outcome(state: ParticipantProgression, **changes: object) -> ParticipantProgression: + outcome = replace(state.outcomes[0], **changes) + return replace(state, outcomes=_replace_tuple_item(state.outcomes, 0, outcome)) + + +def _replace_first_mapping(state: ParticipantProgression, **changes: object) -> ParticipantProgression: + mapping = replace(state.external_mappings[0], **changes) + return replace(state, external_mappings=_replace_tuple_item(state.external_mappings, 0, mapping)) + + +def _replace_first_content(state: ParticipantProgression, **changes: object) -> ParticipantProgression: + content = replace(state.versioned_content[0], **changes) + return replace(state, versioned_content=_replace_tuple_item(state.versioned_content, 0, content)) + + +def _replace_first_boundary_object(state: ParticipantProgression, **changes: object) -> ParticipantProgression: + boundary_object = replace(state.boundary_objects[0], **changes) + return replace(state, boundary_objects=_replace_tuple_item(state.boundary_objects, 0, boundary_object)) + + +def canonical_progression() -> ParticipantProgression: + apparatus = ObservationApparatus( + capture_basis="sensor-stream", + capture_granularity="event", + loss_model="bounded-loss-disclosed", + redaction_policy="participant-view-redaction", + observer_effects="passive-capture", + ) + return ParticipantProgression( + episode_id="episode-reference", + participants=( + Participant("participant-red", "human", "participant-semantics-v1"), + Participant("participant-blue", "ai_agent", "participant-semantics-v1"), + ), + actions=( + ActionAttempt( + action_id="action-1", + participant_id="participant-red", + contract_id="contract.scan.v1", + preconditions=( + PreconditionResult("authority", True, True), + PreconditionResult("knowledge", True, True), + PreconditionResult("temporal", True, True), + ), + executed=True, + failure_class=None, + declared_effect_classes=frozenset({"intended_effect", "evidence_effect"}), + actual_effect_classes=frozenset({"intended_effect", "evidence_effect"}), + declared_side_effect_classes=frozenset({"detection_effect", "visibility_effect"}), + actual_side_effect_classes=frozenset({"detection_effect"}), + interaction_classes=frozenset({"coordination"}), + provenance_interactions=frozenset({"joint-action-set-1"}), + time_domain="episode_step", + clock_authority="scenario-clock", + ), + ), + observations=( + Observation( + observation_id="observation-1", + participant_id="participant-red", + source="participant-terminal", + capture_basis="sensor-stream", + visibility_basis="view-rule-visible-host", + latency_domain="episode_step", + certainty="high", + loss_disclosure="bounded-loss-disclosed", + evidence_relationship="supports-observed-service", + evidence_refs=frozenset({"evidence.service-banner"}), + visible_refs=frozenset({"asset.public-host"}), + disclosure_rule_refs=frozenset(), + apparatus=apparatus, + inferred_from_archival_evidence=False, + explicit_view_rule=True, + ), + ), + attribution_edges=( + AttributionEdge( + edge_id="attr-1", + cause_action_id="action-1", + effect_ref="observation-1", + ordering_basis="happened_before", + evidence_strength="observation_support", + evidence_refs=frozenset({"evidence.service-banner"}), + ), + ), + outcomes=( + OutcomeRecord( + action_status="succeeded", + episode_terminal_reason="completed", + objective_success=True, + workflow_state="finished", + evaluation_result="passed", + reward=1, + interpretation_rules=frozenset({"rule.local-action-to-objective.v1"}), + collapsed_layers=frozenset(), + ), + ), + backend_realization=BackendRealization( + declared_guarantees=frozenset({"coordination", "visibility_boundary"}), + unrealized_guarantees=frozenset(), + disclosed_weakened_guarantees=frozenset(), + capability_validation_failed=False, + ), + fidelity_claim=FidelityClaim( + semantic_portability_claimed=True, + fidelity_equivalence_claimed=False, + portability_claim_implies_fidelity=False, + preservation_profile={ + "behavior": "preserved", + "observation": "preserved", + "timing": "weakened", + "failure": "preserved", + "evidence": "preserved", + }, + ), + external_mappings=( + ExternalMapping("ATT&CK", "approximate"), + ExternalMapping("OCSF", "narrower"), + ), + provenance=RunStudyProvenance( + repeated_run_claim=True, + scenario_version="scenario@1.0.0", + action_contract_versions=frozenset({"contract.scan.v1"}), + participant_implementation_version="participant-red@1.0.0", + backend_version="stub-backend@1.0.0", + reset_strategy="clean-snapshot", + random_seed="seed-487", + scaffold_disclosure="public-starter-files-only", + environment_fingerprints=frozenset({"container-sha256:abc123"}), + ), + versioned_content=( + VersionedContent( + content_id="contract.scan.v1", + source="scenario-contract-registry", + semantic_version="1.0.0", + freshness_basis="reviewed-2026-06", + lifecycle_state="active", + ), + ), + boundary_objects=( + BoundaryObject("answer-key", "hidden_truth", True, False), + BoundaryObject("canary-token", "canary", True, False), + BoundaryObject("starter-files", "public_starter_files", True, True), + ), + language_evaluation=LanguageEvaluation( + concrete_syntax_declared=True, + ambiguity_review=True, + maintainability_review=True, + domain_expert_review=True, + consistency_review=True, + ), + hidden_truth_refs=frozenset({"answer-key", "canary-token"}), + explicit_disclosure_refs=frozenset(), + ) + + +@st.composite +def participant_progressions(draw) -> ParticipantProgression: + state = canonical_progression() + participant_count = draw(st.integers(min_value=2, max_value=4)) + participants = tuple( + Participant( + participant_id=f"participant-{index}", + implementation_type=draw(st.sampled_from(IMPLEMENTATION_TYPES)), + semantic_profile="participant-semantics-v1", + ) + for index in range(participant_count) + ) + side_effects = frozenset( + draw( + st.lists( + st.sampled_from(["detection_effect", "visibility_effect", "evidence_effect"]), + min_size=1, + max_size=3, + unique=True, + ) + ) + ) + actual_side_effects = frozenset(draw(st.lists(st.sampled_from(sorted(side_effects)), min_size=1))) + action = replace( + state.actions[0], + participant_id=participants[0].participant_id, + declared_side_effect_classes=side_effects, + actual_side_effect_classes=actual_side_effects, + interaction_classes=frozenset( + draw(st.lists(st.sampled_from(sorted(INTERACTION_CLASSES)), min_size=1, unique=True)) + ), + time_domain=draw(st.sampled_from(["episode_step", "scenario_time", "simulation_time", "backend_time"])), + clock_authority=draw(st.sampled_from(["scenario-clock", "sim-clock", "backend-clock"])), + ) + observation = replace( + state.observations[0], + participant_id=participants[0].participant_id, + latency_domain=action.time_domain, + visible_refs=frozenset( + draw(st.lists(st.sampled_from(["asset.public-host", "asset.public-service"]), min_size=1)) + ), + ) + fidelity_claim = replace( + state.fidelity_claim, + semantic_portability_claimed=draw(st.booleans()), + fidelity_equivalence_claimed=draw(st.booleans()), + portability_claim_implies_fidelity=False, + ) + language_evaluation = replace(state.language_evaluation, concrete_syntax_declared=draw(st.booleans())) + return replace( + state, + participants=participants, + actions=(action,), + observations=(observation,), + fidelity_claim=fidelity_claim, + language_evaluation=language_evaluation, + ) + + +def _i1_role_neutral(state: ParticipantProgression) -> bool: + profiles = {participant.semantic_profile for participant in state.participants} + known_implementations = all( + participant.implementation_type in IMPLEMENTATION_TYPES for participant in state.participants + ) + return len(profiles) == 1 and known_implementations + + +def _i2_hidden_truth_boundary(state: ParticipantProgression) -> bool: + disclosed_hidden_refs = state.hidden_truth_refs & state.explicit_disclosure_refs + for observation in state.observations: + leaked_refs = observation.visible_refs & state.hidden_truth_refs + if leaked_refs - disclosed_hidden_refs: + return False + return True + + +def _i3_observation_projection(state: ParticipantProgression) -> bool: + required = ( + "source", + "capture_basis", + "visibility_basis", + "latency_domain", + "certainty", + "loss_disclosure", + "evidence_relationship", + ) + return all(all(getattr(observation, field_name) for field_name in required) for observation in state.observations) + + +def _i4_fail_closed_action_applicability(state: ParticipantProgression) -> bool: + for action in state.actions: + applicable = all(result.resolved and result.satisfied for result in action.preconditions) + if applicable: + continue + if action.executed or action.failure_class not in FAIL_CLOSED_CLASSES: + return False + return True + + +def _i5_explicit_side_effects(state: ParticipantProgression) -> bool: + return all( + action.actual_side_effect_classes <= action.declared_side_effect_classes + and action.actual_effect_classes <= action.declared_effect_classes + for action in state.actions + ) + + +def _i6_explicit_interaction_semantics(state: ParticipantProgression) -> bool: + for action in state.actions: + if not action.interaction_classes: + continue + if not action.interaction_classes <= INTERACTION_CLASSES or not action.provenance_interactions: + return False + return True + + +def _i7_temporal_domain_separation(state: ParticipantProgression) -> bool: + action_domains = {action.time_domain for action in state.actions} + observation_domains = {observation.latency_domain for observation in state.observations} + return all(action.time_domain and action.clock_authority for action in state.actions) and "" not in ( + action_domains | observation_domains + ) + + +def _i8_ordering_before_causality(state: ParticipantProgression) -> bool: + return all(edge.ordering_basis in ORDERING_BASES for edge in state.attribution_edges) + + +def _i9_evidence_labeled_attribution(state: ParticipantProgression) -> bool: + return all(edge.evidence_strength in ATTRIBUTION_SUPPORT and edge.evidence_refs for edge in state.attribution_edges) + + +def _i10_outcome_layer_separation(state: ParticipantProgression) -> bool: + return all(outcome.interpretation_rules and not outcome.collapsed_layers for outcome in state.outcomes) + + +def _i11_realization_disclosure(state: ParticipantProgression) -> bool: + unrealized = state.backend_realization.unrealized_guarantees + return ( + not unrealized + or state.backend_realization.capability_validation_failed + or unrealized <= (state.backend_realization.disclosed_weakened_guarantees) + ) + + +def _i12_fidelity_claim_separation(state: ParticipantProgression) -> bool: + expected_aspects = {"behavior", "observation", "timing", "failure", "evidence"} + profile = state.fidelity_claim.preservation_profile + return ( + not state.fidelity_claim.portability_claim_implies_fidelity + and set(profile) == expected_aspects + and all(value in PRESERVATION_STATES for value in profile.values()) + ) + + +def _i13_observation_apparatus_disclosure(state: ParticipantProgression) -> bool: + for observation in state.observations: + apparatus = observation.apparatus + disclosed = ( + apparatus.capture_basis, + apparatus.capture_granularity, + apparatus.loss_model, + apparatus.redaction_policy, + apparatus.observer_effects, + ) + if not all(disclosed): + return False + if observation.inferred_from_archival_evidence and not observation.explicit_view_rule: + return False + return True + + +def _i14_external_mapping_loss_labels(state: ParticipantProgression) -> bool: + return all(mapping.relation in MAPPING_RELATIONS for mapping in state.external_mappings) + + +def _i15_run_and_study_provenance(state: ParticipantProgression) -> bool: + if not state.provenance.repeated_run_claim: + return True + provenance = state.provenance + return all( + ( + provenance.scenario_version, + provenance.action_contract_versions, + provenance.participant_implementation_version, + provenance.backend_version, + provenance.reset_strategy, + provenance.random_seed, + provenance.scaffold_disclosure, + provenance.environment_fingerprints, + ) + ) + + +def _i16_content_and_contract_lifecycle(state: ParticipantProgression) -> bool: + return all( + content.source and content.semantic_version and content.freshness_basis and content.lifecycle_state + for content in state.versioned_content + ) + + +def _i17_benchmark_leakage_and_holdout_discipline(state: ParticipantProgression) -> bool: + boundary_ids = {boundary.object_id for boundary in state.boundary_objects} + if not state.hidden_truth_refs <= boundary_ids: + return False + protected_classes = {"hidden_truth", "canary", "private_reference", "holdout_variant"} + for boundary in state.boundary_objects: + if not boundary.exposure_recorded: + return False + protected = boundary.object_id in state.hidden_truth_refs or boundary.object_class in protected_classes + if protected and boundary.exposed_to_participant and boundary.object_id not in state.explicit_disclosure_refs: + return False + return True + + +def _i18_language_evaluation_obligation(state: ParticipantProgression) -> bool: + if not state.language_evaluation.concrete_syntax_declared: + return True + return all( + ( + state.language_evaluation.ambiguity_review, + state.language_evaluation.maintainability_review, + state.language_evaluation.domain_expert_review, + state.language_evaluation.consistency_review, + ) + ) + + +def _mutate_i1(state: ParticipantProgression) -> ParticipantProgression: + return _replace_first_participant(state, semantic_profile="backend-local-human-only") + + +def _mutate_i2(state: ParticipantProgression) -> ParticipantProgression: + observation = state.observations[0] + return _replace_first_observation(state, visible_refs=observation.visible_refs | frozenset({"answer-key"})) + + +def _mutate_i3(state: ParticipantProgression) -> ParticipantProgression: + return _replace_first_observation(state, source="") + + +def _mutate_i4(state: ParticipantProgression) -> ParticipantProgression: + preconditions = (PreconditionResult("authority", resolved=False, satisfied=False),) + return _replace_first_action(state, preconditions=preconditions, executed=True, failure_class=None) + + +def _mutate_i5(state: ParticipantProgression) -> ParticipantProgression: + action = state.actions[0] + return _replace_first_action( + state, + actual_side_effect_classes=action.actual_side_effect_classes | frozenset({"telemetry_surface_change"}), + ) + + +def _mutate_i6(state: ParticipantProgression) -> ParticipantProgression: + return _replace_first_action( + state, interaction_classes=frozenset({"contention"}), provenance_interactions=frozenset() + ) + + +def _mutate_i7(state: ParticipantProgression) -> ParticipantProgression: + return _replace_first_action(state, time_domain="", clock_authority="") + + +def _mutate_i8(state: ParticipantProgression) -> ParticipantProgression: + return _replace_first_attribution(state, ordering_basis="timestamp_only") + + +def _mutate_i9(state: ParticipantProgression) -> ParticipantProgression: + return _replace_first_attribution(state, evidence_strength="", evidence_refs=frozenset()) + + +def _mutate_i10(state: ParticipantProgression) -> ParticipantProgression: + return _replace_first_outcome( + state, + interpretation_rules=frozenset(), + collapsed_layers=frozenset({("action_status", "objective_success")}), + ) + + +def _mutate_i11(state: ParticipantProgression) -> ParticipantProgression: + realization = replace( + state.backend_realization, + unrealized_guarantees=frozenset({"simultaneity"}), + disclosed_weakened_guarantees=frozenset(), + capability_validation_failed=False, + ) + return replace(state, backend_realization=realization) + + +def _mutate_i12(state: ParticipantProgression) -> ParticipantProgression: + fidelity_claim = replace( + state.fidelity_claim, + semantic_portability_claimed=True, + fidelity_equivalence_claimed=True, + portability_claim_implies_fidelity=True, + preservation_profile={"behavior": "preserved"}, + ) + return replace(state, fidelity_claim=fidelity_claim) + + +def _mutate_i13(state: ParticipantProgression) -> ParticipantProgression: + apparatus = replace(state.observations[0].apparatus, loss_model="") + return _replace_first_observation( + state, + apparatus=apparatus, + inferred_from_archival_evidence=True, + explicit_view_rule=False, + ) + + +def _mutate_i14(state: ParticipantProgression) -> ParticipantProgression: + return _replace_first_mapping(state, relation=None) + + +def _mutate_i15(state: ParticipantProgression) -> ParticipantProgression: + provenance = replace(state.provenance, random_seed="", reset_strategy="") + return replace(state, provenance=provenance) + + +def _mutate_i16(state: ParticipantProgression) -> ParticipantProgression: + return _replace_first_content(state, semantic_version="", lifecycle_state="") + + +def _mutate_i17(state: ParticipantProgression) -> ParticipantProgression: + return _replace_first_boundary_object(state, exposed_to_participant=True) + + +def _mutate_i18(state: ParticipantProgression) -> ParticipantProgression: + language_evaluation = replace( + state.language_evaluation, + concrete_syntax_declared=True, + domain_expert_review=False, + ) + return replace(state, language_evaluation=language_evaluation) + + +INVARIANTS = ( + Invariant("I1", "### I1 - Role-Neutral Participant Semantics", ("SEM-208",), _i1_role_neutral, _mutate_i1), + Invariant("I2", "### I2 - Hidden Truth Boundary", ("SEM-210",), _i2_hidden_truth_boundary, _mutate_i2), + Invariant("I3", "### I3 - Observation Projection", ("SEM-208", "SEM-210"), _i3_observation_projection, _mutate_i3), + Invariant( + "I4", + "### I4 - Fail-Closed Action Applicability", + ("SEM-211",), + _i4_fail_closed_action_applicability, + _mutate_i4, + ), + Invariant("I5", "### I5 - Explicit Side Effects", ("SEM-211",), _i5_explicit_side_effects, _mutate_i5), + Invariant( + "I6", "### I6 - Explicit Interaction Semantics", ("SEM-209",), _i6_explicit_interaction_semantics, _mutate_i6 + ), + Invariant("I7", "### I7 - Temporal Domain Separation", ("SEM-213",), _i7_temporal_domain_separation, _mutate_i7), + Invariant( + "I8", "### I8 - Ordering Before Causality", ("SEM-212", "SEM-213"), _i8_ordering_before_causality, _mutate_i8 + ), + Invariant( + "I9", "### I9 - Evidence-Labeled Attribution", ("SEM-212",), _i9_evidence_labeled_attribution, _mutate_i9 + ), + Invariant("I10", "### I10 - Outcome-Layer Separation", ("SEM-215",), _i10_outcome_layer_separation, _mutate_i10), + Invariant( + "I11", "### I11 - Realization Disclosure", ("SEM-208", "SEM-209"), _i11_realization_disclosure, _mutate_i11 + ), + Invariant( + "I12", + "### I12 - Fidelity Claim Separation", + ("SEM-208", "SEM-215"), + _i12_fidelity_claim_separation, + _mutate_i12, + ), + Invariant( + "I13", + "### I13 - Observation Apparatus Disclosure", + ("SEM-210",), + _i13_observation_apparatus_disclosure, + _mutate_i13, + ), + Invariant( + "I14", "### I14 - External Mapping Loss Labels", ("SEM-208",), _i14_external_mapping_loss_labels, _mutate_i14 + ), + Invariant("I15", "### I15 - Run And Study Provenance", ("SEM-215",), _i15_run_and_study_provenance, _mutate_i15), + Invariant( + "I16", + "### I16 - Content And Contract Lifecycle", + ("SEM-208",), + _i16_content_and_contract_lifecycle, + _mutate_i16, + ), + Invariant( + "I17", + "### I17 - Benchmark Leakage And Holdout Discipline", + ("SEM-210", "SEM-215"), + _i17_benchmark_leakage_and_holdout_discipline, + _mutate_i17, + ), + Invariant( + "I18", + "### I18 - Language Evaluation Obligation", + ("SEM-208",), + _i18_language_evaluation_obligation, + _mutate_i18, + ), +) + + +def test_invariant_catalog_covers_i1_through_i18() -> None: + assert [invariant.invariant_id for invariant in INVARIANTS] == [f"I{index}" for index in range(1, 19)] + + +def test_oracle_and_spec_headings_map_both_directions() -> None: + spec_text = SPEC_PATH.read_text(encoding="utf-8") + spec_headings = { + line.split(" - ", 1)[0].removeprefix("### ") + for line in spec_text.splitlines() + if line.startswith("### I") and " - " in line + } + + assert spec_headings == {invariant.invariant_id for invariant in INVARIANTS} + for invariant in INVARIANTS: + assert invariant.spec_section in spec_text + assert invariant.sem_refs + + +@pytest.mark.parametrize("invariant", INVARIANTS, ids=lambda invariant: invariant.invariant_id) +def test_each_invariant_rejects_its_targeted_mutation(invariant: Invariant) -> None: + base_state = canonical_progression() + mutated = invariant.mutate(base_state) + + assert not invariant.predicate(mutated), invariant.invariant_id + + +def test_canonical_progression_satisfies_all_invariants() -> None: + state = canonical_progression() + + for invariant in INVARIANTS: + assert invariant.predicate(state), invariant.invariant_id + + +@settings(max_examples=50, deadline=None) +@given(state=participant_progressions()) +def test_generated_valid_progressions_satisfy_all_invariants(state: ParticipantProgression) -> None: + for invariant in INVARIANTS: + assert invariant.predicate(state), invariant.invariant_id + + +@settings(max_examples=25, deadline=None) +@given(base_state=participant_progressions()) +@pytest.mark.parametrize("invariant", INVARIANTS, ids=lambda invariant: invariant.invariant_id) +def test_generated_targeted_mutations_are_rejected( + invariant: Invariant, + base_state: ParticipantProgression, +) -> None: + assert not invariant.predicate(invariant.mutate(base_state)), invariant.invariant_id diff --git a/specs/formal/participant-semantics/README.md b/specs/formal/participant-semantics/README.md index 4f39d55e4..9a8a0d8a2 100644 --- a/specs/formal/participant-semantics/README.md +++ b/specs/formal/participant-semantics/README.md @@ -48,8 +48,13 @@ What is missing: - no participant-local outcome interpretation layer relating action/episode outcomes to objectives, workflows, evaluations, rewards, and evidence -The repository is therefore correctly at `partial` design coverage after this -artifact, and not at implementation coverage. +The repository therefore remains at `partial` participant-semantics coverage +where runtime implementation slices are incomplete. Issue #487 adds +`implementations/python/tests/test_participant_semantics_invariant_oracle.py` +as the executable FM-2 assurance artifact for the abstract model invariants +`I1` through `I18`: it is implementation evidence for the published invariant +oracle, not a claim that every staged SEM-208 through SEM-215 runtime contract +is complete. ## Primary-Source Review @@ -634,6 +639,47 @@ examples, negative fixtures, and compiled contracts. Issue #346 tracks this as a dedicated DSL language-evaluation evidence gate; this document only records the participant-semantics obligation. +## Executable Invariant Oracle + +`implementations/python/tests/test_participant_semantics_invariant_oracle.py` +is the executable oracle for the abstract model above. The oracle defines a +test-local `ParticipantProgression` model for episode, action, observation, +attribution, outcome, realization, provenance, lifecycle, mapping-loss, and +language-evaluation surfaces. Its `INVARIANTS` catalog maps each spec invariant +to a stable heading in this document and to the predicate that executes it. + +The mapping is checked in both directions: + +- the test suite extracts this README's `### I*` headings and requires them to + match the oracle catalog exactly; +- every catalog entry stores the corresponding spec heading and requirement + slice references; +- Hypothesis-generated valid progressions must satisfy every cataloged + invariant; +- each invariant has a targeted mutation factory that produces at least one + rejected counterexample. + +| Spec invariant | Oracle predicate | +| --- | --- | +| `I1` | `_i1_role_neutral` | +| `I2` | `_i2_hidden_truth_boundary` | +| `I3` | `_i3_observation_projection` | +| `I4` | `_i4_fail_closed_action_applicability` | +| `I5` | `_i5_explicit_side_effects` | +| `I6` | `_i6_explicit_interaction_semantics` | +| `I7` | `_i7_temporal_domain_separation` | +| `I8` | `_i8_ordering_before_causality` | +| `I9` | `_i9_evidence_labeled_attribution` | +| `I10` | `_i10_outcome_layer_separation` | +| `I11` | `_i11_realization_disclosure` | +| `I12` | `_i12_fidelity_claim_separation` | +| `I13` | `_i13_observation_apparatus_disclosure` | +| `I14` | `_i14_external_mapping_loss_labels` | +| `I15` | `_i15_run_and_study_provenance` | +| `I16` | `_i16_content_and_contract_lifecycle` | +| `I17` | `_i17_benchmark_leakage_and_holdout_discipline` | +| `I18` | `_i18_language_evaluation_obligation` | + ## SEM-208 - Participant Behavior Semantics `SEM-208` requires explicit semantics for participant actions, observations, @@ -1018,13 +1064,21 @@ Implementation artifacts: The complete participant surface is `FM3`. -Future implementation PRs should include: +Delivered executable assurance artifact: + +- `implementations/python/tests/test_participant_semantics_invariant_oracle.py` + provides the FM-2 invariant oracle for `I1` through `I18`, including + property-based valid episode/action/observation/outcome progressions and + invariant-specific rejecting mutations. + +Future implementation PRs should still include: -- invariant lists for each child UID; +- child UID invariant refinements that specialize the abstract `I1` through + `I18` oracle for concrete runtime slices; - typed IR or published contracts for actions, observations, visibility, attribution, temporal clauses, and outcomes; -- abstract state-machine coverage for episode/action/observation/outcome - progression; +- runtime-integrated abstract state-machine coverage for + episode/action/observation/outcome progression; - machine-checkable action, observation, visibility, failure, temporal, attribution, and outcome semantics; prose-only definitions are insufficient for conformance; From c65775f4b05238e5c1f930f914f9a68130c108cc Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 13 Jun 2026 19:11:44 +0200 Subject: [PATCH 28/70] Add participant runtime invariant oracle --- changelog.d/486.changed.md | 1 + .../reference/fm-classification-ledger.yaml | 5 +- .../test_participant_runtime_invariants.py | 1013 +++++++++++++++++ specs/formal/participant-runtime/README.md | 34 +- 4 files changed, 1049 insertions(+), 4 deletions(-) create mode 100644 changelog.d/486.changed.md create mode 100644 implementations/python/tests/test_participant_runtime_invariants.py diff --git a/changelog.d/486.changed.md b/changelog.d/486.changed.md new file mode 100644 index 000000000..eb72868ba --- /dev/null +++ b/changelog.d/486.changed.md @@ -0,0 +1 @@ +Added executable participant-runtime invariant oracle evidence for ADR-054 / ASR-505. diff --git a/docs/explain/reference/fm-classification-ledger.yaml b/docs/explain/reference/fm-classification-ledger.yaml index fd8bce2a9..7a9758e4b 100644 --- a/docs/explain/reference/fm-classification-ledger.yaml +++ b/docs/explain/reference/fm-classification-ledger.yaml @@ -386,11 +386,10 @@ entries: path: implementations/python/tests/test_runtime_control_plane.py - kind: typed_ir_or_contract_coverage path: implementations/python/tests/test_runtime_contracts.py - waived_artifacts: - kind: property_based_or_differential_tests - rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + path: implementations/python/tests/test_participant_runtime_invariants.py - kind: abstract_state_machine_model - rationale: Historical backfill records current evidence; no abstract state-machine artifact was recorded for this accepted ADR. + path: implementations/python/tests/test_participant_runtime_invariants.py - adr: ADR-055 surface: Experiment core contract boundary fm_level: FM2 diff --git a/implementations/python/tests/test_participant_runtime_invariants.py b/implementations/python/tests/test_participant_runtime_invariants.py new file mode 100644 index 000000000..cef11ae61 --- /dev/null +++ b/implementations/python/tests/test_participant_runtime_invariants.py @@ -0,0 +1,1013 @@ +"""Executable oracle for participant-runtime trace predicates. + +Spec mapping: +- `ValidTrace`, `MonotoneSequence`, `RevisionDiscipline`, and + `OrderDiscipline`: specs/formal/participant-runtime/README.md:1265 +- `ConflictOK` and `TimeManagementOK`: + specs/formal/participant-runtime/README.md:2417 +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Literal + +from hypothesis import given, settings +from hypothesis import strategies as st + +RevisionSupport = Literal["known", "unknown", "unsupported"] +OrderStrength = Literal["display", "causal", "simultaneous", "serializable", "time-management"] +OrderBasis = Literal["wall-clock", "logical-clock", "happens-before", "scheduler", "backend-serialized"] +ConflictClass = Literal["none", "read-write", "write-write"] +ConflictPolicy = Literal["none", "serialize", "retry", "unsupported"] +IsolationClaim = Literal["none", "serializable"] +AtomicityScope = Literal["single-object", "multi-object"] +TimeMode = Literal["display", "pacing", "lookahead", "rollback", "devs", "fmi", "backend-serialized"] +TimeClaimStrength = Literal["display", "bounded", "exact"] + +PROPERTY_SETTINGS = settings(max_examples=40, deadline=None) +REVISION_SUPPORT_VARIANTS: tuple[RevisionSupport, ...] = ("known", "unknown", "unsupported") +CONFLICT_VARIANTS: tuple[str, ...] = ( + "none", + "read_write_serialize", + "write_write_serialize", + "write_write_retry", + "unsupported_disclosed", +) +TIME_CONTEXT_VARIANTS: tuple[str, ...] = ( + "display", + "backend_serialized", + "lookahead", + "pacing", + "rollback", + "devs", + "fmi", + "unsupported_disclosed", +) +INVALID_REVISION_VARIANTS: tuple[str, ...] = ( + "unknown_cites_prior", + "unsupported_without_disclosure", + "unsupported_produces_revision", +) +INVALID_CONFLICT_VARIANTS: tuple[str, ...] = ( + "retry_without_limit", + "unsupported_exact_overclaim", + "serializable_without_order", +) +INVALID_TIME_VARIANTS: tuple[str, ...] = ( + "lookahead_without_lookahead", + "rollback_without_lineage", + "unsupported_exact_overclaim", +) + + +@dataclass(frozen=True) +class StateRevision: + state_address: str + revision: int + + +@dataclass(frozen=True) +class StateWrite: + state_address: str + prior_revision: int | None + new_revision: int | None + new_digest: str | None + support: RevisionSupport = "known" + unsupported_update: bool = False + + +@dataclass(frozen=True) +class OrderClaim: + strength: OrderStrength + basis: OrderBasis | None + + +ORDER_CLAIM_VARIANTS: tuple[OrderClaim, ...] = ( + OrderClaim(strength="display", basis="wall-clock"), + OrderClaim(strength="causal", basis="logical-clock"), + OrderClaim(strength="simultaneous", basis="scheduler"), + OrderClaim(strength="serializable", basis="backend-serialized"), + OrderClaim(strength="time-management", basis="logical-clock"), +) + + +@dataclass(frozen=True) +class ParticipantEvent: + event_id: str + participant_address: str | None + episode_id: str | None + sequence_number: int | None + state_writes: tuple[StateWrite, ...] = () + order_claims: tuple[OrderClaim, ...] = () + + +@dataclass(frozen=True) +class AccessSet: + event_id: str + read_addresses: tuple[str, ...] = () + write_addresses: tuple[str, ...] = () + + +@dataclass(frozen=True) +class JointActionRecord: + joint_action_id: str + member_event_ids: tuple[str, ...] + access_sets: tuple[AccessSet, ...] + conflict_class: ConflictClass + conflict_policy: ConflictPolicy + isolation: IsolationClaim + atomicity_scope: AtomicityScope + realized_order: tuple[str, ...] = () + rollback_event_ids: tuple[str, ...] = () + retry_limit: int | None = None + unsupported_disclosure: bool = False + exact_concurrency_claim: bool = False + + +@dataclass(frozen=True) +class TimeManagementContext: + context_id: str + mode: TimeMode + claim_strength: TimeClaimStrength + basis: OrderBasis | None + clock_ref: str | None + lookahead: int | None = None + advance_by: int | None = None + rollback_event_ids: tuple[str, ...] = () + unsupported_disclosure: bool = False + backend_serialized: bool = False + + +@dataclass(frozen=True) +class RuntimeTrace: + initial_revisions: tuple[StateRevision, ...] + events: tuple[ParticipantEvent, ...] + joint_actions: tuple[JointActionRecord, ...] + time_contexts: tuple[TimeManagementContext, ...] + + +def _event_ids(trace: RuntimeTrace) -> set[str]: + return {event.event_id for event in trace.events} + + +def _initial_revision_map(trace: RuntimeTrace) -> dict[str, set[int]]: + revisions: dict[str, set[int]] = {} + for revision in trace.initial_revisions: + revisions.setdefault(revision.state_address, set()).add(revision.revision) + return revisions + + +def _access_by_event(record: JointActionRecord) -> dict[str, AccessSet]: + return {access.event_id: access for access in record.access_sets} + + +def _is_exact_permutation(values: tuple[str, ...], expected: set[str]) -> bool: + return len(values) == len(expected) and set(values) == expected + + +def _actual_conflict_class(record: JointActionRecord) -> ConflictClass: + access_sets = tuple(_access_by_event(record).values()) + read_write_conflict = False + for left_index, left in enumerate(access_sets): + left_reads = set(left.read_addresses) + left_writes = set(left.write_addresses) + for right in access_sets[left_index + 1 :]: + right_reads = set(right.read_addresses) + right_writes = set(right.write_addresses) + if left_writes & right_writes: + return "write-write" + if (left_writes & right_reads) or (left_reads & right_writes): + read_write_conflict = True + return "read-write" if read_write_conflict else "none" + + +def _event_pair(trace: RuntimeTrace) -> tuple[str, str]: + return (trace.events[0].event_id, trace.events[1].event_id) + + +def _with_first_write(trace: RuntimeTrace, write: StateWrite) -> RuntimeTrace: + events = list(trace.events) + events[0] = replace(events[0], state_writes=(write,)) + return replace(trace, events=tuple(events)) + + +def with_revision_support_variant(trace: RuntimeTrace, support: RevisionSupport) -> RuntimeTrace: + if support == "known": + write = StateWrite( + state_address="state.shared", + prior_revision=0, + new_revision=1, + new_digest="sha256:shared:known", + ) + elif support == "unknown": + write = StateWrite( + state_address="state.shared", + prior_revision=None, + new_revision=1, + new_digest="sha256:shared:unknown", + support="unknown", + ) + else: + write = StateWrite( + state_address="state.shared", + prior_revision=None, + new_revision=None, + new_digest=None, + support="unsupported", + unsupported_update=True, + ) + return _with_first_write(trace, write) + + +def with_order_claim_variant(trace: RuntimeTrace, claim: OrderClaim) -> RuntimeTrace: + events = list(trace.events) + events[1] = replace(events[1], order_claims=(claim,)) + return replace(trace, events=tuple(events)) + + +def with_conflict_variant(trace: RuntimeTrace, variant: str) -> RuntimeTrace: + left_event_id, right_event_id = _event_pair(trace) + if variant == "none": + access_sets = ( + AccessSet(event_id=left_event_id, write_addresses=("state.shared",)), + AccessSet(event_id=right_event_id, read_addresses=("state.other",), write_addresses=("state.local",)), + ) + record = JointActionRecord( + joint_action_id="joint-main", + member_event_ids=(left_event_id, right_event_id), + access_sets=access_sets, + conflict_class="none", + conflict_policy="none", + isolation="none", + atomicity_scope="single-object", + ) + elif variant == "read_write_serialize": + access_sets = ( + AccessSet(event_id=left_event_id, write_addresses=("state.shared",)), + AccessSet(event_id=right_event_id, read_addresses=("state.shared",), write_addresses=("state.local",)), + ) + record = JointActionRecord( + joint_action_id="joint-main", + member_event_ids=(left_event_id, right_event_id), + access_sets=access_sets, + conflict_class="read-write", + conflict_policy="serialize", + isolation="serializable", + atomicity_scope="single-object", + realized_order=(left_event_id, right_event_id), + ) + elif variant == "write_write_serialize": + access_sets = ( + AccessSet(event_id=left_event_id, write_addresses=("state.shared",)), + AccessSet(event_id=right_event_id, write_addresses=("state.shared",)), + ) + record = JointActionRecord( + joint_action_id="joint-main", + member_event_ids=(left_event_id, right_event_id), + access_sets=access_sets, + conflict_class="write-write", + conflict_policy="serialize", + isolation="serializable", + atomicity_scope="multi-object", + realized_order=(left_event_id, right_event_id), + ) + elif variant == "write_write_retry": + access_sets = ( + AccessSet(event_id=left_event_id, write_addresses=("state.shared",)), + AccessSet(event_id=right_event_id, write_addresses=("state.shared",)), + ) + record = JointActionRecord( + joint_action_id="joint-main", + member_event_ids=(left_event_id, right_event_id), + access_sets=access_sets, + conflict_class="write-write", + conflict_policy="retry", + isolation="none", + atomicity_scope="single-object", + rollback_event_ids=(left_event_id,), + retry_limit=1, + ) + else: + access_sets = ( + AccessSet(event_id=left_event_id, write_addresses=("state.shared",)), + AccessSet(event_id=right_event_id, write_addresses=("state.shared",)), + ) + record = JointActionRecord( + joint_action_id="joint-main", + member_event_ids=(left_event_id, right_event_id), + access_sets=access_sets, + conflict_class="none", + conflict_policy="unsupported", + isolation="none", + atomicity_scope="single-object", + unsupported_disclosure=True, + exact_concurrency_claim=False, + ) + return replace(trace, joint_actions=(record,)) + + +def with_time_context_variant(trace: RuntimeTrace, variant: str) -> RuntimeTrace: + first_event_id = trace.events[0].event_id + if variant == "display": + context = TimeManagementContext( + context_id="tm-main", + mode="display", + claim_strength="display", + basis="wall-clock", + clock_ref=None, + ) + elif variant == "backend_serialized": + context = TimeManagementContext( + context_id="tm-main", + mode="backend-serialized", + claim_strength="bounded", + basis="backend-serialized", + clock_ref="clock.logical", + backend_serialized=True, + ) + elif variant == "lookahead": + context = TimeManagementContext( + context_id="tm-main", + mode="lookahead", + claim_strength="bounded", + basis="logical-clock", + clock_ref="clock.logical", + lookahead=1, + ) + elif variant == "pacing": + context = TimeManagementContext( + context_id="tm-main", + mode="pacing", + claim_strength="bounded", + basis="logical-clock", + clock_ref="clock.logical", + advance_by=1, + ) + elif variant == "rollback": + context = TimeManagementContext( + context_id="tm-main", + mode="rollback", + claim_strength="bounded", + basis="logical-clock", + clock_ref="clock.logical", + rollback_event_ids=(first_event_id,), + ) + elif variant in {"devs", "fmi"}: + context = TimeManagementContext( + context_id="tm-main", + mode=variant, + claim_strength="exact", + basis="logical-clock", + clock_ref="clock.logical", + ) + else: + context = TimeManagementContext( + context_id="tm-main", + mode="display", + claim_strength="display", + basis="logical-clock", + clock_ref=None, + unsupported_disclosure=True, + ) + return replace(trace, time_contexts=(context,)) + + +def valid_trace(trace: RuntimeTrace) -> bool: + """Oracle for `ValidTrace(tr)` in the participant-runtime formal spec.""" + event_ids = [event.event_id for event in trace.events] + return ( + bool(trace.events) + and all(event_ids) + and len(event_ids) == len(set(event_ids)) + and monotone_sequence(trace) + and revision_discipline(trace) + and order_discipline(trace) + and all(conflict_ok(record, trace) for record in trace.joint_actions) + and all(time_management_ok(context, trace) for context in trace.time_contexts) + ) + + +def monotone_sequence(trace: RuntimeTrace) -> bool: + """Oracle for `MonotoneSequence(tr)` in the participant-runtime formal spec.""" + last_by_stream: dict[tuple[str, str], int] = {} + for event in trace.events: + participant_address = event.participant_address + episode_id = event.episode_id + sequence_number = event.sequence_number + if participant_address is None and episode_id is None: + if sequence_number is not None: + return False + continue + if participant_address is None or episode_id is None or sequence_number is None: + return False + key = (participant_address, episode_id) + previous = last_by_stream.get(key) + if previous is not None and sequence_number <= previous: + return False + last_by_stream[key] = sequence_number + return True + + +def revision_discipline(trace: RuntimeTrace) -> bool: + """Oracle for `RevisionDiscipline(tr)` in the participant-runtime formal spec.""" + known_revisions = _initial_revision_map(trace) + for event in trace.events: + for write in event.state_writes: + state_revisions = known_revisions.setdefault(write.state_address, set()) + produces_revision_or_digest = write.new_revision is not None or write.new_digest is not None + + if write.support == "known": + if write.prior_revision is None or write.prior_revision not in state_revisions: + return False + if not produces_revision_or_digest: + return False + elif write.support == "unknown": + if write.prior_revision is not None or not produces_revision_or_digest: + return False + else: + if not write.unsupported_update or produces_revision_or_digest: + return False + + if write.new_revision is not None: + if write.prior_revision is not None and write.new_revision <= write.prior_revision: + return False + if write.new_revision in state_revisions: + return False + state_revisions.add(write.new_revision) + return True + + +def order_discipline(trace: RuntimeTrace) -> bool: + """Oracle for `OrderDiscipline(tr)` in the participant-runtime formal spec.""" + display_only_strengths = {"display"} + for event in trace.events: + for claim in event.order_claims: + if claim.basis is None: + return False + if claim.basis == "wall-clock" and claim.strength not in display_only_strengths: + return False + return True + + +def conflict_ok(record: JointActionRecord, trace: RuntimeTrace) -> bool: + """Oracle for `ConflictOK(j, tr)` in the participant-runtime formal spec.""" + trace_event_ids = _event_ids(trace) + member_ids = set(record.member_event_ids) + access_by_event = _access_by_event(record) + actual_conflict = _actual_conflict_class(record) + + if not record.member_event_ids or not _is_exact_permutation(record.member_event_ids, member_ids): + return False + if not member_ids <= trace_event_ids: + return False + if not _is_exact_permutation(tuple(access.event_id for access in record.access_sets), member_ids): + return False + + if record.unsupported_disclosure and record.exact_concurrency_claim: + return False + if not record.unsupported_disclosure and record.conflict_class != actual_conflict: + return False + if actual_conflict != "none" and record.conflict_class == "none" and not record.unsupported_disclosure: + return False + + realized_order = tuple(record.realized_order) + if realized_order and not _is_exact_permutation(realized_order, member_ids): + return False + if record.isolation == "serializable" and not realized_order: + return False + + if record.conflict_policy == "serialize" and not _is_exact_permutation(realized_order, member_ids): + return False + if record.conflict_policy == "retry": + if record.retry_limit is None or record.retry_limit < 0: + return False + if not set(record.rollback_event_ids) <= trace_event_ids: + return False + if record.conflict_policy == "unsupported": + return record.unsupported_disclosure and not record.exact_concurrency_claim + if record.conflict_policy == "none" and actual_conflict != "none": + return False + + if record.atomicity_scope == "multi-object" and actual_conflict != "none" and not realized_order: + return bool(record.rollback_event_ids) + return True + + +def time_management_ok(context: TimeManagementContext, trace: RuntimeTrace) -> bool: + """Oracle for `TimeManagementOK(tm, tr)` in the participant-runtime formal spec.""" + trace_event_ids = _event_ids(trace) + + if context.basis is None: + return False + if context.unsupported_disclosure and context.claim_strength == "exact": + return False + if context.basis == "wall-clock" and context.claim_strength != "display": + return False + if context.claim_strength in {"bounded", "exact"} and context.clock_ref is None: + return False + + if context.mode == "backend-serialized": + return context.backend_serialized and context.basis == "backend-serialized" and context.clock_ref is not None + if context.mode == "lookahead": + return context.lookahead is not None and context.lookahead >= 0 and context.clock_ref is not None + if context.mode == "pacing": + return context.advance_by is not None and context.advance_by > 0 and context.clock_ref is not None + if context.mode == "rollback": + return bool(context.rollback_event_ids) and set(context.rollback_event_ids) <= trace_event_ids + if context.mode in {"devs", "fmi"}: + return context.clock_ref is not None and context.basis != "wall-clock" + return True + + +@st.composite +def valid_traces(draw: st.DrawFn) -> RuntimeTrace: + stream_length = draw(st.integers(min_value=2, max_value=5)) + sequence_numbers = sorted( + draw( + st.lists( + st.integers(min_value=1, max_value=200), min_size=stream_length, max_size=stream_length, unique=True + ) + ) + ) + participant_address = draw(st.sampled_from(("participants.alpha", "participants.bravo", "participants.charlie"))) + episode_id = draw(st.sampled_from(("episodes.main", "episodes.replay"))) + + event_ids = tuple(f"evt-{index}-{sequence_number}" for index, sequence_number in enumerate(sequence_numbers)) + first_event = ParticipantEvent( + event_id=event_ids[0], + participant_address=participant_address, + episode_id=episode_id, + sequence_number=sequence_numbers[0], + state_writes=( + StateWrite( + state_address="state.shared", + prior_revision=0, + new_revision=1, + new_digest=f"sha256:shared:{sequence_numbers[0]}", + ), + ), + ) + second_event = ParticipantEvent( + event_id=event_ids[1], + participant_address=participant_address, + episode_id=episode_id, + sequence_number=sequence_numbers[1], + state_writes=( + StateWrite( + state_address="state.local", + prior_revision=0, + new_revision=1, + new_digest=f"sha256:local:{sequence_numbers[1]}", + ), + ), + order_claims=(OrderClaim(strength="causal", basis="logical-clock"),), + ) + tail_events = tuple( + ParticipantEvent( + event_id=event_ids[index], + participant_address=participant_address, + episode_id=episode_id, + sequence_number=sequence_numbers[index], + ) + for index in range(2, stream_length) + ) + events = (first_event, second_event, *tail_events) + + trace = RuntimeTrace( + initial_revisions=( + StateRevision(state_address="state.shared", revision=0), + StateRevision(state_address="state.local", revision=0), + StateRevision(state_address="state.other", revision=0), + ), + events=events, + joint_actions=( + JointActionRecord( + joint_action_id="joint-main", + member_event_ids=(event_ids[0], event_ids[1]), + access_sets=( + AccessSet(event_id=event_ids[0], write_addresses=("state.shared",)), + AccessSet(event_id=event_ids[1], read_addresses=("state.other",), write_addresses=("state.local",)), + ), + conflict_class="none", + conflict_policy="none", + isolation="none", + atomicity_scope="single-object", + ), + ), + time_contexts=( + TimeManagementContext( + context_id="tm-main", + mode="backend-serialized", + claim_strength="bounded", + basis="backend-serialized", + clock_ref="clock.logical", + backend_serialized=True, + ), + ), + ) + trace = with_revision_support_variant(trace, draw(st.sampled_from(REVISION_SUPPORT_VARIANTS))) + trace = with_order_claim_variant(trace, draw(st.sampled_from(ORDER_CLAIM_VARIANTS))) + trace = with_conflict_variant(trace, draw(st.sampled_from(CONFLICT_VARIANTS))) + return with_time_context_variant(trace, draw(st.sampled_from(TIME_CONTEXT_VARIANTS))) + + +def with_sequence_regression(trace: RuntimeTrace) -> RuntimeTrace: + events = list(trace.events) + events[1] = replace(events[1], sequence_number=events[0].sequence_number) + return replace(trace, events=tuple(events)) + + +def with_revision_violation(trace: RuntimeTrace) -> RuntimeTrace: + bad_write = StateWrite( + state_address="state.shared", + prior_revision=999, + new_revision=1, + new_digest="sha256:shared:bad-prior", + ) + return _with_first_write(trace, bad_write) + + +def with_invalid_revision_support_variant(trace: RuntimeTrace, variant: str) -> RuntimeTrace: + if variant == "unknown_cites_prior": + write = StateWrite( + state_address="state.shared", + prior_revision=0, + new_revision=1, + new_digest="sha256:shared:unknown-bad", + support="unknown", + ) + elif variant == "unsupported_without_disclosure": + write = StateWrite( + state_address="state.shared", + prior_revision=None, + new_revision=None, + new_digest=None, + support="unsupported", + unsupported_update=False, + ) + else: + write = StateWrite( + state_address="state.shared", + prior_revision=None, + new_revision=1, + new_digest="sha256:shared:unsupported-bad", + support="unsupported", + unsupported_update=True, + ) + return _with_first_write(trace, write) + + +def with_order_violation(trace: RuntimeTrace) -> RuntimeTrace: + events = list(trace.events) + events[1] = replace(events[1], order_claims=(OrderClaim(strength="causal", basis="wall-clock"),)) + return replace(trace, events=tuple(events)) + + +def with_missing_order_basis(trace: RuntimeTrace) -> RuntimeTrace: + events = list(trace.events) + events[1] = replace(events[1], order_claims=(OrderClaim(strength="display", basis=None),)) + return replace(trace, events=tuple(events)) + + +def with_conflicting_concurrent_writes(trace: RuntimeTrace) -> RuntimeTrace: + joint = trace.joint_actions[0] + access_sets = ( + replace(joint.access_sets[0], read_addresses=(), write_addresses=("state.shared",)), + replace(joint.access_sets[1], read_addresses=(), write_addresses=("state.shared",)), + ) + bad_joint = replace( + joint, + access_sets=access_sets, + conflict_class="none", + conflict_policy="none", + realized_order=(), + unsupported_disclosure=False, + exact_concurrency_claim=True, + ) + return replace(trace, joint_actions=(bad_joint,)) + + +def with_invalid_joint_action_witnesses(trace: RuntimeTrace) -> RuntimeTrace: + joint = trace.joint_actions[0] + duplicate_order_joint = replace( + joint, + conflict_policy="serialize", + isolation="serializable", + realized_order=(joint.member_event_ids[0], joint.member_event_ids[1], joint.member_event_ids[1]), + ) + missing_access_joint = replace(joint, access_sets=(joint.access_sets[0],)) + return replace(trace, joint_actions=(duplicate_order_joint, missing_access_joint)) + + +def with_invalid_conflict_policy_variant(trace: RuntimeTrace, variant: str) -> RuntimeTrace: + left_event_id, right_event_id = _event_pair(trace) + access_sets = ( + AccessSet(event_id=left_event_id, write_addresses=("state.shared",)), + AccessSet(event_id=right_event_id, write_addresses=("state.shared",)), + ) + if variant == "retry_without_limit": + record = JointActionRecord( + joint_action_id="joint-main", + member_event_ids=(left_event_id, right_event_id), + access_sets=access_sets, + conflict_class="write-write", + conflict_policy="retry", + isolation="none", + atomicity_scope="single-object", + rollback_event_ids=(left_event_id,), + retry_limit=None, + ) + elif variant == "unsupported_exact_overclaim": + record = JointActionRecord( + joint_action_id="joint-main", + member_event_ids=(left_event_id, right_event_id), + access_sets=access_sets, + conflict_class="none", + conflict_policy="unsupported", + isolation="none", + atomicity_scope="single-object", + unsupported_disclosure=True, + exact_concurrency_claim=True, + ) + else: + record = JointActionRecord( + joint_action_id="joint-main", + member_event_ids=(left_event_id, right_event_id), + access_sets=access_sets, + conflict_class="write-write", + conflict_policy="serialize", + isolation="serializable", + atomicity_scope="single-object", + realized_order=(), + ) + return replace(trace, joint_actions=(record,)) + + +def with_time_domain_violation(trace: RuntimeTrace) -> RuntimeTrace: + bad_context = replace( + trace.time_contexts[0], + claim_strength="exact", + basis="wall-clock", + clock_ref=None, + backend_serialized=False, + ) + return replace(trace, time_contexts=(bad_context,)) + + +def with_invalid_time_context_variant(trace: RuntimeTrace, variant: str) -> RuntimeTrace: + first_event_id = trace.events[0].event_id + if variant == "lookahead_without_lookahead": + context = TimeManagementContext( + context_id="tm-main", + mode="lookahead", + claim_strength="bounded", + basis="logical-clock", + clock_ref="clock.logical", + lookahead=None, + ) + elif variant == "rollback_without_lineage": + context = TimeManagementContext( + context_id="tm-main", + mode="rollback", + claim_strength="bounded", + basis="logical-clock", + clock_ref="clock.logical", + rollback_event_ids=(), + ) + else: + context = TimeManagementContext( + context_id="tm-main", + mode="display", + claim_strength="exact", + basis="logical-clock", + clock_ref="clock.logical", + rollback_event_ids=(first_event_id,), + unsupported_disclosure=True, + ) + return replace(trace, time_contexts=(context,)) + + +@PROPERTY_SETTINGS +@given(valid_traces()) +def test_valid_trace_accepts_generated_valid_traces(trace: RuntimeTrace) -> None: + assert valid_trace(trace) + + +@PROPERTY_SETTINGS +@given(valid_traces()) +def test_valid_trace_rejects_targeted_mutations(trace: RuntimeTrace) -> None: + assert not valid_trace(with_sequence_regression(trace)) + assert not valid_trace(with_revision_violation(trace)) + assert not valid_trace(with_order_violation(trace)) + assert not valid_trace(with_conflicting_concurrent_writes(trace)) + assert not valid_trace(with_time_domain_violation(trace)) + + +@PROPERTY_SETTINGS +@given(valid_traces()) +def test_monotone_sequence_accepts_generated_valid_traces(trace: RuntimeTrace) -> None: + assert monotone_sequence(trace) + + +@PROPERTY_SETTINGS +@given(valid_traces()) +def test_monotone_sequence_rejects_sequence_regression(trace: RuntimeTrace) -> None: + mutated = with_sequence_regression(trace) + + assert not monotone_sequence(mutated) + assert revision_discipline(mutated) + assert order_discipline(mutated) + assert all(conflict_ok(record, mutated) for record in mutated.joint_actions) + assert all(time_management_ok(context, mutated) for context in mutated.time_contexts) + + +@PROPERTY_SETTINGS +@given(valid_traces()) +def test_revision_discipline_accepts_generated_valid_traces(trace: RuntimeTrace) -> None: + assert revision_discipline(trace) + + +@PROPERTY_SETTINGS +@given(valid_traces(), st.sampled_from(REVISION_SUPPORT_VARIANTS)) +def test_revision_discipline_accepts_known_unknown_and_unsupported_write_support( + trace: RuntimeTrace, + support: RevisionSupport, +) -> None: + mutated = with_revision_support_variant(trace, support) + + assert valid_trace(mutated) + assert revision_discipline(mutated) + + +@PROPERTY_SETTINGS +@given(valid_traces()) +def test_revision_discipline_rejects_unknown_prior_revision(trace: RuntimeTrace) -> None: + mutated = with_revision_violation(trace) + + assert monotone_sequence(mutated) + assert not revision_discipline(mutated) + assert order_discipline(mutated) + assert all(conflict_ok(record, mutated) for record in mutated.joint_actions) + assert all(time_management_ok(context, mutated) for context in mutated.time_contexts) + + +@PROPERTY_SETTINGS +@given(valid_traces(), st.sampled_from(INVALID_REVISION_VARIANTS)) +def test_revision_discipline_rejects_invalid_disclosure_specific_writes( + trace: RuntimeTrace, + variant: str, +) -> None: + mutated = with_invalid_revision_support_variant(trace, variant) + + assert monotone_sequence(mutated) + assert not revision_discipline(mutated) + assert order_discipline(mutated) + assert all(conflict_ok(record, mutated) for record in mutated.joint_actions) + assert all(time_management_ok(context, mutated) for context in mutated.time_contexts) + + +@PROPERTY_SETTINGS +@given(valid_traces()) +def test_order_discipline_accepts_generated_valid_traces(trace: RuntimeTrace) -> None: + assert order_discipline(trace) + + +@PROPERTY_SETTINGS +@given(valid_traces(), st.sampled_from(ORDER_CLAIM_VARIANTS)) +def test_order_discipline_accepts_supported_order_claim_strengths( + trace: RuntimeTrace, + claim: OrderClaim, +) -> None: + mutated = with_order_claim_variant(trace, claim) + + assert valid_trace(mutated) + assert order_discipline(mutated) + + +@PROPERTY_SETTINGS +@given(valid_traces()) +def test_order_discipline_rejects_wall_clock_causality(trace: RuntimeTrace) -> None: + mutated = with_order_violation(trace) + + assert monotone_sequence(mutated) + assert revision_discipline(mutated) + assert not order_discipline(mutated) + assert all(conflict_ok(record, mutated) for record in mutated.joint_actions) + assert all(time_management_ok(context, mutated) for context in mutated.time_contexts) + + +@PROPERTY_SETTINGS +@given(valid_traces()) +def test_order_discipline_rejects_claim_without_declared_basis(trace: RuntimeTrace) -> None: + mutated = with_missing_order_basis(trace) + + assert monotone_sequence(mutated) + assert revision_discipline(mutated) + assert not order_discipline(mutated) + assert all(conflict_ok(record, mutated) for record in mutated.joint_actions) + assert all(time_management_ok(context, mutated) for context in mutated.time_contexts) + + +@PROPERTY_SETTINGS +@given(valid_traces()) +def test_conflict_ok_accepts_generated_valid_traces(trace: RuntimeTrace) -> None: + assert all(conflict_ok(record, trace) for record in trace.joint_actions) + + +@PROPERTY_SETTINGS +@given(valid_traces(), st.sampled_from(CONFLICT_VARIANTS)) +def test_conflict_ok_accepts_supported_conflict_policy_variants( + trace: RuntimeTrace, + variant: str, +) -> None: + mutated = with_conflict_variant(trace, variant) + + assert valid_trace(mutated) + assert all(conflict_ok(record, mutated) for record in mutated.joint_actions) + + +@PROPERTY_SETTINGS +@given(valid_traces()) +def test_conflict_ok_rejects_undisclosed_concurrent_write_conflict(trace: RuntimeTrace) -> None: + mutated = with_conflicting_concurrent_writes(trace) + + assert monotone_sequence(mutated) + assert revision_discipline(mutated) + assert order_discipline(mutated) + assert all(not conflict_ok(record, mutated) for record in mutated.joint_actions) + assert all(time_management_ok(context, mutated) for context in mutated.time_contexts) + + +@PROPERTY_SETTINGS +@given(valid_traces(), st.sampled_from(INVALID_CONFLICT_VARIANTS)) +def test_conflict_ok_rejects_invalid_policy_specific_records( + trace: RuntimeTrace, + variant: str, +) -> None: + mutated = with_invalid_conflict_policy_variant(trace, variant) + + assert monotone_sequence(mutated) + assert revision_discipline(mutated) + assert order_discipline(mutated) + assert all(not conflict_ok(record, mutated) for record in mutated.joint_actions) + assert all(time_management_ok(context, mutated) for context in mutated.time_contexts) + + +@PROPERTY_SETTINGS +@given(valid_traces()) +def test_conflict_ok_rejects_joint_action_witnesses_that_are_not_exact_permutations( + trace: RuntimeTrace, +) -> None: + mutated = with_invalid_joint_action_witnesses(trace) + + assert monotone_sequence(mutated) + assert revision_discipline(mutated) + assert order_discipline(mutated) + assert all(not conflict_ok(record, mutated) for record in mutated.joint_actions) + assert all(time_management_ok(context, mutated) for context in mutated.time_contexts) + + +@PROPERTY_SETTINGS +@given(valid_traces()) +def test_time_management_ok_accepts_generated_valid_traces(trace: RuntimeTrace) -> None: + assert all(time_management_ok(context, trace) for context in trace.time_contexts) + + +@PROPERTY_SETTINGS +@given(valid_traces(), st.sampled_from(TIME_CONTEXT_VARIANTS)) +def test_time_management_ok_accepts_supported_time_mode_variants( + trace: RuntimeTrace, + variant: str, +) -> None: + mutated = with_time_context_variant(trace, variant) + + assert valid_trace(mutated) + assert all(time_management_ok(context, mutated) for context in mutated.time_contexts) + + +@PROPERTY_SETTINGS +@given(valid_traces()) +def test_time_management_ok_rejects_wall_clock_exact_time_claim(trace: RuntimeTrace) -> None: + mutated = with_time_domain_violation(trace) + + assert monotone_sequence(mutated) + assert revision_discipline(mutated) + assert order_discipline(mutated) + assert all(conflict_ok(record, mutated) for record in mutated.joint_actions) + assert all(not time_management_ok(context, mutated) for context in mutated.time_contexts) + + +@PROPERTY_SETTINGS +@given(valid_traces(), st.sampled_from(INVALID_TIME_VARIANTS)) +def test_time_management_ok_rejects_invalid_mode_specific_contexts( + trace: RuntimeTrace, + variant: str, +) -> None: + mutated = with_invalid_time_context_variant(trace, variant) + + assert monotone_sequence(mutated) + assert revision_discipline(mutated) + assert order_discipline(mutated) + assert all(conflict_ok(record, mutated) for record in mutated.joint_actions) + assert all(not time_management_ok(context, mutated) for context in mutated.time_contexts) diff --git a/specs/formal/participant-runtime/README.md b/specs/formal/participant-runtime/README.md index 4e7bc0e1c..676de4e07 100644 --- a/specs/formal/participant-runtime/README.md +++ b/specs/formal/participant-runtime/README.md @@ -154,7 +154,9 @@ Required artifacts for future implementation: This document supplies the invariant list, abstract state-machine model, normalized vocabulary, conformance obligations, and structural examples for the design issue. Typed contracts, tests, evidence fixtures, and executable models -belong to the spawned implementation issues. +belong to the spawned implementation issues. Issue #486 delivers a bounded +executable oracle for the named participant-runtime trace predicates listed in +the implementation mapping below. ## V1 Traceability Criteria Matrix @@ -5399,6 +5401,36 @@ Remaining artifacts not claimed by issue #192: - fixtures proving mismatched evidence digests and unsupported validity-threat omissions are rejected or downgraded. +## Executable Invariant Oracle - Participant Runtime Trace Predicates + +Issue #486 delivers an executable abstract oracle for the participant-runtime +trace predicates named by `ValidTrace(tr)`. The artifact is intentionally an +assurance/test model for `ASR-505` and ADR-054 evidence; it does not implement +the full RUN-306 lifecycle, RUN-307 shared-state model, or RUN-308 concurrent +execution runtime surfaces. + +Implementation artifact: + +- `implementations/python/tests/test_participant_runtime_invariants.py` + +Bidirectional predicate mapping: + +| Spec predicate | Spec anchor | Oracle/test coverage | +| --- | --- | --- | +| `ValidTrace(tr)` | `Valid Trace Predicate` | `valid_trace`, `test_valid_trace_accepts_generated_valid_traces`, `test_valid_trace_rejects_targeted_mutations` | +| `MonotoneSequence(tr)` | `Valid Trace Predicate` / predicate bullet | `monotone_sequence`, `test_monotone_sequence_accepts_generated_valid_traces`, `test_monotone_sequence_rejects_sequence_regression` | +| `RevisionDiscipline(tr)` | `Valid Trace Predicate` / predicate bullet | `revision_discipline`, `test_revision_discipline_accepts_generated_valid_traces`, `test_revision_discipline_accepts_known_unknown_and_unsupported_write_support`, `test_revision_discipline_rejects_unknown_prior_revision`, `test_revision_discipline_rejects_invalid_disclosure_specific_writes` | +| `OrderDiscipline(tr)` | `Valid Trace Predicate` / predicate bullet | `order_discipline`, `test_order_discipline_accepts_generated_valid_traces`, `test_order_discipline_accepts_supported_order_claim_strengths`, `test_order_discipline_rejects_wall_clock_causality`, `test_order_discipline_rejects_claim_without_declared_basis` | +| `ConflictOK(j, tr)` | `Concurrent Participant Execution` conflict predicates | `conflict_ok`, `test_conflict_ok_accepts_generated_valid_traces`, `test_conflict_ok_accepts_supported_conflict_policy_variants`, `test_conflict_ok_rejects_undisclosed_concurrent_write_conflict`, `test_conflict_ok_rejects_invalid_policy_specific_records`, `test_conflict_ok_rejects_joint_action_witnesses_that_are_not_exact_permutations` | +| `TimeManagementOK(tm, tr)` | `Concurrent Participant Execution` time-management predicates | `time_management_ok`, `test_time_management_ok_accepts_generated_valid_traces`, `test_time_management_ok_accepts_supported_time_mode_variants`, `test_time_management_ok_rejects_wall_clock_exact_time_claim`, `test_time_management_ok_rejects_invalid_mode_specific_contexts` | + +The test module maps back to these spec anchors in its module docstring and +predicate docstrings. Its Hypothesis generator varies valid abstract traces +across revision-support disclosures, order-claim strengths, conflict policies, +and time-management modes. Its mutation helpers isolate sequence regression, +revision violation, order violation, invalid conflict witnesses/policies, and +time-domain violations as negative evidence. + ## RUN-306 - Participant Decision And Execution Lifecycle `RUN-306` requires a portable lifecycle for action proposal, selection, From c54925f4353f3fc471dd7b79f0f44d439528d9c3 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 13 Jun 2026 20:58:28 +0200 Subject: [PATCH 29/70] Point SonarCloud config at brad-edwards personal org Rebinds sonar-project.properties, .ground-control.yaml, and .mcp.json from the keplerops org / aces-framework_aces-sdl project to the brad-edwards org / Brad-Edwards_aces project, matching the GitHub repo owner so SonarCloud PR decoration (the check + summary comment) can post. The SONAR_TOKEN works for both orgs; only the org/project binding changes. --- .ground-control.yaml | 4 ++-- .mcp.json | 2 +- sonar-project.properties | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.ground-control.yaml b/.ground-control.yaml index c98742218..ec92cb271 100644 --- a/.ground-control.yaml +++ b/.ground-control.yaml @@ -17,7 +17,7 @@ requirements: - RUN-311 - ADR-012 sonarcloud: - project_key: aces-framework_aces-sdl - organization: KeplerOps + project_key: Brad-Edwards_aces + organization: brad-edwards rules: plan_rules: .gc/plan-rules.md diff --git a/.mcp.json b/.mcp.json index b38a03b0b..ab5241406 100644 --- a/.mcp.json +++ b/.mcp.json @@ -26,7 +26,7 @@ ], "env": { "SONARQUBE_TOKEN": "${SONAR_TOKEN}", - "SONARQUBE_ORG": "KeplerOps" + "SONARQUBE_ORG": "brad-edwards" } } } diff --git a/sonar-project.properties b/sonar-project.properties index 32b3b3e88..44a3ab919 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,8 +1,8 @@ # SonarCloud Configuration -# https://sonarcloud.io/project/overview?id=aces-framework_aces-sdl +# https://sonarcloud.io/project/overview?id=Brad-Edwards_aces -sonar.projectKey=aces-framework_aces-sdl -sonar.organization=keplerops +sonar.projectKey=Brad-Edwards_aces +sonar.organization=brad-edwards # Source and test directories sonar.sources=implementations/python/packages,implementations/python/src From 0043fdaf5991c5fe40f9185742b99b4d4c610296 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 13 Jun 2026 22:26:05 +0200 Subject: [PATCH 30/70] Wait on SonarCloud quality gate in CI (#527) Adds sonar.qualitygate.wait=true (+timeout) so the CI sonar job blocks on the SonarCloud quality gate, and nothing merges past a failing gate. The project's gate is now the custom 'aces-strict' gate (new_violations>0 plus new-code rating/coverage/duplication/hotspot conditions), mirroring moonbase-strict, so any new issue fails the gate. --- sonar-project.properties | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sonar-project.properties b/sonar-project.properties index 44a3ab919..8e2bdfed8 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -4,6 +4,14 @@ sonar.projectKey=Brad-Edwards_aces sonar.organization=brad-edwards +# Quality gate (issue #527): make the scanner wait for the SonarCloud quality +# gate and fail the CI `sonar` job when the gate fails, so nothing merges past a +# failing gate. The assigned gate `aces-strict` fails on any new issue (plus +# new-code rating/coverage/duplication/hotspot conditions), mirroring +# `moonbase-strict`. +sonar.qualitygate.wait=true +sonar.qualitygate.timeout=600 + # Source and test directories sonar.sources=implementations/python/packages,implementations/python/src sonar.tests=implementations/python/tests From c79b7508c836b11fb4852fcbe754de5c80bfa3a2 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 03:35:23 +0200 Subject: [PATCH 31/70] Add episodes concept authority family --- changelog.d/492.changed.md | 3 ++ .../concept-families-v1.json | 16 +++++++++ .../concept-families-v1/valid/reference.json | 16 +++++++++ .../valid/reference-stack-v1.json | 6 ++++ .../profiles/semantic/reference-stack-v1.json | 6 ++++ .../python/tests/test_concept_authority.py | 36 +++++++++++++++++++ .../python/tests/test_semantic_profiles.py | 15 ++++++++ specs/concept-authority/concept-authority.md | 8 +++++ specs/concept-authority/reference-models.md | 18 ++++++++++ specs/concept-authority/semantic-profiles.md | 12 +++++++ 10 files changed, 136 insertions(+) create mode 100644 changelog.d/492.changed.md diff --git a/changelog.d/492.changed.md b/changelog.d/492.changed.md new file mode 100644 index 000000000..cec7f197b --- /dev/null +++ b/changelog.d/492.changed.md @@ -0,0 +1,3 @@ +Added a native `episodes` concept family with participant-runtime lineage, +semantic-profile coverage for participant episode contracts, and tests that +anchor episode-keyed contracts to the shared concept authority. diff --git a/contracts/concept-authority/concept-families-v1.json b/contracts/concept-authority/concept-families-v1.json index 78945f450..5a8505ea9 100644 --- a/contracts/concept-authority/concept-families-v1.json +++ b/contracts/concept-authority/concept-families-v1.json @@ -71,6 +71,22 @@ "Run or study concepts must not be used to classify nodes, accounts, observables, tools, or events." ] }, + "episodes": { + "title": "Episodes", + "description": "Participant runtime episode identity, lifecycle state, and history boundaries.", + "provenance": "native", + "extension_scope": "ACES participant runtime episode identity, lifecycle state, state/history contracts, reset/restart/terminate semantics, and participant-scoped behavior sequencing.", + "relation_rules": [ + "May relate to tasks-runs-studies as the participant-level runtime segment that occurs within a task, run, or study without becoming that task, run, or study.", + "May relate to scenarios as the runtime execution instance of a participant admitted from scenario context, while scenario authoring remains governed by scenarios.", + "May relate to actions-and-events and observables through behavior or history records emitted inside an episode without classifying those records as the episode itself." + ], + "non_ambiguity_constraints": [ + "Must not be used as a synonym for tasks, runs, studies, scenarios, workflow steps, operation receipts, or backend process restarts.", + "Must not classify participant actions, events, observables, tools, artifacts, or evidence records; those meanings must bind to their narrower families.", + "Episode identity and lifecycle state must remain separate from participant behavior sequence events and participant-local action outcomes." + ] + }, "apparatus-declarations": { "title": "Apparatus Declarations", "description": "Processor, backend, and participant-implementation manifests.", diff --git a/contracts/fixtures/concept-authority/concept-families-v1/valid/reference.json b/contracts/fixtures/concept-authority/concept-families-v1/valid/reference.json index 78945f450..5a8505ea9 100644 --- a/contracts/fixtures/concept-authority/concept-families-v1/valid/reference.json +++ b/contracts/fixtures/concept-authority/concept-families-v1/valid/reference.json @@ -71,6 +71,22 @@ "Run or study concepts must not be used to classify nodes, accounts, observables, tools, or events." ] }, + "episodes": { + "title": "Episodes", + "description": "Participant runtime episode identity, lifecycle state, and history boundaries.", + "provenance": "native", + "extension_scope": "ACES participant runtime episode identity, lifecycle state, state/history contracts, reset/restart/terminate semantics, and participant-scoped behavior sequencing.", + "relation_rules": [ + "May relate to tasks-runs-studies as the participant-level runtime segment that occurs within a task, run, or study without becoming that task, run, or study.", + "May relate to scenarios as the runtime execution instance of a participant admitted from scenario context, while scenario authoring remains governed by scenarios.", + "May relate to actions-and-events and observables through behavior or history records emitted inside an episode without classifying those records as the episode itself." + ], + "non_ambiguity_constraints": [ + "Must not be used as a synonym for tasks, runs, studies, scenarios, workflow steps, operation receipts, or backend process restarts.", + "Must not classify participant actions, events, observables, tools, artifacts, or evidence records; those meanings must bind to their narrower families.", + "Episode identity and lifecycle state must remain separate from participant behavior sequence events and participant-local action outcomes." + ] + }, "apparatus-declarations": { "title": "Apparatus Declarations", "description": "Processor, backend, and participant-implementation manifests.", diff --git a/contracts/fixtures/semantic-profile/semantic-profile-v1/valid/reference-stack-v1.json b/contracts/fixtures/semantic-profile/semantic-profile-v1/valid/reference-stack-v1.json index b57a08f80..df171679c 100644 --- a/contracts/fixtures/semantic-profile/semantic-profile-v1/valid/reference-stack-v1.json +++ b/contracts/fixtures/semantic-profile/semantic-profile-v1/valid/reference-stack-v1.json @@ -42,6 +42,8 @@ "processor-manifest-v2", "backend-manifest-v2", "runtime-snapshot-v1", + "participant-episode-state-envelope-v1", + "participant-episode-history-event-stream-v1", "workflow-result-envelope-v1", "workflow-history-event-stream-v1", "evaluation-result-envelope-v1", @@ -53,6 +55,7 @@ "actions-and-events", "apparatus-declarations", "assets", + "episodes", "identities", "observables", "provenance-and-evidence", @@ -121,6 +124,8 @@ "operation-receipt-v1", "operation-status-v1", "runtime-snapshot-v1", + "participant-episode-state-envelope-v1", + "participant-episode-history-event-stream-v1", "workflow-result-envelope-v1", "workflow-history-event-stream-v1", "evaluation-result-envelope-v1", @@ -129,6 +134,7 @@ "required_concept_families": [ "actions-and-events", "assets", + "episodes", "identities", "observables", "tools-and-artifacts" diff --git a/contracts/profiles/semantic/reference-stack-v1.json b/contracts/profiles/semantic/reference-stack-v1.json index b57a08f80..df171679c 100644 --- a/contracts/profiles/semantic/reference-stack-v1.json +++ b/contracts/profiles/semantic/reference-stack-v1.json @@ -42,6 +42,8 @@ "processor-manifest-v2", "backend-manifest-v2", "runtime-snapshot-v1", + "participant-episode-state-envelope-v1", + "participant-episode-history-event-stream-v1", "workflow-result-envelope-v1", "workflow-history-event-stream-v1", "evaluation-result-envelope-v1", @@ -53,6 +55,7 @@ "actions-and-events", "apparatus-declarations", "assets", + "episodes", "identities", "observables", "provenance-and-evidence", @@ -121,6 +124,8 @@ "operation-receipt-v1", "operation-status-v1", "runtime-snapshot-v1", + "participant-episode-state-envelope-v1", + "participant-episode-history-event-stream-v1", "workflow-result-envelope-v1", "workflow-history-event-stream-v1", "evaluation-result-envelope-v1", @@ -129,6 +134,7 @@ "required_concept_families": [ "actions-and-events", "assets", + "episodes", "identities", "observables", "tools-and-artifacts" diff --git a/implementations/python/tests/test_concept_authority.py b/implementations/python/tests/test_concept_authority.py index 32d554ea6..a3a411cd0 100644 --- a/implementations/python/tests/test_concept_authority.py +++ b/implementations/python/tests/test_concept_authority.py @@ -20,9 +20,14 @@ REPO_ROOT = Path(__file__).resolve().parents[3] CATALOG_PATH = REPO_ROOT / "contracts" / "concept-authority" / "concept-families-v1.json" +CONTROLLED_VOCABULARY_PATH = REPO_ROOT / "contracts" / "concept-authority" / "controlled-vocabularies-v1.json" FIXTURES_ROOT = REPO_ROOT / "contracts" / "fixtures" VALID_DIR = FIXTURES_ROOT / "concept-authority" / "concept-families-v1" / "valid" INVALID_DIR = FIXTURES_ROOT / "concept-authority" / "concept-families-v1" / "invalid" +EPISODE_CONTRACT_TERM_IDS = { + "participant-episode-history-event-stream-v1", + "participant-episode-state-envelope-v1", +} def _native_family_payload() -> dict[str, object]: @@ -266,6 +271,37 @@ def test_authoritative_catalog_native_families_have_no_authority_metadata(): ) +def test_authoritative_catalog_declares_episode_family(): + payload = json.loads(CATALOG_PATH.read_text(encoding="utf-8")) + model = ConceptFamilyCatalogModel.model_validate(payload) + + episode_family = model.families["episodes"] + assert episode_family.provenance == ConceptProvenanceCategory.NATIVE + assert episode_family.authority is None + assert episode_family.authority_reference is None + assert "participant runtime episode identity" in episode_family.extension_scope + + relation_rules = " ".join(episode_family.relation_rules).lower() + for related_concept in ("task", "run", "scenario"): + assert related_concept in relation_rules + + non_ambiguity_constraints = " ".join(episode_family.non_ambiguity_constraints).lower() + for forbidden_synonym in ("task", "run", "observable", "action"): + assert forbidden_synonym in non_ambiguity_constraints + + +def test_episode_contract_terms_have_catalog_family_anchor(): + catalog_payload = json.loads(CATALOG_PATH.read_text(encoding="utf-8")) + vocabulary_payload = json.loads(CONTROLLED_VOCABULARY_PATH.read_text(encoding="utf-8")) + catalog = ConceptFamilyCatalogModel.model_validate(catalog_payload) + + participant_contract_terms = set( + vocabulary_payload["vocabularies"]["participant-implementation-contracts"]["terms"] + ) + assert participant_contract_terms >= EPISODE_CONTRACT_TERM_IDS + assert "episodes" in catalog.families + + def test_valid_fixture_passes_validation(): for path in sorted(VALID_DIR.glob("*.json")): payload = json.loads(path.read_text(encoding="utf-8")) diff --git a/implementations/python/tests/test_semantic_profiles.py b/implementations/python/tests/test_semantic_profiles.py index a400b034e..cf86b631e 100644 --- a/implementations/python/tests/test_semantic_profiles.py +++ b/implementations/python/tests/test_semantic_profiles.py @@ -17,6 +17,10 @@ FIXTURES_ROOT = REPO_ROOT / "contracts" / "fixtures" / "semantic-profile" / "semantic-profile-v1" VALID_DIR = FIXTURES_ROOT / "valid" INVALID_DIR = FIXTURES_ROOT / "invalid" +EPISODE_CONTRACTS = { + "participant-episode-history-event-stream-v1", + "participant-episode-state-envelope-v1", +} def _binding_set(bindings: object) -> set[tuple[str, str]]: @@ -92,3 +96,14 @@ def test_stub_backend_satisfies_execution_profile(): assert required_contracts <= manifest.supported_contract_versions assert required_bindings <= _binding_set(manifest.concept_bindings) + + +def test_reference_profile_declares_episode_family_for_runtime_exchange_and_execution(): + profile = load_semantic_profile("reference-stack-v1") + + assert "episodes" not in profile.authoring.required_concept_families + assert "episodes" not in profile.processing.required_concept_families + + for phase in (profile.exchange, profile.execution): + assert set(phase.required_contracts) >= EPISODE_CONTRACTS + assert "episodes" in phase.required_concept_families diff --git a/specs/concept-authority/concept-authority.md b/specs/concept-authority/concept-authority.md index dbf8794b7..020fb5eec 100644 --- a/specs/concept-authority/concept-authority.md +++ b/specs/concept-authority/concept-authority.md @@ -76,11 +76,19 @@ ecosystem-specific concerns. |--------|-------| | `scenarios` | SDL scenarios, compositions, modules, and authoring constructs. | | `tasks-runs-studies` | Execution lifecycle, run records, and study organization. | +| `episodes` | Participant runtime episode identity, lifecycle state, and history boundaries. | | `apparatus-declarations` | Processor, backend, and participant-implementation manifests. | | `realization-and-disclosure` | Instantiation, planning, compilation, and realization artifacts. | | `provenance-and-evidence` | Run provenance records, evidence expectations, and audit artifacts. | | `time-and-apparatus` | Clocks, timing constraints, and apparatus-level concerns. | +The `episodes` family is native because participant episode semantics are an +ACES runtime boundary, not a UCO cyber object and not a task/run/study alias. +It covers participant-scoped episode identity, lifecycle state, reset/restart +boundaries, and append-only episode history. Episode-bound behavior events, +observables, actions, tools, artifacts, and evidence still bind to their +narrower concept families when those records are the artifact's subject. + ## Extension Discipline ACES-native families must be explicit extensions over the shared concept diff --git a/specs/concept-authority/reference-models.md b/specs/concept-authority/reference-models.md index a968bbe9a..880cbbca5 100644 --- a/specs/concept-authority/reference-models.md +++ b/specs/concept-authority/reference-models.md @@ -55,6 +55,24 @@ slice: - scenario events as action or event structures - scenario content as tool or artifact structures +## Participant Episode Lineage + +Participant episodes are cataloged as the native `episodes` concept family +rather than as an SDL reference model entry. The external lineage is the +reinforcement-learning environment episode notion used by Gymnasium and +OpenAI Gym: a bounded interaction sequence that starts after initialization or +reset and ends at a terminal, timeout, or truncation boundary. ACES narrows +that lineage to participant-runtime contracts by requiring stable +`participant_address`, per-episode `episode_id`, explicit lifecycle state, and +append-only state/history surfaces. + +The internal authority is +[ADR-013](../../docs/decisions/adrs/adr-013-participant-episode-lifecycle-boundaries.md), +which makes participant episode state its own processor/runtime contract +surface and keeps it separate from workflow state, evaluation state, +operation receipts, backend process restarts, tasks, runs, scenarios, and +participant-local actions or observations. + ## Machine-Readable Artifacts The JSON Schema for the catalog format is published at: diff --git a/specs/concept-authority/semantic-profiles.md b/specs/concept-authority/semantic-profiles.md index ff41227b9..acb87e9bf 100644 --- a/specs/concept-authority/semantic-profiles.md +++ b/specs/concept-authority/semantic-profiles.md @@ -59,6 +59,18 @@ The reference stack profile is intentionally concrete. It is the first repo-owned semantic profile, not a promise that the ecosystem will forever use only one profile. +The reference stack requires the native `episodes` concept family in the +`exchange` and `execution` phases because those phases exchange and execute the +participant episode state and episode history contracts. Authoring and +processing do not require the family today: ADR-013 explicitly avoids adding +SDL authoring syntax for episode semantics, and the reference processor does +not publish participant episode state/history contracts as a processing +capability. The profile therefore records episode coverage through +`required_contracts` and `required_concept_families`, not through a +`required_bindings` entry on `capabilities.supported_participant_contracts`; +that manifest scope also contains implementation, behavior, and provenance +contracts and is not an episode-only binding surface. + ## Machine-Readable Artifacts The JSON Schema for semantic profiles is published at: From 842f524b8ad9fa198591c0be122ba91df85c15d8 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 03:42:37 +0200 Subject: [PATCH 32/70] Add explicitness classifier semantics --- changelog.d/489.changed.md | 1 + .../reference/shared-semantic-integrity.md | 2 +- .../python/packages/aces_sdl/explicitness.py | 256 ++++++++++++++++++ .../python/packages/aces_sdl/instantiate.py | 5 + .../python/packages/aces_sdl/scenario.py | 10 + .../python/packages/aces_sdl/validator.py | 8 + .../python/tests/test_sem_218_explicitness.py | 98 +++++++ specs/formal/realization/README.md | 18 +- .../explicitness-and-realization.md | 40 +-- 9 files changed, 416 insertions(+), 22 deletions(-) create mode 100644 changelog.d/489.changed.md create mode 100644 implementations/python/packages/aces_sdl/explicitness.py create mode 100644 implementations/python/tests/test_sem_218_explicitness.py diff --git a/changelog.d/489.changed.md b/changelog.d/489.changed.md new file mode 100644 index 000000000..9efec7521 --- /dev/null +++ b/changelog.d/489.changed.md @@ -0,0 +1 @@ +Add SEM-218 explicitness classification metadata to SDL validation and preserve authored exact/constrained/open classes through instantiation. diff --git a/docs/explain/reference/shared-semantic-integrity.md b/docs/explain/reference/shared-semantic-integrity.md index eaa433c69..9d6323a98 100644 --- a/docs/explain/reference/shared-semantic-integrity.md +++ b/docs/explain/reference/shared-semantic-integrity.md @@ -244,5 +244,5 @@ so they are tracked by their own requirements, not here. | Derived operational context views (portable meaning and comparability) | SEM-214 | — | — | planned | | Evidence, evaluation, view-boundary, and observability-plane semantics | SEM-216, SEM-224, SEM-225 | — | — | planned | | External knowledge bindings semantics | SEM-217 | — | — | planned | -| Explicitness and realization semantics (binding declarations vs processor/backend realization) | SEM-218 | validation | `specs/formal/realization/explicitness-and-realization.md`, `specs/formal/realization/README.md`, `docs/explain/reference/explicitness-realization-semantics.md`, `implementations/python/packages/aces_contracts/apparatus.py`, `implementations/python/packages/aces_contracts/vocabulary.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/packages/aces_backend_protocols/manifest.py`, `implementations/python/tests/test_backend_manifest.py`, `implementations/python/tests/test_processor_manifest.py`, `implementations/python/tests/test_runtime_contracts.py` | partial | +| Explicitness and realization semantics (binding declarations vs processor/backend realization) | SEM-218 | validation, instantiation | `specs/formal/realization/explicitness-and-realization.md`, `specs/formal/realization/README.md`, `docs/explain/reference/explicitness-realization-semantics.md`, `implementations/python/packages/aces_sdl/explicitness.py`, `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/packages/aces_contracts/apparatus.py`, `implementations/python/packages/aces_contracts/vocabulary.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/packages/aces_backend_protocols/manifest.py`, `implementations/python/tests/test_sem_218_explicitness.py`, `implementations/python/tests/test_backend_manifest.py`, `implementations/python/tests/test_processor_manifest.py`, `implementations/python/tests/test_runtime_contracts.py` | partial | | Clock, time-domain, advancement/pacing/synchronization, and temporal ordering/causality semantics | SEM-227, SEM-228, SEM-229 | — | — | planned | diff --git a/implementations/python/packages/aces_sdl/explicitness.py b/implementations/python/packages/aces_sdl/explicitness.py new file mode 100644 index 000000000..902383b56 --- /dev/null +++ b/implementations/python/packages/aces_sdl/explicitness.py @@ -0,0 +1,256 @@ +"""SEM-218 explicitness classification for authored SDL declarations.""" + +from __future__ import annotations + +import re +from collections.abc import Iterable +from dataclasses import dataclass +from enum import Enum + +from pydantic import BaseModel + +from ._base import extract_variable_name +from .variables import Variable + +__all__ = [ + "ExplicitnessClass", + "ExplicitnessProvenance", + "ExplicitnessRecord", + "ExplicitnessResult", + "classify_scenario_explicitness", + "derive_instantiated_explicitness", +] + +_VARIABLE_TOKEN_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_-]*)\}") +_OPEN_ENUM_SENTINELS = frozenset({"unknown", "other"}) +_EXPLICITNESS_ORDER: dict[ExplicitnessClass, int] = {} + + +class ExplicitnessClass(str, Enum): + """SEM-218 author-intent class for a declaration.""" + + EXACT = "exact" + CONSTRAINED = "constrained" + OPEN = "open" + + +class ExplicitnessProvenance(str, Enum): + """Where the classified value came from in the SDL lifecycle.""" + + AUTHOR_DECLARED = "author-declared" + PROCESSOR_DERIVED = "processor-derived" + BACKEND_REALIZED = "backend-realized" + + +_EXPLICITNESS_ORDER.update( + { + ExplicitnessClass.OPEN: 0, + ExplicitnessClass.CONSTRAINED: 1, + ExplicitnessClass.EXACT: 2, + } +) + + +@dataclass(frozen=True) +class ExplicitnessRecord: + """Classification metadata for one SDL model path.""" + + path: str + classification: ExplicitnessClass + provenance: ExplicitnessProvenance = ExplicitnessProvenance.AUTHOR_DECLARED + reason: str = "" + variables: tuple[str, ...] = () + + +@dataclass(frozen=True) +class ExplicitnessResult: + """Classifier result plus non-fatal diagnostics for validation.""" + + records: dict[str, ExplicitnessRecord] + errors: tuple[str, ...] = () + + +def classify_scenario_explicitness(scenario: BaseModel) -> ExplicitnessResult: + """Classify authored SDL declarations on ``scenario``.""" + + variables = getattr(scenario, "variables", {}) + classifier = _ExplicitnessClassifier(variables) + classifier.visit(scenario, "") + return ExplicitnessResult(records=dict(classifier.records), errors=tuple(classifier.errors)) + + +def derive_instantiated_explicitness( + raw_scenario: BaseModel, + instantiated_scenario: BaseModel, +) -> ExplicitnessResult: + """Derive instantiated explicitness without promoting substitutions to exact.""" + + raw_result = classify_scenario_explicitness(raw_scenario) + instantiated_paths = _authored_paths(instantiated_scenario) + records: dict[str, ExplicitnessRecord] = {} + for path, record in raw_result.records.items(): + if path not in instantiated_paths: + continue + provenance = ( + ExplicitnessProvenance.PROCESSOR_DERIVED if record.variables else ExplicitnessProvenance.AUTHOR_DECLARED + ) + reason = record.reason + if record.variables: + reason = "parameter/default substitution preserved authored explicitness class" + records[path] = ExplicitnessRecord( + path=record.path, + classification=record.classification, + provenance=provenance, + reason=reason, + variables=record.variables, + ) + return ExplicitnessResult(records=records, errors=raw_result.errors) + + +class _ExplicitnessClassifier: + def __init__(self, variables: dict[str, Variable]) -> None: + self._variables = variables + self.records: dict[str, ExplicitnessRecord] = {} + self.errors: list[str] = [] + + def visit(self, value: object, path: str) -> ExplicitnessRecord | None: + if isinstance(value, BaseModel): + return self._visit_model(value, path) + if isinstance(value, dict): + return self._visit_mapping(value, path) + if isinstance(value, list): + return self._visit_sequence(value, path) + if not path: + return None + return self._record_scalar(value, path) + + def _visit_model(self, model: BaseModel, path: str) -> ExplicitnessRecord | None: + child_records: list[ExplicitnessRecord] = [] + fields_set = set(model.model_fields_set) + for field_name in model.__class__.model_fields: + if field_name not in fields_set: + continue + child_path = f"{path}.{field_name}" if path else field_name + child_record = self.visit(getattr(model, field_name), child_path) + if child_record is not None: + child_records.append(child_record) + return self._record_container(path, child_records) + + def _visit_mapping(self, value: dict[object, object], path: str) -> ExplicitnessRecord | None: + child_records: list[ExplicitnessRecord] = [] + for key, child in value.items(): + child_path = f"{path}.{key}" if path else str(key) + child_record = self.visit(child, child_path) + if child_record is not None: + child_records.append(child_record) + return self._record_container(path, child_records) + + def _visit_sequence(self, value: list[object], path: str) -> ExplicitnessRecord | None: + child_records: list[ExplicitnessRecord] = [] + for index, child in enumerate(value): + child_record = self.visit(child, f"{path}[{index}]") + if child_record is not None: + child_records.append(child_record) + return self._record_container(path, child_records) + + def _record_container( + self, + path: str, + child_records: list[ExplicitnessRecord], + ) -> ExplicitnessRecord | None: + if not path: + return None + classification = _weakest_class(record.classification for record in child_records) + variables = tuple(sorted({name for record in child_records for name in record.variables})) + record = ExplicitnessRecord( + path=path, + classification=classification, + reason="derived from child declarations" if child_records else "authored empty structure", + variables=variables, + ) + self.records[path] = record + return record + + def _record_scalar(self, value: object, path: str) -> ExplicitnessRecord: + variable_names = _variable_names(value) + if variable_names: + missing = tuple(name for name in variable_names if name not in self._variables) + if missing: + self.errors.append( + f"Cannot classify explicitness for '{path}': undefined variable(s) {', '.join(missing)}" + ) + record = ExplicitnessRecord( + path=path, + classification=ExplicitnessClass.CONSTRAINED, + reason=_variable_constraint_reason(variable_names, self._variables), + variables=variable_names, + ) + self.records[path] = record + return record + + classification = ExplicitnessClass.OPEN if _is_open_enum_sentinel(value) else ExplicitnessClass.EXACT + reason = "open taxonomy sentinel" if classification is ExplicitnessClass.OPEN else "authored concrete value" + record = ExplicitnessRecord(path=path, classification=classification, reason=reason) + self.records[path] = record + return record + + +def _variable_names(value: object) -> tuple[str, ...]: + if not isinstance(value, str): + return () + full_name = extract_variable_name(value) + if full_name is not None: + return (full_name,) + return tuple(dict.fromkeys(_VARIABLE_TOKEN_RE.findall(value))) + + +def _variable_constraint_reason(variable_names: tuple[str, ...], variables: dict[str, Variable]) -> str: + parts: list[str] = [] + for variable_name in variable_names: + variable = variables.get(variable_name) + if variable is None: + parts.append(f"{variable_name}: undefined") + continue + if variable.allowed_values: + parts.append(f"{variable_name}: allowed_values") + else: + parts.append(f"{variable_name}: type {variable.type.value}") + return "; ".join(parts) + + +def _is_open_enum_sentinel(value: object) -> bool: + return isinstance(value, Enum) and isinstance(value.value, str) and value.value in _OPEN_ENUM_SENTINELS + + +def _weakest_class(classes: Iterable[ExplicitnessClass]) -> ExplicitnessClass: + weakest = ExplicitnessClass.EXACT + for classification in classes: + if _EXPLICITNESS_ORDER[classification] < _EXPLICITNESS_ORDER[weakest]: + weakest = classification + return weakest + + +def _authored_paths(value: object) -> set[str]: + paths: set[str] = set() + + def visit(nested: object, path: str) -> None: + if path: + paths.add(path) + if isinstance(nested, BaseModel): + for field_name in nested.__class__.model_fields: + if field_name not in nested.model_fields_set: + continue + child_path = f"{path}.{field_name}" if path else field_name + visit(getattr(nested, field_name), child_path) + return + if isinstance(nested, dict): + for key, child in nested.items(): + child_path = f"{path}.{key}" if path else str(key) + visit(child, child_path) + return + if isinstance(nested, list): + for index, child in enumerate(nested): + visit(child, f"{path}[{index}]") + + visit(value, "") + return paths diff --git a/implementations/python/packages/aces_sdl/instantiate.py b/implementations/python/packages/aces_sdl/instantiate.py index 3cdb16fd5..008f11f17 100644 --- a/implementations/python/packages/aces_sdl/instantiate.py +++ b/implementations/python/packages/aces_sdl/instantiate.py @@ -15,6 +15,7 @@ from ._base import extract_variable_name from ._errors import SDLInstantiationError, SDLValidationError +from .explicitness import derive_instantiated_explicitness from .scenario import InstantiatedScenario, Scenario from .validator import SemanticValidator from .variables import Variable, VariableType @@ -209,4 +210,8 @@ def instantiate_scenario( ) instantiated._set_node_variable_refs(node_variable_refs) instantiated._set_module_variable_specs(module_variable_specs) + explicitness = derive_instantiated_explicitness(raw_scenario, instantiated) + if explicitness.errors: + raise SDLInstantiationError(list(explicitness.errors)) + instantiated._set_explicitness(explicitness.records) return instantiated diff --git a/implementations/python/packages/aces_sdl/scenario.py b/implementations/python/packages/aces_sdl/scenario.py index a1ca3460c..9b25acee5 100644 --- a/implementations/python/packages/aces_sdl/scenario.py +++ b/implementations/python/packages/aces_sdl/scenario.py @@ -18,6 +18,7 @@ from .conditions import Condition from .content import Content from .entities import Entity +from .explicitness import ExplicitnessRecord from .features import Feature from .infrastructure import InfraNode from .nodes import Node @@ -139,6 +140,7 @@ class Scenario(SDLModel): # Inner mapping shape mirrors `InstantiatedScenario._node_variable_refs`. _module_variable_specs: dict[str, dict[str, object]] = PrivateAttr(default_factory=dict) _module_node_variable_refs: dict[str, dict[str, str | None]] = PrivateAttr(default_factory=dict) + _explicitness: dict[str, ExplicitnessRecord] = PrivateAttr(default_factory=dict) @property def advisories(self) -> list[str]: @@ -156,6 +158,14 @@ def semantic_validated(self) -> bool: def _set_semantic_validated(self, validated: bool) -> None: self._semantic_validated = bool(validated) + @property + def explicitness(self) -> dict[str, ExplicitnessRecord]: + """SEM-218 explicitness records keyed by SDL model path.""" + return dict(self._explicitness) + + def _set_explicitness(self, explicitness: dict[str, ExplicitnessRecord]) -> None: + self._explicitness = dict(explicitness) + @property def module_variable_specs(self) -> dict[str, dict[str, object]]: """Namespace-prefixed variable specs preserved across module imports.""" diff --git a/implementations/python/packages/aces_sdl/validator.py b/implementations/python/packages/aces_sdl/validator.py index 4a06d7774..08ba13d36 100644 --- a/implementations/python/packages/aces_sdl/validator.py +++ b/implementations/python/packages/aces_sdl/validator.py @@ -17,6 +17,7 @@ from ._errors import SDLValidationError from ._runtime_service_families import collect_qualified_runtime_family_refs from .entities import flatten_entities +from .explicitness import classify_scenario_explicitness from .infrastructure import SimpleProperties from .nodes import MAX_NODE_NAME_LENGTH, NodeType from .orchestration import Workflow, WorkflowPredicate, WorkflowStep, WorkflowStepType @@ -630,6 +631,7 @@ def validate(self) -> None: self._verify_workflows() self._verify_participant_outcomes() self._verify_variables() + self._verify_explicitness() self._collect_advisories() if self._errors: @@ -3968,6 +3970,12 @@ def visit(value: object, path: str) -> None: visit(self._s, "") + def _verify_explicitness(self) -> None: + result = classify_scenario_explicitness(self._s) + self._s._set_explicitness(result.records) + for error in result.errors: + self._err(error) + def _all_named_elements(self) -> set[str]: """Collect all named element keys across all scenario sections.""" return set(self._named_ref_index().keys()) diff --git a/implementations/python/tests/test_sem_218_explicitness.py b/implementations/python/tests/test_sem_218_explicitness.py new file mode 100644 index 000000000..55ad71546 --- /dev/null +++ b/implementations/python/tests/test_sem_218_explicitness.py @@ -0,0 +1,98 @@ +"""SEM-218 explicitness classifier and instantiation downgrade tests.""" + +from __future__ import annotations + +import textwrap + +import pytest +from aces_sdl import SDLValidationError, instantiate_scenario, parse_sdl +from aces_sdl.explicitness import ExplicitnessClass, ExplicitnessProvenance + + +def _scenario_with_explicitness_cases(): + return parse_sdl( + textwrap.dedent(""" + name: sem-218-explicitness + variables: + os_choice: + type: string + default: linux + allowed_values: [linux, windows] + node_count: + type: integer + default: 1 + allowed_values: [1, 3] + nodes: + net: + type: switch + vm: + type: vm + os: ${os_choice} + resources: + ram: 1 gib + cpu: 2 + runtime: + network: + endpoints: + - network: net + network-id-stability: unknown + infrastructure: + net: + count: 1 + properties: + cidr: 10.0.0.0/24 + gateway: 10.0.0.1 + vm: + count: ${node_count} + links: [net] + """) + ) + + +def test_semantic_validator_attaches_exact_constrained_and_open_classifications(): + scenario = _scenario_with_explicitness_cases() + + explicitness = scenario.explicitness + + assert explicitness["nodes.vm.resources.cpu"].classification is ExplicitnessClass.EXACT + assert explicitness["nodes.vm.os"].classification is ExplicitnessClass.CONSTRAINED + assert ( + explicitness["nodes.vm.runtime.network.endpoints[0].network_id_stability"].classification + is ExplicitnessClass.OPEN + ) + + +def test_instantiation_downgrades_substituted_values_without_creating_false_exactness(): + raw = _scenario_with_explicitness_cases() + + instantiated = instantiate_scenario(raw, parameters={"os_choice": "windows", "node_count": 3}) + explicitness = instantiated.explicitness + + assert instantiated.nodes["vm"].os == "windows" + assert instantiated.infrastructure["vm"].count == 3 + assert explicitness["nodes.vm.os"].classification is ExplicitnessClass.CONSTRAINED + assert explicitness["nodes.vm.os"].provenance is ExplicitnessProvenance.PROCESSOR_DERIVED + assert explicitness["nodes.vm.os"].variables == ("os_choice",) + assert explicitness["infrastructure.vm.count"].classification is ExplicitnessClass.CONSTRAINED + assert explicitness["infrastructure.vm.count"].provenance is ExplicitnessProvenance.PROCESSOR_DERIVED + assert explicitness["nodes.vm.resources.cpu"].classification is ExplicitnessClass.EXACT + assert explicitness["nodes.vm.resources.cpu"].provenance is ExplicitnessProvenance.AUTHOR_DECLARED + + +def test_unclassifiable_variable_reference_is_reported_through_validation_errors(): + with pytest.raises(SDLValidationError) as excinfo: + parse_sdl( + textwrap.dedent(""" + name: sem-218-unclassifiable + nodes: + vm: + type: vm + os: ${missing_os} + resources: + ram: 1 gib + cpu: 1 + """) + ) + + assert "Undefined variable 'missing_os' referenced at 'nodes.vm.os'" in str(excinfo.value) + assert "Cannot classify explicitness for 'nodes.vm.os'" in str(excinfo.value) diff --git a/specs/formal/realization/README.md b/specs/formal/realization/README.md index b40d7a78c..d1c008767 100644 --- a/specs/formal/realization/README.md +++ b/specs/formal/realization/README.md @@ -40,11 +40,12 @@ backend manifests carry `realization_support`. The spec's semantic boundary is normative immediately. End-to-end realization across the seven `SEM-200` lifecycle phases is staged: today -the boundary is enforced at the authoring and validation phases through -the apparatus contract layer (the shape gates on backend -`RealizationSupportDeclaration` and the JSON-schema conditional gate, -plus the asymmetric rejection of `realization_support` on processor -manifests). Instantiation, compilation, planning, execution, and +the boundary is enforced at the authoring, validation, and +instantiation phases through the apparatus contract layer (the shape +gates on backend `RealizationSupportDeclaration` and the JSON-schema +conditional gate, plus the asymmetric rejection of `realization_support` +on processor manifests), the SDL explicitness classifier, and +instantiation downgrade metadata. Compilation, planning, execution, and observation responsibilities in the spec are normative but not yet realized end-to-end. The coverage row in `docs/explain/reference/shared-semantic-integrity.md` carries the @@ -69,6 +70,10 @@ work the SEM-218 row tracks. - native concept family for realization semantics: `contracts/concept-authority/concept-families-v1.json` (`realization-and-disclosure`) +- SDL classifier and instantiation downgrade: + `implementations/python/packages/aces_sdl/explicitness.py`, + `implementations/python/packages/aces_sdl/validator.py`, + `implementations/python/packages/aces_sdl/instantiate.py` - invalid fixtures evidencing fail-closed rejection: `contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/hollow-realization-support.json`, `contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/malformed-realization-support.json`, @@ -85,6 +90,9 @@ work the SEM-218 row tracks. - `implementations/python/tests/test_runtime_contracts.py` — JSON-schema conditional gate for backend manifests and the assertion that the generated processor schema carries no `realization_support` property +- `implementations/python/tests/test_sem_218_explicitness.py` — + classifier coverage for exact, constrained, and open SDL declarations, + instantiation downgrade, and unclassifiable variable diagnostics ## Notes diff --git a/specs/formal/realization/explicitness-and-realization.md b/specs/formal/realization/explicitness-and-realization.md index 85b975164..824e9b012 100644 --- a/specs/formal/realization/explicitness-and-realization.md +++ b/specs/formal/realization/explicitness-and-realization.md @@ -48,14 +48,17 @@ is `partial`. What is *enforced today* is narrow and structural: non-realizing role is enforced structurally; - the closed-Pydantic SDL model boundary (`extra="forbid"`), which fails closed on unknown keys at points the schema does not designate - as realizable. + as realizable; +- the SEM-218 classifier in `aces_sdl.explicitness`, invoked by + `SemanticValidator`, which tags authored SDL declarations as + *exact*, *constrained*, or *open* for downstream consumers; +- the instantiation downgrade rule in `instantiate_scenario`, which + preserves authored explicitness metadata across parameter/default + substitution without promoting substituted concrete values to false + exact declarations. What is *normative but not yet realized*: -- the SEM-218 classifier that tags each authored declaration as - *exact*, *constrained*, or *open* and that downstream stages would - consume — `SemanticValidator` does not carry that classifier today; -- the substitution-downgrade rule in instantiation; - the typed compiler emission that preserves the exact / constrained / open class through to the planner; - the planner-side match of compiled exact-requirement-kinds against @@ -251,9 +254,9 @@ implement end-to-end. | Phase | Responsibility | Status | | --- | --- | --- | -| Authoring | Source of declarations. The authoring layer is the only authority that may classify a construct as exact, constrained, or open; downstream stages MUST treat that classification as immutable input. | normative (future) — closed Pydantic SDL models (`extra="forbid"`) and the apparatus-contract type system carry the structural shape today, but the SEM-218 classifier that tags each declaration with an exact / constrained / open class is staged work; until it lands, "authoring" is not a SEM-218-enforced phase. | -| Validation | At the apparatus-contract layer: the shape gates on backend `RealizationSupportDeclaration` (I4 structural floor), the JSON-schema conditional gate, and `ProcessorManifestV2Model`'s asymmetric rejection of `realization_support` are enforced. At the SDL scenario layer: `SemanticValidator` enforces fail-closed validation on the *existing* closed SDL models, but does not yet classify SDL declarations by exact / constrained / open class. The classifier pass and the open-vs-exact validation rule on SDL scenarios are normative (future). | partial — apparatus-contract validation (manifest shape) is enforced; SDL-scenario SEM-218 validation (the classifier pass) is normative (future). | -| Instantiation | Parameter and default substitution may resolve open concerns and constrained surfaces. Substitution MUST NOT downgrade an exact declaration into a constrained or open one, and MUST NOT introduce an exact declaration that the author did not write. The concrete scenario MUST be revalidated after substitution. | normative (future) | +| Authoring | Source of declarations. The authoring layer is the only authority that may classify a construct as exact, constrained, or open; downstream stages MUST treat that classification as immutable input. | partial — closed Pydantic SDL models (`extra="forbid"`), the apparatus-contract type system, and the `aces_sdl.explicitness` classifier carry the exact / constrained / open classification for authored SDL declarations. | +| Validation | At the apparatus-contract layer: the shape gates on backend `RealizationSupportDeclaration` (I4 structural floor), the JSON-schema conditional gate, and `ProcessorManifestV2Model`'s asymmetric rejection of `realization_support` are enforced. At the SDL scenario layer: `SemanticValidator` enforces fail-closed validation on the *existing* closed SDL models and attaches SEM-218 classifier output to the validated scenario. | partial — apparatus-contract validation (manifest shape) and SDL-scenario classifier output are enforced; compiler/planner/runtime consumers remain staged. | +| Instantiation | Parameter and default substitution may resolve open concerns and constrained surfaces. Substitution MUST NOT downgrade an exact declaration into a constrained or open one, and MUST NOT introduce an exact declaration that the author did not write. The concrete scenario MUST be revalidated after substitution. | partial — `instantiate_scenario` revalidates after substitution and derives instantiated explicitness from the authored classification so substituted values do not become false exact declarations. | | Compilation | Lowers each declaration into a typed runtime requirement preserving class. Exact requirements carry their declared kind into the compiled representation; constrained requirements carry the typed constraint surface; open requirements are emitted as realizable slots tagged with the realization-and-disclosure family. | normative (future) | | Planning | Matches every compiled requirement against the candidate backend manifest. An unsupported exact-requirement-kind MUST cause plan rejection through a structured `Diagnostic` before deployment; an unsupported constraint-kind MUST cause the same outcome. An open realizable slot MAY be left for the backend only when its manifest declares matching support. | normative (future) | | Execution | Backend realizers honor the compiled class. A runtime adapter MUST NOT silently broaden an exact requirement, MUST NOT silently narrow an open realization beyond its declared constraints, and MUST surface incompatibilities through the existing runtime error envelope rather than approximate. | normative (future) | @@ -288,9 +291,9 @@ realization status, are: rejection rule. - **Instantiation gate** — substitution MUST NOT downgrade an exact declaration; concrete scenarios MUST be revalidated after - substitution (I1). *Future*; the validation rerun exists today via - `instantiate_scenario`, but the explicit-vs-open invariant for - substitution is not separately enforced. + substitution (I1). *Enforced today* by `instantiate_scenario`, + `SDLInstantiationError`, and `aces_sdl.explicitness` deriving the + instantiated explicitness map from the pre-substitution authored map. - **Compiler / planner gate** — compiled exact-requirement-kinds MUST be matched against the selected backend's `realization_support`; unsupported kinds MUST cause `Diagnostic`-bearing rejection before @@ -411,10 +414,11 @@ realization is staged work tracked under the SEM-218 coverage row. (`realization-and-disclosure` family is the native authority for what may be realized at all). - I1, I3 fail-closed authoring / validation — + `implementations/python/packages/aces_sdl/explicitness.py`, `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/packages/aces_sdl/instantiate.py` - (closed-world validation, revalidation after substitution; the - explicit-vs-open substitution-downgrade rule is *future*). + (closed-world validation, classifier output on validated scenarios, + and substitution-downgrade metadata on instantiated scenarios). - I4 fail-closed evidence — invalid fixtures `contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/hollow-realization-support.json`, `contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/malformed-realization-support.json`, @@ -441,11 +445,15 @@ realization is staged work tracked under the SEM-218 coverage row. JSON-schema conditional gate for backend manifests and the assertion that the generated processor schema has no `realization_support` property (I4 shape + scope). +- `implementations/python/tests/test_sem_218_explicitness.py` — + SDL-scenario classifier output for representative exact, + constrained, and open declarations; instantiation substitution + downgrade; and unclassifiable variable diagnostics. Tests for the planner-gate match against `realization_support`, the -substitution-downgrade rule, the runtime non-approximation envelope, -and SEM-218 provenance fields are *future* and will land with the -implementations they exercise. +runtime non-approximation envelope, and SEM-218 runtime provenance +fields are *future* and will land with the implementations they +exercise. ## Non-Goals From 4eb9604e2ef561c96aac2d4defb86c8419b83956 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 03:51:00 +0200 Subject: [PATCH 33/70] Add runtime profile guard invariant lint --- changelog.d/503.fixed.md | 1 + docs/explain/sdl/validation.md | 20 ++ .../tests/test_runtime_family_invariants.py | 218 +++++++++++++++++- 3 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 changelog.d/503.fixed.md diff --git a/changelog.d/503.fixed.md b/changelog.d/503.fixed.md new file mode 100644 index 000000000..c0021e20b --- /dev/null +++ b/changelog.d/503.fixed.md @@ -0,0 +1 @@ +Added a cross-family runtime invariant lint that prevents documented required-profile discriminators from shipping without registered Pydantic guard wiring. diff --git a/docs/explain/sdl/validation.md b/docs/explain/sdl/validation.md index a455e28e2..bc2de6240 100644 --- a/docs/explain/sdl/validation.md +++ b/docs/explain/sdl/validation.md @@ -439,6 +439,26 @@ enum introduced in a single-sentinel state fails the suite immediately. When a new enum is genuinely closed, it must carry neither sentinel; otherwise it must carry both. +## Runtime required-profile guard convention + +Some runtime-family spines use an open enum-or-string discriminator to select a +required profile from sibling structured fields. Those discriminators are not +documentation-only claims: each documented required-profile discriminator must +have a matching `require_profile_for_` guard invoked by a registered +Pydantic `mode="after"` model validator. This is the executable "cannot +silently shallow-encode" guarantee for the current `data_model`, +`platform_kind`, `agent_kind`, and `privilege_class` spines. + +The convention is enforced by +`test_discriminated_runtime_spines_register_required_profile_guards` in +`tests/test_runtime_family_invariants.py`. The lint discovers runtime-family +models from `RuntimeConfiguration.model_fields`, identifies discriminator +fields whose docs say they select a required profile and whose models carry +sibling structured profile fields, and checks Pydantic's registered +model-validator metadata for the corresponding guard call. A future runtime +spine that declares the same required-profile discriminator shape but omits the +guard fails the test suite. + ## Static Semantic Invariants The validator is the main enforcement point for static SDL semantics, but not diff --git a/implementations/python/tests/test_runtime_family_invariants.py b/implementations/python/tests/test_runtime_family_invariants.py index 5461cf71f..5e9525f37 100644 --- a/implementations/python/tests/test_runtime_family_invariants.py +++ b/implementations/python/tests/test_runtime_family_invariants.py @@ -20,16 +20,19 @@ import ast import enum import importlib +import inspect import pkgutil +import types import typing from pathlib import Path import aces_sdl from aces_sdl import _runtime_service_families as rsf -from aces_sdl._base import parse_enum_or_var +from aces_sdl._base import SDLModel, parse_enum_or_var from aces_sdl.runtime_configuration import RuntimeConfiguration from aces_sdl.runtime_directory_identity import RuntimeIdentityRelationshipKind from aces_sdl.runtime_values import parse_runtime_enum_or_var +from pydantic import Field def _singularize(plural: str) -> str: @@ -74,6 +77,194 @@ def _current_violations() -> set[str]: # whole surface satisfies the invariant set. KNOWN_VIOLATIONS: set[str] = set() +KNOWN_PROFILE_GUARD_VIOLATIONS: set[str] = set() + + +class GuardlessProfileKind(str, enum.Enum): + """Open discriminator for the synthetic required-profile lint target. + + The ``example_kind`` discriminator selects a required profile; the class is + intentionally missing the matching guard so the lint's negative path stays + executable. + """ + + CONCRETE = "concrete" + UNKNOWN = "unknown" + OTHER = "other" + + +class GuardlessProfileChild(SDLModel): + """Synthetic profile-bearing child for the guardless lint target.""" + + child_id: str + + +class GuardlessDiscriminatedRuntimeSpine(SDLModel): + """Synthetic discriminated runtime spine with no required-profile guard. + + The ``example_kind`` discriminator selects a required profile from + ``profile_children``. + """ + + example_id: str + example_kind: GuardlessProfileKind | str = GuardlessProfileKind.UNKNOWN + profile_children: list[GuardlessProfileChild] = Field(default_factory=list) + + +def _union_args(annotation: object) -> tuple[object, ...]: + """Return union members, or the annotation itself for non-unions.""" + + origin = typing.get_origin(annotation) + if origin in (typing.Union, types.UnionType): + return typing.get_args(annotation) + return (annotation,) + + +def _enum_or_str(annotation: object) -> type[enum.Enum] | None: + """Return the enum from an ``Enum | str`` annotation, if present.""" + + args = _union_args(annotation) + enum_args = [arg for arg in args if isinstance(arg, type) and issubclass(arg, enum.Enum)] + if len(enum_args) == 1 and str in args: + return enum_args[0] + return None + + +def _is_open_runtime_enum(enum_cls: type[enum.Enum]) -> bool: + """Return whether the enum carries the runtime open-taxonomy sentinels.""" + + values = {member.value for member in enum_cls} + return {"unknown", "other"} <= values + + +def _sdl_model_types(annotation: object) -> tuple[type[SDLModel], ...]: + """Return SDLModel types nested under list/optional annotations.""" + + origin = typing.get_origin(annotation) + if origin is list: + args = typing.get_args(annotation) + return _sdl_model_types(args[0]) if args else () + if origin in (typing.Union, types.UnionType): + found: list[type[SDLModel]] = [] + for arg in typing.get_args(annotation): + if arg is type(None): + continue + found.extend(_sdl_model_types(arg)) + return tuple(found) + if isinstance(annotation, type) and issubclass(annotation, SDLModel): + return (annotation,) + return () + + +def _runtime_configuration_models() -> tuple[type[SDLModel], ...]: + """Return the runtime models discovered from ``RuntimeConfiguration``.""" + + models: list[type[SDLModel]] = [] + seen: set[type[SDLModel]] = set() + for field in RuntimeConfiguration.model_fields.values(): + for model in _sdl_model_types(field.annotation): + if model in seen: + continue + seen.add(model) + models.append(model) + return tuple(models) + + +def _has_profile_bearing_sibling(model: type[SDLModel], discriminator_field: str) -> bool: + """A profile discriminator must select between sibling structured fields.""" + + for field_name, field in model.model_fields.items(): + if field_name == discriminator_field: + continue + if _sdl_model_types(field.annotation): + return True + return False + + +def _required_profile_signal(model: type[SDLModel], enum_cls: type[enum.Enum], field_name: str) -> bool: + """Return whether docs declare that ``field_name`` selects a profile. + + The detector is intentionally structural plus documented-signal based: + many runtime enum-or-string fields are product taxonomies, not profile + selectors. A required-profile discriminator is one whose model/enum/module + documentation calls it a discriminator and ties it to a required profile. + """ + + module = importlib.import_module(model.__module__) + docs = "\n".join( + text + for text in ( + inspect.getdoc(module), + inspect.getdoc(enum_cls), + inspect.getdoc(model), + ) + if text + ).lower() + guard_name = f"require_profile_for_{field_name}" + discriminator_terms = ( + f"{field_name} discriminator", + f"`{field_name}` discriminator", + f"``{field_name}`` discriminator", + ) + return (guard_name in docs or any(term in docs for term in discriminator_terms)) and ( + "required profile" in docs or guard_name in docs + ) + + +def _required_profile_discriminators( + models: typing.Iterable[type[SDLModel]], +) -> tuple[tuple[type[SDLModel], str], ...]: + """Return runtime models whose discriminator is documented as profile-selecting.""" + + found: list[tuple[type[SDLModel], str]] = [] + for model in models: + for field_name, field in model.model_fields.items(): + enum_cls = _enum_or_str(field.annotation) + if enum_cls is None: + continue + if not _is_open_runtime_enum(enum_cls): + continue + if not _has_profile_bearing_sibling(model, field_name): + continue + if not _required_profile_signal(model, enum_cls, field_name): + continue + found.append((model, field_name)) + return tuple(found) + + +def _registered_after_model_validator_calls(model: type[SDLModel], guard_name: str) -> bool: + """Return whether Pydantic registered an after-validator invoking ``guard_name``.""" + + decorators = getattr(model, "__pydantic_decorators__", None) + model_validators = getattr(decorators, "model_validators", {}) + for validator in model_validators.values(): + info = getattr(validator, "info", None) + if getattr(info, "mode", None) != "after": + continue + if getattr(validator, "cls_var_name", "") == guard_name: + return True + func = getattr(validator, "func", None) + code = getattr(func, "__code__", None) + if code is not None and guard_name in code.co_names: + return True + return False + + +def _required_profile_guard_violations(models: typing.Iterable[type[SDLModel]]) -> set[str]: + """Compute missing or unregistered required-profile guard violations.""" + + violations: set[str] = set() + for model, field_name in _required_profile_discriminators(models): + guard_name = f"require_profile_for_{field_name}" + label = f"{model.__module__}.{model.__name__}.{field_name}" + if not callable(getattr(model, guard_name, None)): + violations.add(f"{label}: missing {guard_name}") + continue + if not _registered_after_model_validator_calls(model, guard_name): + violations.add(f"{label}: {guard_name} is not called by a registered after model-validator") + return violations + + _SHARED_HELPER_DEFINITION_NAMES = frozenset( { "_absolute_refs", @@ -103,6 +294,31 @@ def test_runtime_family_invariants_no_new_drift() -> None: ) +def test_discriminated_runtime_spines_register_required_profile_guards() -> None: + """Required-profile discriminators must be wired through model validators.""" + + current = _required_profile_guard_violations(_runtime_configuration_models()) + new = sorted(current - KNOWN_PROFILE_GUARD_VIOLATIONS) + resolved = sorted(KNOWN_PROFILE_GUARD_VIOLATIONS - current) + assert current == KNOWN_PROFILE_GUARD_VIOLATIONS, ( + "Runtime required-profile guard drift detected.\n" + f" NEW violations (register a require_profile_for_* model validator): {new}\n" + f" RESOLVED but still allow-listed (remove from KNOWN_PROFILE_GUARD_VIOLATIONS): {resolved}" + ) + + +def test_required_profile_lint_rejects_guardless_discriminated_family() -> None: + """A test-local discriminated spine without a guard must trip the lint.""" + + violations = _required_profile_guard_violations([GuardlessDiscriminatedRuntimeSpine]) + + assert any( + "GuardlessDiscriminatedRuntimeSpine.example_kind" in violation + and "require_profile_for_example_kind" in violation + for violation in violations + ), violations + + def test_runtime_modules_do_not_redeclare_shared_validation_helpers() -> None: """Runtime families import shared helper policy instead of shadowing it.""" From 766becc5ce39801c82045859d114900cb0af4fa8 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 04:14:56 +0200 Subject: [PATCH 34/70] Fix SonarCloud findings (cycle 1) --- changelog.d/489.changed.md | 2 +- .../python/packages/aces_sdl/explicitness.py | 84 +++++++++++-------- 2 files changed, 50 insertions(+), 36 deletions(-) diff --git a/changelog.d/489.changed.md b/changelog.d/489.changed.md index 9efec7521..272f74e0f 100644 --- a/changelog.d/489.changed.md +++ b/changelog.d/489.changed.md @@ -1 +1 @@ -Add SEM-218 explicitness classification metadata to SDL validation and preserve authored exact/constrained/open classes through instantiation. +Add SEM-218 explicitness classification metadata to SDL validation and preserve authored exact/constrained/open classes through instantiation, including helper traversal paths that keep downstream metadata derivation consistent. diff --git a/implementations/python/packages/aces_sdl/explicitness.py b/implementations/python/packages/aces_sdl/explicitness.py index 902383b56..c5dcc265b 100644 --- a/implementations/python/packages/aces_sdl/explicitness.py +++ b/implementations/python/packages/aces_sdl/explicitness.py @@ -114,15 +114,16 @@ def __init__(self, variables: dict[str, Variable]) -> None: self.errors: list[str] = [] def visit(self, value: object, path: str) -> ExplicitnessRecord | None: + record: ExplicitnessRecord | None = None if isinstance(value, BaseModel): - return self._visit_model(value, path) - if isinstance(value, dict): - return self._visit_mapping(value, path) - if isinstance(value, list): - return self._visit_sequence(value, path) - if not path: - return None - return self._record_scalar(value, path) + record = self._visit_model(value, path) + elif isinstance(value, dict): + record = self._visit_mapping(value, path) + elif isinstance(value, list): + record = self._visit_sequence(value, path) + elif path: + record = self._record_scalar(value, path) + return record def _visit_model(self, model: BaseModel, path: str) -> ExplicitnessRecord | None: child_records: list[ExplicitnessRecord] = [] @@ -196,12 +197,14 @@ def _record_scalar(self, value: object, path: str) -> ExplicitnessRecord: def _variable_names(value: object) -> tuple[str, ...]: - if not isinstance(value, str): - return () - full_name = extract_variable_name(value) - if full_name is not None: - return (full_name,) - return tuple(dict.fromkeys(_VARIABLE_TOKEN_RE.findall(value))) + names: list[str] = [] + if isinstance(value, str): + full_name = extract_variable_name(value) + if full_name is not None: + names.append(full_name) + else: + names.extend(dict.fromkeys(_VARIABLE_TOKEN_RE.findall(value))) + return tuple(names) def _variable_constraint_reason(variable_names: tuple[str, ...], variables: dict[str, Variable]) -> str: @@ -231,26 +234,37 @@ def _weakest_class(classes: Iterable[ExplicitnessClass]) -> ExplicitnessClass: def _authored_paths(value: object) -> set[str]: - paths: set[str] = set() + collector = _AuthoredPathCollector() + collector.visit(value, "") + return collector.paths - def visit(nested: object, path: str) -> None: + +class _AuthoredPathCollector: + def __init__(self) -> None: + self.paths: set[str] = set() + + def visit(self, value: object, path: str) -> None: if path: - paths.add(path) - if isinstance(nested, BaseModel): - for field_name in nested.__class__.model_fields: - if field_name not in nested.model_fields_set: - continue - child_path = f"{path}.{field_name}" if path else field_name - visit(getattr(nested, field_name), child_path) - return - if isinstance(nested, dict): - for key, child in nested.items(): - child_path = f"{path}.{key}" if path else str(key) - visit(child, child_path) - return - if isinstance(nested, list): - for index, child in enumerate(nested): - visit(child, f"{path}[{index}]") - - visit(value, "") - return paths + self.paths.add(path) + if isinstance(value, BaseModel): + self._visit_model(value, path) + elif isinstance(value, dict): + self._visit_mapping(value, path) + elif isinstance(value, list): + self._visit_sequence(value, path) + + def _visit_model(self, model: BaseModel, path: str) -> None: + for field_name in model.__class__.model_fields: + if field_name not in model.model_fields_set: + continue + child_path = f"{path}.{field_name}" if path else field_name + self.visit(getattr(model, field_name), child_path) + + def _visit_mapping(self, value: dict[object, object], path: str) -> None: + for key, child in value.items(): + child_path = f"{path}.{key}" if path else str(key) + self.visit(child, child_path) + + def _visit_sequence(self, value: list[object], path: str) -> None: + for index, child in enumerate(value): + self.visit(child, f"{path}[{index}]") From 0ab690691ca3408523feffd6e80367f8c1b4ea70 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 04:15:08 +0200 Subject: [PATCH 35/70] Add schema publication compatibility gates --- README.md | 8 + changelog.d/497.changed.md | 4 + contracts/README.md | 10 +- contracts/schema-publication-manifest.json | 153 ++++++-- docs/decisions/adrs/README.md | 2 + ...r-061-published-schema-evolution-policy.md | 142 +++++++ docs/decisions/adrs/adr-index.yaml | 3 + .../python/tests/test_repo_policy_tools.py | 324 +++++++++++++++- noxfile.py | 10 +- tools/check_schema_publication.py | 356 +++++++++++++++++- 10 files changed, 962 insertions(+), 50 deletions(-) create mode 100644 changelog.d/497.changed.md create mode 100644 docs/decisions/adrs/adr-061-published-schema-evolution-policy.md diff --git a/README.md b/README.md index 68e2ae69b..32ad5a8ac 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,14 @@ The Python package currently declares its version in Release notes are collated from towncrier fragments in [`changelog.d/`](changelog.d/). Do not hand-edit `CHANGELOG.md`. +Published JSON Schemas use versioned contract identifiers such as +`sdl-authoring-input-v1`, but the suffix is not the same as a stability promise. +The authoritative schema publication manifest records each schema's `draft` or +`stable` stability class and canonical content hash. Current checked-in schemas +are draft until a maintainer explicitly promotes them; stable breaking changes +must mint a new schema version as described in +[ADR-061](docs/decisions/adrs/adr-061-published-schema-evolution-policy.md). + ## Maintainers - Brad Edwards — [Personal GitHub](https://github.com/Brad-Edwards), [PANW GitHub](https://github.com/Brad-Edwards-SecOps), [LinkedIn](https://www.linkedin.com/in/bradley-edwards-dev/) diff --git a/changelog.d/497.changed.md b/changelog.d/497.changed.md new file mode 100644 index 000000000..f9c88df78 --- /dev/null +++ b/changelog.d/497.changed.md @@ -0,0 +1,4 @@ +Published schema evolution is now governed by an ADR-backed manifest policy: +current schemas are marked draft with canonical content hashes, and stable +schemas cannot take incompatible in-place structural changes without a version +bump. diff --git a/contracts/README.md b/contracts/README.md index 50d3485c3..09ab3f4f2 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -11,7 +11,15 @@ The goal of this bucket is organizational clarity: `schema-publication-manifest.json` is the authoritative publication inventory for the current machine-readable schema set. The contracts verification gate checks that every entry points at `contracts/schemas/`, that every listed schema -exists, and that every JSON Schema file under `contracts/schemas/` is listed. +exists, that every JSON Schema file under `contracts/schemas/` is listed, and +that every entry records its stability class and canonical content hash. + +Current checked-in schemas are marked `draft` in the manifest. A `v1` or `v2` +filename suffix identifies the schema lineage; it does not by itself promise a +stable compatibility surface. Stable schema evolution is governed by +[ADR-061](../docs/decisions/adrs/adr-061-published-schema-evolution-policy.md): +additive changes may stay under the same suffix, while breaking changes require +a new version suffix. These assets are intentionally language-neutral. Any conformance runners or implementation-specific validation helpers belong under `implementations/`, diff --git a/contracts/schema-publication-manifest.json b/contracts/schema-publication-manifest.json index fe6f3b91b..5ef13e6d2 100644 --- a/contracts/schema-publication-manifest.json +++ b/contracts/schema-publication-manifest.json @@ -1,157 +1,234 @@ { "schema_version": "schema-publication-manifest/v1", + "hash_algorithm": "sha256", "schemas": [ { "contract_id": "aces-semantic-invariants-v1", - "schema_path": "contracts/schemas/profiles/aces-semantic-invariants-v1.json" + "schema_path": "contracts/schemas/profiles/aces-semantic-invariants-v1.json", + "stability": "draft", + "content_hash": "e7b858c93b7ec763c361439b1d9c7cc3979a1d150ca64a7f41ebc12c050f5cff" }, { "contract_id": "backend-manifest-v2", - "schema_path": "contracts/schemas/backend-manifest/backend-manifest-v2.json" + "schema_path": "contracts/schemas/backend-manifest/backend-manifest-v2.json", + "stability": "draft", + "content_hash": "da156411d9877ad75045569b47e3b98597b6dec23a827d176c1fc64d9532cc90" }, { "contract_id": "backend-profile-v1", - "schema_path": "contracts/schemas/profiles/backend-profile-v1.json" + "schema_path": "contracts/schemas/profiles/backend-profile-v1.json", + "stability": "draft", + "content_hash": "1c9ffe10f1c2c420610d1353dc1406bb0e0d63001c4f6a32e1aca061b164af1d" }, { "contract_id": "concept-families-v1", - "schema_path": "contracts/schemas/concept-authority/concept-families-v1.json" + "schema_path": "contracts/schemas/concept-authority/concept-families-v1.json", + "stability": "draft", + "content_hash": "5e75b2d14d9f2537f5bb2e172edccb8ab158671b587dc01abc7477aabf2a5a3a" }, { "contract_id": "controlled-vocabularies-v1", - "schema_path": "contracts/schemas/concept-authority/controlled-vocabularies-v1.json" + "schema_path": "contracts/schemas/concept-authority/controlled-vocabularies-v1.json", + "stability": "draft", + "content_hash": "3c3b4e517c42bd3822a2b9bbc69dc359773ee79da24f67abd4cafe8b205be2b8" }, { "contract_id": "evaluation-history-event-stream-v1", - "schema_path": "contracts/schemas/control-plane/evaluation-history-event-stream-v1.json" + "schema_path": "contracts/schemas/control-plane/evaluation-history-event-stream-v1.json", + "stability": "draft", + "content_hash": "f34f5e940e267a2f5cc449fafcc79b8d8da9f537fd0ec4fd296c7843278ed7f4" }, { "contract_id": "evaluation-plan-v1", - "schema_path": "contracts/schemas/plans/evaluation-plan-v1.json" + "schema_path": "contracts/schemas/plans/evaluation-plan-v1.json", + "stability": "draft", + "content_hash": "eeff605ae19059eb23648c717438189758f22ea8a35510c4561aa6f5f469a573" }, { "contract_id": "evaluation-result-envelope-v1", - "schema_path": "contracts/schemas/control-plane/evaluation-result-envelope-v1.json" + "schema_path": "contracts/schemas/control-plane/evaluation-result-envelope-v1.json", + "stability": "draft", + "content_hash": "a3a99913c8e2092fb39483e278d52be2f2a41c553a500b776d08a2500638c95d" }, { "contract_id": "experiment-apparatus-context-v1", - "schema_path": "contracts/schemas/experiment-core/experiment-apparatus-context-v1.json" + "schema_path": "contracts/schemas/experiment-core/experiment-apparatus-context-v1.json", + "stability": "draft", + "content_hash": "6e1f9885835bc6b6e8af922f5624ef09084c2fc8992b2a7f8fdb4e078f6ae2c0" }, { "contract_id": "experiment-run-v1", - "schema_path": "contracts/schemas/experiment-core/experiment-run-v1.json" + "schema_path": "contracts/schemas/experiment-core/experiment-run-v1.json", + "stability": "draft", + "content_hash": "ceeead6df3a0d71477221bba8d8ac64b5a5f234a315d7c695357b04101bbb155" }, { "contract_id": "experiment-study-v1", - "schema_path": "contracts/schemas/experiment-core/experiment-study-v1.json" + "schema_path": "contracts/schemas/experiment-core/experiment-study-v1.json", + "stability": "draft", + "content_hash": "8065247bb9185692c7de922e60b88e49420a32f3e57300801c3218c629194fa6" }, { "contract_id": "experiment-task-v1", - "schema_path": "contracts/schemas/experiment-core/experiment-task-v1.json" + "schema_path": "contracts/schemas/experiment-core/experiment-task-v1.json", + "stability": "draft", + "content_hash": "fcdbd1d6d1d455ef2f43360dd0408c006ac2d1cd6f6454f46f7ba63365e9a661" }, { "contract_id": "instantiated-scenario-v1", - "schema_path": "contracts/schemas/sdl/instantiated-scenario-v1.json" + "schema_path": "contracts/schemas/sdl/instantiated-scenario-v1.json", + "stability": "draft", + "content_hash": "6849f6bf1353f2fb530b3fd4e62e854ff31d3e140fca23f975bdbc65390d62ef" }, { "contract_id": "operation-receipt-v1", - "schema_path": "contracts/schemas/control-plane/operation-receipt-v1.json" + "schema_path": "contracts/schemas/control-plane/operation-receipt-v1.json", + "stability": "draft", + "content_hash": "07bdbdaf5def120d43500da30a7ea38b736fdce8125e2cdb62b5b6851bf90504" }, { "contract_id": "operation-status-v1", - "schema_path": "contracts/schemas/control-plane/operation-status-v1.json" + "schema_path": "contracts/schemas/control-plane/operation-status-v1.json", + "stability": "draft", + "content_hash": "a4db430e1bef55cc954e9620fdd91897175baeab8d132088647642c02792f0ec" }, { "contract_id": "orchestration-plan-v1", - "schema_path": "contracts/schemas/plans/orchestration-plan-v1.json" + "schema_path": "contracts/schemas/plans/orchestration-plan-v1.json", + "stability": "draft", + "content_hash": "9f132b651267f5cafa460879550a49eb18d735ab1e02b241e664ec6c73275bae" }, { "contract_id": "participant-behavior-history-event-stream-v1", - "schema_path": "contracts/schemas/control-plane/participant-behavior-history-event-stream-v1.json" + "schema_path": "contracts/schemas/control-plane/participant-behavior-history-event-stream-v1.json", + "stability": "draft", + "content_hash": "4aeeaba0d4834a38bed72aca834733806fe8625d763b15e7d2dba764637f4234" }, { "contract_id": "participant-context-view-v1", - "schema_path": "contracts/schemas/control-plane/participant-context-view-v1.json" + "schema_path": "contracts/schemas/control-plane/participant-context-view-v1.json", + "stability": "draft", + "content_hash": "ff2e5866bde73898ead051cd5cfb20d32168ad3f8a6f9775d8481c728c40c24b" }, { "contract_id": "participant-episode-history-event-stream-v1", - "schema_path": "contracts/schemas/control-plane/participant-episode-history-event-stream-v1.json" + "schema_path": "contracts/schemas/control-plane/participant-episode-history-event-stream-v1.json", + "stability": "draft", + "content_hash": "9fb1a9b0edaf79987e66dd61a34d9c3d4f95f020424783778a2ba4f64eaa2272" }, { "contract_id": "participant-episode-state-envelope-v1", - "schema_path": "contracts/schemas/control-plane/participant-episode-state-envelope-v1.json" + "schema_path": "contracts/schemas/control-plane/participant-episode-state-envelope-v1.json", + "stability": "draft", + "content_hash": "e442e8c17d47fa275855664db993b53c501f0fb04e82b661f5bbe9fcb68ae39c" }, { "contract_id": "participant-history-view-v1", - "schema_path": "contracts/schemas/control-plane/participant-history-view-v1.json" + "schema_path": "contracts/schemas/control-plane/participant-history-view-v1.json", + "stability": "draft", + "content_hash": "380257f288c098c0b94e343dbeb6d51c5216c6b14d91543cc2c147dd06633435" }, { "contract_id": "participant-implementation-manifest-v1", - "schema_path": "contracts/schemas/participant-implementation-manifest/participant-implementation-manifest-v1.json" + "schema_path": "contracts/schemas/participant-implementation-manifest/participant-implementation-manifest-v1.json", + "stability": "draft", + "content_hash": "93e30291adbc08b1969adb9b938f888b004d317ac864e90151d0f43a395948e4" }, { "contract_id": "participant-implementation-provenance-v1", - "schema_path": "contracts/schemas/participant-implementation-provenance/participant-implementation-provenance-v1.json" + "schema_path": "contracts/schemas/participant-implementation-provenance/participant-implementation-provenance-v1.json", + "stability": "draft", + "content_hash": "a4c050fb2a53129148f7d2960a7cb06d36c4682483064734e505acda4677d7ec" }, { "contract_id": "participant-lifecycle-event-v1", - "schema_path": "contracts/schemas/participant-runtime/participant-lifecycle-event-v1.json" + "schema_path": "contracts/schemas/participant-runtime/participant-lifecycle-event-v1.json", + "stability": "draft", + "content_hash": "769ac4f54a36ec7e6031f584595555f7e8faea41ce0bf05b656aaf33e413d5c6" }, { "contract_id": "participant-observation-envelope-v1", - "schema_path": "contracts/schemas/participant-runtime/participant-observation-envelope-v1.json" + "schema_path": "contracts/schemas/participant-runtime/participant-observation-envelope-v1.json", + "stability": "draft", + "content_hash": "57c7ca12df146ce59b164551a5a87a2e72dd1f9b6644e3b0975b7da1a71e8d40" }, { "contract_id": "participant-outcome-report-v1", - "schema_path": "contracts/schemas/participant-runtime/participant-outcome-report-v1.json" + "schema_path": "contracts/schemas/participant-runtime/participant-outcome-report-v1.json", + "stability": "draft", + "content_hash": "1b65d36136812ae18c80ba7822df69b0ca65b569fd4cc2e2bd6c1fd07e5e4abf" }, { "contract_id": "participant-shared-state-record-v1", - "schema_path": "contracts/schemas/participant-runtime/participant-shared-state-record-v1.json" + "schema_path": "contracts/schemas/participant-runtime/participant-shared-state-record-v1.json", + "stability": "draft", + "content_hash": "a3ad56415946417bfbd19cd637886d8442a3e2be510212471e1fcb139cf241d1" }, { "contract_id": "participant-status-view-v1", - "schema_path": "contracts/schemas/control-plane/participant-status-view-v1.json" + "schema_path": "contracts/schemas/control-plane/participant-status-view-v1.json", + "stability": "draft", + "content_hash": "aef73b7d52c99de2a51cb2a58de0e30c37fc7fb2af5627cd6241a6aad288d07c" }, { "contract_id": "processor-manifest-v2", - "schema_path": "contracts/schemas/processor-manifest/processor-manifest-v2.json" + "schema_path": "contracts/schemas/processor-manifest/processor-manifest-v2.json", + "stability": "draft", + "content_hash": "f47537f72e1f607b634b0d1f9d8854cc8abd11e0edec7272549d4d910f38993e" }, { "contract_id": "provisioning-plan-v1", - "schema_path": "contracts/schemas/plans/provisioning-plan-v1.json" + "schema_path": "contracts/schemas/plans/provisioning-plan-v1.json", + "stability": "draft", + "content_hash": "e200fb7e8e68f05eb61c35cc2f724882e3b8977df7f4d5ae97e70401a1a76e01" }, { "contract_id": "reference-models-v1", - "schema_path": "contracts/schemas/concept-authority/reference-models-v1.json" + "schema_path": "contracts/schemas/concept-authority/reference-models-v1.json", + "stability": "draft", + "content_hash": "28ae8b46e4bcb4436a01adf3a70472b2f8ffc1fc8c13ff7ea8766492652f8267" }, { "contract_id": "runtime-snapshot-v1", - "schema_path": "contracts/schemas/snapshots/runtime-snapshot-v1.json" + "schema_path": "contracts/schemas/snapshots/runtime-snapshot-v1.json", + "stability": "draft", + "content_hash": "189e1fe99a31e64853238cea4423942577ceb0a86d2edf72ccf7031b6b1d7361" }, { "contract_id": "scenario-instantiation-request-v1", - "schema_path": "contracts/schemas/sdl/scenario-instantiation-request-v1.json" + "schema_path": "contracts/schemas/sdl/scenario-instantiation-request-v1.json", + "stability": "draft", + "content_hash": "72a4a1969bc39d551efd0be364804dcdfca7694eceb2a58c402a026d981cd470" }, { "contract_id": "sdl-authoring-input-v1", - "schema_path": "contracts/schemas/sdl/sdl-authoring-input-v1.json" + "schema_path": "contracts/schemas/sdl/sdl-authoring-input-v1.json", + "stability": "draft", + "content_hash": "805355fa8df9c0ca360f89c1379fb55052dcc176c83d80a0b8963096ffa38134" }, { "contract_id": "semantic-profile-v1", - "schema_path": "contracts/schemas/profiles/semantic-profile-v1.json" + "schema_path": "contracts/schemas/profiles/semantic-profile-v1.json", + "stability": "draft", + "content_hash": "ca78369c63879f21cf57b3ee29e2015163dbdc6a17d496b22ea98b1bd0b38bce" }, { "contract_id": "workflow-cancellation-request-v1", - "schema_path": "contracts/schemas/control-plane/workflow-cancellation-request-v1.json" + "schema_path": "contracts/schemas/control-plane/workflow-cancellation-request-v1.json", + "stability": "draft", + "content_hash": "89ab39ece6fb1473a0f967a50186b29e0c6581257db3ad113324b31d05401435" }, { "contract_id": "workflow-history-event-stream-v1", - "schema_path": "contracts/schemas/control-plane/workflow-history-event-stream-v1.json" + "schema_path": "contracts/schemas/control-plane/workflow-history-event-stream-v1.json", + "stability": "draft", + "content_hash": "6fa5f0a9b87da0cc1582204dc1324c5d7ffa30c2171aba74aedbbda7e109c243" }, { "contract_id": "workflow-result-envelope-v1", - "schema_path": "contracts/schemas/control-plane/workflow-result-envelope-v1.json" + "schema_path": "contracts/schemas/control-plane/workflow-result-envelope-v1.json", + "stability": "draft", + "content_hash": "7050c30c073fa690f17b17f43a1310e9200892ec8a6f5c0fa1efd790a2bfc442" } ] } diff --git a/docs/decisions/adrs/README.md b/docs/decisions/adrs/README.md index 8a7035741..cf465bab0 100644 --- a/docs/decisions/adrs/README.md +++ b/docs/decisions/adrs/README.md @@ -105,6 +105,7 @@ adr-057-runtime-secret-name-classifier-boundaries adr-058-datastore-node-engine-provenance-and-endpoints adr-059-adr-amendment-policy-and-pin-gate adr-060-participant-backend-facing-contract-surface +adr-061-published-schema-evolution-policy ``` | ADR | Title | Status | Date | @@ -170,3 +171,4 @@ adr-060-participant-backend-facing-contract-surface | [058](adr-058-datastore-node-engine-provenance-and-endpoints.md) | Datastore Node Engine Provenance and Endpoints | accepted | 2026-06-07 | | [059](adr-059-adr-amendment-policy-and-pin-gate.md) | ADR Amendment Policy and Acceptance-Content Pin Gate | accepted | 2026-06-10 | | [060](adr-060-participant-backend-facing-contract-surface.md) | Participant Backend-Facing Contract Surface | proposed | 2026-06-11 | +| [061](adr-061-published-schema-evolution-policy.md) | Published Schema Evolution Policy | accepted | 2026-06-14 | diff --git a/docs/decisions/adrs/adr-061-published-schema-evolution-policy.md b/docs/decisions/adrs/adr-061-published-schema-evolution-policy.md new file mode 100644 index 000000000..8a3106a51 --- /dev/null +++ b/docs/decisions/adrs/adr-061-published-schema-evolution-policy.md @@ -0,0 +1,142 @@ +# ADR-061: Published Schema Evolution Policy + +## Status + +accepted + +## Date + +2026-06-14 + +## Classification + +Classification: FM2 +Required artifacts: ADR, manifest metadata, checker gate, regression tests +Waivers: none + +## Context + +`contracts/schemas/` publishes JSON Schema documents with versioned filenames, +but the repository did not define what the version suffix promises. A file named +`*-v1.json` could change structurally while keeping the same contract id, and +`contracts/schema-publication-manifest.json` only proved that every schema was +listed. It did not say whether a listed schema was stable, whether drift was +recorded, or what change must mint a new suffix. + +This gap matters for GOV-901: versioning and compatibility rules must cover +contract surfaces, not only Python package or repository-layout compatibility. +ADR-010 remains the policy for repository realignment and internal package +compatibility. Published JSON Schemas need their own concrete policy because +independent processors, backends, profiles, and fixtures can target these +files. + +## Decision + +`contracts/schema-publication-manifest.json` is the only publication registry +for machine-readable schemas. Each entry records: + +- `contract_id`: the published contract identifier, matching the schema + filename stem; +- `schema_path`: the repo-relative schema path under `contracts/schemas/`; +- `stability`: one of `draft` or `stable`; and +- `content_hash`: the canonical JSON `sha256` digest of the schema content. + +The manifest root records `hash_algorithm: sha256`. The digest is computed from +parsed JSON with deterministic key ordering, so formatting-only churn does not +change the recorded schema identity. + +### Stability + +`draft` means the schema is published for repository-internal coordination and +early consumers, but it is not compatibility-guaranteed. Draft schemas may +change under the same version suffix, but every checked-in change must update +the manifest hash so churn is visible in review. + +`stable` means the schema is a compatibility surface. Stable schemas may accept +additive changes under the same suffix, but breaking changes require a new +version suffix and contract id, for example `example-contract-v1` to +`example-contract-v2`. The old stable schema stays available until a documented +deprecation or removal window completes. + +The current checked-in schemas are `draft`. A `v1` or `v2` suffix identifies the +schema lineage; it does not by itself mean the schema is stable. + +### Additive And Breaking Changes + +For published JSON Schemas, additive changes include: + +- adding an optional property; +- adding enum values; +- loosening validation constraints; and +- changing annotations such as descriptions, titles, examples, or comments. + +Breaking changes include: + +- removing a property; +- retyping a property or changing its `$ref`, composition, item, or constraint + shape incompatibly; +- adding a newly required property; +- removing enum values; +- tightening `additionalProperties`; +- tightening ranges, patterns, formats, string lengths, array bounds, or object + cardinality; and +- changing defaults or prose semantics when consumers could observe a different + contract meaning. + +When a change has both additive and breaking parts, treat the whole schema edit +as breaking. + +### Version Bumps And Deprecation + +A stable breaking change is delivered by adding a new schema file and contract +id with the next version suffix. The previous stable schema remains listed in +the manifest unless a later release removes it under an explicit deprecation +record. Deprecation records live in the relevant ADR or contract documentation +and release notes; this ADR sets the expectation, while the first automated +gate only enforces manifest shape, hashes, and stable in-place breaking edits. + +### Enforcement + +`tools/check_schema_publication.py` enforces the publication registry: + +- every checked-in schema under `contracts/schemas/` is listed exactly once; +- every entry stays under `contracts/schemas/` and matches its filename stem; +- every entry declares `stability` and a canonical content hash; and +- when invoked with a base revision, an incompatible structural edit to a + `stable` schema under the same `contract_id` fails. + +The stable change classifier is intentionally conservative. Existing-property +schema fingerprint changes, property removals, newly required properties, enum +removals, and `additionalProperties` tightening are breaking until a future ADR +narrows the rule. + +## Alternatives Considered + +Extend ADR-010 to cover schemas. Rejected: ADR-010 is about repository +realignment and internal package compatibility. Stretching it would make the +published schema contract depend on an unrelated migration decision. + +Create a second sidecar ledger. Rejected: a separate registry would drift from +the existing publication manifest. The manifest is already the canonical schema +inventory, so the policy metadata belongs there. + +Declare all current `v1` and `v2` schemas stable. Rejected: the repository is +still evolving these surfaces, and issue #497 was opened because current +schemas mutate under constant suffixes. Calling them stable would make the +claim false on day one. + +Block every stable schema change. Rejected: optional additions and loosening +changes can be compatible. Stable means compatibility-governed, not frozen. + +## Consequences + +Consumers can distinguish lineage version from stability. A current `v1` draft +schema can change, but the manifest records the exact content. A future stable +schema can evolve additively, while breaking changes must create a new suffix. + +The checker remains filesystem and Git based. It does not call Ground Control, +does not read secrets, and does not depend on CI-only environment variables. + +The initial classifier does not model every JSON Schema compatibility edge. It +prioritizes clear breaking cases and leaves more precise deprecation-window and +semantic compatibility checks for later policy work under GOV-901. diff --git a/docs/decisions/adrs/adr-index.yaml b/docs/decisions/adrs/adr-index.yaml index b4e981806..f69195dec 100644 --- a/docs/decisions/adrs/adr-index.yaml +++ b/docs/decisions/adrs/adr-index.yaml @@ -246,3 +246,6 @@ adrs: - id: ADR-059 path: docs/decisions/adrs/adr-059-adr-amendment-policy-and-pin-gate.md pin: dd089a4cd415280fb87d278b70537d4a1144d2f28198314a22031fc88eaf5ce2 + - id: ADR-061 + path: docs/decisions/adrs/adr-061-published-schema-evolution-policy.md + pin: 08849a676cdaaf21bd1ac425f552d6347032d3f545b9cb5618090d22c56cd5dd diff --git a/implementations/python/tests/test_repo_policy_tools.py b/implementations/python/tests/test_repo_policy_tools.py index 2b6f24640..905affcc7 100644 --- a/implementations/python/tests/test_repo_policy_tools.py +++ b/implementations/python/tests/test_repo_policy_tools.py @@ -23,7 +23,7 @@ ) from tools.check_generated_schemas import _extra_published_schema_paths from tools.check_json_artifacts import collect_validation_targets, should_run_full_validation -from tools.check_schema_publication import validate_schema_publication_manifest +from tools.check_schema_publication import schema_content_hash, validate_schema_publication_manifest from tools.gitleaks_tool import _checksums_asset_name, _release_asset_name, gitleaks_binary_path from tools.policy.common import PolicyFailure from tools.policy.repo_policy import evaluate_repo_policy @@ -996,15 +996,52 @@ def setup_json_validation_repo(tmp_path: Path) -> Path: return tmp_path -def write_schema_publication_manifest(repo_root: Path, entries: list[dict[str, str]]) -> None: +def write_schema_publication_manifest( + repo_root: Path, + entries: list[dict[str, Any]], + *, + fill_defaults: bool = True, +) -> None: import json + normalized: list[dict[str, Any]] = [] + for entry in entries: + normalized_entry = dict(entry) + if fill_defaults: + normalized_entry.setdefault("stability", "draft") + schema_path = normalized_entry.get("schema_path") + if "content_hash" not in normalized_entry and isinstance(schema_path, str): + path = repo_root / schema_path + normalized_entry["content_hash"] = schema_content_hash(path) if path.is_file() else "0" * 64 + normalized.append(normalized_entry) + write_text( repo_root / "contracts" / "schema-publication-manifest.json", - json.dumps({"schema_version": "schema-publication-manifest/v1", "schemas": entries}, indent=2) + "\n", + json.dumps( + { + "schema_version": "schema-publication-manifest/v1", + "hash_algorithm": "sha256", + "schemas": normalized, + }, + indent=2, + ) + + "\n", ) +def _published_schema(properties: dict[str, Any], *, required: list[str] | None = None) -> str: + import json + + payload = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": False, + "properties": properties, + "required": required or [], + } + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + + def test_should_run_full_validation_for_schema_driver_paths() -> None: assert should_run_full_validation(["tools/generate_contract_schemas.py"]) is True assert should_run_full_validation(["implementations/python/packages/aces_contracts/contracts.py"]) is True @@ -1035,6 +1072,26 @@ def test_schema_publication_manifest_accepts_complete_current_schema_inventory(t assert validate_schema_publication_manifest(repo_root) == [] +def test_schema_publication_manifest_requires_stability_and_content_hash(tmp_path: Path) -> None: + repo_root = tmp_path + write_text(repo_root / "contracts" / "schemas" / "sdl" / "sdl-authoring-input-v1.json", "{}\n") + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "sdl-authoring-input-v1", + "schema_path": "contracts/schemas/sdl/sdl-authoring-input-v1.json", + }, + ], + fill_defaults=False, + ) + + assert validate_schema_publication_manifest(repo_root) == [ + "schema manifest entry sdl-authoring-input-v1 stability must be one of: draft, stable", + "schema manifest entry sdl-authoring-input-v1 content_hash must be a 64-character sha256 hex digest", + ] + + def test_schema_publication_manifest_rejects_missing_published_schema_entry(tmp_path: Path) -> None: repo_root = tmp_path write_text(repo_root / "contracts" / "schemas" / "sdl" / "sdl-authoring-input-v1.json", "{}\n") @@ -1072,6 +1129,267 @@ def test_schema_publication_manifest_rejects_paths_outside_contract_schemas(tmp_ ] +def test_schema_publication_manifest_rejects_resolved_schema_path_escape(tmp_path: Path) -> None: + repo_root = tmp_path + write_text(repo_root / "contracts" / "secret-v1.json", "{}\n") + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "secret-v1", + "schema_path": "contracts/schemas/../secret-v1.json", + }, + ], + ) + + assert validate_schema_publication_manifest(repo_root) == [ + "schema manifest path resolves outside contracts/schemas/: contracts/schemas/../secret-v1.json" + ] + + +def test_schema_publication_manifest_allows_recorded_draft_schema_churn(tmp_path: Path) -> None: + repo_root = tmp_path + schema_path = repo_root / "contracts" / "schemas" / "sdl" / "draft-contract-v1.json" + write_text(schema_path, _published_schema({"name": {"type": "string"}})) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "draft-contract-v1", + "schema_path": "contracts/schemas/sdl/draft-contract-v1.json", + "stability": "draft", + }, + ], + ) + _init_git_repo(repo_root) + _git_commit_all(repo_root, "base") + + write_text(schema_path, _published_schema({"name": {"type": "integer"}})) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "draft-contract-v1", + "schema_path": "contracts/schemas/sdl/draft-contract-v1.json", + "stability": "draft", + }, + ], + ) + + assert validate_schema_publication_manifest(repo_root, base_rev="HEAD") == [] + + +def test_schema_publication_manifest_allows_stable_additive_schema_change(tmp_path: Path) -> None: + repo_root = tmp_path + schema_path = repo_root / "contracts" / "schemas" / "sdl" / "stable-contract-v1.json" + write_text(schema_path, _published_schema({"name": {"type": "string"}}, required=["name"])) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "stable-contract-v1", + "schema_path": "contracts/schemas/sdl/stable-contract-v1.json", + "stability": "stable", + }, + ], + ) + _init_git_repo(repo_root) + _git_commit_all(repo_root, "base") + + write_text( + schema_path, + _published_schema({"name": {"type": "string"}, "display_name": {"type": "string"}}, required=["name"]), + ) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "stable-contract-v1", + "schema_path": "contracts/schemas/sdl/stable-contract-v1.json", + "stability": "stable", + }, + ], + ) + + assert validate_schema_publication_manifest(repo_root, base_rev="HEAD") == [] + + +def test_schema_publication_manifest_allows_stable_enum_addition(tmp_path: Path) -> None: + repo_root = tmp_path + schema_path = repo_root / "contracts" / "schemas" / "sdl" / "stable-contract-v1.json" + write_text(schema_path, _published_schema({"kind": {"enum": ["alpha"], "type": "string"}}, required=["kind"])) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "stable-contract-v1", + "schema_path": "contracts/schemas/sdl/stable-contract-v1.json", + "stability": "stable", + }, + ], + ) + _init_git_repo(repo_root) + _git_commit_all(repo_root, "base") + + write_text( + schema_path, + _published_schema({"kind": {"enum": ["alpha", "beta"], "type": "string"}}, required=["kind"]), + ) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "stable-contract-v1", + "schema_path": "contracts/schemas/sdl/stable-contract-v1.json", + "stability": "stable", + }, + ], + ) + + assert validate_schema_publication_manifest(repo_root, base_rev="HEAD") == [] + + +def test_schema_publication_manifest_rejects_stable_default_change_without_version_bump( + tmp_path: Path, +) -> None: + repo_root = tmp_path + schema_path = repo_root / "contracts" / "schemas" / "sdl" / "stable-contract-v1.json" + write_text( + schema_path, + _published_schema({"name": {"default": "alpha", "type": "string"}}, required=["name"]), + ) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "stable-contract-v1", + "schema_path": "contracts/schemas/sdl/stable-contract-v1.json", + "stability": "stable", + }, + ], + ) + _init_git_repo(repo_root) + _git_commit_all(repo_root, "base") + + write_text( + schema_path, + _published_schema({"name": {"default": "beta", "type": "string"}}, required=["name"]), + ) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "stable-contract-v1", + "schema_path": "contracts/schemas/sdl/stable-contract-v1.json", + "stability": "stable", + }, + ], + ) + + assert validate_schema_publication_manifest(repo_root, base_rev="HEAD") == [ + "stable schema stable-contract-v1 changed incompatibly without a version bump: properties/name default changed" + ] + + +def test_schema_publication_manifest_rejects_stable_breaking_schema_change_without_version_bump( + tmp_path: Path, +) -> None: + repo_root = tmp_path + schema_path = repo_root / "contracts" / "schemas" / "sdl" / "stable-contract-v1.json" + write_text(schema_path, _published_schema({"name": {"type": "string"}}, required=["name"])) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "stable-contract-v1", + "schema_path": "contracts/schemas/sdl/stable-contract-v1.json", + "stability": "stable", + }, + ], + ) + _init_git_repo(repo_root) + _git_commit_all(repo_root, "base") + + write_text(schema_path, _published_schema({"name": {"type": "integer"}}, required=["name"])) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "stable-contract-v1", + "schema_path": "contracts/schemas/sdl/stable-contract-v1.json", + "stability": "stable", + }, + ], + ) + + assert validate_schema_publication_manifest(repo_root, base_rev="HEAD") == [ + "stable schema stable-contract-v1 changed incompatibly without a version bump: properties/name schema changed" + ] + + +def test_schema_publication_manifest_rejects_unreadable_base_manifest_for_stable_schema( + tmp_path: Path, +) -> None: + repo_root = tmp_path + schema_path = repo_root / "contracts" / "schemas" / "sdl" / "stable-contract-v1.json" + write_text(schema_path, _published_schema({"name": {"type": "string"}}, required=["name"])) + write_text(repo_root / "contracts" / "schema-publication-manifest.json", "{not-json\n") + _init_git_repo(repo_root) + _git_commit_all(repo_root, "base") + + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "stable-contract-v1", + "schema_path": "contracts/schemas/sdl/stable-contract-v1.json", + "stability": "stable", + }, + ], + ) + + assert validate_schema_publication_manifest(repo_root, base_rev="HEAD") == [ + "base schema publication manifest at HEAD is not valid JSON" + ] + + +def test_schema_publication_manifest_rejects_missing_base_schema_for_stable_schema( + tmp_path: Path, +) -> None: + repo_root = tmp_path + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "stable-contract-v1", + "schema_path": "contracts/schemas/sdl/stable-contract-v1.json", + "stability": "stable", + "content_hash": "0" * 64, + }, + ], + fill_defaults=False, + ) + _init_git_repo(repo_root) + _git_commit_all(repo_root, "base") + + schema_path = repo_root / "contracts" / "schemas" / "sdl" / "stable-contract-v1.json" + write_text(schema_path, _published_schema({"name": {"type": "string"}}, required=["name"])) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "stable-contract-v1", + "schema_path": "contracts/schemas/sdl/stable-contract-v1.json", + "stability": "stable", + }, + ], + ) + + assert validate_schema_publication_manifest(repo_root, base_rev="HEAD") == [ + "base schema for stable-contract-v1 is missing at HEAD: contracts/schemas/sdl/stable-contract-v1.json" + ] + + def test_collect_validation_targets_includes_only_schema_governed_artifacts(tmp_path: Path) -> None: repo_root = setup_json_validation_repo(tmp_path) diff --git a/noxfile.py b/noxfile.py index 00a86b5d5..f0a0cb889 100644 --- a/noxfile.py +++ b/noxfile.py @@ -558,9 +558,15 @@ def _run_policy(session: nox.Session, reporter: SessionReporter, *args: str) -> def _run_contracts(session: nox.Session, reporter: SessionReporter, *args: str) -> None: _sync_project(session) + arg_list = list(args) + schema_publication_args: list[str] = [] + if "--base-rev" in arg_list: + base_index = arg_list.index("--base-rev") + if base_index + 1 < len(arg_list): + schema_publication_args = ["--base-rev", arg_list[base_index + 1]] reporter.run( "contracts / schema publication manifest", - lambda: _run_project_python(session, "tools/check_schema_publication.py"), + lambda: _run_project_python(session, "tools/check_schema_publication.py", *schema_publication_args), ) reporter.run( "contracts / generated schema drift", @@ -828,7 +834,7 @@ def verify(session: nox.Session) -> None: ) _run_policy(session, reporter, *session.posargs) _run_lint(session, reporter) - _run_contracts(session, reporter) + _run_contracts(session, reporter, *session.posargs) _run_tests(session, reporter) _run_integration_tests(session, reporter) _run_docs(session, reporter) diff --git a/tools/check_schema_publication.py b/tools/check_schema_publication.py index a444e4079..6a3164791 100644 --- a/tools/check_schema_publication.py +++ b/tools/check_schema_publication.py @@ -3,8 +3,14 @@ from __future__ import annotations +import argparse +import hashlib import json +import re +import subprocess import sys +from collections.abc import Iterable +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -12,6 +18,38 @@ MANIFEST_PATH = Path("contracts/schema-publication-manifest.json") SCHEMAS_PREFIX = "contracts/schemas/" SCHEMA_VERSION = "schema-publication-manifest/v1" +HASH_ALGORITHM = "sha256" +STABILITY_VALUES = {"draft", "stable"} +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + +JSON_SCHEMA_ANNOTATION_KEYS = { + "$comment", + "$id", + "$schema", + "deprecated", + "description", + "examples", + "readOnly", + "title", + "writeOnly", +} +OBJECT_COMPATIBILITY_CHILD_KEYS = { + "additionalProperties", + "patternProperties", + "properties", + "required", +} +PROPERTY_SPECIAL_COMPATIBILITY_KEYS = {"default", "enum"} +_MISSING = object() + + +@dataclass(frozen=True) +class ManifestEntry: + contract_id: str + schema_path: str + stability: str + content_hash: str + schema: Any def _published_schema_paths(repo_root: Path) -> set[str]: @@ -32,7 +70,266 @@ def _load_manifest(repo_root: Path) -> tuple[dict[str, Any] | None, list[str]]: return payload, [] -def validate_schema_publication_manifest(repo_root: Path = REPO_ROOT) -> list[str]: +def _canonical_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def _schema_content_digest(schema: Any) -> str: + return hashlib.sha256(_canonical_json(schema).encode("utf-8")).hexdigest() + + +def schema_content_hash(path: Path) -> str: + """Return the canonical JSON sha256 for a published schema file.""" + + return _schema_content_digest(json.loads(path.read_text(encoding="utf-8"))) + + +def _load_schema(path: Path, schema_path: str) -> tuple[Any | None, str | None]: + try: + return json.loads(path.read_text(encoding="utf-8")), None + except json.JSONDecodeError as exc: + return None, f"schema manifest path is not valid JSON: {schema_path}: {exc.msg}" + + +def _git_show(repo_root: Path, gitref: str) -> str | None: + proc = subprocess.run( + ["git", "show", gitref], + cwd=repo_root, + capture_output=True, + text=True, + ) + if proc.returncode != 0: + return None + return proc.stdout + + +def _manifest_entries(payload: dict[str, Any]) -> dict[str, dict[str, Any]]: + entries = payload.get("schemas") + if not isinstance(entries, list): + return {} + by_id: dict[str, dict[str, Any]] = {} + for entry in entries: + if not isinstance(entry, dict): + continue + contract_id = entry.get("contract_id") + if isinstance(contract_id, str): + by_id[contract_id] = entry + return by_id + + +def _json_path(base: str, *parts: str) -> str: + escaped = [part.replace("~", "~0").replace("/", "~1") for part in parts] + return "/".join([part for part in (base, *escaped) if part]) + + +def _object_schema_nodes(schema: Any, path: str = "") -> dict[str, dict[str, Any]]: + nodes: dict[str, dict[str, Any]] = {} + if isinstance(schema, dict): + if any(key in schema for key in OBJECT_COMPATIBILITY_CHILD_KEYS): + nodes[path] = schema + for key, value in schema.items(): + if isinstance(value, dict): + nodes.update(_object_schema_nodes(value, _json_path(path, key))) + elif isinstance(value, list): + for index, item in enumerate(value): + nodes.update(_object_schema_nodes(item, _json_path(path, key, str(index)))) + return nodes + + +def _without_annotations(value: Any) -> Any: + if isinstance(value, dict): + ignored = set(JSON_SCHEMA_ANNOTATION_KEYS) + return {key: _without_annotations(item) for key, item in sorted(value.items()) if key not in ignored} + if isinstance(value, list): + return [_without_annotations(item) for item in value] + return value + + +def _property_core(value: Any) -> Any: + if not isinstance(value, dict): + return _without_annotations(value) + ignored = set(JSON_SCHEMA_ANNOTATION_KEYS) + ignored.update(OBJECT_COMPATIBILITY_CHILD_KEYS) + ignored.update(PROPERTY_SPECIAL_COMPATIBILITY_KEYS) + return {key: _without_annotations(item) for key, item in sorted(value.items()) if key not in ignored} + + +def _enum_values(value: Any) -> list[Any] | None: + if not isinstance(value, dict): + return None + enum = value.get("enum") + if not isinstance(enum, list): + return None + return enum + + +def _enum_missing_values(old_enum: list[Any], new_enum: list[Any]) -> list[Any]: + new_keys = {_canonical_json(value) for value in new_enum} + return [value for value in old_enum if _canonical_json(value) not in new_keys] + + +def _render_enum_value(value: Any) -> str: + if isinstance(value, str): + return value + return _canonical_json(value) + + +def _property_breaking_changes(pointer: str, old_property: Any, new_property: Any) -> list[str]: + changes: list[str] = [] + if not isinstance(old_property, dict) or not isinstance(new_property, dict): + if _without_annotations(old_property) != _without_annotations(new_property): + changes.append(f"{pointer} schema changed") + return changes + + old_default = old_property.get("default", _MISSING) + new_default = new_property.get("default", _MISSING) + if old_default != new_default: + changes.append(f"{pointer} default changed") + + old_enum = _enum_values(old_property) + new_enum = _enum_values(new_property) + if old_enum is None and new_enum is not None: + changes.append(f"{pointer} enum constraint added") + elif old_enum is not None and new_enum is None: + changes.append(f"{pointer} enum constraint removed") + elif old_enum is not None and new_enum is not None: + missing_values = _enum_missing_values(old_enum, new_enum) + if missing_values: + rendered = ", ".join(_render_enum_value(value) for value in missing_values) + changes.append(f"{pointer} enum values removed: {rendered}") + + if _property_core(old_property) != _property_core(new_property): + changes.append(f"{pointer} schema changed") + return changes + + +def _required_values(schema: dict[str, Any]) -> set[str]: + values = schema.get("required") + if not isinstance(values, list): + return set() + return {value for value in values if isinstance(value, str)} + + +def _properties(schema: dict[str, Any]) -> dict[str, Any]: + values = schema.get("properties") + if not isinstance(values, dict): + return {} + return values + + +def _schema_breaking_changes(old_schema: Any, new_schema: Any) -> list[str]: + changes: list[str] = [] + old_nodes = _object_schema_nodes(old_schema) + new_nodes = _object_schema_nodes(new_schema) + + for pointer in sorted(old_nodes): + old_node = old_nodes[pointer] + new_node = new_nodes.get(pointer) + if new_node is None: + changes.append(f"{pointer or ''} object schema removed") + continue + + old_properties = _properties(old_node) + new_properties = _properties(new_node) + for name in sorted(set(old_properties) - set(new_properties)): + changes.append(f"{_json_path(pointer, 'properties', name)} removed") + for name in sorted(set(old_properties) & set(new_properties)): + changes.extend( + _property_breaking_changes( + _json_path(pointer, "properties", name), + old_properties[name], + new_properties[name], + ) + ) + + old_required = _required_values(old_node) + new_required = _required_values(new_node) + for name in sorted(new_required - old_required): + changes.append(f"{_json_path(pointer, 'required', name)} newly required") + + old_additional = old_node.get("additionalProperties") + new_additional = new_node.get("additionalProperties") + if old_additional is not False and new_additional is False: + changes.append(f"{pointer or ''} additionalProperties tightened") + + return changes + + +def _load_base_manifest(repo_root: Path, base_rev: str) -> tuple[dict[str, dict[str, Any]] | None, str | None]: + text = _git_show(repo_root, f"{base_rev}:{MANIFEST_PATH.as_posix()}") + if text is None: + return None, f"base schema publication manifest is missing at {base_rev}" + try: + payload = json.loads(text) + except json.JSONDecodeError: + return None, f"base schema publication manifest at {base_rev} is not valid JSON" + if not isinstance(payload, dict): + return None, f"base schema publication manifest at {base_rev} must be a JSON object" + return _manifest_entries(payload), None + + +def _check_stable_schema_changes( + repo_root: Path, + current_entries: Iterable[ManifestEntry], + *, + base_rev: str, +) -> list[str]: + failures: list[str] = [] + stable_entries = [entry for entry in current_entries if entry.stability == "stable"] + if not stable_entries: + return failures + base_entries, base_error = _load_base_manifest(repo_root, base_rev) + if base_error is not None or base_entries is None: + return [base_error or f"base schema publication manifest at {base_rev} could not be read"] + for entry in stable_entries: + base_entry = base_entries.get(entry.contract_id) + if not base_entry or base_entry.get("stability") != "stable": + continue + base_path = base_entry.get("schema_path") + if not isinstance(base_path, str): + continue + if base_path != entry.schema_path: + failures.append( + f"stable schema {entry.contract_id} changed schema_path without a version bump: " + f"{base_path} -> {entry.schema_path}" + ) + continue + base_text = _git_show(repo_root, f"{base_rev}:{base_path}") + if base_text is None: + failures.append(f"base schema for {entry.contract_id} is missing at {base_rev}: {base_path}") + continue + try: + base_schema = json.loads(base_text) + except json.JSONDecodeError: + failures.append(f"base schema for {entry.contract_id} at {base_rev}:{base_path} is not valid JSON") + continue + if _schema_content_digest(base_schema) == entry.content_hash: + continue + breaking_changes = _schema_breaking_changes(base_schema, entry.schema) + if breaking_changes: + failures.append( + f"stable schema {entry.contract_id} changed incompatibly without a version bump: " + f"{'; '.join(breaking_changes)}" + ) + return failures + + +def _safe_schema_path(repo_root: Path, schema_path: str) -> tuple[Path | None, str | None]: + schemas_root = (repo_root / SCHEMAS_PREFIX).resolve() + candidate = repo_root / schema_path + try: + resolved = candidate.resolve(strict=False) + resolved.relative_to(schemas_root) + except (OSError, ValueError): + return None, f"schema manifest path resolves outside contracts/schemas/: {schema_path}" + return resolved, None + + +def validate_schema_publication_manifest( + repo_root: Path = REPO_ROOT, + *, + base_rev: str | None = None, +) -> list[str]: """Return validation failures for the checked-in schema publication manifest.""" payload, failures = _load_manifest(repo_root) @@ -41,6 +338,8 @@ def validate_schema_publication_manifest(repo_root: Path = REPO_ROOT) -> list[st if payload.get("schema_version") != SCHEMA_VERSION: failures.append(f"schema manifest schema_version must be {SCHEMA_VERSION!r}") + if payload.get("hash_algorithm") != HASH_ALGORITHM: + failures.append(f"schema manifest hash_algorithm must be {HASH_ALGORITHM!r}") entries = payload.get("schemas") if not isinstance(entries, list) or not entries: @@ -48,7 +347,9 @@ def validate_schema_publication_manifest(repo_root: Path = REPO_ROOT) -> list[st return failures manifest_paths: set[str] = set() + seen_manifest_paths: set[str] = set() manifest_ids: set[str] = set() + validated_entries: list[ManifestEntry] = [] previous_id = "" for index, entry in enumerate(entries): if not isinstance(entry, dict): @@ -57,12 +358,20 @@ def validate_schema_publication_manifest(repo_root: Path = REPO_ROOT) -> list[st contract_id = entry.get("contract_id") schema_path = entry.get("schema_path") + stability = entry.get("stability") + content_hash = entry.get("content_hash") if not isinstance(contract_id, str) or not contract_id: failures.append(f"schema manifest entry {index} contract_id must be a non-empty string") continue if not isinstance(schema_path, str) or not schema_path: failures.append(f"schema manifest entry {contract_id} schema_path must be a non-empty string") continue + if stability not in STABILITY_VALUES: + failures.append(f"schema manifest entry {contract_id} stability must be one of: draft, stable") + if not isinstance(content_hash, str) or not SHA256_RE.match(content_hash): + failures.append( + f"schema manifest entry {contract_id} content_hash must be a 64-character sha256 hex digest" + ) if contract_id <= previous_id: failures.append("schema manifest entries must be sorted by contract_id") @@ -72,9 +381,9 @@ def validate_schema_publication_manifest(repo_root: Path = REPO_ROOT) -> list[st failures.append(f"schema manifest contains duplicate contract_id: {contract_id}") manifest_ids.add(contract_id) - if schema_path in manifest_paths: + if schema_path in seen_manifest_paths: failures.append(f"schema manifest contains duplicate schema_path: {schema_path}") - manifest_paths.add(schema_path) + seen_manifest_paths.add(schema_path) if schema_path != Path(schema_path).as_posix() or schema_path.startswith(("/", "../")): failures.append(f"schema manifest path must be a normalized repo-relative path: {schema_path}") @@ -86,12 +395,37 @@ def validate_schema_publication_manifest(repo_root: Path = REPO_ROOT) -> list[st failures.append(f"schema manifest path must point to a JSON schema file: {schema_path}") continue - path = repo_root / schema_path + path, path_error = _safe_schema_path(repo_root, schema_path) + if path_error is not None or path is None: + failures.append(path_error or f"schema manifest path is unsafe: {schema_path}") + continue + manifest_paths.add(schema_path) if not path.is_file(): failures.append(f"schema manifest path does not exist: {schema_path}") continue if path.stem != contract_id: failures.append(f"schema manifest contract_id {contract_id!r} must match schema filename {path.stem!r}") + schema, schema_error = _load_schema(path, schema_path) + if schema_error is not None: + failures.append(schema_error) + continue + if isinstance(content_hash, str) and SHA256_RE.match(content_hash): + actual_hash = _schema_content_digest(schema) + if actual_hash != content_hash: + failures.append( + f"schema manifest entry {contract_id} content_hash {content_hash} does not match " + f"canonical schema hash {actual_hash}" + ) + if stability in STABILITY_VALUES and isinstance(content_hash, str) and SHA256_RE.match(content_hash): + validated_entries.append( + ManifestEntry( + contract_id=contract_id, + schema_path=schema_path, + stability=stability, + content_hash=content_hash, + schema=schema, + ) + ) published_paths = _published_schema_paths(repo_root) for path in sorted(published_paths - manifest_paths): @@ -100,11 +434,21 @@ def validate_schema_publication_manifest(repo_root: Path = REPO_ROOT) -> list[st if path.startswith(SCHEMAS_PREFIX): failures.append(f"schema manifest references unpublished schema: {path}") + if not failures and base_rev: + failures.extend(_check_stable_schema_changes(repo_root, validated_entries, base_rev=base_rev)) + return failures -def main() -> int: - failures = validate_schema_publication_manifest(REPO_ROOT) +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Verify the authoritative schema publication manifest.") + parser.add_argument("--base-rev", help="Compare stable schemas against a base git revision.") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + failures = validate_schema_publication_manifest(REPO_ROOT, base_rev=args.base_rev) if failures: for failure in failures: print(f"[schema-publication] {failure}", file=sys.stderr) From e624dea0c0ad6c9e96fd215a33c21570b044757d Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 04:29:49 +0200 Subject: [PATCH 36/70] Fix verify contract argument filtering --- noxfile.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/noxfile.py b/noxfile.py index f0a0cb889..44022b907 100644 --- a/noxfile.py +++ b/noxfile.py @@ -560,10 +560,29 @@ def _run_contracts(session: nox.Session, reporter: SessionReporter, *args: str) _sync_project(session) arg_list = list(args) schema_publication_args: list[str] = [] - if "--base-rev" in arg_list: - base_index = arg_list.index("--base-rev") - if base_index + 1 < len(arg_list): - schema_publication_args = ["--base-rev", arg_list[base_index + 1]] + json_artifact_args: list[str] = [] + index = 0 + while index < len(arg_list): + arg = arg_list[index] + if arg == "--staged": + json_artifact_args.append(arg) + index += 1 + continue + if arg == "--base-rev": + if index + 1 < len(arg_list): + base_rev = arg_list[index + 1] + schema_publication_args = ["--base-rev", base_rev] + json_artifact_args.extend(["--base-rev", base_rev]) + index += 2 + continue + if arg == "--requirement-uid": + index += 2 + continue + if arg == "--skip-requirement" or arg.startswith("-"): + index += 1 + continue + json_artifact_args.append(arg) + index += 1 reporter.run( "contracts / schema publication manifest", lambda: _run_project_python(session, "tools/check_schema_publication.py", *schema_publication_args), @@ -574,7 +593,7 @@ def _run_contracts(session: nox.Session, reporter: SessionReporter, *args: str) ) reporter.run( "contracts / json artifact validation", - lambda: _run_project_python(session, "tools/check_json_artifacts.py", *args), + lambda: _run_project_python(session, "tools/check_json_artifacts.py", *json_artifact_args), ) From 33ded616e7cf6cd7d318a0c1e76998cc81e233e2 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 06:54:16 +0200 Subject: [PATCH 37/70] Add runtime-inventory concept family and node runtime reference model --- changelog.d/493.changed.md | 4 ++ changelog.d/527.security.md | 5 ++ .../concept-families-v1.json | 16 +++++ .../reference-models-v1.json | 25 ++++++++ .../concept-families-v1/valid/reference.json | 16 +++++ .../reference-models-v1/valid/reference.json | 25 ++++++++ .../valid/reference-stack-v1.json | 1 + .../profiles/semantic/reference-stack-v1.json | 1 + .../packages/aces_contracts/contracts.py | 39 ++++++++++-- .../python/tests/test_concept_authority.py | 20 ++++++ .../python/tests/test_reference_models.py | 63 +++++++++++++++++++ .../python/tests/test_semantic_profiles.py | 12 ++++ specs/concept-authority/concept-authority.md | 46 ++++++++++++++ specs/concept-authority/reference-models.md | 19 ++++++ specs/concept-authority/semantic-profiles.md | 8 +++ 15 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 changelog.d/493.changed.md create mode 100644 changelog.d/527.security.md diff --git a/changelog.d/493.changed.md b/changelog.d/493.changed.md new file mode 100644 index 000000000..a531810bf --- /dev/null +++ b/changelog.d/493.changed.md @@ -0,0 +1,4 @@ +Added a native `runtime-inventory` concept family and a `scenario-node-runtime` +reference model for `nodes.*.runtime`, with authoring-phase semantic-profile +coverage, an extension-governance decision path for runtime fields, and +reference-model binding resolution for nullable-optional schema surfaces. diff --git a/changelog.d/527.security.md b/changelog.d/527.security.md new file mode 100644 index 000000000..2e2e3e112 --- /dev/null +++ b/changelog.d/527.security.md @@ -0,0 +1,5 @@ +Protected branches (`main`, `dev`) now enforce the CI-strictness baseline: all +CI checks are required and strict, the SonarCloud quality gate is waited on and +fails on any new issue, and pre-commit file hygiene plus secret scanning run in +CI — so nothing merges past a failing check or a failing quality gate, while +admin override is retained. diff --git a/contracts/concept-authority/concept-families-v1.json b/contracts/concept-authority/concept-families-v1.json index 5a8505ea9..c60aaf1a8 100644 --- a/contracts/concept-authority/concept-families-v1.json +++ b/contracts/concept-authority/concept-families-v1.json @@ -87,6 +87,22 @@ "Episode identity and lifecycle state must remain separate from participant behavior sequence events and participant-local action outcomes." ] }, + "runtime-inventory": { + "title": "Runtime Inventory", + "description": "Observed and declared runtime configuration state attached to scenario nodes.", + "provenance": "native", + "extension_scope": "ACES observed and declared runtime inventory of scenario node state under nodes.*.runtime: services, platforms, packages and software components, controls and security posture, filesystem and mounts, processes and scheduled jobs, and other node-scoped apparatus-state facts.", + "relation_rules": [ + "May relate to assets as the observed runtime configuration state of an asset-bearing scenario node without becoming the node or its asset identity.", + "May relate to observables when an inventory fact is surfaced as a condition, metric, or telemetry signal, while the structured inventory record itself remains runtime-inventory.", + "May relate to identities, tools-and-artifacts, relationships, and actions-and-events through individual inventory fields that denote those narrower cyber-domain objects, which bind to the narrower family rather than to the inventory record as a whole." + ], + "non_ambiguity_constraints": [ + "Must record observed or declared node runtime state and must not be used as a synonym for the actions or events that produce, change, or react to that state.", + "Must not redefine asset or node identity, scenario authoring intent, apparatus capability declarations, or participant episode lifecycle as runtime inventory.", + "When an inventory field denotes a narrower cyber-domain object such as an asset, identity, observable, tool, artifact, or relationship, that field must bind to the narrower family rather than being absorbed into runtime-inventory." + ] + }, "apparatus-declarations": { "title": "Apparatus Declarations", "description": "Processor, backend, and participant-implementation manifests.", diff --git a/contracts/concept-authority/reference-models-v1.json b/contracts/concept-authority/reference-models-v1.json index f8ec4091d..de4fac9d7 100644 --- a/contracts/concept-authority/reference-models-v1.json +++ b/contracts/concept-authority/reference-models-v1.json @@ -142,6 +142,31 @@ "source", "items" ] + }, + "scenario-node-runtime": { + "title": "Scenario Node Runtime", + "description": "Shared reference model for observed runtime inventory configuration attached to scenario nodes.", + "concept_family": "runtime-inventory", + "authoritative_schema": { + "contract_id": "sdl-authoring-input-v1", + "schema_pointer": "#/$defs/RuntimeConfiguration", + "instance_path": "nodes.*.runtime" + }, + "reused_schemas": [ + { + "contract_id": "instantiated-scenario-v1", + "schema_pointer": "#/$defs/RuntimeConfiguration", + "instance_path": "nodes.*.runtime" + } + ], + "key_fields": [ + "container", + "network", + "service_listeners", + "filesystem_inventory", + "operational_policy", + "local_control_interfaces" + ] } } } diff --git a/contracts/fixtures/concept-authority/concept-families-v1/valid/reference.json b/contracts/fixtures/concept-authority/concept-families-v1/valid/reference.json index 5a8505ea9..c60aaf1a8 100644 --- a/contracts/fixtures/concept-authority/concept-families-v1/valid/reference.json +++ b/contracts/fixtures/concept-authority/concept-families-v1/valid/reference.json @@ -87,6 +87,22 @@ "Episode identity and lifecycle state must remain separate from participant behavior sequence events and participant-local action outcomes." ] }, + "runtime-inventory": { + "title": "Runtime Inventory", + "description": "Observed and declared runtime configuration state attached to scenario nodes.", + "provenance": "native", + "extension_scope": "ACES observed and declared runtime inventory of scenario node state under nodes.*.runtime: services, platforms, packages and software components, controls and security posture, filesystem and mounts, processes and scheduled jobs, and other node-scoped apparatus-state facts.", + "relation_rules": [ + "May relate to assets as the observed runtime configuration state of an asset-bearing scenario node without becoming the node or its asset identity.", + "May relate to observables when an inventory fact is surfaced as a condition, metric, or telemetry signal, while the structured inventory record itself remains runtime-inventory.", + "May relate to identities, tools-and-artifacts, relationships, and actions-and-events through individual inventory fields that denote those narrower cyber-domain objects, which bind to the narrower family rather than to the inventory record as a whole." + ], + "non_ambiguity_constraints": [ + "Must record observed or declared node runtime state and must not be used as a synonym for the actions or events that produce, change, or react to that state.", + "Must not redefine asset or node identity, scenario authoring intent, apparatus capability declarations, or participant episode lifecycle as runtime inventory.", + "When an inventory field denotes a narrower cyber-domain object such as an asset, identity, observable, tool, artifact, or relationship, that field must bind to the narrower family rather than being absorbed into runtime-inventory." + ] + }, "apparatus-declarations": { "title": "Apparatus Declarations", "description": "Processor, backend, and participant-implementation manifests.", 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 f8ec4091d..de4fac9d7 100644 --- a/contracts/fixtures/concept-authority/reference-models-v1/valid/reference.json +++ b/contracts/fixtures/concept-authority/reference-models-v1/valid/reference.json @@ -142,6 +142,31 @@ "source", "items" ] + }, + "scenario-node-runtime": { + "title": "Scenario Node Runtime", + "description": "Shared reference model for observed runtime inventory configuration attached to scenario nodes.", + "concept_family": "runtime-inventory", + "authoritative_schema": { + "contract_id": "sdl-authoring-input-v1", + "schema_pointer": "#/$defs/RuntimeConfiguration", + "instance_path": "nodes.*.runtime" + }, + "reused_schemas": [ + { + "contract_id": "instantiated-scenario-v1", + "schema_pointer": "#/$defs/RuntimeConfiguration", + "instance_path": "nodes.*.runtime" + } + ], + "key_fields": [ + "container", + "network", + "service_listeners", + "filesystem_inventory", + "operational_policy", + "local_control_interfaces" + ] } } } diff --git a/contracts/fixtures/semantic-profile/semantic-profile-v1/valid/reference-stack-v1.json b/contracts/fixtures/semantic-profile/semantic-profile-v1/valid/reference-stack-v1.json index df171679c..f29bd52b7 100644 --- a/contracts/fixtures/semantic-profile/semantic-profile-v1/valid/reference-stack-v1.json +++ b/contracts/fixtures/semantic-profile/semantic-profile-v1/valid/reference-stack-v1.json @@ -20,6 +20,7 @@ "provenance-and-evidence", "realization-and-disclosure", "relationships", + "runtime-inventory", "scenarios", "tasks-runs-studies", "time-and-apparatus", diff --git a/contracts/profiles/semantic/reference-stack-v1.json b/contracts/profiles/semantic/reference-stack-v1.json index df171679c..f29bd52b7 100644 --- a/contracts/profiles/semantic/reference-stack-v1.json +++ b/contracts/profiles/semantic/reference-stack-v1.json @@ -20,6 +20,7 @@ "provenance-and-evidence", "realization-and-disclosure", "relationships", + "runtime-inventory", "scenarios", "tasks-runs-studies", "time-and-apparatus", diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index 531aa4ae1..b2915a681 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -4790,14 +4790,41 @@ def _resolve_schema_pointer(schema_root: dict[str, Any], pointer: str) -> dict[s return current +def _collapse_nullable_optional_schema(schema_node: dict[str, Any]) -> dict[str, Any]: + """Collapse a nullable-optional ``anyOf`` wrapper to its single non-null branch. + + Pydantic renders an optional reference field (``X | None``) as a two-member + ``anyOf`` whose branches are the referenced schema and ``{"type": "null"}``. + Reference-model bindings address the underlying structure, so the resolver + looks through that wrapper (for example ``nodes.*.runtime``, an optional + ``RuntimeConfiguration``). Anything that is not exactly this nullable-optional + shape is returned unchanged, so non-optional and non-reference schemas are + unaffected. + """ + any_of = schema_node.get("anyOf") + if not isinstance(any_of, list) or len(any_of) != 2: + return schema_node + non_null = [branch for branch in any_of if not (isinstance(branch, dict) and branch.get("type") == "null")] + null_branches = [branch for branch in any_of if isinstance(branch, dict) and branch.get("type") == "null"] + if len(non_null) == 1 and len(null_branches) == 1 and isinstance(non_null[0], dict): + return non_null[0] + return schema_node + + def _resolve_ref_schema(schema_root: dict[str, Any], schema_node: dict[str, Any]) -> dict[str, Any]: current = schema_node - while "$ref" in current: - ref = current["$ref"] - if not isinstance(ref, str): - raise KeyError(ref) - current = _resolve_schema_pointer(schema_root, ref) - return current + while True: + if "$ref" in current: + ref = current["$ref"] + if not isinstance(ref, str): + raise KeyError(ref) + current = _resolve_schema_pointer(schema_root, ref) + continue + collapsed = _collapse_nullable_optional_schema(current) + if collapsed is not current: + current = collapsed + continue + return current def _resolve_instance_path_schema(schema_root: dict[str, Any], instance_path: str) -> dict[str, Any]: diff --git a/implementations/python/tests/test_concept_authority.py b/implementations/python/tests/test_concept_authority.py index a3a411cd0..5119a6069 100644 --- a/implementations/python/tests/test_concept_authority.py +++ b/implementations/python/tests/test_concept_authority.py @@ -290,6 +290,26 @@ def test_authoritative_catalog_declares_episode_family(): assert forbidden_synonym in non_ambiguity_constraints +def test_authoritative_catalog_declares_runtime_inventory_family(): + payload = json.loads(CATALOG_PATH.read_text(encoding="utf-8")) + model = ConceptFamilyCatalogModel.model_validate(payload) + + runtime_family = model.families["runtime-inventory"] + assert runtime_family.provenance == ConceptProvenanceCategory.NATIVE + assert runtime_family.authority is None + assert runtime_family.authority_reference is None + assert "nodes.*.runtime" in runtime_family.extension_scope + + relation_rules = " ".join(runtime_family.relation_rules).lower() + for related_concept in ("assets", "observables"): + assert related_concept in relation_rules + + # Inventory fields are observed/declared node state, not actions or events. + non_ambiguity_constraints = " ".join(runtime_family.non_ambiguity_constraints).lower() + assert "action" in non_ambiguity_constraints + assert "event" in non_ambiguity_constraints + + def test_episode_contract_terms_have_catalog_family_anchor(): catalog_payload = json.loads(CATALOG_PATH.read_text(encoding="utf-8")) vocabulary_payload = json.loads(CONTROLLED_VOCABULARY_PATH.read_text(encoding="utf-8")) diff --git a/implementations/python/tests/test_reference_models.py b/implementations/python/tests/test_reference_models.py index a42e52ca6..bdc29dc23 100644 --- a/implementations/python/tests/test_reference_models.py +++ b/implementations/python/tests/test_reference_models.py @@ -83,3 +83,66 @@ def test_reference_model_rejects_missing_key_fields(): with pytest.raises(ValidationError, match="key_fields"): ReferenceModelCatalogModel.model_validate(payload) + + +def test_reference_model_catalog_declares_node_runtime_inventory(): + catalog = load_reference_model_catalog() + + runtime_model = catalog.models["scenario-node-runtime"] + assert runtime_model.concept_family == "runtime-inventory" + assert runtime_model.authoritative_schema.contract_id == "sdl-authoring-input-v1" + assert runtime_model.authoritative_schema.schema_pointer == "#/$defs/RuntimeConfiguration" + assert runtime_model.authoritative_schema.instance_path == "nodes.*.runtime" + assert [binding.contract_id for binding in runtime_model.reused_schemas] == ["instantiated-scenario-v1"] + assert "container" in runtime_model.key_fields + + +def test_reference_model_binding_resolves_nullable_optional_instance_path(): + # `nodes.*.runtime` is an optional node field (anyOf[RuntimeConfiguration, null]), + # so binding resolution must look through the nullable-optional wrapper. This is + # what lets the scenario-node-runtime reference model bind to a real definition. + from aces_contracts.contracts import ( + ReferenceModelSchemaBindingModel, + _resolve_instance_path_schema, + _resolve_ref_schema, + _resolve_schema_pointer, + _validate_reference_model_schema_binding, + schema_bundle, + ) + + root = schema_bundle()["sdl-authoring-input-v1"] + pointer_schema = _resolve_ref_schema(root, _resolve_schema_pointer(root, "#/$defs/RuntimeConfiguration")) + instance_schema = _resolve_instance_path_schema(root, "nodes.*.runtime") + assert pointer_schema == instance_schema + + binding = ReferenceModelSchemaBindingModel( + contract_id="sdl-authoring-input-v1", + schema_pointer="#/$defs/RuntimeConfiguration", + instance_path="nodes.*.runtime", + ) + # Must not raise: the nullable-optional surface resolves to the real definition. + _validate_reference_model_schema_binding( + model_id="scenario-node-runtime", + binding_label="authoritative_schema", + binding=binding, + key_fields=["container"], + ) + + +def test_collapse_nullable_optional_schema_is_conservative(): + from aces_contracts.contracts import _collapse_nullable_optional_schema, _resolve_ref_schema + + ref_branch = {"$ref": "#/$defs/RuntimeConfiguration"} + nullable = {"anyOf": [ref_branch, {"type": "null"}], "default": None} + assert _collapse_nullable_optional_schema(nullable) is ref_branch + + # Non-nullable and non-optional unions are returned unchanged. + plain = {"type": "object", "properties": {}} + assert _collapse_nullable_optional_schema(plain) is plain + union = {"anyOf": [{"type": "string"}, {"type": "integer"}]} + assert _collapse_nullable_optional_schema(union) is union + + # The resolver follows the wrapper through to the referenced definition. + root = {"$defs": {"Thing": {"type": "object", "properties": {"x": {"type": "string"}}}}} + resolved = _resolve_ref_schema(root, {"anyOf": [{"$ref": "#/$defs/Thing"}, {"type": "null"}]}) + assert resolved == root["$defs"]["Thing"] diff --git a/implementations/python/tests/test_semantic_profiles.py b/implementations/python/tests/test_semantic_profiles.py index cf86b631e..552feb9b9 100644 --- a/implementations/python/tests/test_semantic_profiles.py +++ b/implementations/python/tests/test_semantic_profiles.py @@ -107,3 +107,15 @@ def test_reference_profile_declares_episode_family_for_runtime_exchange_and_exec for phase in (profile.exchange, profile.execution): assert set(phase.required_contracts) >= EPISODE_CONTRACTS assert "episodes" in phase.required_concept_families + + +def test_reference_profile_declares_runtime_inventory_family_for_authoring(): + profile = load_semantic_profile("reference-stack-v1") + + assert "runtime-inventory" in profile.authoring.required_concept_families + assert "runtime-inventory" not in profile.processing.required_concept_families + assert "runtime-inventory" not in profile.execution.required_concept_families + # Authoring carries observed node runtime inventory under nodes.*.runtime but + # defines no governed binding surfaces, so coverage is recorded through + # required_concept_families without a required_binding. + assert profile.authoring.required_bindings == [] diff --git a/specs/concept-authority/concept-authority.md b/specs/concept-authority/concept-authority.md index 020fb5eec..867b6c284 100644 --- a/specs/concept-authority/concept-authority.md +++ b/specs/concept-authority/concept-authority.md @@ -77,6 +77,7 @@ ecosystem-specific concerns. | `scenarios` | SDL scenarios, compositions, modules, and authoring constructs. | | `tasks-runs-studies` | Execution lifecycle, run records, and study organization. | | `episodes` | Participant runtime episode identity, lifecycle state, and history boundaries. | +| `runtime-inventory` | Observed and declared runtime configuration state attached to scenario nodes. | | `apparatus-declarations` | Processor, backend, and participant-implementation manifests. | | `realization-and-disclosure` | Instantiation, planning, compilation, and realization artifacts. | | `provenance-and-evidence` | Run provenance records, evidence expectations, and audit artifacts. | @@ -89,6 +90,17 @@ boundaries, and append-only episode history. Episode-bound behavior events, observables, actions, tools, artifacts, and evidence still bind to their narrower concept families when those records are the artifact's subject. +The `runtime-inventory` family is native because observed node runtime state is +an ACES extension over the cyber-domain authority, not a UCO object in its own +right and not a task/run/study or apparatus declaration. It covers the typed +runtime inventory carried under `nodes.*.runtime` — services, platforms, +packages and software components, controls and security posture, filesystem and +mounts, processes and scheduled jobs, and other node-scoped apparatus state. +Individual inventory fields that denote a narrower cyber-domain object — an +asset, identity, observable, tool, artifact, or relationship — still bind to +that narrower family. Runtime inventory records the observed or declared state, +never the actions or events that produce, change, or react to it. + ## Extension Discipline ACES-native families must be explicit extensions over the shared concept @@ -106,6 +118,40 @@ Native families must use these fields to state when an artifact should bind to the native family and when it should instead bind to a narrower adopted or adapted family from the shared authority. +### Adding a Runtime Inventory Field or Family + +Runtime inventory is the largest extension surface, so the decision path for +adding to it is explicit. A contributor can determine the required governance +steps from this section and the machine-readable catalog alone: + +1. **Extend the existing runtime surface first.** A new observed node fact + should extend `RuntimeConfiguration` and register through the canonical + runtime service-family registry (`aces_sdl._runtime_service_families`) + instead of introducing a new top-level concept family. The + `runtime-inventory` family and the `nodes.*.runtime` reference model are the + shared seam for this surface. +2. **Pass the cross-family invariant lint.** Every registered runtime service + family must satisfy the single structural invariant set enforced by + `implementations/python/tests/test_runtime_family_invariants.py` (the runtime + SDL cross-family consistency epic + [#439](https://github.com/Brad-Edwards/aces/issues/439) and children + #442 / #443 / #444): a `Runtime` model class, a + `singular(collection_name) + "_id"` primary identifier, and a plural + typed-child container registered in the registry. A new family that violates + an invariant fails the suite immediately. +3. **Keep observed values redaction-safe.** Values that can carry secrets must + route through the runtime redaction helpers (ADR-056 / ADR-057, + `aces_sdl.runtime_values`); raw credential material is unrepresentable on the + model surface. +4. **Add controlled vocabulary, not free strings.** New enumerated terms belong + in `contracts/concept-authority/controlled-vocabularies-v1.json` (GOV-922), + not as artifact-local labels. +5. **Promote to a new concept family only when warranted.** A genuinely new + concept that is not a runtime sub-surface requires an ADR, a catalog entry in + `concept-families-v1.json` declaring `extension_scope`, `relation_rules`, and + `non_ambiguity_constraints` (the discipline above), and a reference-model + entry when it has a recurrent SDL structure. + ## Machine-Readable Catalog The authoritative concept family catalog is published at: diff --git a/specs/concept-authority/reference-models.md b/specs/concept-authority/reference-models.md index 880cbbca5..2d798885e 100644 --- a/specs/concept-authority/reference-models.md +++ b/specs/concept-authority/reference-models.md @@ -54,6 +54,8 @@ slice: - scenario conditions as observable structures - scenario events as action or event structures - scenario content as tool or artifact structures +- node runtime inventory as observed runtime configuration state under + `nodes.*.runtime` ## Participant Episode Lineage @@ -73,6 +75,23 @@ surface and keeps it separate from workflow state, evaluation state, operation receipts, backend process restarts, tasks, runs, scenarios, and participant-local actions or observations. +## Node Runtime Inventory + +`nodes.*.runtime` is cataloged as the `scenario-node-runtime` reference model in +the native `runtime-inventory` concept family. It is the largest recurrent SDL +structure and the surface where new inventory fields are most often added, so it +is anchored to the published `RuntimeConfiguration` definition +(`#/$defs/RuntimeConfiguration`) in both `sdl-authoring-input-v1` and +`instantiated-scenario-v1` rather than restated inline. + +The `runtime` node field is optional, so its published shape is a nullable +`anyOf` of the `RuntimeConfiguration` reference and `null`. Reference-model +binding resolution looks through that nullable-optional wrapper to the +underlying definition, which lets optional surfaces participate as reference +models while bindings still resolve to real published schema definitions. The +governance decision path for adding a new runtime inventory field is documented +under Extension Discipline in [concept-authority.md](./concept-authority.md). + ## Machine-Readable Artifacts The JSON Schema for the catalog format is published at: diff --git a/specs/concept-authority/semantic-profiles.md b/specs/concept-authority/semantic-profiles.md index acb87e9bf..b39576a45 100644 --- a/specs/concept-authority/semantic-profiles.md +++ b/specs/concept-authority/semantic-profiles.md @@ -71,6 +71,14 @@ capability. The profile therefore records episode coverage through that manifest scope also contains implementation, behavior, and provenance contracts and is not an episode-only binding surface. +The reference stack requires the native `runtime-inventory` concept family in +the `authoring` phase because SDL authoring and instantiation carry observed +node runtime inventory under `nodes.*.runtime` in the `sdl-authoring-input-v1` +and `instantiated-scenario-v1` contracts. As with the other authoring families, +the profile records this through `required_concept_families` only: `authoring` +does not define governed `required_bindings` surfaces, so runtime-inventory +coverage is not expressed as a binding. + ## Machine-Readable Artifacts The JSON Schema for semantic profiles is published at: From eee28c0d7a76d4c677a421fa069b19df898ba7ed Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 07:49:06 +0200 Subject: [PATCH 38/70] Add SEM-218 typed compiler emission and planner realization-support gate --- changelog.d/490.added.md | 1 + .../explicitness-realization-semantics.md | 26 +++ .../reference/shared-semantic-integrity.md | 2 +- .../packages/aces_processor/compiler.py | 55 ++++++ .../python/packages/aces_processor/models.py | 9 + .../python/packages/aces_processor/planner.py | 2 + .../aces_processor/semantics/realization.py | 153 +++++++++++++++ .../python/tests/test_runtime_planner.py | 16 +- .../python/tests/test_sem_218_realization.py | 183 ++++++++++++++++++ .../explicitness-and-realization.md | 66 ++++--- 10 files changed, 482 insertions(+), 31 deletions(-) create mode 100644 changelog.d/490.added.md create mode 100644 implementations/python/packages/aces_processor/semantics/realization.py create mode 100644 implementations/python/tests/test_sem_218_realization.py diff --git a/changelog.d/490.added.md b/changelog.d/490.added.md new file mode 100644 index 000000000..96ff5063f --- /dev/null +++ b/changelog.d/490.added.md @@ -0,0 +1 @@ +Carry the SEM-218 explicitness class through processor compilation as typed realization-requirement metadata on the runtime model, and add a planner realization-support gate that rejects an unrealizable exact (or unsupported constrained) requirement against the selected backend's `realization_support` with a structured diagnostic instead of silently approximating it (invariants I1/I2/I4). diff --git a/docs/explain/reference/explicitness-realization-semantics.md b/docs/explain/reference/explicitness-realization-semantics.md index 4b5b89667..448f81407 100644 --- a/docs/explain/reference/explicitness-realization-semantics.md +++ b/docs/explain/reference/explicitness-realization-semantics.md @@ -123,6 +123,32 @@ additional exact requirement kinds for other artifact families; that should require adding governed terms and shared semantic checks, not rewriting planner or conformance call sites. +## Part 2 Implementation Boundary + +The typed compiler emission and planner gate must preserve the classifier output +as runtime-model metadata owned by `aces_processor.models.RuntimeModel` and +emitted by `aces_processor.compiler`. The planner should consume that compiled +metadata directly; it must not re-walk SDL YAML, rerun the classifier, infer +exactness from backend capability failures, or treat the backend manifest as an +author-intent source. + +Planner rejection belongs in the existing `aces_processor.planner.plan()` / +`ExecutionPlan.diagnostics` path. Unsupported exact or constrained requirement +kinds should be reported as stable `Diagnostic` objects that name the compiled +resource address, SDL field path or equivalent field identifier, requirement +kind, and missing `realization_support` capability. Do not introduce a +SEM-218-specific exception hierarchy or throw from normal planning for this +case. + +Keep realization-support matching as one manifest-bound helper over +`BackendManifest.realization_support` / `RealizationSupportDeclaration`. +`domain`, `supported_exact_requirement_kinds`, and +`supported_constraint_kinds` are currently opaque non-empty strings, so the +match is exact string membership plus support-mode compatibility. If compiled +explicitness metadata is added to plan payloads or published contracts, it must +go through the existing `ContractModel` / schema-generation path; otherwise keep +it model-side like existing compiler provenance metadata. + ## Gotchas And Anti-Patterns Avoid: diff --git a/docs/explain/reference/shared-semantic-integrity.md b/docs/explain/reference/shared-semantic-integrity.md index 9d6323a98..01b541fa9 100644 --- a/docs/explain/reference/shared-semantic-integrity.md +++ b/docs/explain/reference/shared-semantic-integrity.md @@ -244,5 +244,5 @@ so they are tracked by their own requirements, not here. | Derived operational context views (portable meaning and comparability) | SEM-214 | — | — | planned | | Evidence, evaluation, view-boundary, and observability-plane semantics | SEM-216, SEM-224, SEM-225 | — | — | planned | | External knowledge bindings semantics | SEM-217 | — | — | planned | -| Explicitness and realization semantics (binding declarations vs processor/backend realization) | SEM-218 | validation, instantiation | `specs/formal/realization/explicitness-and-realization.md`, `specs/formal/realization/README.md`, `docs/explain/reference/explicitness-realization-semantics.md`, `implementations/python/packages/aces_sdl/explicitness.py`, `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/packages/aces_contracts/apparatus.py`, `implementations/python/packages/aces_contracts/vocabulary.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/packages/aces_backend_protocols/manifest.py`, `implementations/python/tests/test_sem_218_explicitness.py`, `implementations/python/tests/test_backend_manifest.py`, `implementations/python/tests/test_processor_manifest.py`, `implementations/python/tests/test_runtime_contracts.py` | partial | +| Explicitness and realization semantics (binding declarations vs processor/backend realization) | SEM-218 | validation, instantiation, compilation, planning | `specs/formal/realization/explicitness-and-realization.md`, `specs/formal/realization/README.md`, `docs/explain/reference/explicitness-realization-semantics.md`, `implementations/python/packages/aces_sdl/explicitness.py`, `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/packages/aces_contracts/apparatus.py`, `implementations/python/packages/aces_contracts/vocabulary.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/packages/aces_backend_protocols/manifest.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_processor/planner.py`, `implementations/python/packages/aces_processor/semantics/realization.py`, `implementations/python/tests/test_sem_218_explicitness.py`, `implementations/python/tests/test_sem_218_realization.py`, `implementations/python/tests/test_runtime_planner.py`, `implementations/python/tests/test_backend_manifest.py`, `implementations/python/tests/test_processor_manifest.py`, `implementations/python/tests/test_runtime_contracts.py` | partial | | Clock, time-domain, advancement/pacing/synchronization, and temporal ordering/causality semantics | SEM-227, SEM-228, SEM-229 | — | — | planned | diff --git a/implementations/python/packages/aces_processor/compiler.py b/implementations/python/packages/aces_processor/compiler.py index c3f4c1f41..84561b225 100644 --- a/implementations/python/packages/aces_processor/compiler.py +++ b/implementations/python/packages/aces_processor/compiler.py @@ -10,6 +10,7 @@ ) from aces_contracts.versions import WORKFLOW_STATE_SCHEMA_VERSION from aces_sdl.entities import flatten_entities +from aces_sdl.explicitness import ExplicitnessClass from aces_sdl.instantiate import instantiate_scenario from aces_sdl.nodes import NodeType from aces_sdl.orchestration import WorkflowStepType @@ -64,6 +65,11 @@ WorkflowStepStatePredicateRuntime, WorkflowSwitchCaseRuntime, ) +from .semantics.realization import ( + REALIZATION_DOMAIN, + CompiledRealizationRequirement, + resolve_realization_concern, +) def _dump(model: Any) -> dict[str, Any]: @@ -2198,6 +2204,54 @@ def _node_variable_refs_by_address( return refs_by_address +def _realization_requirement_address(scenario: InstantiatedScenario, field_path: str) -> str: + """Resolve the compiled resource address for a realization-concern path. + + Falls back to the field path (an equivalent field identifier) when the + concern is not tied to a single compiled provisioning resource. + """ + + parts = field_path.split(".") + head, name = parts[0], parts[1] + if head == "nodes": + node = scenario.nodes.get(name) + if node is not None and node.type == NodeType.SWITCH: + return _network_address(name) + return _node_address(name) + if head == "content": + return _content_address(name) + return field_path + + +def _compile_realization_requirements( + scenario: InstantiatedScenario, +) -> tuple[CompiledRealizationRequirement, ...]: + """SEM-218 typed compiler emission: lower each authored realization concern + into a compiled requirement carrying its classifier explicitness class. + + Open concerns are not gated and are not emitted; constrained and exact + concerns carry their concern kind for the planner realization gate. + """ + + requirements: list[CompiledRealizationRequirement] = [] + for field_path, record in scenario.explicitness.items(): + if record.classification is ExplicitnessClass.OPEN: + continue + concern_kind = resolve_realization_concern(field_path) + if concern_kind is None: + continue + requirements.append( + CompiledRealizationRequirement( + field_path=field_path, + address=_realization_requirement_address(scenario, field_path), + domain=REALIZATION_DOMAIN, + requirement_kind=concern_kind, + explicitness=record.classification, + ) + ) + return tuple(requirements) + + def compile_scenario_runtime_model( scenario: Scenario | InstantiatedScenario, *, @@ -2284,4 +2338,5 @@ def compile_runtime_model(scenario: Scenario | InstantiatedScenario) -> RuntimeM goals=goals, objectives=objectives, diagnostics=diagnostics, + realization_requirements=_compile_realization_requirements(scenario), ) diff --git a/implementations/python/packages/aces_processor/models.py b/implementations/python/packages/aces_processor/models.py index d45a0999a..4b52f3c1a 100644 --- a/implementations/python/packages/aces_processor/models.py +++ b/implementations/python/packages/aces_processor/models.py @@ -236,6 +236,10 @@ WorkflowStepSemanticContract, ) +from aces_processor.semantics.realization import ( + CompiledRealizationRequirement as CompiledRealizationRequirement, +) + _PARTICIPANT_ACTION_CONTRACT_PREFIX = "participant.action-contract." _PARTICIPANT_OBSERVATION_BOUNDARY_PREFIX = "participant.observation-boundary." _PARTICIPANT_OUTCOME_RULE_PREFIX = "participant.outcome-interpretation-rule." @@ -4250,6 +4254,11 @@ class RuntimeModel: goals: dict[str, GoalRuntime] = field(default_factory=dict) objectives: dict[str, ObjectiveRuntime] = field(default_factory=dict) diagnostics: list[Diagnostic] = field(default_factory=list) + # SEM-218 typed compiler emission: each authored realization concern with + # its preserved explicitness class. Model-side metadata (like + # `node_variable_refs`); it never enters the backend-facing + # `resource_payload()` envelope. Consumed by the planner realization gate. + realization_requirements: tuple[CompiledRealizationRequirement, ...] = () @dataclass(frozen=True) diff --git a/implementations/python/packages/aces_processor/planner.py b/implementations/python/packages/aces_processor/planner.py index dd2ebc141..1474b98d8 100644 --- a/implementations/python/packages/aces_processor/planner.py +++ b/implementations/python/packages/aces_processor/planner.py @@ -31,6 +31,7 @@ resource_dependency_cycles, resource_topological_order, ) +from .semantics.realization import realization_support_diagnostics def _planned_resource(address: str, domain: RuntimeDomain, resource_type: str, resource) -> PlannedResource: @@ -951,6 +952,7 @@ def plan( diagnostics = [ *model.diagnostics, *_validate_manifest(model, manifest), + *realization_support_diagnostics(model.realization_requirements, manifest), *_ordering_cycle_diagnostics(resources), ] actions, deleted_entries = _build_operations(resources, snapshot) diff --git a/implementations/python/packages/aces_processor/semantics/realization.py b/implementations/python/packages/aces_processor/semantics/realization.py new file mode 100644 index 000000000..fa6893958 --- /dev/null +++ b/implementations/python/packages/aces_processor/semantics/realization.py @@ -0,0 +1,153 @@ +"""SEM-218 realization-support semantics: typed compiled requirements and the +manifest-bound planner gate. + +The normative boundary lives in +``specs/formal/realization/explicitness-and-realization.md``. The compiler +lowers each authored realization concern into a +``CompiledRealizationRequirement`` carrying its SEM-218 explicitness class +(part 1's ``aces_sdl.explicitness`` classifier output). The planner consumes +that compiled metadata directly and matches it against the selected backend's +``realization_support`` declarations. An unsupported exact or constrained +requirement kind becomes a stable error ``Diagnostic`` before deployment, +enforcing invariants I1/I2/I4 without silent approximation. + +The match is a single manifest-bound helper over +``BackendManifest.realization_support`` / ``RealizationSupportDeclaration``. +``domain`` and the kind strings are opaque non-empty strings today (the +extensibility seam; a governed vocabulary is future work), so matching is +exact string membership plus support-mode compatibility. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from aces_backend_protocols.capabilities import BackendManifest +from aces_contracts.diagnostics import Diagnostic, Severity +from aces_sdl.explicitness import ExplicitnessClass + +__all__ = [ + "EXACT_REQUIREMENT_KIND", + "REALIZATION_DOMAIN", + "CompiledRealizationRequirement", + "realization_support_diagnostics", + "resolve_realization_concern", +] + +# The single coarse realization domain string already published by backend +# manifests (see ``aces_backend_stubs.stubs``). Kept opaque per the SEM-218 +# extensibility seam. +REALIZATION_DOMAIN = "runtime-realization" + +# The exact-requirement kind every concrete (exact) author declaration maps to. +# A backend that honors exact declarations lists this in +# ``supported_exact_requirement_kinds``; one that cannot must reject (I2). +EXACT_REQUIREMENT_KIND = "declared-capability-match" + +# Authored realization concerns mapped onto the published constraint-kind +# vocabulary, keyed by (head section, leaf field) of the classifier path. The +# node/content instance name is the wildcard middle segment. This is the +# realization-concern set the planner already validates against backend +# capabilities — not a general per-field designation authority (that is staged +# under the SEM-218 coverage row). +_CONCERN_KIND_BY_PATH: dict[tuple[str, str], str] = { + ("nodes", "os"): "os-family", + ("nodes", "type"): "node-type", + ("content", "type"): "content-type", +} + + +@dataclass(frozen=True) +class CompiledRealizationRequirement: + """A compiled realization concern carrying its SEM-218 explicitness class. + + Owned by ``RuntimeModel`` as model-side metadata — like the existing + ``node_variable_refs`` provenance it never enters the backend-facing + ``resource_payload()`` envelope — and consumed directly by the planner gate. + """ + + field_path: str + address: str + domain: str + requirement_kind: str + explicitness: ExplicitnessClass + + +def resolve_realization_concern(field_path: str) -> str | None: + """Return the realization concern kind for a classifier path, or ``None``. + + Only the concerns the planner validates against backend capabilities map to + a kind today; every other authored field is not a realization concern with + a published kind and yields ``None``. + """ + + parts = field_path.split(".") + if len(parts) != 3: + return None + head, _name, leaf = parts + return _CONCERN_KIND_BY_PATH.get((head, leaf)) + + +def realization_support_diagnostics( + requirements: tuple[CompiledRealizationRequirement, ...], + manifest: BackendManifest, +) -> list[Diagnostic]: + """Match compiled requirements against the manifest's ``realization_support``. + + Exact requirements need ``EXACT_REQUIREMENT_KIND`` in some matching-domain + declaration's ``supported_exact_requirement_kinds``; constrained + requirements need their concern kind in ``supported_constraint_kinds``. + Unsupported kinds become stable error diagnostics naming the resource + address, SDL field path, requirement kind, and missing capability. Open + requirements are not emitted by the compiler and so are not gated here. + + Diagnostics deliberately name only the field path and kind strings, never + the exact author value, which may carry sensitive material (SEM-218 + security / host-exposure gate). + """ + + diagnostics: list[Diagnostic] = [] + for requirement in requirements: + declarations = [ + declaration for declaration in manifest.realization_support if declaration.domain == requirement.domain + ] + if requirement.explicitness is ExplicitnessClass.EXACT: + supported = any( + EXACT_REQUIREMENT_KIND in declaration.supported_exact_requirement_kinds for declaration in declarations + ) + if not supported: + diagnostics.append( + Diagnostic( + code="realization.unsupported-exact-requirement", + domain=requirement.domain, + address=requirement.address, + message=( + f"Backend declares no exact realization support " + f"('{EXACT_REQUIREMENT_KIND}') for exact " + f"'{requirement.requirement_kind}' requirement at " + f"'{requirement.field_path}' in domain " + f"'{requirement.domain}'." + ), + severity=Severity.ERROR, + ) + ) + elif requirement.explicitness is ExplicitnessClass.CONSTRAINED: + supported = any( + requirement.requirement_kind in declaration.supported_constraint_kinds for declaration in declarations + ) + if not supported: + diagnostics.append( + Diagnostic( + code="realization.unsupported-constraint-requirement", + domain=requirement.domain, + address=requirement.address, + message=( + f"Backend declares no constraint realization support " + f"for constraint kind '{requirement.requirement_kind}' at " + f"'{requirement.field_path}' in domain " + f"'{requirement.domain}'." + ), + severity=Severity.ERROR, + ) + ) + return diagnostics diff --git a/implementations/python/tests/test_runtime_planner.py b/implementations/python/tests/test_runtime_planner.py index 26bc9546a..99ace3dde 100644 --- a/implementations/python/tests/test_runtime_planner.py +++ b/implementations/python/tests/test_runtime_planner.py @@ -66,11 +66,25 @@ def _limited_backend_manifest( version="0.0.1", supported_contract_versions=frozenset({"backend-manifest-v2"}), compatible_processors=frozenset({"aces-reference-processor"}), + # The manifest is limited in provisioner capability, not realization + # support: it declares full SEM-218 realization support so the + # realization gate is a no-op and these tests isolate the provisioner + # capability checks they target. realization_support=( RealizationSupportDeclaration( domain="runtime-realization", support_mode=RealizationSupportMode.CONSTRAINED, - supported_constraint_kinds=frozenset({"node-type"}), + supported_constraint_kinds=frozenset( + { + "node-type", + "os-family", + "content-type", + "account-feature", + "workflow-feature", + "workflow-state-predicate", + } + ), + supported_exact_requirement_kinds=frozenset({"declared-capability-match"}), disclosure_kinds=frozenset({"runtime-snapshot-v1"}), ), ), diff --git a/implementations/python/tests/test_sem_218_realization.py b/implementations/python/tests/test_sem_218_realization.py new file mode 100644 index 000000000..c49a01192 --- /dev/null +++ b/implementations/python/tests/test_sem_218_realization.py @@ -0,0 +1,183 @@ +"""SEM-218 part 2: typed compiler emission + planner realization-support gate. + +These tests exercise enforcement points 3 (typed compiler emission preserving +the exact/constrained/open class) and 4 (the planner realization-support gate) +of the SEM-218 spec ``specs/formal/realization/explicitness-and-realization.md``. +They follow the cross-stage differential idiom of ``test_fm2_semantics.py``: +the compiled runtime model must preserve the classifier's class, and the +planner must reject an unrealizable exact declaration with a structured +``Diagnostic`` rather than silently approximate it (invariant I2). +""" + +from __future__ import annotations + +import textwrap + +from aces_contracts.apparatus import ConceptBinding, RealizationSupportDeclaration +from aces_contracts.vocabulary import RealizationSupportMode +from aces_sdl.explicitness import ExplicitnessClass + +from aces.backends.stubs import create_stub_manifest +from aces.core.runtime.capabilities import BackendManifest, ProvisionerCapabilities +from aces.core.runtime.compiler import compile_runtime_model +from aces.core.runtime.planner import plan +from aces.core.sdl import instantiate_scenario, parse_sdl + +_EXACT_SCENARIO = """ +name: sem-218-exact-realization +nodes: + web: + type: vm + os: linux + resources: {ram: 1 gib, cpu: 1} +""" + +_CONSTRAINED_SCENARIO = """ +name: sem-218-constrained-realization +variables: + os_choice: {type: string, default: linux, allowed_values: [linux, windows]} +nodes: + web: + type: vm + os: ${os_choice} + resources: {ram: 1 gib, cpu: 1} +""" + + +def _scenario(yaml_str: str): + return parse_sdl(textwrap.dedent(yaml_str)) + + +def _manifest( + *, + realization_support: tuple[RealizationSupportDeclaration, ...], + node_types: frozenset[str] = frozenset({"vm"}), + os_families: frozenset[str] = frozenset({"linux"}), +) -> BackendManifest: + """A minimal backend manifest with caller-controlled realization support. + + The provisioner is sized to accept the test nodes so the only diagnostics + under test come from the realization-support gate, not the pre-existing + capability checks. + """ + + return BackendManifest( + name="realization-gate-test", + version="0.0.1", + supported_contract_versions=frozenset({"backend-manifest-v2"}), + compatible_processors=frozenset({"aces-reference-processor"}), + realization_support=realization_support, + concept_bindings=(ConceptBinding(scope="capabilities.provisioner.supported_node_types", family="assets"),), + provisioner=ProvisionerCapabilities( + name="realization-gate-provisioner", + supported_node_types=node_types, + supported_os_families=os_families, + ), + ) + + +def test_compiler_preserves_classifier_class_on_realization_requirements(): + """Enforcement point 3: the class flows through compilation as typed fields.""" + + model = compile_runtime_model(_scenario(_CONSTRAINED_SCENARIO)) + by_field = {req.field_path: req for req in model.realization_requirements} + + assert by_field["nodes.web.type"].explicitness is ExplicitnessClass.EXACT + assert by_field["nodes.web.type"].requirement_kind == "node-type" + assert by_field["nodes.web.type"].domain == "runtime-realization" + + assert by_field["nodes.web.os"].explicitness is ExplicitnessClass.CONSTRAINED + assert by_field["nodes.web.os"].requirement_kind == "os-family" + + +def test_compiled_class_matches_scenario_classifier_output(): + """Differential: compiled class == classifier class for every emitted requirement.""" + + classified = instantiate_scenario(_scenario(_CONSTRAINED_SCENARIO)).explicitness + model = compile_runtime_model(_scenario(_CONSTRAINED_SCENARIO)) + + assert model.realization_requirements + for req in model.realization_requirements: + assert req.field_path in classified + assert req.explicitness is classified[req.field_path].classification + + +def test_planner_rejects_unrealizable_exact_declaration(): + """Enforcement point 4 / invariant I2: an exact requirement with no exact + realization support fails planning with a structured diagnostic.""" + + model = compile_runtime_model(_scenario(_EXACT_SCENARIO)) + manifest = _manifest( + realization_support=( + RealizationSupportDeclaration( + domain="runtime-realization", + support_mode=RealizationSupportMode.CONSTRAINED, + supported_constraint_kinds=frozenset({"os-family", "node-type"}), + disclosure_kinds=frozenset({"runtime-snapshot-v1"}), + ), + ), + ) + + execution_plan = plan(model, manifest) + + codes = {diag.code for diag in execution_plan.diagnostics} + assert "realization.unsupported-exact-requirement" in codes + assert not execution_plan.is_valid + + messages = [ + diag.message for diag in execution_plan.diagnostics if diag.code == "realization.unsupported-exact-requirement" + ] + assert any("nodes.web.os" in message for message in messages) + assert any("declared-capability-match" in message for message in messages) + + +def test_planner_accepts_exact_declaration_when_backend_supports_it(): + """The stub manifest declares declared-capability-match, so an exact + declaration plans without a realization diagnostic.""" + + execution_plan = plan(compile_runtime_model(_scenario(_EXACT_SCENARIO)), create_stub_manifest()) + + codes = {diag.code for diag in execution_plan.diagnostics} + assert not any(code.startswith("realization.") for code in codes) + + +def test_planner_rejects_unsupported_constraint_kind(): + """An unsupported constraint kind fails planning the same way (spec Planning row).""" + + model = compile_runtime_model(_scenario(_CONSTRAINED_SCENARIO)) + manifest = _manifest( + # Support every allowed os value so the pre-existing provisioner + # os-family check passes and only the new constraint-kind gate fires. + os_families=frozenset({"linux", "windows"}), + realization_support=( + RealizationSupportDeclaration( + domain="runtime-realization", + support_mode=RealizationSupportMode.CONSTRAINED, + supported_constraint_kinds=frozenset({"node-type"}), + supported_exact_requirement_kinds=frozenset({"declared-capability-match"}), + disclosure_kinds=frozenset({"runtime-snapshot-v1"}), + ), + ), + ) + + execution_plan = plan(model, manifest) + + codes = {diag.code for diag in execution_plan.diagnostics} + assert "realization.unsupported-constraint-requirement" in codes + assert not execution_plan.is_valid + + messages = [ + diag.message + for diag in execution_plan.diagnostics + if diag.code == "realization.unsupported-constraint-requirement" + ] + assert any("nodes.web.os" in message and "os-family" in message for message in messages) + + +def test_planner_accepts_constrained_declaration_when_backend_supports_it(): + """Constrained (and open) declarations plan as before against a supporting backend.""" + + execution_plan = plan(compile_runtime_model(_scenario(_CONSTRAINED_SCENARIO)), create_stub_manifest()) + + codes = {diag.code for diag in execution_plan.diagnostics} + assert not any(code.startswith("realization.") for code in codes) diff --git a/specs/formal/realization/explicitness-and-realization.md b/specs/formal/realization/explicitness-and-realization.md index 824e9b012..f4912185a 100644 --- a/specs/formal/realization/explicitness-and-realization.md +++ b/specs/formal/realization/explicitness-and-realization.md @@ -55,16 +55,17 @@ is `partial`. What is *enforced today* is narrow and structural: - the instantiation downgrade rule in `instantiate_scenario`, which preserves authored explicitness metadata across parameter/default substitution without promoting substituted concrete values to false - exact declarations. + exact declarations; +- the typed compiler emission in `compile_runtime_model`, which lowers + each authored realization concern into a `CompiledRealizationRequirement` + on the `RuntimeModel` preserving its exact / constrained / open class; +- the planner realization-support gate in `aces_processor.planner.plan`, + which matches each compiled exact / constrained requirement kind against + the selected backend's `realization_support` and rejects an unsupported + kind with a structured `Diagnostic` before deployment. What is *normative but not yet realized*: -- the typed compiler emission that preserves the exact / constrained / - open class through to the planner; -- the planner-side match of compiled exact-requirement-kinds against - the selected backend's `realization_support` (the planner currently - checks concrete provisioner / orchestrator / evaluator capability - fields and does not consult `realization_support`); - the runtime non-approximation gate on backend adapters; - the SEM-218 provenance fields on snapshot / result / history / evidence envelopes. @@ -214,11 +215,10 @@ by `RealizationSupportDeclaration.__post_init__` and planner-side matching of compiled exact-requirement-kinds against the selected backend's `realization_support` — by which an unsupported exact requirement causes plan rejection before deployment — is -normative for the planner phase but is **not yet realized** by the -`aces_processor.planner._validate_manifest` path, which today checks -concrete provisioner / orchestrator / evaluator capability fields only. -Closing that gap is implementation work that the SEM-218 coverage row -tracks; the rule itself is binding under this spec. +realized by the realization-support gate in `aces_processor.planner.plan` +(over the compiled `RuntimeModel.realization_requirements`), alongside the +concrete provisioner / orchestrator / evaluator capability checks in +`_validate_manifest`. The rule is binding under this spec. **I5 — Provenance is preserved.** Values that enter snapshots, results, history, or evidence envelopes MUST be recorded with provenance @@ -257,8 +257,8 @@ implement end-to-end. | Authoring | Source of declarations. The authoring layer is the only authority that may classify a construct as exact, constrained, or open; downstream stages MUST treat that classification as immutable input. | partial — closed Pydantic SDL models (`extra="forbid"`), the apparatus-contract type system, and the `aces_sdl.explicitness` classifier carry the exact / constrained / open classification for authored SDL declarations. | | Validation | At the apparatus-contract layer: the shape gates on backend `RealizationSupportDeclaration` (I4 structural floor), the JSON-schema conditional gate, and `ProcessorManifestV2Model`'s asymmetric rejection of `realization_support` are enforced. At the SDL scenario layer: `SemanticValidator` enforces fail-closed validation on the *existing* closed SDL models and attaches SEM-218 classifier output to the validated scenario. | partial — apparatus-contract validation (manifest shape) and SDL-scenario classifier output are enforced; compiler/planner/runtime consumers remain staged. | | Instantiation | Parameter and default substitution may resolve open concerns and constrained surfaces. Substitution MUST NOT downgrade an exact declaration into a constrained or open one, and MUST NOT introduce an exact declaration that the author did not write. The concrete scenario MUST be revalidated after substitution. | partial — `instantiate_scenario` revalidates after substitution and derives instantiated explicitness from the authored classification so substituted values do not become false exact declarations. | -| Compilation | Lowers each declaration into a typed runtime requirement preserving class. Exact requirements carry their declared kind into the compiled representation; constrained requirements carry the typed constraint surface; open requirements are emitted as realizable slots tagged with the realization-and-disclosure family. | normative (future) | -| Planning | Matches every compiled requirement against the candidate backend manifest. An unsupported exact-requirement-kind MUST cause plan rejection through a structured `Diagnostic` before deployment; an unsupported constraint-kind MUST cause the same outcome. An open realizable slot MAY be left for the backend only when its manifest declares matching support. | normative (future) | +| Compilation | Lowers each declaration into a typed runtime requirement preserving class. Exact requirements carry their declared kind into the compiled representation; constrained requirements carry the typed constraint surface; open requirements are emitted as realizable slots tagged with the realization-and-disclosure family. | partial — `compile_runtime_model` emits `CompiledRealizationRequirement` metadata on the `RuntimeModel` preserving the exact / constrained / open class for the authored realization concerns the planner validates; per-field designation for the remaining concerns stays staged. | +| Planning | Matches every compiled requirement against the candidate backend manifest. An unsupported exact-requirement-kind MUST cause plan rejection through a structured `Diagnostic` before deployment; an unsupported constraint-kind MUST cause the same outcome. An open realizable slot MAY be left for the backend only when its manifest declares matching support. | partial — `aces_processor.planner.plan` matches each compiled exact / constrained requirement kind against the backend's `realization_support` and emits a rejecting `Diagnostic` for an unsupported kind; the runtime non-approximation gate on backend adapters remains future. | | Execution | Backend realizers honor the compiled class. A runtime adapter MUST NOT silently broaden an exact requirement, MUST NOT silently narrow an open realization beyond its declared constraints, and MUST surface incompatibilities through the existing runtime error envelope rather than approximate. | normative (future) | | Observation | Realized values land in plan, result, snapshot, history, and evidence surfaces with provenance per I5. Realization choices are observation data, not private backend state. | normative (future) | @@ -297,14 +297,16 @@ realization status, are: - **Compiler / planner gate** — compiled exact-requirement-kinds MUST be matched against the selected backend's `realization_support`; unsupported kinds MUST cause `Diagnostic`-bearing rejection before - deployment (I1, I2, I4). *Future*; the planner currently checks - concrete provisioner / orchestrator / evaluator capability fields - and does not consult `realization_support`. + deployment (I1, I2, I4). *Enforced today* by the typed compiler + emission on `RuntimeModel.realization_requirements` and the + realization-support gate in `aces_processor.planner.plan`. - **Error-envelope gate** — unsupported exact requirements and forbidden approximations MUST be surfaced through stable validation - errors or structured diagnostics (I1, I2). *Partial*; the shape-gate - errors surface today; the unsupported-exact-kind diagnostic surface - is future work bound to the compiler / planner gap above. + errors or structured diagnostics (I1, I2). *Enforced today*; the + shape-gate errors and the planner's + `realization.unsupported-exact-requirement` / + `realization.unsupported-constraint-requirement` diagnostics surface + unsupported kinds before deployment. - **Persistence and observation gate** — values entering snapshots, results, history, and evidence MUST carry provenance distinguishing author-declared, processor-derived, and backend-realized origins (I5). @@ -424,10 +426,13 @@ realization is staged work tracked under the SEM-218 coverage row. `contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/malformed-realization-support.json`, `contracts/fixtures/processor-manifest/processor-manifest-v2/invalid/hollow-realization-support.json`, `contracts/fixtures/processor-manifest/processor-manifest-v2/invalid/malformed-realization-support.json`. -- I1, I2, I4 planner gate — *future*; today - `aces_processor.planner._validate_manifest` checks concrete - provisioner / orchestrator / evaluator capability fields and does - not consult `realization_support`. +- I1, I2, I4 typed compiler emission + planner gate — + `aces_processor.compiler._compile_realization_requirements` emits + `RuntimeModel.realization_requirements`, and + `aces_processor.semantics.realization.realization_support_diagnostics` + (called from `aces_processor.planner.plan`) matches them against the + backend's `realization_support`, rejecting unsupported kinds with a + `Diagnostic`. - I2, I5 runtime envelopes — *future*; runtime plan / result / snapshot / history contracts exist, but SEM-218-specific provenance fields are not yet added. @@ -449,11 +454,14 @@ realization is staged work tracked under the SEM-218 coverage row. SDL-scenario classifier output for representative exact, constrained, and open declarations; instantiation substitution downgrade; and unclassifiable variable diagnostics. - -Tests for the planner-gate match against `realization_support`, the -runtime non-approximation envelope, and SEM-218 runtime provenance -fields are *future* and will land with the implementations they -exercise. +- `implementations/python/tests/test_sem_218_realization.py` — + typed compiler emission preserving the class through compilation, + and the planner realization-support gate rejecting an unsupported + exact or constrained requirement kind. + +Tests for the runtime non-approximation envelope and SEM-218 runtime +provenance fields are *future* and will land with the implementations +they exercise. ## Non-Goals From a2b667607a470f8b0838bc18140fe3e15e516365 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 08:00:36 +0200 Subject: [PATCH 39/70] Define "surface" normatively and classify agent-guidance authority CA-2: add a single normative definition of "surface" to the concept-authority spec, including the one-surface-vs-two decision rule distilled from ADR-033, plus a derivative glossary entry that points back to it. CA-4: classify specs/agent-guidance/agent-guidance.yaml as a governance-guidance artifact via a new normative_artifact_families block in the authority-boundary manifest, enforced and canonically pinned by tools/check_authority_boundary.py, so its authority class is decidable from the manifest alone. --- changelog.d/494.added.md | 1 + docs/explain/reference/glossary.md | 6 + .../python/tests/test_authority_boundary.py | 184 +++++++++++++++ specs/README.md | 6 + specs/authority/README.md | 30 ++- specs/authority/authority-boundary.yaml | 21 ++ specs/concept-authority/concept-authority.md | 47 ++++ tools/check_authority_boundary.py | 209 ++++++++++++++++++ 8 files changed, 502 insertions(+), 2 deletions(-) create mode 100644 changelog.d/494.added.md diff --git a/changelog.d/494.added.md b/changelog.d/494.added.md new file mode 100644 index 000000000..3d52dd4f4 --- /dev/null +++ b/changelog.d/494.added.md @@ -0,0 +1 @@ +Added a normative definition of "surface" to the concept-authority specification — including the one-surface-versus-two decision rule from ADR-033 — with a derivative glossary entry that points back to it; and classified the agent-usable guidance profile (`specs/agent-guidance/agent-guidance.yaml`, AUT-811) as a `governance-guidance` artifact through a new `normative_artifact_families` block in the authority-boundary manifest, enforced by `tools/check_authority_boundary.py`, so its authority class is decidable from the manifest alone. diff --git a/docs/explain/reference/glossary.md b/docs/explain/reference/glossary.md index b7162241c..e86716dc5 100644 --- a/docs/explain/reference/glossary.md +++ b/docs/explain/reference/glossary.md @@ -164,6 +164,12 @@ published schemas, source code, and ADRs. : The contract and fixture discipline used to check that backend-facing declarations and results satisfy published ACES contracts. +**Surface** +: A named, bounded, contract-bearing scope of an ACES artifact or apparatus to + which governed vocabularies and concept bindings apply. The normative + definition, including the one-surface-versus-two decision rule, lives in + `specs/concept-authority/concept-authority.md`. + **Apparatus** : The processor, backend, participant, live execution, evidence, and provenance surfaces around an authored scenario. diff --git a/implementations/python/tests/test_authority_boundary.py b/implementations/python/tests/test_authority_boundary.py index fc00ca033..064591096 100644 --- a/implementations/python/tests/test_authority_boundary.py +++ b/implementations/python/tests/test_authority_boundary.py @@ -15,6 +15,7 @@ ADR_SEAM_REF, ADR_SOURCE_REF, AUTHORITY_BOUNDARY_RELATIVE_PATH, + CANONICAL_ARTIFACT_FAMILY_BINDING, CANONICAL_AUTHORITY_ROOT_IDS, CANONICAL_LEGACY_TOP_LEVEL_DIRS, CANONICAL_NON_NORMATIVE_ROOT_IDS, @@ -91,6 +92,13 @@ - implementations/ forbidden_schema_filename_suffixes: - .schema.json +normative_artifact_families: + - id: agent_guidance_profile + artifact: specs/agent-guidance/agent-guidance.yaml + authority: agent-usable governance-guidance profile + family: governance-guidance + requirement_refs: + - AUT-811 """ # ADR-009 is immutable; the drift guard requires every authority-root token @@ -208,6 +216,15 @@ def _seed_repo( specs_path.parent.mkdir(parents=True, exist_ok=True) specs_path.write_text(specs_readme, encoding="utf-8") + # The canonical `normative_artifact_families` entry in `_GOOD_POLICY` + # classifies specs/agent-guidance/agent-guidance.yaml, and the gate + # requires that artifact to exist on disk. Seed a minimal real file so the + # positive case clears the artifact-family existence check. + guidance_path = tmp_path / "specs" / "agent-guidance" / "agent-guidance.yaml" + guidance_path.parent.mkdir(parents=True, exist_ok=True) + if not guidance_path.exists(): + guidance_path.write_text("profile: aces-agent-guidance\n", encoding="utf-8") + for root in authority_roots: _materialise_root(tmp_path, root) for root in non_normative_roots: @@ -344,6 +361,18 @@ def test_policy_root_must_be_mapping(tmp_path: Path) -> None: # remove the entire block, not just the heading, to avoid an orphaned # mapping that fails YAML parsing instead of the field-missing check. ("schema_authority", _SCHEMA_AUTHORITY_BLOCK), + ( + "normative_artifact_families", + ( + "normative_artifact_families:\n" + " - id: agent_guidance_profile\n" + " artifact: specs/agent-guidance/agent-guidance.yaml\n" + " authority: agent-usable governance-guidance profile\n" + " family: governance-guidance\n" + " requirement_refs:\n" + " - AUT-811\n" + ), + ), ] @@ -1136,6 +1165,161 @@ def test_invalid_forbidden_root_does_not_walk_unrelated_tree(tmp_path: Path) -> ) +# --------------------------------------------------------------------------- # +# normative_artifact_families -- CA-4: a non-prose artifact (agent-guidance # +# AUT-811 profile) that lives inside the normative `specs/` root but carries a # +# distinct `governance-guidance` family. The classification is declared in the # +# manifest so the artifact's authority class is decidable from the manifest # +# alone, and pinned (CANONICAL_ARTIFACT_FAMILY_BINDING) against silent drift. # +# --------------------------------------------------------------------------- # + + +_NAF_BLOCK = ( + "normative_artifact_families:\n" + " - id: agent_guidance_profile\n" + " artifact: specs/agent-guidance/agent-guidance.yaml\n" + " authority: agent-usable governance-guidance profile\n" + " family: governance-guidance\n" + " requirement_refs:\n" + " - AUT-811\n" +) + + +def test_canonical_artifact_family_binding_pins_agent_guidance() -> None: + # The agent-guidance AUT-811 profile is the pinned governance-guidance + # artifact; a rename of the constant surfaces in the suite rather than + # silently relaxing the gate. + assert CANONICAL_ARTIFACT_FAMILY_BINDING == { + "specs/agent-guidance/agent-guidance.yaml": "governance-guidance", + } + + +def test_missing_canonical_artifact_family_entry_is_flagged(tmp_path: Path) -> None: + # Drop the agent-guidance entry (leaving an empty block) — its authority + # class is no longer decidable from the manifest, which is exactly the + # ambiguity CA-4 closes. + body = _GOOD_POLICY.replace(_NAF_BLOCK, "normative_artifact_families: []\n", 1) + assert body != _GOOD_POLICY, "setup error: NAF block not found in _GOOD_POLICY" + failures = evaluate_authority_boundary(_seed_repo(tmp_path, policy_body=body)) + assert _flagged(failures, "authority-boundary-artifact-family-canonical-missing") + assert any("agent-guidance.yaml" in failure.message for failure in failures), ( + f"expected the canonical artifact to be named; got: {[f.render() for f in failures]}" + ) + + +def test_artifact_family_relabel_is_flagged(tmp_path: Path) -> None: + # Relabelling the pinned artifact's family (governance-guidance → prose) + # must fail even though the entry is otherwise well-formed. + body = _GOOD_POLICY.replace(" family: governance-guidance\n", " family: prose\n", 1) + failures = evaluate_authority_boundary(_seed_repo(tmp_path, policy_body=body)) + assert _flagged(failures, "authority-boundary-artifact-family-canonical-binding") + + +def test_artifact_family_missing_required_field_is_flagged(tmp_path: Path) -> None: + # An entry missing `family` is a malformation, not a default. + body = _GOOD_POLICY.replace(" family: governance-guidance\n", "", 1) + failures = evaluate_authority_boundary(_seed_repo(tmp_path, policy_body=body)) + assert _flagged(failures, "authority-boundary-artifact-family-entry-field") + + +def test_artifact_family_entry_must_be_mapping(tmp_path: Path) -> None: + body = _GOOD_POLICY.replace(_NAF_BLOCK, "normative_artifact_families:\n - just-a-string\n", 1) + failures = evaluate_authority_boundary(_seed_repo(tmp_path, policy_body=body)) + assert _flagged(failures, "authority-boundary-artifact-family-entry-field") + + +def test_artifact_family_non_list_value_is_rejected(tmp_path: Path) -> None: + body = _GOOD_POLICY.replace(_NAF_BLOCK, "normative_artifact_families: oops\n", 1) + failures = evaluate_authority_boundary(_seed_repo(tmp_path, policy_body=body)) + assert _flagged(failures, "authority-boundary-field-type") + + +def test_artifact_family_duplicate_id_is_flagged(tmp_path: Path) -> None: + # A second entry reusing `agent_guidance_profile` as its id. + extra = ( + " - id: agent_guidance_profile\n" + " artifact: specs/agent-guidance/agent-guidance.yaml\n" + " authority: duplicate id\n" + " family: governance-guidance\n" + ) + body = _GOOD_POLICY.replace(_NAF_BLOCK, _NAF_BLOCK + extra, 1) + failures = evaluate_authority_boundary(_seed_repo(tmp_path, policy_body=body)) + assert _flagged(failures, "authority-boundary-artifact-family-duplicate") + + +def test_artifact_family_path_not_on_disk_is_flagged(tmp_path: Path) -> None: + # A second, well-formed entry whose artifact does not exist on disk. The + # canonical agent-guidance entry stays intact so only the missing-file + # rule fires. + extra = ( + " - id: ghost_profile\n" + " artifact: specs/agent-guidance/does-not-exist.yaml\n" + " authority: ghost\n" + " family: governance-guidance\n" + ) + body = _GOOD_POLICY.replace(_NAF_BLOCK, _NAF_BLOCK + extra, 1) + failures = evaluate_authority_boundary(_seed_repo(tmp_path, policy_body=body)) + assert _flagged(failures, "authority-boundary-artifact-family-missing") + assert any("does-not-exist.yaml" in failure.render() for failure in failures), ( + f"expected the missing artifact path to be named; got: {[f.render() for f in failures]}" + ) + + +def test_artifact_family_under_non_normative_root_is_flagged(tmp_path: Path) -> None: + # A classified artifact must live under a NORMATIVE authority root. + # Pointing one at docs/ (non-normative) must fail even when the file + # exists, because docs/ consumes authority and does not define it. + extra = ( + " - id: misplaced_profile\n" + " artifact: docs/misplaced-guidance.yaml\n" + " authority: misplaced\n" + " family: governance-guidance\n" + ) + body = _GOOD_POLICY.replace(_NAF_BLOCK, _NAF_BLOCK + extra, 1) + failures = evaluate_authority_boundary( + _seed_repo( + tmp_path, + policy_body=body, + extra_files={"docs/misplaced-guidance.yaml": "profile: x\n"}, + ) + ) + assert _flagged(failures, "authority-boundary-artifact-family-non-normative") + + +def test_artifact_family_absolute_path_is_flagged(tmp_path: Path) -> None: + extra = ( + " - id: abs_profile\n" + " artifact: /abs/specs/agent-guidance/agent-guidance.yaml\n" + " authority: abs\n" + " family: governance-guidance\n" + ) + body = _GOOD_POLICY.replace(_NAF_BLOCK, _NAF_BLOCK + extra, 1) + failures = evaluate_authority_boundary(_seed_repo(tmp_path, policy_body=body)) + assert _flagged(failures, "authority-boundary-artifact-family-path-shape") + + +def test_artifact_family_traversal_path_is_flagged(tmp_path: Path) -> None: + extra = ( + " - id: escape_profile\n" + " artifact: specs/../../escape.yaml\n" + " authority: escape\n" + " family: governance-guidance\n" + ) + body = _GOOD_POLICY.replace(_NAF_BLOCK, _NAF_BLOCK + extra, 1) + failures = evaluate_authority_boundary(_seed_repo(tmp_path, policy_body=body)) + assert _flagged(failures, "authority-boundary-artifact-family-path-shape") + + +def test_artifact_family_requirement_refs_must_be_string_list(tmp_path: Path) -> None: + body = _GOOD_POLICY.replace( + " requirement_refs:\n - AUT-811\n", + " requirement_refs:\n - 811\n", + 1, + ) + failures = evaluate_authority_boundary(_seed_repo(tmp_path, policy_body=body)) + assert _flagged(failures, "authority-boundary-artifact-family-entry-field") + + # --------------------------------------------------------------------------- # # Real-repo positive case -- run the validator against the actual repo and # # confirm it reports no failures. This is the integration-style check that # diff --git a/specs/README.md b/specs/README.md index 05716fcbe..8da2c19d1 100644 --- a/specs/README.md +++ b/specs/README.md @@ -28,6 +28,12 @@ hook). ## Subdirectories - `authority/` — the canonical authority-boundary manifest +- `agent-guidance/` — the agent-usable governance-guidance profile + (AUT-811). It is machine-readable rather than prose, so the manifest + classifies `agent-guidance.yaml` as a `governance-guidance` artifact via + the `normative_artifact_families` block (see + [`authority/README.md`](authority/README.md)) rather than the default + `prose` family of the `specs/` root. - `concept-authority/` — concept-family and controlled-vocabulary authority artifacts (governed by ADR-012) - `formal/` — optional formal-methods artifacts for semantic and diff --git a/specs/authority/README.md b/specs/authority/README.md index ce8ad5fc5..7328782a6 100644 --- a/specs/authority/README.md +++ b/specs/authority/README.md @@ -13,9 +13,35 @@ decided in prose. `contracts/concept-authority/`), each non-normative root (`implementations/`, `docs/`, `examples/`, `research/`, `notes/`, `tools/`, `changelog.d/`), the legacy top-level directories ADR-009 - transitioned out (`schemas/`, `conformance/`, `src/`), and the + transitioned out (`schemas/`, `conformance/`, `src/`), the schema-authority direction (no published schema may live under - `implementations/`). + `implementations/`), and the `normative_artifact_families` block described + below. + +## Artifact-family classification + +`authority_roots` classify whole directory trees, and `specs/` carries the +default family `prose`. Some artifacts under a normative root are not prose — +the agent-usable guidance profile (`specs/agent-guidance/agent-guidance.yaml`, +AUT-811) is machine-readable governance guidance, not a prose specification. +The `normative_artifact_families` block classifies such artifacts explicitly: +each entry pins one artifact path to a distinct authority `family` (for +agent-guidance, `governance-guidance`) so the artifact's authority class is +decidable from the manifest alone. Such a classified, governed scope is a +**surface** in the +[concept-authority](../concept-authority/concept-authority.md) sense. + +The classification follows a **most-specific rule**: an artifact path is +strictly more specific than any root prefix, so an explicit +`normative_artifact_families[].artifact` entry classifies that exact file and +the containing root supplies the default family for every other artifact. The +next governed non-prose artifact is classified by adding one entry here plus a +focused test in +[`test_authority_boundary.py`](../../implementations/python/tests/test_authority_boundary.py); +no parallel list lives anywhere else in the repo. +`tools/check_authority_boundary.py` validates each entry's shape, that the +artifact exists under a normative authority root, and pins the canonical +agent-guidance binding against silent drift. ## Governance diff --git a/specs/authority/authority-boundary.yaml b/specs/authority/authority-boundary.yaml index c98cda767..cc80eb033 100644 --- a/specs/authority/authority-boundary.yaml +++ b/specs/authority/authority-boundary.yaml @@ -107,3 +107,24 @@ schema_authority: - implementations/ forbidden_schema_filename_suffixes: - .schema.json + +# Non-prose artifacts that live inside a normative authority root but carry a +# distinct, more specific authority family — a governed "surface" in the +# concept-authority sense (specs/concept-authority/concept-authority.md). The +# `specs/` root's default family is `prose`; an entry here classifies one exact +# file more specifically, so the artifact's authority class is decidable from +# this manifest alone rather than inferred from its containing root. +# +# Most-specific rule: an artifact path is strictly more specific than any root +# prefix, so an explicit `normative_artifact_families[].artifact` entry wins for +# that exact file and the containing root supplies the default for every other +# artifact — there is no parent/child ambiguity to resolve. Classifying the +# next governed profile is a single entry here plus a focused test; nothing +# else in the repo carries a parallel list. +normative_artifact_families: + - id: agent_guidance_profile + artifact: specs/agent-guidance/agent-guidance.yaml + authority: agent-usable governance-guidance profile (AUT-811) + family: governance-guidance + requirement_refs: + - AUT-811 diff --git a/specs/concept-authority/concept-authority.md b/specs/concept-authority/concept-authority.md index 867b6c284..1d9043422 100644 --- a/specs/concept-authority/concept-authority.md +++ b/specs/concept-authority/concept-authority.md @@ -38,6 +38,53 @@ Where SDL, manifests, contracts, provenance, and reports bind their declared meaning to canonical concepts. This layer prevents artifact-local strings from becoming de facto semantics. +## Surface + +A **surface** is a named, bounded, contract-bearing scope of an ACES artifact +or apparatus — owned by a single declaring authority — across which concept +bindings, governed vocabularies, and conformance rules apply. "Surface" is the +ecosystem's central organizing term: authoring (the SDL surface), processing +and contracts (the contract, control-plane, and runtime surfaces), the +apparatus (processor, backend, participant, live-execution, evidence, and +provenance surfaces), and authority itself (normative-prose, schema, and +governance-guidance surfaces) are all surfaces in this sense. A surface is not a +file, a package, an endpoint, a schema, or a concept family; any of those may +carry, realize, or sit within a surface, but the surface is the governed scope, +not its container. + +A surface has three defining properties: + +- **Named and bounded** — it has an identity and an explicit edge; a fact is + either inside it or outside it. +- **Singly owned** — exactly one declaring authority owns the meaning on the + surface, so artifact-local strings cannot become de facto semantics (the + Artifact Binding Layer above is where surfaces bind to canonical concepts). +- **Contract-bearing** — the surface is the unit against which conformance, + parity, and governed vocabularies are defined. + +### One Surface Versus Two + +When two facts arise, the inventory program decides whether they belong to one +surface or two using a single rule established by +[ADR-033](../../docs/decisions/adrs/adr-033-scenario-delivery-boundary-for-runtime-node-state.md): + +- Two facts belong to the **same surface** when they share the same semantic + boundary *and* the same typed owner. +- They belong to **separate surfaces** when they sit at different semantic + boundaries — authored scenario intent, observed runtime state, source-artifact + and image provenance, and backend or host delivery mechanics are four distinct + boundaries — **even when the same real-world evidence supports both**. + Surfaces do not collapse into each other. + +Worked example (ADR-033): a container-side service listener, a host-published +port binding, and an image-default exposed port are three different facts on +three different surfaces, even though one inspection of a running container can +produce all three. The shared evidence is not a reason to merge them; the +differing semantic boundary is the reason to keep them apart. + +The [glossary](../../docs/explain/reference/glossary.md) carries a reference-aid +entry for "surface" that points back to this normative definition. + ## Provenance Categories Every concept family declares its provenance: diff --git a/tools/check_authority_boundary.py b/tools/check_authority_boundary.py index f2376929c..f05259a4a 100644 --- a/tools/check_authority_boundary.py +++ b/tools/check_authority_boundary.py @@ -94,6 +94,18 @@ # bucket. CANONICAL_LEGACY_TOP_LEVEL_DIRS: tuple[str, ...] = ("schemas", "conformance", "src") +# Canonical artifact-family bindings (CA-4): non-prose artifacts that live +# inside a normative authority root but bear a distinct authority family — +# a "surface" in the concept-authority sense (specs/concept-authority/ +# concept-authority.md). The `specs/` root's default family is `prose`; an +# explicit `normative_artifact_families` entry classifies one exact file more +# specifically, so the artifact's authority class is decidable from the +# manifest alone. Pinned here the same way CANONICAL_AUTHORITY_ROOT_BINDING +# pins roots, so the classification cannot be silently dropped or relabelled. +CANONICAL_ARTIFACT_FAMILY_BINDING: dict[str, str] = { + "specs/agent-guidance/agent-guidance.yaml": "governance-guidance", +} + # Required top-level fields and per-entry fields. Used by both the YAML shape # check and the test fixture parametrisation. _REQUIRED_TOP_LEVEL_FIELDS: tuple[str, ...] = ( @@ -104,9 +116,11 @@ "non_normative_roots", "legacy_top_level_dirs", "schema_authority", + "normative_artifact_families", ) _REQUIRED_AUTHORITY_ROOT_FIELDS: tuple[str, ...] = ("id", "root", "authority", "family") _REQUIRED_NON_NORMATIVE_ROOT_FIELDS: tuple[str, ...] = ("id", "root", "note") +_REQUIRED_ARTIFACT_FAMILY_FIELDS: tuple[str, ...] = ("id", "artifact", "authority", "family") _REQUIRED_SCHEMA_AUTHORITY_FIELDS: tuple[str, ...] = ( "normative_root", "publication_manifest", @@ -222,6 +236,14 @@ def _check_top_level_fields(raw: dict, source_path: str) -> list[PolicyFailure]: source_path, ) ) + if "normative_artifact_families" in raw and not isinstance(raw["normative_artifact_families"], list): + failures.append( + _fail( + "authority-boundary-field-type", + f"normative_artifact_families must be a list; got {type(raw['normative_artifact_families']).__name__}", + source_path, + ) + ) return failures @@ -636,6 +658,188 @@ def _check_schema_authority_block(raw: dict, source_path: str) -> list[PolicyFai return failures +# --------------------------------------------------------------------------- # +# Normative artifact-family classification (CA-4). # +# --------------------------------------------------------------------------- # + + +def _check_artifact_path_shape(artifact: str) -> str | None: + """Return why ``artifact`` is a malformed repo-relative file path, or None. + + Mirrors ``_check_root_path_shape`` but for files: a classified artifact is a + concrete file, so it must NOT end with ``/`` (the root rule is inverted). + """ + if not artifact: + return "must be a non-empty string" + if artifact.startswith("/"): + return f"must be a repo-relative path; got {artifact!r}" + if ".." in Path(artifact).parts: + return f"must not traverse parent directories; got {artifact!r}" + if artifact.endswith("/"): + return f"must be a file path, not a directory; got {artifact!r}" + return None + + +def _check_normative_artifact_families( + repo_root: Path, + raw: dict, + authority_roots: list[dict], + non_normative_roots: list[dict], + source_path: str, +) -> list[PolicyFailure]: + """Validate the ``normative_artifact_families`` block (CA-4). + + Each entry classifies one non-prose artifact that lives inside a normative + authority root but bears a distinct authority family (a governed + "surface", per specs/concept-authority/concept-authority.md). The block is + the most-specific classification seam: because an artifact path is strictly + more specific than any root prefix, an explicit entry classifies that exact + file and the containing root supplies the default for everything else, so + there is no parent/child ambiguity. The canonical bindings are pinned so + the classification cannot be silently dropped or relabelled. + """ + failures: list[PolicyFailure] = [] + raw_entries = raw.get("normative_artifact_families") + if not isinstance(raw_entries, list): + return failures # missing-field / type-level failure already reported + + authority_prefixes = [entry["root"] for entry in authority_roots] + non_normative_prefixes = [entry["root"] for entry in non_normative_roots] + + validated: list[dict] = [] + seen_ids: set[str] = set() + for index, entry in enumerate(raw_entries): + if not isinstance(entry, dict): + failures.append( + _fail( + "authority-boundary-artifact-family-entry-field", + f"normative_artifact_families[{index}] must be a mapping; got {type(entry).__name__}", + source_path, + ) + ) + continue + + entry_ok = True + for field in _REQUIRED_ARTIFACT_FAMILY_FIELDS: + if field not in entry: + failures.append( + _fail( + "authority-boundary-artifact-family-entry-field", + f"normative_artifact_families[{index}] is missing required field: {field}", + source_path, + ) + ) + entry_ok = False + elif not (_is_str(entry[field]) and entry[field]): + failures.append( + _fail( + "authority-boundary-artifact-family-entry-field", + f"normative_artifact_families[{index}].{field} must be a non-empty string", + source_path, + ) + ) + entry_ok = False + + # requirement_refs is optional, but when present it carries the same + # list-of-strings strictness as the top-level refs. + if "requirement_refs" in entry and _str_list(entry["requirement_refs"]) is None: + failures.append( + _fail( + "authority-boundary-artifact-family-entry-field", + f"normative_artifact_families[{index}].requirement_refs must be a list of strings", + source_path, + ) + ) + entry_ok = False + + if not entry_ok: + continue + + entry_id = entry["id"] + if entry_id in seen_ids: + failures.append( + _fail( + "authority-boundary-artifact-family-duplicate", + f"normative_artifact_families[{index}].id '{entry_id}' is duplicated", + source_path, + ) + ) + seen_ids.add(entry_id) + + artifact = entry["artifact"] + shape_problem = _check_artifact_path_shape(artifact) + if shape_problem is not None: + failures.append( + _fail( + "authority-boundary-artifact-family-path-shape", + f"normative_artifact_families[{index}].artifact {shape_problem}", + source_path, + ) + ) + continue + + # A governed surface must live under a NORMATIVE authority root — it + # cannot be classified into a tree that only consumes authority. + if not any(artifact.startswith(prefix) for prefix in authority_prefixes): + under_non_normative = any(artifact.startswith(prefix) for prefix in non_normative_prefixes) + detail = ( + "is under a non-normative root" + if under_non_normative + else "is not under any declared normative authority root" + ) + failures.append( + _fail( + "authority-boundary-artifact-family-non-normative", + f"normative_artifact_families[{index}].artifact '{artifact}' {detail}", + source_path, + ) + ) + continue + + if not (repo_root / artifact).is_file(): + failures.append( + _fail( + "authority-boundary-artifact-family-missing", + f"normative_artifact_families[{index}].artifact '{artifact}' does not exist on disk", + source_path, + ) + ) + continue + + validated.append({"id": entry_id, "artifact": artifact, "family": entry["family"]}) + + # Pin the canonical artifact-family bindings, mirroring the canonical-root + # binding floor: each must be present with its expected family. + by_artifact = {entry["artifact"]: entry for entry in validated} + for canon_artifact, canon_family in CANONICAL_ARTIFACT_FAMILY_BINDING.items(): + entry = by_artifact.get(canon_artifact) + if entry is None: + failures.append( + _fail( + "authority-boundary-artifact-family-canonical-missing", + ( + f"normative_artifact_families is missing the canonical classification for " + f"{canon_artifact} (expected family {canon_family!r})" + ), + source_path, + ) + ) + continue + if entry["family"] != canon_family: + failures.append( + _fail( + "authority-boundary-artifact-family-canonical-binding", + ( + f"normative_artifact_families entry for {canon_artifact} must declare " + f"family={canon_family!r}; got {entry['family']!r}" + ), + source_path, + ) + ) + + return failures + + # --------------------------------------------------------------------------- # # Filesystem invariants. # # --------------------------------------------------------------------------- # @@ -1192,6 +1396,11 @@ def evaluate_authority_boundary(repo_root: Path) -> list[PolicyFailure]: non_normative_roots, non_normative_failures = _check_non_normative_roots(raw, AUTHORITY_BOUNDARY_RELATIVE_PATH) failures.extend(non_normative_failures) failures.extend(_check_schema_authority_block(raw, AUTHORITY_BOUNDARY_RELATIVE_PATH)) + failures.extend( + _check_normative_artifact_families( + repo_root, raw, authority_roots, non_normative_roots, AUTHORITY_BOUNDARY_RELATIVE_PATH + ) + ) legacy_dirs = _str_list(raw.get("legacy_top_level_dirs")) or [] # The YAML's legacy list must be a superset of the canonical floor that From 502b3e7ce4113a6159dd234e51ac0d53fe2061bc Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 08:13:09 +0200 Subject: [PATCH 40/70] Fix SonarCloud findings (cycle 1) --- .../python/packages/aces_processor/compiler.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/implementations/python/packages/aces_processor/compiler.py b/implementations/python/packages/aces_processor/compiler.py index 84561b225..c9f69841f 100644 --- a/implementations/python/packages/aces_processor/compiler.py +++ b/implementations/python/packages/aces_processor/compiler.py @@ -2215,12 +2215,9 @@ def _realization_requirement_address(scenario: InstantiatedScenario, field_path: head, name = parts[0], parts[1] if head == "nodes": node = scenario.nodes.get(name) - if node is not None and node.type == NodeType.SWITCH: - return _network_address(name) - return _node_address(name) - if head == "content": - return _content_address(name) - return field_path + is_switch = node is not None and node.type == NodeType.SWITCH + return _network_address(name) if is_switch else _node_address(name) + return _content_address(name) if head == "content" else field_path def _compile_realization_requirements( From 5e77a5d0ca2f7f230b470f160afeb8f43ff7985b Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 08:15:07 +0200 Subject: [PATCH 41/70] =?UTF-8?q?Flip=20published-schema=20authority=20dir?= =?UTF-8?q?ection=20per=20ADR-009=20=C2=A77?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make contracts/schemas/ the hand-governed normative authority and repurpose schema generation as a compatibility proof. - check_generated_schemas.py generates into a throwaway directory and proves the reference implementation matches the published schemas without overwriting them; failure messaging inverted accordingly. - check_schema_publication.py gains a manifest change-ledger gate: a changed or newly published schema requires a contract-facing last_change entry, and a removed schema requires a removed_schemas tombstone, checked against --base-rev. - repo_policy.rego: replace generated-schema-direct-edit with schema-change-missing-manifest (a contracts/schemas/ change must touch the publication manifest); adr_policy.yaml gains manifest_path. - Docs/governance aligned to the inverted direction (contracts/README, .gc/plan-rules, agent-guidance, authority-boundary, the normative-artifact-authority reference); the ADR-009 §7 milestone is recorded via an ADR-059 amendment. Refs #499 --- .gc/plan-rules.md | 12 +- changelog.d/499.changed.md | 1 + contracts/README.md | 20 + ...fact-authority-and-repository-structure.md | 6 + docs/decisions/adrs/adr-index.yaml | 4 + .../issue-499-schema-authority-preflight.md | 126 +++++ .../reference/normative-artifact-authority.md | 20 +- .../python/tests/test_repo_policy_tools.py | 456 +++++++++++++++++- specs/agent-guidance/agent-guidance.yaml | 9 +- specs/authority/authority-boundary.yaml | 14 +- tools/check_generated_schemas.py | 62 ++- tools/check_schema_publication.py | 179 +++++++ tools/generate_contract_schemas.py | 24 +- tools/policy/adr_policy.yaml | 11 +- tools/policy/conftest/repo_policy.rego | 12 +- tools/policy/conftest/repo_policy_test.rego | 44 +- 16 files changed, 904 insertions(+), 96 deletions(-) create mode 100644 changelog.d/499.changed.md create mode 100644 docs/decisions/issue-499-schema-authority-preflight.md diff --git a/.gc/plan-rules.md b/.gc/plan-rules.md index c9efdff01..1cd5385b5 100644 --- a/.gc/plan-rules.md +++ b/.gc/plan-rules.md @@ -13,8 +13,16 @@ These encode the hard rules previously in `AGENTS.md` prose. already contain a UID such as `GOV-918`. - Plans MUST NOT add new authority-bearing artifacts outside `specs/`, `contracts/`, `docs/`, and `implementations/`. -- Plans MUST NOT edit `contracts/schemas/` directly; change generator - inputs and regenerate. +- Plans that change a published schema under `contracts/schemas/` (the + hand-governed normative authority per ADR-009 §7) MUST record a + contract-facing change-ledger entry (`last_change`: summary + content hash) + in `contracts/schema-publication-manifest.json`, and a plan that **removes** + a published schema MUST record a `removed_schemas` tombstone (schema path + + summary) in the same manifest. Plans MUST keep the reference implementation + (`schema_bundle()`) generating an identical bundle so + `tools/check_generated_schemas.py` passes. The published schema is the + authority; a generator/Python edit alone is NOT authorization for a schema + change. - Plans MUST NOT add new implementation logic to `implementations/python/src/aces/`; that tree is compatibility-only wrappers. diff --git a/changelog.d/499.changed.md b/changelog.d/499.changed.md new file mode 100644 index 000000000..bea73b4b5 --- /dev/null +++ b/changelog.d/499.changed.md @@ -0,0 +1 @@ +Flipped published-schema authority per ADR-009 §7: `contracts/schemas/` is now the hand-governed normative authority, `tools/check_generated_schemas.py` proves the reference implementation generates an identical bundle without overwriting the published schemas, and a manifest change-ledger (`schema-publication-manifest.json` `last_change` for added/modified schemas and `removed_schemas` tombstones for deletions) plus the `schema-change-missing-manifest` policy rule require a contract-facing description for any schema change, including removals. diff --git a/contracts/README.md b/contracts/README.md index 09ab3f4f2..17d905274 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -14,6 +14,26 @@ checks that every entry points at `contracts/schemas/`, that every listed schema exists, that every JSON Schema file under `contracts/schemas/` is listed, and that every entry records its stability class and canonical content hash. +## Schema authority direction (ADR-009 §7) + +The published schemas under `contracts/schemas/` are the **hand-governed +normative authority**. Changes to a contract originate as edits to the published +schema, reviewed against the SDL prose specification — not as a side-effect of +regenerating from a reference implementation. The Python `schema_bundle()` and +`tools/generate_contract_schemas.py` are the reference implementation's output; +`tools/check_generated_schemas.py` runs that generation into a throwaway +directory and **proves the reference implementation still matches the published +normative schemas** (it never overwrites them). A schema change must record a +contract-facing change-ledger entry (`last_change`: summary + content hash) in +`schema-publication-manifest.json`; a schema **removal** must record a +`removed_schemas` tombstone (schema path + summary) in the same manifest. +`tools/check_schema_publication.py --base-rev` and the +`schema-change-missing-manifest` policy rule reject a `contracts/schemas/` +change — including a removal — that lands without one, so a schema cannot +change without contract-level review. This is the steady state ADR-009 §7 calls for; +optional code generation *from* the normative schemas into implementation +bindings remains future work. + Current checked-in schemas are marked `draft` in the manifest. A `v1` or `v2` filename suffix identifies the schema lineage; it does not by itself promise a stable compatibility surface. Stable schema evolution is governed by diff --git a/docs/decisions/adrs/adr-009-normative-artifact-authority-and-repository-structure.md b/docs/decisions/adrs/adr-009-normative-artifact-authority-and-repository-structure.md index 2280761c8..28ac222cb 100644 --- a/docs/decisions/adrs/adr-009-normative-artifact-authority-and-repository-structure.md +++ b/docs/decisions/adrs/adr-009-normative-artifact-authority-and-repository-structure.md @@ -217,3 +217,9 @@ they were independent normative authority. - If code generation remains one-way from Python into schemas, the ecosystem will still be coupled to the reference stack in practice even if the folder names improve. + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-06-14 | #499 | Section 7 milestone recorded as reached: `contracts/schemas/` is the hand-governed normative authority, `tools/check_generated_schemas.py` is repurposed to prove the reference implementation generates an identical bundle without overwriting the published schemas, and a manifest change-ledger (`schema-publication-manifest.json` `last_change` for added/modified schemas and `removed_schemas` tombstones for deletions) plus the `schema-change-missing-manifest` policy rule govern schema edits. Remaining: optional code generation *from* the normative schemas into implementation bindings, and incidental per-feature/historical doc language that describes the former generation direction. This amendment records the milestone only; the decision text above is unchanged. diff --git a/docs/decisions/adrs/adr-index.yaml b/docs/decisions/adrs/adr-index.yaml index f69195dec..a723802d7 100644 --- a/docs/decisions/adrs/adr-index.yaml +++ b/docs/decisions/adrs/adr-index.yaml @@ -34,6 +34,10 @@ adrs: - id: ADR-009 path: docs/decisions/adrs/adr-009-normative-artifact-authority-and-repository-structure.md pin: f7a74751a70e8d5d96266e2c45d94b1aafda832afe7b418df819de1cb1983fcc + amendments: + - date: 2026-06-14 + ref: "#499" + summary: "Recorded the section 7 schema-authority milestone as reached (contracts/schemas/ normative; reference implementation proves compatibility; manifest change-ledger governs schema edits)." - id: ADR-010 path: docs/decisions/adrs/adr-010-repository-realignment-order-and-compatibility-policy.md pin: 752d8577e900e9b6257ff0db362c275bf7b26e14162a892dcd9578cbd71717c3 diff --git a/docs/decisions/issue-499-schema-authority-preflight.md b/docs/decisions/issue-499-schema-authority-preflight.md new file mode 100644 index 000000000..830e733c1 --- /dev/null +++ b/docs/decisions/issue-499-schema-authority-preflight.md @@ -0,0 +1,126 @@ +# Issue 499 Schema Authority Preflight + +Date: 2026-06-14 + +Issue: #499. + +Requirement: none. The issue title, body, and acceptance criteria are the +contract. + +This note records architecture preflight guardrails for flipping published +schema authority per ADR-009 section 7. It is guidance for the implementation +and does not implement the checker, manifest ledger, schema edits, or tests. + +## Binding Sources + +- ADR-009 decides that normative schemas exist independently and that + validation proves implementation compatibility with normative artifacts. +- ADR-019 and `specs/authority/authority-boundary.yaml` are the canonical + authority-boundary seam: `contracts/schemas/` is normative, and + implementations are non-normative consumers. +- ADR-061 and `contracts/schema-publication-manifest.json` are the schema + publication and evolution seam. Extend this seam; do not add a second schema + registry or review ledger. +- The GitHub issue depends on CT-6 prose-spec work and pairs with CT-1 + schema-evolution policy. If either surface is absent, make the remaining + milestone explicit instead of implying full steady state. + +## Architecture Decisions + +- Published files under `contracts/schemas/` are the hand-governed normative + contract artifacts. Python models and `schema_bundle()` are compatibility + evidence, not schema authority. +- `tools/check_generated_schemas.py` should keep its byte-match mechanics but + report the inverted meaning: the reference implementation must generate the + same schemas as the published normative set. +- `contracts/schema-publication-manifest.json` remains the only schema + publication registry. Add any per-schema contract-review/change ledger fields + there, keyed by the existing `contract_id` and `schema_path` identity. +- The process gate should fail a `contracts/schemas/` change unless the same + change set carries a manifest ledger entry with a contract-facing or + spec-facing rationale. Do not satisfy this with a generator-driver edit. +- `.gc/plan-rules.md`, `tools/policy/conftest/repo_policy.rego`, tool help, + and living contributor guidance must stop calling direct schema edits + forbidden generated-output edits. Historical accepted ADR text should not be + rewritten in place unless ADR-059 amendment rules are followed. + +## Required Incumbents + +- Authority boundary: ADR-009, ADR-019, + `specs/authority/authority-boundary.yaml`, and + `tools/check_authority_boundary.py`. +- Schema publication/evolution: ADR-061, + `contracts/schema-publication-manifest.json`, + `tools/check_schema_publication.py`, and + `implementations/python/tests/test_repo_policy_tools.py`. +- Compatibility proof: `aces_contracts.contracts.schema_bundle()`, + `tools/generate_contract_schemas.py`, `tools/check_generated_schemas.py`, + and `test_published_contract_schemas_exist_and_match_bundle`. +- Workflow policy: `.ground-control.yaml`, `.gc/plan-rules.md`, + `tools/check_repo_policy.py`, `tools/policy/adr_policy.yaml`, + `tools/policy/conftest/repo_policy.rego`, and `noxfile.py` policy/contracts + sessions. +- Artifact validation: `tools/check_json_artifacts.py`, + `check-jsonschema`, closed-world `ContractModel` descendants, and existing + valid/invalid fixture conventions. + +## Cross-Cutting Layers + +- JSON/config parsing: use `json.loads` and `yaml.safe_load` with explicit + mapping/list/string checks. Do not evaluate schema content, fetch remote + `$ref` targets, or coerce malformed ledger fields to empty defaults. +- Repo-path security: ledger paths and refs must be normalized repo-relative + paths, reject absolute paths and `..`, and reuse `_safe_schema_path()` or + `safe_repo_path()`-equivalent checks before any file read. +- Git/base-revision input: compare changed schemas through the existing + `--base-rev` path in `noxfile.py` and fixed-argv `subprocess.run` calls. + Do not introduce shell command construction. +- Policy error surface: preserve existing CLI behavior: Rego failures return + `rule_id`/`msg`/`path`, Python policy failures use `PolicyFailure`, and + schema-publication failures stay concise. Do not dump schema bodies, + environment variables, or tracebacks. +- Secret and OS exposure: this governance path should read only checked-in + repo files and Git metadata. Review refs, spec refs, and change summaries + must not contain credentials, bearer tokens, private keys, environment + bindings, or command examples that put secrets in process argv. + +## Extension Boundary + +The extension seam is the manifest entry for a published `contract_id`, plus a +change-ledger field that can be checked against a base revision. Future schema +families, stability levels, or review references should extend that manifest +shape and checker validation, not add sidecar ledgers or hard-code today's +contract ids in Rego or Python. + +The obvious future parameter is the comparison base revision. Keep it threaded +through the existing `--base-rev` nox/checker route so local, CI, and staged +checks do not grow separate policy semantics. + +## Gotchas And Anti-Patterns + +Avoid: + +- keeping the old Rego rule that treats a generator-driver edit as sufficient + authorization for schema changes; +- adding a second schema manifest, review ledger, schema registry, fixture + loader, or compatibility checker; +- making the ledger a PR-template prose convention that policy cannot inspect; +- keying review evidence by Python class, generator function, or output path + alone instead of `contract_id` and `schema_path`; +- silently dropping the extra-published-schema check from + `check_generated_schemas.py`; in the new semantics it proves the reference + bundle covers every normative schema; +- weakening ADR-061 stable-schema compatibility checks while adding review + metadata; +- rewriting accepted historical ADR bodies or old research notes as part of + this issue unless the ADR-059 amendment and pin gate requires it. + +## Non-Goals + +- Editing published schemas or the manifest ledger in this preflight note. +- Implementing the policy gate, checker tests, or nox wiring here. +- Changing SDL prose semantics, contract payload shapes, or stability classes + except where the implementation PR deliberately edits a contract surface. +- Moving schema authority into Python models, generated bindings, fixtures, + examples, docs, or the compatibility `implementations/python/src/aces/` + tree. diff --git a/docs/explain/reference/normative-artifact-authority.md b/docs/explain/reference/normative-artifact-authority.md index 1394e8483..b9ef602bd 100644 --- a/docs/explain/reference/normative-artifact-authority.md +++ b/docs/explain/reference/normative-artifact-authority.md @@ -71,10 +71,14 @@ but it still passes through these gates: - Publication validation: every published schema must be listed once in `contracts/schema-publication-manifest.json`, and every manifest entry must point under `contracts/schemas/`. -- Generated-schema drift validation: changes that affect `schema_bundle()` or - generator inputs must regenerate schemas and pass - `tools/check_generated_schemas.py`; do not hand-edit - `contracts/schemas/`. +- Schema authority direction (ADR-009 §7): the published schemas under + `contracts/schemas/` are the hand-governed normative authority. A contract + change is an edit to the published schema plus a contract-facing change-ledger + entry in `contracts/schema-publication-manifest.json` (`last_change` for an + added or modified schema; a `removed_schemas` tombstone for a deletion); + `tools/check_generated_schemas.py` proves the reference implementation + (`schema_bundle()`) still generates an identical bundle (into a throwaway + directory — it never overwrites the published authority). - Requirement governance: changed governed paths must carry the `ASR-517` context through the branch name or `ACES_REQUIREMENT_UID`, and Ground Control traceability must stay aligned. @@ -112,8 +116,12 @@ Avoid: - collapsing concept authority, artifact schema authority, backend capability profiles, and semantic profiles into one generic "profile" or "runtime" bucket -- editing `contracts/schemas/` directly instead of changing the generator - inputs and regenerating +- changing or removing a published schema under `contracts/schemas/` without + recording a contract-facing change-ledger entry (`last_change`, or a + `removed_schemas` tombstone for a deletion) in + `contracts/schema-publication-manifest.json`, or treating a generator/Python + edit as authorization for a schema change (the published schema is the + authority; the generator only proves compatibility) - adding new authority-bearing artifacts anywhere other than `specs/` (for normative prose) or `contracts/` (for normative machine-readable artifacts). `docs/`, `implementations/`, `examples/`, `research/`, diff --git a/implementations/python/tests/test_repo_policy_tools.py b/implementations/python/tests/test_repo_policy_tools.py index 905affcc7..3258bef43 100644 --- a/implementations/python/tests/test_repo_policy_tools.py +++ b/implementations/python/tests/test_repo_policy_tools.py @@ -1001,6 +1001,7 @@ def write_schema_publication_manifest( entries: list[dict[str, Any]], *, fill_defaults: bool = True, + removed_schemas: list[dict[str, Any]] | None = None, ) -> None: import json @@ -1015,17 +1016,17 @@ def write_schema_publication_manifest( normalized_entry["content_hash"] = schema_content_hash(path) if path.is_file() else "0" * 64 normalized.append(normalized_entry) + document: dict[str, Any] = { + "schema_version": "schema-publication-manifest/v1", + "hash_algorithm": "sha256", + "schemas": normalized, + } + if removed_schemas is not None: + document["removed_schemas"] = removed_schemas + write_text( repo_root / "contracts" / "schema-publication-manifest.json", - json.dumps( - { - "schema_version": "schema-publication-manifest/v1", - "hash_algorithm": "sha256", - "schemas": normalized, - }, - indent=2, - ) - + "\n", + json.dumps(document, indent=2) + "\n", ) @@ -1172,6 +1173,10 @@ def test_schema_publication_manifest_allows_recorded_draft_schema_churn(tmp_path "contract_id": "draft-contract-v1", "schema_path": "contracts/schemas/sdl/draft-contract-v1.json", "stability": "draft", + "last_change": { + "summary": "Retype name to integer for the draft contract.", + "content_hash": schema_content_hash(schema_path), + }, }, ], ) @@ -1207,6 +1212,10 @@ def test_schema_publication_manifest_allows_stable_additive_schema_change(tmp_pa "contract_id": "stable-contract-v1", "schema_path": "contracts/schemas/sdl/stable-contract-v1.json", "stability": "stable", + "last_change": { + "summary": "Add optional display_name property.", + "content_hash": schema_content_hash(schema_path), + }, }, ], ) @@ -1242,6 +1251,10 @@ def test_schema_publication_manifest_allows_stable_enum_addition(tmp_path: Path) "contract_id": "stable-contract-v1", "schema_path": "contracts/schemas/sdl/stable-contract-v1.json", "stability": "stable", + "last_change": { + "summary": "Add enum value beta to kind.", + "content_hash": schema_content_hash(schema_path), + }, }, ], ) @@ -1282,6 +1295,10 @@ def test_schema_publication_manifest_rejects_stable_default_change_without_versi "contract_id": "stable-contract-v1", "schema_path": "contracts/schemas/sdl/stable-contract-v1.json", "stability": "stable", + "last_change": { + "summary": "Change default value of name.", + "content_hash": schema_content_hash(schema_path), + }, }, ], ) @@ -1318,6 +1335,10 @@ def test_schema_publication_manifest_rejects_stable_breaking_schema_change_witho "contract_id": "stable-contract-v1", "schema_path": "contracts/schemas/sdl/stable-contract-v1.json", "stability": "stable", + "last_change": { + "summary": "Retype name to integer.", + "content_hash": schema_content_hash(schema_path), + }, }, ], ) @@ -1381,6 +1402,10 @@ def test_schema_publication_manifest_rejects_missing_base_schema_for_stable_sche "contract_id": "stable-contract-v1", "schema_path": "contracts/schemas/sdl/stable-contract-v1.json", "stability": "stable", + "last_change": { + "summary": "Publish the stable contract.", + "content_hash": schema_content_hash(schema_path), + }, }, ], ) @@ -1390,6 +1415,321 @@ def test_schema_publication_manifest_rejects_missing_base_schema_for_stable_sche ] +def test_schema_publication_manifest_accepts_valid_last_change_ledger(tmp_path: Path) -> None: + repo_root = tmp_path + schema_path = repo_root / "contracts" / "schemas" / "sdl" / "draft-contract-v1.json" + write_text(schema_path, _published_schema({"name": {"type": "string"}})) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "draft-contract-v1", + "schema_path": "contracts/schemas/sdl/draft-contract-v1.json", + "stability": "draft", + "last_change": { + "summary": "Initial draft of the authoring contract.", + "content_hash": schema_content_hash(schema_path), + }, + }, + ], + ) + + assert validate_schema_publication_manifest(repo_root) == [] + + +def test_schema_publication_manifest_rejects_last_change_without_summary(tmp_path: Path) -> None: + repo_root = tmp_path + schema_path = repo_root / "contracts" / "schemas" / "sdl" / "draft-contract-v1.json" + write_text(schema_path, _published_schema({"name": {"type": "string"}})) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "draft-contract-v1", + "schema_path": "contracts/schemas/sdl/draft-contract-v1.json", + "stability": "draft", + "last_change": {"content_hash": schema_content_hash(schema_path)}, + }, + ], + ) + + failures = validate_schema_publication_manifest(repo_root) + assert any("last_change.summary" in failure for failure in failures) + + +def test_schema_publication_manifest_rejects_last_change_hash_mismatch(tmp_path: Path) -> None: + repo_root = tmp_path + schema_path = repo_root / "contracts" / "schemas" / "sdl" / "draft-contract-v1.json" + write_text(schema_path, _published_schema({"name": {"type": "string"}})) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "draft-contract-v1", + "schema_path": "contracts/schemas/sdl/draft-contract-v1.json", + "stability": "draft", + "last_change": {"summary": "stale ledger entry", "content_hash": "0" * 64}, + }, + ], + ) + + failures = validate_schema_publication_manifest(repo_root) + assert any("last_change.content_hash" in failure and "does not match" in failure for failure in failures) + + +def test_schema_publication_manifest_requires_ledger_when_schema_changes(tmp_path: Path) -> None: + repo_root = tmp_path + schema_path = repo_root / "contracts" / "schemas" / "sdl" / "draft-contract-v1.json" + write_text(schema_path, _published_schema({"name": {"type": "string"}})) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "draft-contract-v1", + "schema_path": "contracts/schemas/sdl/draft-contract-v1.json", + "stability": "draft", + }, + ], + ) + _init_git_repo(repo_root) + _git_commit_all(repo_root, "base") + + # Schema content changes vs base, manifest hash bumps, but no contract-facing + # ledger entry records why — the process gate must reject this. + write_text(schema_path, _published_schema({"name": {"type": "integer"}})) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "draft-contract-v1", + "schema_path": "contracts/schemas/sdl/draft-contract-v1.json", + "stability": "draft", + }, + ], + ) + + failures = validate_schema_publication_manifest(repo_root, base_rev="HEAD") + assert any("contract-facing change description" in failure for failure in failures) + + +def test_schema_publication_manifest_accepts_changed_schema_with_current_ledger(tmp_path: Path) -> None: + repo_root = tmp_path + schema_path = repo_root / "contracts" / "schemas" / "sdl" / "draft-contract-v1.json" + write_text(schema_path, _published_schema({"name": {"type": "string"}})) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "draft-contract-v1", + "schema_path": "contracts/schemas/sdl/draft-contract-v1.json", + "stability": "draft", + }, + ], + ) + _init_git_repo(repo_root) + _git_commit_all(repo_root, "base") + + write_text(schema_path, _published_schema({"name": {"type": "integer"}})) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "draft-contract-v1", + "schema_path": "contracts/schemas/sdl/draft-contract-v1.json", + "stability": "draft", + "last_change": { + "summary": "Retype name to integer per contract review.", + "content_hash": schema_content_hash(schema_path), + }, + }, + ], + ) + + assert validate_schema_publication_manifest(repo_root, base_rev="HEAD") == [] + + +def test_schema_publication_manifest_requires_ledger_for_new_schema(tmp_path: Path) -> None: + repo_root = tmp_path + existing = repo_root / "contracts" / "schemas" / "sdl" / "existing-contract-v1.json" + write_text(existing, _published_schema({"name": {"type": "string"}})) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "existing-contract-v1", + "schema_path": "contracts/schemas/sdl/existing-contract-v1.json", + "stability": "draft", + }, + ], + ) + _init_git_repo(repo_root) + _git_commit_all(repo_root, "base") + + new_schema = repo_root / "contracts" / "schemas" / "sdl" / "new-contract-v1.json" + write_text(new_schema, _published_schema({"id": {"type": "string"}})) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "existing-contract-v1", + "schema_path": "contracts/schemas/sdl/existing-contract-v1.json", + "stability": "draft", + }, + { + "contract_id": "new-contract-v1", + "schema_path": "contracts/schemas/sdl/new-contract-v1.json", + "stability": "draft", + }, + ], + ) + + failures = validate_schema_publication_manifest(repo_root, base_rev="HEAD") + assert any("new-contract-v1" in failure and "contract-facing change description" in failure for failure in failures) + + +def test_schema_publication_manifest_unchanged_schema_needs_no_ledger(tmp_path: Path) -> None: + repo_root = tmp_path + schema_path = repo_root / "contracts" / "schemas" / "sdl" / "draft-contract-v1.json" + write_text(schema_path, _published_schema({"name": {"type": "string"}})) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "draft-contract-v1", + "schema_path": "contracts/schemas/sdl/draft-contract-v1.json", + "stability": "draft", + }, + ], + ) + _init_git_repo(repo_root) + _git_commit_all(repo_root, "base") + + # No schema change vs base: an existing entry without a ledger stays valid, + # so the gate never forces backfilling ledgers onto unchanged schemas. + assert validate_schema_publication_manifest(repo_root, base_rev="HEAD") == [] + + +def _seed_two_schema_repo(repo_root: Path) -> None: + keep = repo_root / "contracts" / "schemas" / "sdl" / "keep-contract-v1.json" + drop = repo_root / "contracts" / "schemas" / "sdl" / "drop-contract-v1.json" + write_text(keep, _published_schema({"name": {"type": "string"}})) + write_text(drop, _published_schema({"id": {"type": "string"}})) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "drop-contract-v1", + "schema_path": "contracts/schemas/sdl/drop-contract-v1.json", + "stability": "draft", + }, + { + "contract_id": "keep-contract-v1", + "schema_path": "contracts/schemas/sdl/keep-contract-v1.json", + "stability": "draft", + }, + ], + ) + _init_git_repo(repo_root) + _git_commit_all(repo_root, "base") + # Delete the published schema and drop its manifest entry. + drop.unlink() + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "keep-contract-v1", + "schema_path": "contracts/schemas/sdl/keep-contract-v1.json", + "stability": "draft", + }, + ], + ) + + +def test_schema_publication_manifest_requires_tombstone_for_removed_schema(tmp_path: Path) -> None: + repo_root = tmp_path + _seed_two_schema_repo(repo_root) + + # Removing the file and its manifest entry without a tombstone bypasses the + # contract-facing ledger the gate requires for every other schema change. + failures = validate_schema_publication_manifest(repo_root, base_rev="HEAD") + assert any( + "drop-contract-v1.json" in failure and "removed without a contract-facing removal description" in failure + for failure in failures + ) + + +def test_schema_publication_manifest_accepts_removed_schema_with_tombstone(tmp_path: Path) -> None: + repo_root = tmp_path + _seed_two_schema_repo(repo_root) + + # The tombstone records why the contract was removed; the gate is satisfied. + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "keep-contract-v1", + "schema_path": "contracts/schemas/sdl/keep-contract-v1.json", + "stability": "draft", + }, + ], + removed_schemas=[ + { + "schema_path": "contracts/schemas/sdl/drop-contract-v1.json", + "summary": "Retired per contract review; superseded by keep-contract-v1.", + }, + ], + ) + + assert validate_schema_publication_manifest(repo_root, base_rev="HEAD") == [] + + +def test_schema_publication_manifest_rejects_tombstone_without_summary(tmp_path: Path) -> None: + repo_root = tmp_path + _seed_two_schema_repo(repo_root) + + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "keep-contract-v1", + "schema_path": "contracts/schemas/sdl/keep-contract-v1.json", + "stability": "draft", + }, + ], + removed_schemas=[{"schema_path": "contracts/schemas/sdl/drop-contract-v1.json"}], + ) + + failures = validate_schema_publication_manifest(repo_root, base_rev="HEAD") + assert any("removed_schemas" in failure and "summary must be a non-empty string" in failure for failure in failures) + + +def test_schema_publication_manifest_rejects_tombstone_for_published_schema(tmp_path: Path) -> None: + repo_root = tmp_path + schema_path = repo_root / "contracts" / "schemas" / "sdl" / "draft-contract-v1.json" + write_text(schema_path, _published_schema({"name": {"type": "string"}})) + write_schema_publication_manifest( + repo_root, + [ + { + "contract_id": "draft-contract-v1", + "schema_path": "contracts/schemas/sdl/draft-contract-v1.json", + "stability": "draft", + }, + ], + removed_schemas=[ + { + "schema_path": "contracts/schemas/sdl/draft-contract-v1.json", + "summary": "Tombstone contradicts a still-published schema.", + }, + ], + ) + _init_git_repo(repo_root) + _git_commit_all(repo_root, "base") + + failures = validate_schema_publication_manifest(repo_root, base_rev="HEAD") + assert any("removed_schemas tombstone" in failure and "still-published schema" in failure for failure in failures) + + def test_collect_validation_targets_includes_only_schema_governed_artifacts(tmp_path: Path) -> None: repo_root = setup_json_validation_repo(tmp_path) @@ -1452,34 +1792,102 @@ def test_extra_published_schema_paths_detects_stale_generated_files(tmp_path: Pa ) == ["backend-manifest/backend-manifest-v1.json"] -def test_check_generated_schemas_main_rejects_stale_extra_schema_files( - tmp_path: Path, +def _install_fake_schema_generator( monkeypatch: pytest.MonkeyPatch, + *, + repo_root: Path, + schemas_root: Path, + generated: dict[str, str], ) -> None: - repo_root = tmp_path - schemas_root = repo_root / "contracts" / "schemas" - write_text(schemas_root / "backend-manifest" / "backend-manifest-v2.json", "{}\n") - write_text(schemas_root / "backend-manifest" / "backend-manifest-v1.json", "{}\n") - + """Install a fake ``tools.generate_contract_schemas`` whose + ``write_schema_bundle`` emits ``generated`` (rel_path -> content) into + whatever directory it is given, and repoint ``check_generated_schemas`` at a + temp repo. The reference bundle is written into a throwaway directory so the + check can prove the implementation matches the published normative schemas + (ADR-009 §7) without overwriting them.""" fake_generator = types.ModuleType("tools.generate_contract_schemas") - fake_generator.main = lambda: None - fake_generator._schema_output_path = lambda root, name: root / "backend-manifest" / f"{name}.json" - fake_contracts = types.ModuleType("aces_contracts.contracts") - fake_contracts.schema_bundle = lambda: {"backend-manifest-v2": {}} - fake_package = types.ModuleType("aces_contracts") - fake_package.contracts = fake_contracts + + def _write_schema_bundle(schemas_dir: Path) -> None: + for rel_path, content in generated.items(): + target = schemas_dir / rel_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + fake_generator.write_schema_bundle = _write_schema_bundle monkeypatch.setattr(check_generated_schemas, "REPO_ROOT", repo_root) monkeypatch.setattr(check_generated_schemas, "SCHEMAS_ROOT", schemas_root) monkeypatch.setattr(check_generated_schemas, "PYTHON_ROOT", repo_root / "implementations" / "python") monkeypatch.setattr(sys, "argv", ["check_generated_schemas.py"]) monkeypatch.setitem(sys.modules, "tools.generate_contract_schemas", fake_generator) - monkeypatch.setitem(sys.modules, "aces_contracts", fake_package) - monkeypatch.setitem(sys.modules, "aces_contracts.contracts", fake_contracts) + + +def test_check_generated_schemas_rejects_stale_extra_schema_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo_root = tmp_path + schemas_root = repo_root / "contracts" / "schemas" + write_text(schemas_root / "backend-manifest" / "backend-manifest-v2.json", "{}\n") + write_text(schemas_root / "backend-manifest" / "backend-manifest-v1.json", "{}\n") + + # The reference bundle produces only v2; the published v1 is an extra + # normative schema the reference implementation no longer generates. + _install_fake_schema_generator( + monkeypatch, + repo_root=repo_root, + schemas_root=schemas_root, + generated={"backend-manifest/backend-manifest-v2.json": "{}\n"}, + ) assert check_generated_schemas.main() == 1 +def test_check_generated_schemas_reports_drift_without_mutating_published( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo_root = tmp_path + schemas_root = repo_root / "contracts" / "schemas" + published = schemas_root / "backend-manifest" / "backend-manifest-v2.json" + published_content = '{\n "x": 2\n}\n' + write_text(published, published_content) + + # Reference implementation generates different bytes than the published + # normative schema: drift must be reported AND the published authority must + # not be overwritten by the compatibility proof (ADR-009 §7). + _install_fake_schema_generator( + monkeypatch, + repo_root=repo_root, + schemas_root=schemas_root, + generated={"backend-manifest/backend-manifest-v2.json": '{\n "x": 1\n}\n'}, + ) + + assert check_generated_schemas.main() == 1 + assert published.read_text(encoding="utf-8") == published_content + + +def test_check_generated_schemas_passes_when_reference_matches_published( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo_root = tmp_path + schemas_root = repo_root / "contracts" / "schemas" + content = '{\n "x": 1\n}\n' + published = schemas_root / "backend-manifest" / "backend-manifest-v2.json" + write_text(published, content) + + _install_fake_schema_generator( + monkeypatch, + repo_root=repo_root, + schemas_root=schemas_root, + generated={"backend-manifest/backend-manifest-v2.json": content}, + ) + + assert check_generated_schemas.main() == 0 + assert published.read_text(encoding="utf-8") == content + + # --- ADR acceptance-content pin gate (ADR-059 / GOV-941) ---------------------- AMENDMENTS_TABLE_HEADER = "## Amendments\n\n| Date | Commit/PR | Summary |\n|------|-----------|---------|\n" diff --git a/specs/agent-guidance/agent-guidance.yaml b/specs/agent-guidance/agent-guidance.yaml index ed2340b5a..c97995236 100644 --- a/specs/agent-guidance/agent-guidance.yaml +++ b/specs/agent-guidance/agent-guidance.yaml @@ -89,9 +89,12 @@ guidance: audience: [contributor] surfaces: [specs, contracts, implementations, docs] statement: >- - New ecosystem authority belongs in approved authority roots. Do not - hand-edit generated schemas or add implementation-owned normative - contracts. + New ecosystem authority belongs in approved authority roots. Published + schemas under contracts/schemas/ are the hand-governed normative + authority (ADR-009 section 7); the reference implementation only proves + compatibility with them. Do not relocate schema authority into + implementation models or generated output, and do not add + implementation-owned normative contracts. source_refs: - .gc/plan-rules.md - specs/authority/authority-boundary.yaml diff --git a/specs/authority/authority-boundary.yaml b/specs/authority/authority-boundary.yaml index c98cda767..0962461a4 100644 --- a/specs/authority/authority-boundary.yaml +++ b/specs/authority/authority-boundary.yaml @@ -95,11 +95,15 @@ legacy_top_level_dirs: - conformance - src -# Schema authority direction: contracts/schemas/ owns published schemas and is -# bound to the publication manifest. Generator drivers under tools/ and the -# aces_contracts owning package emit into this root; they do not define -# authority. Published schemas may not live anywhere else, and reference -# implementations may not own schema files. +# Schema authority direction (ADR-009 section 7): contracts/schemas/ owns the +# hand-governed published schemas and is bound to the publication manifest. +# The aces_contracts owning package and the generator drivers under tools/ are +# the reference implementation; tools/check_generated_schemas.py only proves +# that implementation generates an identical bundle (into a throwaway +# directory) — it does not define authority and never overwrites the published +# schemas. A schema change must carry a contract-facing change-ledger entry in +# the publication manifest. Published schemas may not live anywhere else, and +# reference implementations may not own schema files. schema_authority: normative_root: contracts/schemas/ publication_manifest: contracts/schema-publication-manifest.json diff --git a/tools/check_generated_schemas.py b/tools/check_generated_schemas.py index 3f2c4b2f3..0ec076df8 100644 --- a/tools/check_generated_schemas.py +++ b/tools/check_generated_schemas.py @@ -3,6 +3,7 @@ import argparse import sys +import tempfile from hashlib import sha256 from pathlib import Path @@ -13,7 +14,12 @@ def parse_args() -> argparse.Namespace: return argparse.ArgumentParser( - description="Check that schema generation does not change the current working tree." + description=( + "Prove the reference implementation generates schemas identical to the published " + "normative contracts under contracts/schemas/ (ADR-009 section 7). The published " + "schemas are the authority; this check generates into a throwaway directory and never " + "overwrites them." + ) ).parse_args() @@ -24,14 +30,6 @@ def _snapshot_schema_tree(root: Path) -> dict[str, str]: return snapshot -def _diff_snapshots(before: dict[str, str], after: dict[str, str]) -> list[str]: - changed = sorted(set(before) ^ set(after)) - for path, checksum in before.items(): - if path in after and after[path] != checksum: - changed.append(path) - return sorted(set(changed)) - - def _extra_published_schema_paths(root: Path, *, expected_relative_paths: set[str]) -> list[str]: published = {path.relative_to(root).as_posix() for path in sorted(root.rglob("*.json"))} return sorted(published - expected_relative_paths) @@ -39,7 +37,6 @@ def _extra_published_schema_paths(root: Path, *, expected_relative_paths: set[st def main() -> int: parse_args() - before = _snapshot_schema_tree(SCHEMAS_ROOT) if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) @@ -47,26 +44,39 @@ def main() -> int: python_path_str = str(python_path) if python_path_str not in sys.path: sys.path.insert(0, python_path_str) - from aces_contracts.contracts import schema_bundle - - from tools.generate_contract_schemas import _schema_output_path - from tools.generate_contract_schemas import main as generate_contract_schemas - - generate_contract_schemas() - after = _snapshot_schema_tree(SCHEMAS_ROOT) - changed = _diff_snapshots(before, after) - expected_relative_paths = { - _schema_output_path(SCHEMAS_ROOT, name).relative_to(SCHEMAS_ROOT).as_posix() for name in schema_bundle() - } - extra_paths = _extra_published_schema_paths(SCHEMAS_ROOT, expected_relative_paths=expected_relative_paths) - if changed: - print("Published JSON schemas are out of date. Regeneration changed:", file=sys.stderr) - for path in changed: + from tools.generate_contract_schemas import write_schema_bundle + + # ADR-009 section 7: contracts/schemas/ is the hand-governed normative + # authority. Generate the reference implementation's bundle into a throwaway + # directory and prove it matches the published authority — never overwrite + # the published schemas, or the compatibility proof would silently mutate the + # source of truth it is meant to verify. + published = _snapshot_schema_tree(SCHEMAS_ROOT) + with tempfile.TemporaryDirectory() as tmp_dir: + generated_root = Path(tmp_dir) + write_schema_bundle(generated_root) + generated = _snapshot_schema_tree(generated_root) + + drifted = sorted(rel_path for rel_path, digest in generated.items() if published.get(rel_path) != digest) + extra_paths = _extra_published_schema_paths(SCHEMAS_ROOT, expected_relative_paths=set(generated)) + + if drifted: + print( + "Reference implementation output drifted from the published normative schemas " + "(ADR-009 section 7: contracts/schemas/ is the authority; the reference implementation " + "proves compatibility). Update the reference implementation (schema_bundle()) to match " + "the published schemas, or — if the contract itself is changing — edit the published " + "schema and record a manifest ledger entry. Schemas that do not match:", + file=sys.stderr, + ) + for path in drifted: print(f" - contracts/schemas/{path}", file=sys.stderr) return 1 if extra_paths: print( - "Published JSON schemas contain stale files not generated from the current schema bundle:", file=sys.stderr + "Published normative schemas are not generated by the reference implementation bundle; " + "the reference implementation must generate every published normative schema:", + file=sys.stderr, ) for path in extra_paths: print(f" - contracts/schemas/{path}", file=sys.stderr) diff --git a/tools/check_schema_publication.py b/tools/check_schema_publication.py index 6a3164791..032a3de36 100644 --- a/tools/check_schema_publication.py +++ b/tools/check_schema_publication.py @@ -43,6 +43,10 @@ _MISSING = object() +LAST_CHANGE_KEY = "last_change" +REMOVED_SCHEMAS_KEY = "removed_schemas" + + @dataclass(frozen=True) class ManifestEntry: contract_id: str @@ -50,6 +54,7 @@ class ManifestEntry: stability: str content_hash: str schema: Any + last_change: dict[str, Any] | None = None def _published_schema_paths(repo_root: Path) -> set[str]: @@ -91,6 +96,38 @@ def _load_schema(path: Path, schema_path: str) -> tuple[Any | None, str | None]: return None, f"schema manifest path is not valid JSON: {schema_path}: {exc.msg}" +def _validate_last_change( + contract_id: str, value: Any, entry_content_hash: str +) -> tuple[list[str], dict[str, Any] | None]: + """Validate a manifest entry's optional ``last_change`` ledger block. + + The block records the contract-facing rationale for the schema's current + content (ADR-009 section 7: schema changes are governed contract edits, not + regeneration side-effects). It must carry a non-empty ``summary`` and a + ``content_hash`` equal to the entry's canonical schema hash, so a stale + ledger (rationale left pointing at an older schema) cannot satisfy the gate. + """ + if not isinstance(value, dict): + return [f"schema manifest entry {contract_id} last_change must be a JSON object"], None + failures: list[str] = [] + summary = value.get("summary") + if not isinstance(summary, str) or not summary.strip(): + failures.append(f"schema manifest entry {contract_id} last_change.summary must be a non-empty string") + change_hash = value.get("content_hash") + if not isinstance(change_hash, str) or not SHA256_RE.match(change_hash): + failures.append( + f"schema manifest entry {contract_id} last_change.content_hash must be a 64-character sha256 hex digest" + ) + elif change_hash != entry_content_hash: + failures.append( + f"schema manifest entry {contract_id} last_change.content_hash {change_hash} does not match the schema " + f"content_hash {entry_content_hash}; record the ledger entry against the current schema content" + ) + if failures: + return failures, None + return [], {"summary": summary, "content_hash": change_hash} + + def _git_show(repo_root: Path, gitref: str) -> str | None: proc = subprocess.run( ["git", "show", gitref], @@ -314,6 +351,123 @@ def _check_stable_schema_changes( return failures +def _removal_ledger(payload: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], list[str]]: + """Parse and validate the manifest's ``removed_schemas`` tombstone list. + + A removed schema has no current ``schemas`` entry to carry a ``last_change`` + block, so its contract-facing rationale is recorded as a tombstone keyed by + ``schema_path`` (ADR-009 section 7: a contract removal is a governed edit, + not a silent regeneration side-effect). Each tombstone must carry a + non-empty ``summary``; the ``schema_path`` is what links the tombstone back + to the schema that existed at ``base_rev``. + """ + value = payload.get(REMOVED_SCHEMAS_KEY) + if value is None: + return {}, [] + if not isinstance(value, list): + return {}, [f"schema manifest {REMOVED_SCHEMAS_KEY} must be a JSON array"] + failures: list[str] = [] + tombstones: dict[str, dict[str, Any]] = {} + for index, item in enumerate(value): + if not isinstance(item, dict): + failures.append(f"schema manifest {REMOVED_SCHEMAS_KEY} entry {index} must be a JSON object") + continue + schema_path = item.get("schema_path") + if not isinstance(schema_path, str) or not schema_path: + failures.append( + f"schema manifest {REMOVED_SCHEMAS_KEY} entry {index} schema_path must be a non-empty string" + ) + continue + summary = item.get("summary") + if not isinstance(summary, str) or not summary.strip(): + failures.append( + f"schema manifest {REMOVED_SCHEMAS_KEY} entry {schema_path} summary must be a non-empty string" + ) + continue + tombstones[schema_path] = item + return tombstones, failures + + +def _check_change_ledger( + repo_root: Path, + current_entries: Iterable[ManifestEntry], + *, + base_rev: str, + base_entries: dict[str, dict[str, Any]] | None, + removal_tombstones: dict[str, dict[str, Any]], +) -> list[str]: + """Require a contract-facing change-ledger entry whenever a published schema's + content changed relative to ``base_rev`` (or is newly published or removed). + + The manifest ``last_change`` block is what turns a schema change into a + reviewed contract edit rather than a silent regeneration side-effect + (ADR-009 section 7). The per-entry shape check already rejects a stale or + malformed ledger; this gate adds the "a change must carry one at all" rule, + keyed by the same ``schema_path`` identity used elsewhere. Removals have no + current entry to inspect, so they are gated separately against the base + manifest and must carry a ``removed_schemas`` tombstone. + """ + failures: list[str] = [] + for entry in current_entries: + base_text = _git_show(repo_root, f"{base_rev}:{entry.schema_path}") + if base_text is not None: + try: + base_schema = json.loads(base_text) + except json.JSONDecodeError: + base_schema = None + if base_schema is not None and _schema_content_digest(base_schema) == entry.content_hash: + continue + if entry.last_change is None: + failures.append( + f"published schema {entry.contract_id} changed without a contract-facing change description; " + f"add a '{LAST_CHANGE_KEY}' entry (summary + current content_hash) to {MANIFEST_PATH.as_posix()} " + "recording why the contract changed" + ) + + failures.extend( + _check_removal_ledger( + current_entries, + base_entries=base_entries, + removal_tombstones=removal_tombstones, + ) + ) + return failures + + +def _check_removal_ledger( + current_entries: Iterable[ManifestEntry], + *, + base_entries: dict[str, dict[str, Any]] | None, + removal_tombstones: dict[str, dict[str, Any]], +) -> list[str]: + """Require a ``removed_schemas`` tombstone for every published schema that + existed at ``base_rev`` and is gone from the current manifest. + + Without this, a PR can delete ``contracts/schemas/foo.json`` and drop its + manifest entry: the file-level Rego rule is satisfied because the manifest + was touched, and ``_check_change_ledger`` has no current entry to inspect, + so the removal lands without the contract-facing ledger this gate requires + for every other schema change (ADR-009 section 7). + """ + if not base_entries: + return [] + base_paths = { + base_path + for base_entry in base_entries.values() + if isinstance(base_path := base_entry.get("schema_path"), str) and base_path.startswith(SCHEMAS_PREFIX) + } + current_paths = {entry.schema_path for entry in current_entries} + failures: list[str] = [] + for removed_path in sorted(base_paths - current_paths): + if removed_path not in removal_tombstones: + failures.append( + f"published schema {removed_path} was removed without a contract-facing removal description; " + f"add a '{REMOVED_SCHEMAS_KEY}' tombstone (schema_path + summary) to {MANIFEST_PATH.as_posix()} " + "recording why the contract was removed" + ) + return failures + + def _safe_schema_path(repo_root: Path, schema_path: str) -> tuple[Path | None, str | None]: schemas_root = (repo_root / SCHEMAS_PREFIX).resolve() candidate = repo_root / schema_path @@ -416,6 +570,12 @@ def validate_schema_publication_manifest( f"schema manifest entry {contract_id} content_hash {content_hash} does not match " f"canonical schema hash {actual_hash}" ) + parsed_last_change: dict[str, Any] | None = None + if isinstance(content_hash, str) and SHA256_RE.match(content_hash) and LAST_CHANGE_KEY in entry: + last_change_failures, parsed_last_change = _validate_last_change( + contract_id, entry[LAST_CHANGE_KEY], content_hash + ) + failures.extend(last_change_failures) if stability in STABILITY_VALUES and isinstance(content_hash, str) and SHA256_RE.match(content_hash): validated_entries.append( ManifestEntry( @@ -424,6 +584,7 @@ def validate_schema_publication_manifest( stability=stability, content_hash=content_hash, schema=schema, + last_change=parsed_last_change, ) ) @@ -434,8 +595,26 @@ def validate_schema_publication_manifest( if path.startswith(SCHEMAS_PREFIX): failures.append(f"schema manifest references unpublished schema: {path}") + removal_tombstones, tombstone_failures = _removal_ledger(payload) + failures.extend(tombstone_failures) + for removed_path in sorted(removal_tombstones): + if removed_path in manifest_paths: + failures.append( + f"schema manifest {REMOVED_SCHEMAS_KEY} tombstone {removed_path} refers to a still-published schema" + ) + if not failures and base_rev: failures.extend(_check_stable_schema_changes(repo_root, validated_entries, base_rev=base_rev)) + base_entries, _ = _load_base_manifest(repo_root, base_rev) + failures.extend( + _check_change_ledger( + repo_root, + validated_entries, + base_rev=base_rev, + base_entries=base_entries, + removal_tombstones=removal_tombstones, + ) + ) return failures diff --git a/tools/generate_contract_schemas.py b/tools/generate_contract_schemas.py index 7d40c7f7c..5b4cbcebd 100644 --- a/tools/generate_contract_schemas.py +++ b/tools/generate_contract_schemas.py @@ -57,15 +57,18 @@ def _schema_output_path(schemas_dir: Path, name: str) -> Path: return schemas_dir / "control-plane" / f"{name}.json" -def main() -> None: - repo_root = Path(__file__).resolve().parents[1] - python_root = repo_root / "implementations" / "python" - sys.path.insert(0, str(python_root / "src")) - sys.path.insert(0, str(python_root / "packages")) +def write_schema_bundle(schemas_dir: Path) -> None: + """Write the reference implementation's schema bundle into ``schemas_dir``. + The published schemas under ``contracts/schemas/`` are the hand-governed + normative authority (ADR-009 §7); the Python ``schema_bundle()`` is the + reference implementation's output, kept identical to that authority as a + compatibility proof. ``check_generated_schemas.py`` calls this with a + throwaway directory so it can compare the reference output against the + published normative schemas without overwriting them. + """ from aces_contracts.contracts import schema_bundle - schemas_dir = repo_root / "contracts" / "schemas" bundle = schema_bundle() for name, schema in bundle.items(): output_path = _schema_output_path(schemas_dir, name) @@ -76,5 +79,14 @@ def main() -> None: ) +def main() -> None: + repo_root = Path(__file__).resolve().parents[1] + python_root = repo_root / "implementations" / "python" + sys.path.insert(0, str(python_root / "src")) + sys.path.insert(0, str(python_root / "packages")) + + write_schema_bundle(repo_root / "contracts" / "schemas") + + if __name__ == "__main__": main() diff --git a/tools/policy/adr_policy.yaml b/tools/policy/adr_policy.yaml index c4214014c..e9d50de70 100644 --- a/tools/policy/adr_policy.yaml +++ b/tools/policy/adr_policy.yaml @@ -12,15 +12,14 @@ source_roots: changelog_path: CHANGELOG.md changelog_fragment_dir: changelog.d +# ADR-009 section 7: published schemas under contracts/schemas/ are +# hand-governed normative authority. A schema change must record a +# contract-facing change-ledger entry in the publication manifest (the deeper +# rationale/hash check lives in tools/check_schema_publication.py --base-rev). generated_contracts: generated_roots: - contracts/schemas - driver_paths: - - tools/generate_contract_schemas.py - - implementations/python/packages/aces_contracts - - implementations/python/packages/aces_backend_protocols - - implementations/python/packages/aces_processor - - implementations/python/packages/aces_sdl + manifest_path: contracts/schema-publication-manifest.json compatibility_layer: root: implementations/python/src/aces diff --git a/tools/policy/conftest/repo_policy.rego b/tools/policy/conftest/repo_policy.rego index 0a5750320..568809fda 100644 --- a/tools/policy/conftest/repo_policy.rego +++ b/tools/policy/conftest/repo_policy.rego @@ -17,10 +17,11 @@ deny contains result if { deny contains result if { path := input.changed[_] path_matches_any(path, input.policy.generated_contracts.generated_roots) - not generated_driver_touched + endswith(path, ".json") + not manifest_touched result := { - "msg": "published schemas are generated artifacts; update the generator inputs and regenerate instead of editing schemas directly", - "rule_id": "generated-schema-direct-edit", + "msg": "published schemas under contracts/schemas/ are hand-governed normative authority (ADR-009 section 7); a schema change must update contracts/schema-publication-manifest.json with a contract-facing change-ledger entry", + "rule_id": "schema-change-missing-manifest", "path": path, } } @@ -52,9 +53,8 @@ deny contains result if { } -generated_driver_touched if { - some path in input.changed - path_matches_any(path, input.policy.generated_contracts.driver_paths) +manifest_touched if { + input.policy.generated_contracts.manifest_path in input.changed } diff --git a/tools/policy/conftest/repo_policy_test.rego b/tools/policy/conftest/repo_policy_test.rego index 1cb700edd..86b968f0f 100644 --- a/tools/policy/conftest/repo_policy_test.rego +++ b/tools/policy/conftest/repo_policy_test.rego @@ -9,7 +9,7 @@ test_legacy_root_is_blocked if { "check_set": "file-local", "policy": { "legacy_top_level_roots": ["schemas"], - "generated_contracts": {"generated_roots": [], "driver_paths": []}, + "generated_contracts": {"generated_roots": []}, "concept_authority": {"reserved_path_tokens": [], "allowed_paths": []}, "source_roots": [], "changelog_path": "CHANGELOG.md", @@ -22,7 +22,7 @@ test_legacy_root_is_blocked if { } -test_generated_schema_edits_require_driver_changes if { +test_schema_edit_requires_manifest_update if { failures := deny with input as { "changed": ["contracts/schemas/backend-manifest/backend-manifest-v2.json"], "check_set": "file-local", @@ -30,7 +30,7 @@ test_generated_schema_edits_require_driver_changes if { "legacy_top_level_roots": [], "generated_contracts": { "generated_roots": ["contracts/schemas"], - "driver_paths": ["tools/generate_contract_schemas.py"], + "manifest_path": "contracts/schema-publication-manifest.json", }, "concept_authority": {"reserved_path_tokens": [], "allowed_paths": []}, "source_roots": [], @@ -40,22 +40,42 @@ test_generated_schema_edits_require_driver_changes if { } count(failures) == 1 some failure in failures - failure.rule_id == "generated-schema-direct-edit" + failure.rule_id == "schema-change-missing-manifest" } -test_generated_schema_edits_pass_with_driver_change if { +test_schema_edit_passes_with_manifest_update if { failures := deny with input as { "changed": [ "contracts/schemas/backend-manifest/backend-manifest-v2.json", - "tools/generate_contract_schemas.py", + "contracts/schema-publication-manifest.json", ], "check_set": "file-local", "policy": { "legacy_top_level_roots": [], "generated_contracts": { "generated_roots": ["contracts/schemas"], - "driver_paths": ["tools/generate_contract_schemas.py"], + "manifest_path": "contracts/schema-publication-manifest.json", + }, + "concept_authority": {"reserved_path_tokens": [], "allowed_paths": []}, + "source_roots": [], + "changelog_path": "CHANGELOG.md", + "changelog_fragment_dir": "changelog.d", + }, + } + count(failures) == 0 +} + + +test_schema_readme_edit_does_not_require_manifest if { + failures := deny with input as { + "changed": ["contracts/schemas/README.md"], + "check_set": "file-local", + "policy": { + "legacy_top_level_roots": [], + "generated_contracts": { + "generated_roots": ["contracts/schemas"], + "manifest_path": "contracts/schema-publication-manifest.json", }, "concept_authority": {"reserved_path_tokens": [], "allowed_paths": []}, "source_roots": [], @@ -73,7 +93,7 @@ test_reserved_concept_authority_paths_are_enforced if { "check_set": "file-local", "policy": { "legacy_top_level_roots": [], - "generated_contracts": {"generated_roots": [], "driver_paths": []}, + "generated_contracts": {"generated_roots": []}, "concept_authority": { "reserved_path_tokens": ["concept-authority"], "allowed_paths": ["docs/explain/reference/shared-concept-model.md"], @@ -95,7 +115,7 @@ test_changelog_is_required_for_source_changes if { "check_set": "full", "policy": { "legacy_top_level_roots": [], - "generated_contracts": {"generated_roots": [], "driver_paths": []}, + "generated_contracts": {"generated_roots": []}, "concept_authority": {"reserved_path_tokens": [], "allowed_paths": []}, "source_roots": ["implementations/python/packages"], "changelog_path": "CHANGELOG.md", @@ -117,7 +137,7 @@ test_changelog_fragment_satisfies_source_changes if { "check_set": "full", "policy": { "legacy_top_level_roots": [], - "generated_contracts": {"generated_roots": [], "driver_paths": []}, + "generated_contracts": {"generated_roots": []}, "concept_authority": {"reserved_path_tokens": [], "allowed_paths": []}, "source_roots": ["implementations/python/packages"], "changelog_path": "CHANGELOG.md", @@ -137,7 +157,7 @@ test_changelog_readme_does_not_satisfy_source_changes if { "check_set": "full", "policy": { "legacy_top_level_roots": [], - "generated_contracts": {"generated_roots": [], "driver_paths": []}, + "generated_contracts": {"generated_roots": []}, "concept_authority": {"reserved_path_tokens": [], "allowed_paths": []}, "source_roots": ["implementations/python/packages"], "changelog_path": "CHANGELOG.md", @@ -156,7 +176,7 @@ test_file_local_mode_skips_changelog if { "check_set": "file-local", "policy": { "legacy_top_level_roots": [], - "generated_contracts": {"generated_roots": [], "driver_paths": []}, + "generated_contracts": {"generated_roots": []}, "concept_authority": {"reserved_path_tokens": [], "allowed_paths": []}, "source_roots": ["implementations/python/packages"], "changelog_path": "CHANGELOG.md", From 9b4b28aaee792d61be52d30c399b998971d4ed9c Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 17:29:36 +0200 Subject: [PATCH 42/70] Verify test integration in semantic-coverage gate (#504) Extend tools/check_semantic_coverage.py so an 'active' coverage row must have a named test that actually imports one of its realizing modules (coverage-test-integration) and no zero-assertion stub test functions (coverage-stub-test). An isolated import resolver maps artifact paths to canonical modules and resolves direct imports, 'from pkg import sub', the aces.* compatibility wrappers, and absolute/relative package re-exports; a bare package import is not credited. Add a read-only --report mode that lists construct families by status with per-row test and module-coverage counts to surface thin coverage. Honestly reconcile the rows the new check exposes: name the existing importing tests on the Workflow control and Runtime result rows, and downgrade Workflow compensation to partial (its sole realizing module is imported by no test). Document the new behavior in the coverage note. --- changelog.d/504.changed.md | 1 + .../reference/shared-semantic-integrity.md | 20 +- .../python/tests/test_semantic_coverage.py | 209 +++++++++++ tools/check_semantic_coverage.py | 348 ++++++++++++++++++ 4 files changed, 572 insertions(+), 6 deletions(-) create mode 100644 changelog.d/504.changed.md diff --git a/changelog.d/504.changed.md b/changelog.d/504.changed.md new file mode 100644 index 000000000..c2d456d79 --- /dev/null +++ b/changelog.d/504.changed.md @@ -0,0 +1 @@ +The SEM-200 semantic-coverage gate (`tools/check_semantic_coverage.py`) now verifies integration, not just existence: an `active` row whose named tests import none of its realizing Python modules, or whose named test files contain a zero-assertion `test_*` stub, fails `nox -s policy`. Import resolution recognizes compatibility wrappers and package re-exports. Adds a read-only `--report` mode that lists construct families by status with per-row test and module-coverage counts to surface thin coverage. diff --git a/docs/explain/reference/shared-semantic-integrity.md b/docs/explain/reference/shared-semantic-integrity.md index 9d6323a98..0a7695090 100644 --- a/docs/explain/reference/shared-semantic-integrity.md +++ b/docs/explain/reference/shared-semantic-integrity.md @@ -188,9 +188,17 @@ Status is one of: construct's semantics are realized in a shared helper/spec; named tests cover it. The structural gate enforces that the row names at least one lifecycle phase, at least one *existing* non-test repository artifact, and at least one - *existing* test under `implementations/python/tests/test_*.py`; whether the - owning requirement is actually `ACTIVE` is a Ground Control fact verified by - requirement-governance review, not by the gate. + *existing* test under `implementations/python/tests/test_*.py`. It also + enforces *integration, not just existence*: when the row names importable + Python realizing modules, at least one named test must actually **import** one + of them (resolving compatibility wrappers and package re-exports — a bare + `import` of a whole package does not count), and every `test_*` function in the + row's named test files must contain at least one assertion (no zero-assertion + stub tests). Whether the owning requirement is actually `ACTIVE`, and whether + every claimed module is individually exercised, remain Ground Control / + requirement-governance facts verified by review, not by the gate; + `tools/check_semantic_coverage.py --report` surfaces per-row module-coverage + counts so thin rows are visible. - `partial` — some realization exists (a spec, a helper) but the owning requirement is still `DRAFT` or the coverage is incomplete. The gate enforces at least one phase and at least one existing non-test repository artifact. @@ -222,13 +230,13 @@ so they are tracked by their own requirements, not here. | Instantiation and revalidation of concrete scenarios | RUN-301 | instantiation, validation | `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/tests/test_sdl_validator.py`, `implementations/python/tests/test_run_300_lifecycle.py` | active | | Objective windows, referenced scopes, reachability, and refresh | SEM-202 | validation, compilation, planning | `implementations/python/packages/aces_sdl/semantics/objectives.py`, `specs/formal/objectives/README.md`, `specs/formal/objectives/window-consistency.md`, `implementations/python/tests/test_semantics_objectives.py`, `implementations/python/tests/test_fm2_semantics.py` | active | | Declarative objective actor binding, target resolution, success interpretation, and dependency ordering | DSL-112, SEM-207 | authoring, validation, instantiation, compilation, planning | `implementations/python/packages/aces_sdl/objectives.py`, `implementations/python/packages/aces_sdl/semantics/objective_semantics.py`, `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `specs/formal/objectives/README.md`, `specs/formal/objectives/declarative-objective-semantics.md`, `implementations/python/tests/test_semantics_objectives.py`, `implementations/python/tests/test_fm2_semantics.py`, `implementations/python/tests/test_sdl_validator.py` | active | -| Workflow control semantics (branching, joins, calling, retry, completion, history) | DSL-113, SEM-203 | authoring, validation, compilation, planning, execution, observation | `implementations/python/packages/aces_sdl/orchestration.py`, `implementations/python/packages/aces_sdl/semantics/workflow.py`, `specs/formal/workflows/README.md`, `specs/formal/workflows/state-machine.md`, `implementations/python/tests/test_sdl_validator.py`, `implementations/python/tests/test_runtime_models.py` | active | -| Workflow compensation semantics (registration, triggering, ordering, observation) | SEM-204 | validation, compilation, execution, observation | `implementations/python/packages/aces_sdl/semantics/workflow.py`, `specs/formal/workflows/compensation.md`, `implementations/python/tests/test_sdl_validator.py`, `implementations/python/tests/test_runtime_manager.py` | active | +| Workflow control semantics (branching, joins, calling, retry, completion, history) | DSL-113, SEM-203 | authoring, validation, compilation, planning, execution, observation | `implementations/python/packages/aces_sdl/orchestration.py`, `implementations/python/packages/aces_sdl/semantics/workflow.py`, `specs/formal/workflows/README.md`, `specs/formal/workflows/state-machine.md`, `implementations/python/tests/test_sdl_validator.py`, `implementations/python/tests/test_runtime_models.py`, `implementations/python/tests/test_sdl_models.py` | active | +| Workflow compensation semantics (registration, triggering, ordering, observation) | SEM-204 | validation, compilation, execution, observation | `implementations/python/packages/aces_sdl/semantics/workflow.py`, `specs/formal/workflows/compensation.md`, `implementations/python/tests/test_sdl_validator.py`, `implementations/python/tests/test_runtime_manager.py` | partial | | Assessment model and pipeline semantics (conditions, metrics, evaluations, TLOs, goals) | DSL-110, SEM-206 | authoring, validation, compilation, planning, execution, observation | `implementations/python/packages/aces_sdl/scoring.py`, `implementations/python/packages/aces_sdl/conditions.py`, `implementations/python/packages/aces_sdl/semantics/assessment.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `specs/formal/assessment/README.md`, `specs/formal/assessment/pipeline-consistency.md`, `implementations/python/tests/test_semantics_assessment.py`, `implementations/python/tests/test_sdl_models.py`, `implementations/python/tests/test_fm2_semantics.py` | active | | Runtime compiled representation and canonical addresses | RUN-302 | compilation | `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/tests/test_runtime_models.py`, `implementations/python/tests/test_fm2_semantics.py` | active | | Planner dependency, ordering, refresh, and applicability semantics | RUN-303 | planning | `implementations/python/packages/aces_processor/semantics/planner.py`, `implementations/python/packages/aces_processor/planner.py`, `specs/formal/planner/README.md`, `specs/formal/planner/dependency-ordering.md`, `implementations/python/tests/test_semantics_planner.py`, `implementations/python/tests/test_runtime_planner.py` | active | | Live execution state and lifecycle (snapshots, results, history) | RUN-304, API-402 | execution, observation | `implementations/python/packages/aces_runtime/manager.py`, `implementations/python/packages/aces_runtime/result_contracts.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/tests/test_runtime_manager.py`, `implementations/python/tests/test_runtime_models.py` | active | -| Runtime result and evaluator-result contracts | ASR-503, API-402 | execution, observation | `implementations/python/packages/aces_runtime/result_contracts.py`, `specs/formal/runtime-contracts/README.md`, `specs/formal/runtime-contracts/workflow-results.md`, `specs/formal/runtime-contracts/evaluator-results.md`, `implementations/python/tests/test_runtime_contracts.py` | active | +| Runtime result and evaluator-result contracts | ASR-503, API-402 | execution, observation | `implementations/python/packages/aces_runtime/result_contracts.py`, `specs/formal/runtime-contracts/README.md`, `specs/formal/runtime-contracts/workflow-results.md`, `specs/formal/runtime-contracts/evaluator-results.md`, `implementations/python/tests/test_runtime_contracts.py`, `implementations/python/tests/test_run_311_participant_episode_lifecycle.py` | active | | Control-plane semantics (auth, durable state, idempotency, audit) | API-403, API-404 | execution, observation | `implementations/python/packages/aces_runtime/control_plane_api.py`, `implementations/python/packages/aces_runtime/control_plane_security.py`, `implementations/python/packages/aces_runtime/control_plane_store.py`, `implementations/python/tests/test_runtime_control_plane.py`, `implementations/python/tests/test_runtime_control_plane_api.py` | active | | Backend and processor identity, capability, and compatibility manifests | API-401, API-412 | planning, execution | `implementations/python/packages/aces_processor/manifest.py`, `implementations/python/packages/aces_processor/capabilities.py`, `implementations/python/packages/aces_contracts/apparatus.py`, `implementations/python/packages/aces_contracts/manifest_authority.py`, `implementations/python/tests/test_backend_manifest.py`, `implementations/python/tests/test_processor_manifest.py` | active | | Concept authority, controlled vocabularies, reference models, and semantic profiles (meta-layer) | GOV-920 | authoring, validation, compilation, planning, execution | `specs/concept-authority/concept-authority.md`, `specs/concept-authority/semantic-profiles.md`, `implementations/python/packages/aces_contracts/semantic_profiles.py`, `implementations/python/packages/aces_contracts/controlled_vocabularies.py`, `implementations/python/packages/aces_contracts/reference_models.py`, `docs/explain/reference/shared-concept-model.md`, `implementations/python/tests/test_concept_authority.py`, `implementations/python/tests/test_semantic_profiles.py` | active | diff --git a/implementations/python/tests/test_semantic_coverage.py b/implementations/python/tests/test_semantic_coverage.py index 4ff0d4074..65c6aec41 100644 --- a/implementations/python/tests/test_semantic_coverage.py +++ b/implementations/python/tests/test_semantic_coverage.py @@ -253,6 +253,7 @@ def test_cli_returns_zero_when_clean(tmp_path: Path) -> None: assert main(["--repo-root", str(_seed_repo(tmp_path))]) == 0 +@pytest.mark.integration def test_repo_coverage_note_is_well_formed() -> None: """The real SEM-200 coverage note must pass the structural coverage gate.""" assert evaluate_semantic_coverage(REPO_ROOT) == [] @@ -263,3 +264,211 @@ def test_constants_are_sane() -> None: assert set(VALID_STATUSES) == {"active", "partial", "planned"} assert COVERAGE_NOTE_RELATIVE_PATH.endswith("shared-semantic-integrity.md") assert ADR_RELATIVE_PATH.endswith("adr-016-semantic-layer-scope-and-coverage-model.md") + + +# --------------------------------------------------------------------------- # +# Integration check (#504): an active row's named test must import a realizing +# module, and the named test functions must contain assertions. The seeded repo +# below carries a real package tree, optional compat wrappers, and real test +# bodies so the AST resolver has something to resolve. +# --------------------------------------------------------------------------- # + +_INTEGRATION_NOTE = """# Shared Semantic Integrity + +Governed by ADR-016 +(../../decisions/adrs/adr-016-semantic-layer-scope-and-coverage-model.md). + +## Coverage Model + +| Construct family | Owning requirement(s) | Phases covered | Realizing artifacts | Status | +| --- | --- | --- | --- | --- | +{rows} + +## Non-Goals +Nothing else. +""" + + +def _seed_integration_repo(tmp_path: Path, *, rows: str, files: dict[str, str]) -> Path: + """Seed a temp repo: coverage note + governing ADR + a real package/test tree.""" + note_path = tmp_path / COVERAGE_NOTE_RELATIVE_PATH + note_path.parent.mkdir(parents=True, exist_ok=True) + note_path.write_text(_INTEGRATION_NOTE.format(rows=rows), encoding="utf-8") + adr_path = tmp_path / ADR_RELATIVE_PATH + adr_path.parent.mkdir(parents=True, exist_ok=True) + adr_path.write_text(_GOOD_ADR, encoding="utf-8") + for rel, content in files.items(): + target = tmp_path / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + return tmp_path + + +_WIDGET_ROW = ( + "| Widget | ABC-001 | validation | " + "`implementations/python/packages/widget/core.py`, " + "`implementations/python/tests/test_widget.py` | active |" +) +_WIDGET_CORE = "implementations/python/packages/widget/core.py" +_WIDGET_INIT = "implementations/python/packages/widget/__init__.py" +_WIDGET_TEST = "implementations/python/tests/test_widget.py" + + +def _rule_ids(failures) -> set[str]: + return {f.rule_id for f in failures} + + +def test_active_row_named_test_not_importing_module_fails(tmp_path: Path) -> None: + repo = _seed_integration_repo( + tmp_path, + rows=_WIDGET_ROW, + files={ + _WIDGET_INIT: "", + _WIDGET_CORE: "VALUE = 1\n", + # imports nothing from widget.core — the hollow case + _WIDGET_TEST: "def test_widget():\n assert True\n", + }, + ) + failures = evaluate_semantic_coverage(repo) + assert "coverage-test-integration" in _rule_ids(failures) + + +def test_active_row_direct_import_passes(tmp_path: Path) -> None: + repo = _seed_integration_repo( + tmp_path, + rows=_WIDGET_ROW, + files={ + _WIDGET_INIT: "", + _WIDGET_CORE: "VALUE = 1\n", + _WIDGET_TEST: "from widget.core import VALUE\n\ndef test_widget():\n assert VALUE == 1\n", + }, + ) + assert evaluate_semantic_coverage(repo) == [] + + +def test_active_row_import_via_package_reexport_passes(tmp_path: Path) -> None: + repo = _seed_integration_repo( + tmp_path, + rows=_WIDGET_ROW, + files={ + _WIDGET_INIT: "from widget.core import VALUE\n", + _WIDGET_CORE: "VALUE = 1\n", + # imports the symbol from the package, not the submodule + _WIDGET_TEST: "from widget import VALUE\n\ndef test_widget():\n assert VALUE == 1\n", + }, + ) + assert "coverage-test-integration" not in _rule_ids(evaluate_semantic_coverage(repo)) + + +def test_active_row_import_via_relative_package_reexport_passes(tmp_path: Path) -> None: + # The idiomatic package-API form: ``__init__`` re-exports the symbol with a + # relative import (#504 review IMP-2 F1). The resolver must map the package + # symbol back to ``widget.core`` just as it does for the absolute form. + repo = _seed_integration_repo( + tmp_path, + rows=_WIDGET_ROW, + files={ + _WIDGET_INIT: "from .core import VALUE\n", + _WIDGET_CORE: "VALUE = 1\n", + _WIDGET_TEST: "from widget import VALUE\n\ndef test_widget():\n assert VALUE == 1\n", + }, + ) + assert "coverage-test-integration" not in _rule_ids(evaluate_semantic_coverage(repo)) + + +def test_active_row_import_via_relative_submodule_reexport_passes(tmp_path: Path) -> None: + # The ``from . import core`` submodule re-export form: the test reaches the + # submodule through the package namespace (``widget.core``). + repo = _seed_integration_repo( + tmp_path, + rows=_WIDGET_ROW, + files={ + _WIDGET_INIT: "from . import core\n", + _WIDGET_CORE: "VALUE = 1\n", + _WIDGET_TEST: "from widget import core\n\ndef test_widget():\n assert core.VALUE == 1\n", + }, + ) + assert "coverage-test-integration" not in _rule_ids(evaluate_semantic_coverage(repo)) + + +def test_active_row_import_via_compat_wrapper_passes(tmp_path: Path) -> None: + repo = _seed_integration_repo( + tmp_path, + rows=_WIDGET_ROW, + files={ + _WIDGET_INIT: "", + _WIDGET_CORE: "VALUE = 1\n", + "implementations/python/src/aces/widget_compat.py": ( + 'from aces._compat import reexport as _reexport\n_reexport(globals(), "widget.core")\ndel _reexport\n' + ), + _WIDGET_TEST: ("from aces.widget_compat import VALUE\n\ndef test_widget():\n assert VALUE == 1\n"), + }, + ) + assert "coverage-test-integration" not in _rule_ids(evaluate_semantic_coverage(repo)) + + +def test_stub_test_function_fails(tmp_path: Path) -> None: + repo = _seed_integration_repo( + tmp_path, + rows=_WIDGET_ROW, + files={ + _WIDGET_INIT: "", + _WIDGET_CORE: "VALUE = 1\n", + # imports the module (integration ok) but asserts nothing + _WIDGET_TEST: "from widget.core import VALUE\n\ndef test_widget():\n VALUE\n", + }, + ) + assert "coverage-stub-test" in _rule_ids(evaluate_semantic_coverage(repo)) + + +def test_pytest_raises_counts_as_assertion(tmp_path: Path) -> None: + repo = _seed_integration_repo( + tmp_path, + rows=_WIDGET_ROW, + files={ + _WIDGET_INIT: "", + _WIDGET_CORE: "def boom():\n raise ValueError\n", + _WIDGET_TEST: ( + "import pytest\n" + "from widget.core import boom\n\n" + "def test_widget():\n" + " with pytest.raises(ValueError):\n" + " boom()\n" + ), + }, + ) + assert "coverage-stub-test" not in _rule_ids(evaluate_semantic_coverage(repo)) + + +def test_active_row_with_no_module_artifact_skips_integration(tmp_path: Path) -> None: + row = "| Doc only | ABC-001 | validation | `specs/doc.md`, `implementations/python/tests/test_doc.py` | active |" + repo = _seed_integration_repo( + tmp_path, + rows=row, + files={ + "specs/doc.md": "doc\n", + "implementations/python/tests/test_doc.py": "def test_doc():\n assert True\n", + }, + ) + failures = evaluate_semantic_coverage(repo) + assert "coverage-test-integration" not in _rule_ids(failures) + assert failures == [] + + +def test_report_mode_groups_families_by_status(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + rows = _WIDGET_ROW + "\n| Future thing | ABC-002 | — | — | planned |" + repo = _seed_integration_repo( + tmp_path, + rows=rows, + files={ + _WIDGET_INIT: "", + _WIDGET_CORE: "VALUE = 1\n", + _WIDGET_TEST: "from widget.core import VALUE\n\ndef test_widget():\n assert VALUE == 1\n", + }, + ) + assert main(["--report", "--repo-root", str(repo)]) == 0 + out = capsys.readouterr().out + assert "active" in out + assert "planned" in out + assert "Widget" in out + assert "1/1" in out # module coverage count surfaced diff --git a/tools/check_semantic_coverage.py b/tools/check_semantic_coverage.py index e8b8d02c6..03f58a151 100644 --- a/tools/check_semantic_coverage.py +++ b/tools/check_semantic_coverage.py @@ -25,6 +25,18 @@ * an ``active`` row names at least one lifecycle phase, at least one existing *non-test* realizing artifact, and at least one existing ``implementations/python/tests/test_*.py`` test; +* for an ``active`` row that names importable Python realizing modules, at least + one of its named tests actually *imports* one of those modules — existence is + not integration (#504). The resolver recognizes direct submodule imports, + ``from package import submodule``, the ``aces.*`` compatibility wrappers, and + package re-exports written either absolutely (``from pkg.core import name``) or + with the idiomatic relative form (``from .core import name``); a bare + ``import aces_sdl`` does not count as evidence for every ``aces_sdl.*`` + artifact. The check is + row-level: one resolvable import across the row's named tests satisfies it; +* every ``test_*`` function in an ``active`` row's named test files contains at + least one assertion (``assert``, ``pytest.raises``/``warns``, an ``assert*`` + call, ...) — a zero-assertion stub test is not coverage (#504); * a ``partial`` row names at least one lifecycle phase and at least one existing *non-test* realizing artifact; * a ``planned`` row names no phases and no artifacts (if it has artifacts it is @@ -32,6 +44,10 @@ * ADR-016 still exists and still references the coverage note by its repo-relative (or ADR-relative) path, so the ADR↔note linkage cannot silently rot. +``--report`` prints a read-only inventory (families by status with per-row test +and realization-module-coverage counts) and never gates; it surfaces thin +coverage without changing pass/fail semantics. + Failures use ``tools.policy.common.PolicyFailure`` and the CLI honours ``--json`` and the shared ``tools/policy/exceptions.yaml`` waiver mechanism, like the other ``policy`` nox-stage entry points (``check_repo_policy.py``, ``check_requirement_governance.py``). @@ -40,6 +56,7 @@ from __future__ import annotations import argparse +import ast import os import re import sys @@ -115,6 +132,19 @@ _EXPECTED_COLUMNS = 5 +# Source roots used by the import-integration resolver (#504). Realization +# artifacts that are importable Python live under the packages root; the +# ``aces.*`` compatibility wrappers live under the src root. +_PACKAGES_ROOT = "implementations/python/packages" +_COMPAT_SRC_ROOT = "implementations/python/src" +# Names the ``aces._compat`` re-export helper is imported as (``reexport`` or the +# conventional ``_reexport`` alias). +_REEXPORT_FUNCS: frozenset[str] = frozenset({"reexport", "_reexport"}) +# A ``test_*`` function counts as asserting if it contains an ``assert`` statement +# or calls anything whose name carries one of these hints (``pytest.raises``, +# ``self.assertEqual``, ``pytest.fail``, ``assert_*`` helpers, ...). +_ASSERTION_NAME_HINTS: tuple[str, ...] = ("assert", "raises", "warns", "deprecated_call", "fail", "xfail") + class CoverageParseError(ValueError): """The coverage note's Coverage Model section or table is missing or malformed.""" @@ -386,6 +416,274 @@ def _check_adr_links_note(repo_root: Path) -> list[PolicyFailure]: return [] +# --------------------------------------------------------------------------- # +# Import-integration resolver (#504). +# +# A row's ``active`` status claims its named tests cover the construct. The +# structural gate can prove a weaker, useful fact from the filesystem: at least +# one named test *imports* one of the row's realizing Python modules. The +# resolver maps a realizing artifact path to its canonical module and normalizes +# the imports a test actually makes — recognizing direct submodule imports, +# ``from package import submodule``, the ``aces.*`` compatibility wrappers, and +# explicit ``from M import name`` package re-exports — so an honest indirect +# import still counts. It deliberately does NOT import or execute any module, and +# a bare ``import aces_sdl`` is not treated as evidence for every ``aces_sdl.*`` +# artifact. Dynamic ``__getattr__`` re-exports in a canonical package ``__init__`` +# are not resolved; those symbols remain reachable through the compat wrappers +# (which are resolved), and the gate is row-level so one resolvable import per +# row suffices. +# --------------------------------------------------------------------------- # + + +def _safe_parse(path: Path) -> ast.Module | None: + try: + return ast.parse(path.read_text(encoding="utf-8")) + except (OSError, SyntaxError, ValueError): + return None + + +def _module_name_under(root: Path, file: Path) -> str: + parts = list(file.relative_to(root).with_suffix("").parts) + if parts and parts[-1] == "__init__": + parts = parts[:-1] + return ".".join(parts) + + +def _resolve_relative_module(pkg: str, level: int, module: str | None) -> str | None: + """Resolve a relative ``ImportFrom`` (``from .core import X``) inside an + ``__init__.py`` to the absolute dotted module it names. + + ``pkg`` is the importing ``__init__``'s own package (``__init__`` already + stripped by :func:`_module_name_under`), so ``__package__ == pkg`` and the + anchor for ``level`` is ``pkg`` itself: ``level`` 1 stays in ``pkg``, each + further level climbs one parent. Returns ``None`` if the import climbs above + the known root (an out-of-tree target we cannot map). + """ + base_parts = pkg.split(".") if pkg else [] + ascend = level - 1 + if ascend > len(base_parts): + return None + anchor = base_parts[: len(base_parts) - ascend] + tail = module.split(".") if module else [] + parts = anchor + tail + return ".".join(parts) if parts else None + + +def _reexport_target(tree: ast.Module) -> str | None: + """The canonical module a compat wrapper re-exports, from ``reexport(globals(), "...")``.""" + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in _REEXPORT_FUNCS + and len(node.args) == 2 + and isinstance(node.args[1], ast.Constant) + and isinstance(node.args[1].value, str) + ): + return node.args[1].value + return None + + +@dataclass(frozen=True) +class _ImportResolver: + """Maps test imports to the canonical modules they reach. The single extension + seam: new source roots, wrappers, or package exports extend the two maps + without touching row validation.""" + + compat_to_canonical: dict[str, str] + package_symbol_to_module: dict[str, str] + + def normalize(self, module: str) -> str: + return self.compat_to_canonical.get(module, module) + + def imported_modules(self, tree: ast.Module) -> set[str]: + reached: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + reached.add(self.normalize(alias.name)) + elif isinstance(node, ast.ImportFrom): + if node.level or not node.module: + continue # relative imports are not used by the test suite + reached.add(self.normalize(node.module)) + for alias in node.names: + full = f"{node.module}.{alias.name}" + if full in self.package_symbol_to_module: + reached.add(self.package_symbol_to_module[full]) + else: + reached.add(self.normalize(full)) + return reached + + +def _build_import_resolver(repo_root: Path) -> _ImportResolver: + compat: dict[str, str] = {} + symbols: dict[str, str] = {} + src_root = repo_root / _COMPAT_SRC_ROOT + pkg_root = repo_root / _PACKAGES_ROOT + aces_root = src_root / "aces" + + if aces_root.is_dir(): + for file in sorted(aces_root.rglob("*.py")): + tree = _safe_parse(file) + if tree is None: + continue + target = _reexport_target(tree) + if target: + compat[_module_name_under(src_root, file)] = target + + init_files: list[tuple[Path, Path]] = [] + if aces_root.is_dir(): + init_files += [(f, src_root) for f in aces_root.rglob("__init__.py")] + if pkg_root.is_dir(): + init_files += [(f, pkg_root) for f in pkg_root.rglob("__init__.py")] + for file, root in init_files: + tree = _safe_parse(file) + if tree is None: + continue + pkg = _module_name_under(root, file) + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom): + continue + if not node.level: + # ``from package.module import name`` re-export. + if not node.module: + continue + source_module = node.module + elif node.module: + # Relative ``from .core import name`` re-export — the idiomatic + # package-API form. Resolve against the importing ``__init__``. + source_module = _resolve_relative_module(pkg, node.level, node.module) + else: + # Relative ``from . import submodule`` re-export: each name is a + # submodule, resolved individually below. + source_module = None + for alias in node.names: + if alias.name == "*": + continue + if source_module is None: + target = _resolve_relative_module(pkg, node.level, alias.name) + else: + target = source_module + if target is None: + continue + symbols[f"{pkg}.{alias.name}"] = compat.get(target, target) + return _ImportResolver(compat, symbols) + + +def _artifact_module(token: str) -> str | None: + """Canonical importable module name for a realizing artifact path, or None.""" + prefix = f"{_PACKAGES_ROOT}/" + if not token.startswith(prefix) or not token.endswith(".py"): + return None + parts = token[len(prefix) : -len(".py")].split("/") + if parts and parts[-1] == "__init__": + parts = parts[:-1] + return ".".join(parts) if parts else None + + +def _row_realization_modules(row: CoverageRow) -> list[str]: + """The importable realizing modules a row names (excludes test files and prose).""" + modules: list[str] = [] + for token in row.artifacts: + if _is_test_path(token): + continue + module = _artifact_module(token) + if module: + modules.append(module) + return modules + + +def _row_test_files(row: CoverageRow) -> list[str]: + return [token for token in row.artifacts if _is_test_path(token)] + + +def _row_imported_modules(repo_root: Path, row: CoverageRow, resolver: _ImportResolver) -> set[str]: + reached: set[str] = set() + for rel in _row_test_files(row): + resolved = _resolve_within(repo_root, rel) + if resolved is None or not resolved.is_file(): + continue # a missing/escaping test path is already flagged by _check_artifacts + tree = _safe_parse(resolved) + if tree is not None: + reached |= resolver.imported_modules(tree) + return reached + + +def _check_row_test_integration(repo_root: Path, row: CoverageRow, resolver: _ImportResolver) -> list[PolicyFailure]: + """For an ``active`` row, require at least one named test to import a realizing module.""" + modules = _row_realization_modules(row) + if not modules: + return [] # spec/doc-only realization: nothing importable to require + if _row_imported_modules(repo_root, row, resolver) & set(modules): + return [] + where = f"row '{row.family}' (line {row.line_no})" + return [ + _fail( + "coverage-test-integration", + f"{where}: status 'active' requires at least one named test to import a realizing module " + f"({', '.join(modules)}); the named tests import none of them — existence without integration", + COVERAGE_NOTE_RELATIVE_PATH, + ) + ] + + +def _iter_test_functions(tree: ast.Module) -> list[ast.FunctionDef | ast.AsyncFunctionDef]: + """Module-level and ``Test*``-class test functions, matching pytest collection.""" + found: list[ast.FunctionDef | ast.AsyncFunctionDef] = [] + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test"): + found.append(node) + elif isinstance(node, ast.ClassDef) and node.name.startswith("Test"): + found += [ + sub + for sub in node.body + if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)) and sub.name.startswith("test") + ] + return found + + +def _call_name(call: ast.Call) -> str | None: + target = call.func + if isinstance(target, ast.Attribute): + return target.attr + if isinstance(target, ast.Name): + return target.id + return None + + +def _function_has_assertion(func: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + for node in ast.walk(func): + if isinstance(node, ast.Assert): + return True + if isinstance(node, ast.Call): + name = _call_name(node) + if name and any(hint in name.lower() for hint in _ASSERTION_NAME_HINTS): + return True + return False + + +def _check_stub_tests(repo_root: Path, test_rel: str) -> list[PolicyFailure]: + """Flag ``test_*`` functions with no assertion in a named test file (#504).""" + resolved = _resolve_within(repo_root, test_rel) + if resolved is None or not resolved.is_file(): + return [] + tree = _safe_parse(resolved) + if tree is None: + return [] + failures: list[PolicyFailure] = [] + for func in _iter_test_functions(tree): + if not _function_has_assertion(func): + failures.append( + _fail( + "coverage-stub-test", + f"test function '{func.name}' (line {func.lineno}) has no assertion " + f"(no assert / pytest.raises / assert*); a zero-assertion test is not coverage", + test_rel, + ) + ) + return failures + + def evaluate_semantic_coverage(repo_root: Path, note_relative_path: str | None = None) -> list[PolicyFailure]: """Return the list of structural failures for the SEM-200 coverage model (empty = OK).""" rel = note_relative_path or COVERAGE_NOTE_RELATIVE_PATH @@ -405,11 +703,53 @@ def evaluate_semantic_coverage(repo_root: Path, note_relative_path: str | None = failures.append(_fail("coverage-model-empty", "Coverage Model table has no rows", rel)) return failures + resolver = _build_import_resolver(repo_root) + active_test_files: set[str] = set() for row in rows: failures.extend(_check_row(repo_root, row)) + if row.status == "active": + failures.extend(_check_row_test_integration(repo_root, row, resolver)) + active_test_files.update(_row_test_files(row)) + for test_rel in sorted(active_test_files): + failures.extend(_check_stub_tests(repo_root, test_rel)) return failures +def build_coverage_report(repo_root: Path, note_relative_path: str | None = None) -> str: + """Render a read-only coverage report: families grouped by status, with per-row + named-test and realization-module-coverage counts (#504). Never gates.""" + rel = note_relative_path or COVERAGE_NOTE_RELATIVE_PATH + note_path = repo_root / rel + if not note_path.is_file(): + return f"coverage note not found: {rel}" + try: + rows = parse_coverage_rows(note_path.read_text(encoding="utf-8")) + except CoverageParseError as exc: + return f"coverage note unparseable: {exc}" + + resolver = _build_import_resolver(repo_root) + lines = ["Semantic coverage report (SEM-200, ADR-016)", ""] + for status in VALID_STATUSES: + group = [row for row in rows if row.status == status] + lines.append(f"## {status} ({len(group)})") + for row in sorted(group, key=lambda r: r.family.lower()): + modules = _row_realization_modules(row) + tests = _row_test_files(row) + if modules: + reached = _row_imported_modules(repo_root, row, resolver) + covered = sum(1 for module in modules if module in reached) + marker = " THIN" if covered == 0 else "" + module_note = f"modules {covered}/{len(modules)} imported{marker}" + else: + module_note = "no importable modules" + owners = ", ".join(row.owners) or "—" + lines.append( + f" - {row.family} [{owners}]: {len(tests)} test(s), {len(row.phases)} phase(s), {module_note}" + ) + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + def parse_args(argv: list[str] | None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Validate the SEM-200 semantic-layer coverage model (ADR-016).") parser.add_argument( @@ -424,11 +764,19 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: help="Repo-relative path to the coverage note (defaults to the canonical location).", ) parser.add_argument("--json", action="store_true", help="Emit JSON failures.") + parser.add_argument( + "--report", + action="store_true", + help="Print a read-only coverage report (families by status, test/module counts) and exit 0.", + ) return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(argv) + if args.report: + print(build_coverage_report(args.repo_root, args.note_path)) + return 0 failures = evaluate_semantic_coverage(args.repo_root, args.note_path) exceptions_file = args.repo_root / "tools" / "policy" / "exceptions.yaml" if exceptions_file.is_file(): From d162bbc6133e916acef1329ae9e491a23de8a863 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 18:08:05 +0200 Subject: [PATCH 43/70] Add UCO alignment evidence contract (uco-alignment-v1) Map every adopted/adapted cyber-domain concept family to the UCO 1.4.0 object types it aligns to, with the reviewed UCO version pinned and adapted-family divergences enumerated explicitly. Adds the UcoAlignment catalog model with catalog-derived coverage, provenance, and canonical-IRI validators, a loader, the generated JSON Schema, valid/invalid fixtures, a shape/coverage test, the schema-publication manifest entry, and one-directional spec references. Remediates concept-authority review finding CA-5. --- changelog.d/495.added.md | 1 + .../concept-authority/uco-alignment-v1.json | 184 ++++++++++++++++ .../invalid/adapted-without-divergence.json | 180 ++++++++++++++++ .../invalid/empty-divergence.json | 182 ++++++++++++++++ .../invalid/iri-mismatch.json | 184 ++++++++++++++++ .../invalid/missing-family.json | 152 ++++++++++++++ .../invalid/native-family.json | 196 ++++++++++++++++++ .../invalid/provenance-mismatch.json | 186 +++++++++++++++++ .../invalid/unknown-family.json | 196 ++++++++++++++++++ .../uco-alignment-v1/valid/reference.json | 184 ++++++++++++++++ contracts/schema-publication-manifest.json | 6 + contracts/schemas/README.md | 15 ++ .../concept-authority/uco-alignment-v1.json | 124 +++++++++++ .../issue-495-uco-alignment-preflight.md | 158 ++++++++++++++ .../packages/aces_contracts/contracts.py | 122 +++++++++++ .../packages/aces_contracts/uco_alignment.py | 22 ++ .../packages/aces_contracts/versions.py | 1 + .../python/tests/test_uco_alignment.py | 159 ++++++++++++++ specs/concept-authority/concept-authority.md | 7 + specs/concept-authority/reference-models.md | 11 + specs/concept-authority/semantic-profiles.md | 10 + tools/generate_contract_schemas.py | 2 + 22 files changed, 2282 insertions(+) create mode 100644 changelog.d/495.added.md create mode 100644 contracts/concept-authority/uco-alignment-v1.json create mode 100644 contracts/fixtures/concept-authority/uco-alignment-v1/invalid/adapted-without-divergence.json create mode 100644 contracts/fixtures/concept-authority/uco-alignment-v1/invalid/empty-divergence.json create mode 100644 contracts/fixtures/concept-authority/uco-alignment-v1/invalid/iri-mismatch.json create mode 100644 contracts/fixtures/concept-authority/uco-alignment-v1/invalid/missing-family.json create mode 100644 contracts/fixtures/concept-authority/uco-alignment-v1/invalid/native-family.json create mode 100644 contracts/fixtures/concept-authority/uco-alignment-v1/invalid/provenance-mismatch.json create mode 100644 contracts/fixtures/concept-authority/uco-alignment-v1/invalid/unknown-family.json create mode 100644 contracts/fixtures/concept-authority/uco-alignment-v1/valid/reference.json create mode 100644 contracts/schemas/concept-authority/uco-alignment-v1.json create mode 100644 docs/decisions/issue-495-uco-alignment-preflight.md create mode 100644 implementations/python/packages/aces_contracts/uco_alignment.py create mode 100644 implementations/python/tests/test_uco_alignment.py diff --git a/changelog.d/495.added.md b/changelog.d/495.added.md new file mode 100644 index 000000000..efeab5765 --- /dev/null +++ b/changelog.d/495.added.md @@ -0,0 +1 @@ +Added the UCO alignment evidence contract (`uco-alignment-v1`): a machine-checkable mapping from every adopted/adapted cyber-domain concept family to the UCO object types it aligns to, with the reviewed UCO version pinned, adapted-family divergences enumerated explicitly, generated JSON Schema, valid/invalid fixtures, and catalog-derived coverage validation. diff --git a/contracts/concept-authority/uco-alignment-v1.json b/contracts/concept-authority/uco-alignment-v1.json new file mode 100644 index 000000000..3426debb3 --- /dev/null +++ b/contracts/concept-authority/uco-alignment-v1.json @@ -0,0 +1,184 @@ +{ + "schema_version": "uco-alignment/v1", + "uco_version": "1.4.0", + "uco_reference": "https://github.com/ucoProject/UCO/releases/tag/1.4.0", + "review_scope": "Primary-source review of the Unified Cyber Ontology (UCO) at git tag 1.4.0. Reviewed ontology source: ontology/uco/core/core.ttl, ontology/uco/observable/observable.ttl, ontology/uco/identity/identity.ttl, ontology/uco/action/action.ttl, ontology/uco/tool/tool.ttl, and ontology/uco/role/role.ttl. Every uco_class cited below was confirmed declared as an owl:Class in the reviewed file at that tag. Alignment is recorded at UCO object-type granularity; UCO property-level mapping is out of scope for uco-alignment/v1. Coverage is exactly the adopted/adapted cyber-domain families in concept-families-v1.json whose declared authority is UCO. This is a concept-authority (semantic) alignment per ADR-012, not an authoring-syntax or schema-structure mapping.", + "alignments": { + "assets": { + "concept_family": "assets", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object; ACES asset-bearing scenario nodes and deployable resources are represented as observable objects." + }, + { + "uco_class": "observable:Computer", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Computer", + "note": "Host and compute nodes." + }, + { + "uco_class": "observable:Device", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Device", + "note": "Device-class deployable equipment serving a special purpose." + }, + { + "uco_class": "observable:AutonomousSystem", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/AutonomousSystem", + "note": "Network and routing infrastructure." + }, + { + "uco_class": "observable:IPAddress", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/IPAddress", + "note": "Network addressing for nodes and infrastructure." + }, + { + "uco_class": "observable:Hostname", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Hostname", + "note": "Node identification within a network." + } + ], + "divergences": [] + }, + "identities": { + "concept_family": "identities", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "identity:Identity", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Identity", + "note": "Identity-bearing participants and entities." + }, + { + "uco_class": "identity:Person", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Person", + "note": "Individual human identities." + }, + { + "uco_class": "identity:Organization", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Organization", + "note": "Organizational identities." + }, + { + "uco_class": "observable:Account", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Account", + "note": "Account arrangements enabling provision of a capability or service." + }, + { + "uco_class": "observable:DigitalAccount", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/DigitalAccount", + "note": "Accounts within the digital domain." + }, + { + "uco_class": "role:Role", + "iri": "https://ontology.unifiedcyberontology.org/uco/role/Role", + "note": "Roles attributed to identity-bearing participants." + } + ], + "divergences": [] + }, + "relationships": { + "concept_family": "relationships", + "provenance": "adapted", + "uco_types": [ + { + "uco_class": "core:Relationship", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Relationship", + "note": "UCO directed/typed assertion that objects relate to one another; the closest UCO anchor for ACES typed scenario relationships." + } + ], + "divergences": [ + "ACES relationship `type` values derive from STIX 2.1 Relationship SRO vocabulary and OCR dependency patterns, not from the UCO core:kindOfRelationship controlled vocabulary.", + "ACES relationships carry an open `properties` map instead of UCO facet-based characterization; relationship attributes are not modeled as UCO Facets.", + "ACES relationship source and target reference SDL scenario elements (nodes, accounts, content, conditions, events), not exclusively UCO observable objects." + ] + }, + "observables": { + "concept_family": "observables", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object for conditions, metrics, and telemetry-bearing objects." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Observed digital-context event and telemetry records." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Observed blocks of digital data carrying telemetry payloads." + } + ], + "divergences": [] + }, + "actions-and-events": { + "concept_family": "actions-and-events", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "action:Action", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/Action", + "note": "Executable actions and injects (something that may be done or performed)." + }, + { + "uco_class": "action:ActionLifecycle", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionLifecycle", + "note": "Ordered sets of actions for workflow steps and sequenced injects." + }, + { + "uco_class": "action:ActionPattern", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionPattern", + "note": "Characteristic arrangements of actions." + }, + { + "uco_class": "core:Event", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Event", + "note": "A noteworthy occurrence that happens or might happen." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Recorded occurrence of an event in a digital context." + } + ], + "divergences": [] + }, + "tools-and-artifacts": { + "concept_family": "tools-and-artifacts", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "tool:Tool", + "iri": "https://ontology.unifiedcyberontology.org/uco/tool/Tool", + "note": "An element of hardware and/or software utilized to carry out a particular function." + }, + { + "uco_class": "observable:File", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/File", + "note": "File artifacts recorded on a storage device." + }, + { + "uco_class": "observable:Application", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Application", + "note": "Software programs and applications designed for end users." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Content and data artifacts (blocks of digital data)." + }, + { + "uco_class": "core:Item", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Item", + "note": "A distinct article or unit; deployable artifacts as discrete items." + } + ], + "divergences": [] + } + } +} diff --git a/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/adapted-without-divergence.json b/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/adapted-without-divergence.json new file mode 100644 index 000000000..01fa8c5c3 --- /dev/null +++ b/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/adapted-without-divergence.json @@ -0,0 +1,180 @@ +{ + "schema_version": "uco-alignment/v1", + "uco_version": "1.4.0", + "uco_reference": "https://github.com/ucoProject/UCO/releases/tag/1.4.0", + "review_scope": "Primary-source review of the Unified Cyber Ontology (UCO) at git tag 1.4.0. Reviewed ontology source: ontology/uco/core/core.ttl, ontology/uco/observable/observable.ttl, ontology/uco/identity/identity.ttl, ontology/uco/action/action.ttl, ontology/uco/tool/tool.ttl, and ontology/uco/role/role.ttl. Every uco_class cited below was confirmed declared as an owl:Class in the reviewed file at that tag. Alignment is recorded at UCO object-type granularity; UCO property-level mapping is out of scope for uco-alignment/v1. Coverage is exactly the adopted/adapted cyber-domain families in concept-families-v1.json whose declared authority is UCO. This is a concept-authority (semantic) alignment per ADR-012, not an authoring-syntax or schema-structure mapping.", + "alignments": { + "assets": { + "concept_family": "assets", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object; ACES asset-bearing scenario nodes and deployable resources are represented as observable objects." + }, + { + "uco_class": "observable:Computer", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Computer", + "note": "Host and compute nodes." + }, + { + "uco_class": "observable:Device", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Device", + "note": "Device-class deployable equipment serving a special purpose." + }, + { + "uco_class": "observable:AutonomousSystem", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/AutonomousSystem", + "note": "Network and routing infrastructure." + }, + { + "uco_class": "observable:IPAddress", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/IPAddress", + "note": "Network addressing for nodes and infrastructure." + }, + { + "uco_class": "observable:Hostname", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Hostname", + "note": "Node identification within a network." + } + ], + "divergences": [] + }, + "identities": { + "concept_family": "identities", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "identity:Identity", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Identity", + "note": "Identity-bearing participants and entities." + }, + { + "uco_class": "identity:Person", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Person", + "note": "Individual human identities." + }, + { + "uco_class": "identity:Organization", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Organization", + "note": "Organizational identities." + }, + { + "uco_class": "observable:Account", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Account", + "note": "Account arrangements enabling provision of a capability or service." + }, + { + "uco_class": "observable:DigitalAccount", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/DigitalAccount", + "note": "Accounts within the digital domain." + }, + { + "uco_class": "role:Role", + "iri": "https://ontology.unifiedcyberontology.org/uco/role/Role", + "note": "Roles attributed to identity-bearing participants." + } + ], + "divergences": [] + }, + "relationships": { + "concept_family": "relationships", + "provenance": "adapted", + "uco_types": [ + { + "uco_class": "core:Relationship", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Relationship", + "note": "UCO directed/typed assertion that objects relate to one another; the closest UCO anchor for ACES typed scenario relationships." + } + ], + "divergences": [] + }, + "observables": { + "concept_family": "observables", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object for conditions, metrics, and telemetry-bearing objects." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Observed digital-context event and telemetry records." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Observed blocks of digital data carrying telemetry payloads." + } + ], + "divergences": [] + }, + "actions-and-events": { + "concept_family": "actions-and-events", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "action:Action", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/Action", + "note": "Executable actions and injects (something that may be done or performed)." + }, + { + "uco_class": "action:ActionLifecycle", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionLifecycle", + "note": "Ordered sets of actions for workflow steps and sequenced injects." + }, + { + "uco_class": "action:ActionPattern", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionPattern", + "note": "Characteristic arrangements of actions." + }, + { + "uco_class": "core:Event", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Event", + "note": "A noteworthy occurrence that happens or might happen." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Recorded occurrence of an event in a digital context." + } + ], + "divergences": [] + }, + "tools-and-artifacts": { + "concept_family": "tools-and-artifacts", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "tool:Tool", + "iri": "https://ontology.unifiedcyberontology.org/uco/tool/Tool", + "note": "An element of hardware and/or software utilized to carry out a particular function." + }, + { + "uco_class": "observable:File", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/File", + "note": "File artifacts recorded on a storage device." + }, + { + "uco_class": "observable:Application", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Application", + "note": "Software programs and applications designed for end users." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Content and data artifacts (blocks of digital data)." + }, + { + "uco_class": "core:Item", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Item", + "note": "A distinct article or unit; deployable artifacts as discrete items." + } + ], + "divergences": [] + } + } +} diff --git a/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/empty-divergence.json b/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/empty-divergence.json new file mode 100644 index 000000000..8ed376b74 --- /dev/null +++ b/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/empty-divergence.json @@ -0,0 +1,182 @@ +{ + "schema_version": "uco-alignment/v1", + "uco_version": "1.4.0", + "uco_reference": "https://github.com/ucoProject/UCO/releases/tag/1.4.0", + "review_scope": "Primary-source review of the Unified Cyber Ontology (UCO) at git tag 1.4.0. Reviewed ontology source: ontology/uco/core/core.ttl, ontology/uco/observable/observable.ttl, ontology/uco/identity/identity.ttl, ontology/uco/action/action.ttl, ontology/uco/tool/tool.ttl, and ontology/uco/role/role.ttl. Every uco_class cited below was confirmed declared as an owl:Class in the reviewed file at that tag. Alignment is recorded at UCO object-type granularity; UCO property-level mapping is out of scope for uco-alignment/v1. Coverage is exactly the adopted/adapted cyber-domain families in concept-families-v1.json whose declared authority is UCO. This is a concept-authority (semantic) alignment per ADR-012, not an authoring-syntax or schema-structure mapping.", + "alignments": { + "assets": { + "concept_family": "assets", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object; ACES asset-bearing scenario nodes and deployable resources are represented as observable objects." + }, + { + "uco_class": "observable:Computer", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Computer", + "note": "Host and compute nodes." + }, + { + "uco_class": "observable:Device", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Device", + "note": "Device-class deployable equipment serving a special purpose." + }, + { + "uco_class": "observable:AutonomousSystem", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/AutonomousSystem", + "note": "Network and routing infrastructure." + }, + { + "uco_class": "observable:IPAddress", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/IPAddress", + "note": "Network addressing for nodes and infrastructure." + }, + { + "uco_class": "observable:Hostname", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Hostname", + "note": "Node identification within a network." + } + ], + "divergences": [] + }, + "identities": { + "concept_family": "identities", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "identity:Identity", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Identity", + "note": "Identity-bearing participants and entities." + }, + { + "uco_class": "identity:Person", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Person", + "note": "Individual human identities." + }, + { + "uco_class": "identity:Organization", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Organization", + "note": "Organizational identities." + }, + { + "uco_class": "observable:Account", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Account", + "note": "Account arrangements enabling provision of a capability or service." + }, + { + "uco_class": "observable:DigitalAccount", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/DigitalAccount", + "note": "Accounts within the digital domain." + }, + { + "uco_class": "role:Role", + "iri": "https://ontology.unifiedcyberontology.org/uco/role/Role", + "note": "Roles attributed to identity-bearing participants." + } + ], + "divergences": [] + }, + "relationships": { + "concept_family": "relationships", + "provenance": "adapted", + "uco_types": [ + { + "uco_class": "core:Relationship", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Relationship", + "note": "UCO directed/typed assertion that objects relate to one another; the closest UCO anchor for ACES typed scenario relationships." + } + ], + "divergences": [ + "" + ] + }, + "observables": { + "concept_family": "observables", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object for conditions, metrics, and telemetry-bearing objects." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Observed digital-context event and telemetry records." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Observed blocks of digital data carrying telemetry payloads." + } + ], + "divergences": [] + }, + "actions-and-events": { + "concept_family": "actions-and-events", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "action:Action", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/Action", + "note": "Executable actions and injects (something that may be done or performed)." + }, + { + "uco_class": "action:ActionLifecycle", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionLifecycle", + "note": "Ordered sets of actions for workflow steps and sequenced injects." + }, + { + "uco_class": "action:ActionPattern", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionPattern", + "note": "Characteristic arrangements of actions." + }, + { + "uco_class": "core:Event", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Event", + "note": "A noteworthy occurrence that happens or might happen." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Recorded occurrence of an event in a digital context." + } + ], + "divergences": [] + }, + "tools-and-artifacts": { + "concept_family": "tools-and-artifacts", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "tool:Tool", + "iri": "https://ontology.unifiedcyberontology.org/uco/tool/Tool", + "note": "An element of hardware and/or software utilized to carry out a particular function." + }, + { + "uco_class": "observable:File", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/File", + "note": "File artifacts recorded on a storage device." + }, + { + "uco_class": "observable:Application", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Application", + "note": "Software programs and applications designed for end users." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Content and data artifacts (blocks of digital data)." + }, + { + "uco_class": "core:Item", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Item", + "note": "A distinct article or unit; deployable artifacts as discrete items." + } + ], + "divergences": [] + } + } +} diff --git a/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/iri-mismatch.json b/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/iri-mismatch.json new file mode 100644 index 000000000..bc695b48c --- /dev/null +++ b/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/iri-mismatch.json @@ -0,0 +1,184 @@ +{ + "schema_version": "uco-alignment/v1", + "uco_version": "1.4.0", + "uco_reference": "https://github.com/ucoProject/UCO/releases/tag/1.4.0", + "review_scope": "Primary-source review of the Unified Cyber Ontology (UCO) at git tag 1.4.0. Reviewed ontology source: ontology/uco/core/core.ttl, ontology/uco/observable/observable.ttl, ontology/uco/identity/identity.ttl, ontology/uco/action/action.ttl, ontology/uco/tool/tool.ttl, and ontology/uco/role/role.ttl. Every uco_class cited below was confirmed declared as an owl:Class in the reviewed file at that tag. Alignment is recorded at UCO object-type granularity; UCO property-level mapping is out of scope for uco-alignment/v1. Coverage is exactly the adopted/adapted cyber-domain families in concept-families-v1.json whose declared authority is UCO. This is a concept-authority (semantic) alignment per ADR-012, not an authoring-syntax or schema-structure mapping.", + "alignments": { + "assets": { + "concept_family": "assets", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object; ACES asset-bearing scenario nodes and deployable resources are represented as observable objects." + }, + { + "uco_class": "observable:Computer", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Workstation", + "note": "Host and compute nodes." + }, + { + "uco_class": "observable:Device", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Device", + "note": "Device-class deployable equipment serving a special purpose." + }, + { + "uco_class": "observable:AutonomousSystem", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/AutonomousSystem", + "note": "Network and routing infrastructure." + }, + { + "uco_class": "observable:IPAddress", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/IPAddress", + "note": "Network addressing for nodes and infrastructure." + }, + { + "uco_class": "observable:Hostname", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Hostname", + "note": "Node identification within a network." + } + ], + "divergences": [] + }, + "identities": { + "concept_family": "identities", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "identity:Identity", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Identity", + "note": "Identity-bearing participants and entities." + }, + { + "uco_class": "identity:Person", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Person", + "note": "Individual human identities." + }, + { + "uco_class": "identity:Organization", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Organization", + "note": "Organizational identities." + }, + { + "uco_class": "observable:Account", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Account", + "note": "Account arrangements enabling provision of a capability or service." + }, + { + "uco_class": "observable:DigitalAccount", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/DigitalAccount", + "note": "Accounts within the digital domain." + }, + { + "uco_class": "role:Role", + "iri": "https://ontology.unifiedcyberontology.org/uco/role/Role", + "note": "Roles attributed to identity-bearing participants." + } + ], + "divergences": [] + }, + "relationships": { + "concept_family": "relationships", + "provenance": "adapted", + "uco_types": [ + { + "uco_class": "core:Relationship", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Relationship", + "note": "UCO directed/typed assertion that objects relate to one another; the closest UCO anchor for ACES typed scenario relationships." + } + ], + "divergences": [ + "ACES relationship `type` values derive from STIX 2.1 Relationship SRO vocabulary and OCR dependency patterns, not from the UCO core:kindOfRelationship controlled vocabulary.", + "ACES relationships carry an open `properties` map instead of UCO facet-based characterization; relationship attributes are not modeled as UCO Facets.", + "ACES relationship source and target reference SDL scenario elements (nodes, accounts, content, conditions, events), not exclusively UCO observable objects." + ] + }, + "observables": { + "concept_family": "observables", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object for conditions, metrics, and telemetry-bearing objects." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Observed digital-context event and telemetry records." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Observed blocks of digital data carrying telemetry payloads." + } + ], + "divergences": [] + }, + "actions-and-events": { + "concept_family": "actions-and-events", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "action:Action", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/Action", + "note": "Executable actions and injects (something that may be done or performed)." + }, + { + "uco_class": "action:ActionLifecycle", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionLifecycle", + "note": "Ordered sets of actions for workflow steps and sequenced injects." + }, + { + "uco_class": "action:ActionPattern", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionPattern", + "note": "Characteristic arrangements of actions." + }, + { + "uco_class": "core:Event", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Event", + "note": "A noteworthy occurrence that happens or might happen." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Recorded occurrence of an event in a digital context." + } + ], + "divergences": [] + }, + "tools-and-artifacts": { + "concept_family": "tools-and-artifacts", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "tool:Tool", + "iri": "https://ontology.unifiedcyberontology.org/uco/tool/Tool", + "note": "An element of hardware and/or software utilized to carry out a particular function." + }, + { + "uco_class": "observable:File", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/File", + "note": "File artifacts recorded on a storage device." + }, + { + "uco_class": "observable:Application", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Application", + "note": "Software programs and applications designed for end users." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Content and data artifacts (blocks of digital data)." + }, + { + "uco_class": "core:Item", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Item", + "note": "A distinct article or unit; deployable artifacts as discrete items." + } + ], + "divergences": [] + } + } +} diff --git a/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/missing-family.json b/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/missing-family.json new file mode 100644 index 000000000..6fe752d21 --- /dev/null +++ b/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/missing-family.json @@ -0,0 +1,152 @@ +{ + "schema_version": "uco-alignment/v1", + "uco_version": "1.4.0", + "uco_reference": "https://github.com/ucoProject/UCO/releases/tag/1.4.0", + "review_scope": "Primary-source review of the Unified Cyber Ontology (UCO) at git tag 1.4.0. Reviewed ontology source: ontology/uco/core/core.ttl, ontology/uco/observable/observable.ttl, ontology/uco/identity/identity.ttl, ontology/uco/action/action.ttl, ontology/uco/tool/tool.ttl, and ontology/uco/role/role.ttl. Every uco_class cited below was confirmed declared as an owl:Class in the reviewed file at that tag. Alignment is recorded at UCO object-type granularity; UCO property-level mapping is out of scope for uco-alignment/v1. Coverage is exactly the adopted/adapted cyber-domain families in concept-families-v1.json whose declared authority is UCO. This is a concept-authority (semantic) alignment per ADR-012, not an authoring-syntax or schema-structure mapping.", + "alignments": { + "assets": { + "concept_family": "assets", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object; ACES asset-bearing scenario nodes and deployable resources are represented as observable objects." + }, + { + "uco_class": "observable:Computer", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Computer", + "note": "Host and compute nodes." + }, + { + "uco_class": "observable:Device", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Device", + "note": "Device-class deployable equipment serving a special purpose." + }, + { + "uco_class": "observable:AutonomousSystem", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/AutonomousSystem", + "note": "Network and routing infrastructure." + }, + { + "uco_class": "observable:IPAddress", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/IPAddress", + "note": "Network addressing for nodes and infrastructure." + }, + { + "uco_class": "observable:Hostname", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Hostname", + "note": "Node identification within a network." + } + ], + "divergences": [] + }, + "identities": { + "concept_family": "identities", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "identity:Identity", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Identity", + "note": "Identity-bearing participants and entities." + }, + { + "uco_class": "identity:Person", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Person", + "note": "Individual human identities." + }, + { + "uco_class": "identity:Organization", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Organization", + "note": "Organizational identities." + }, + { + "uco_class": "observable:Account", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Account", + "note": "Account arrangements enabling provision of a capability or service." + }, + { + "uco_class": "observable:DigitalAccount", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/DigitalAccount", + "note": "Accounts within the digital domain." + }, + { + "uco_class": "role:Role", + "iri": "https://ontology.unifiedcyberontology.org/uco/role/Role", + "note": "Roles attributed to identity-bearing participants." + } + ], + "divergences": [] + }, + "relationships": { + "concept_family": "relationships", + "provenance": "adapted", + "uco_types": [ + { + "uco_class": "core:Relationship", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Relationship", + "note": "UCO directed/typed assertion that objects relate to one another; the closest UCO anchor for ACES typed scenario relationships." + } + ], + "divergences": [ + "ACES relationship `type` values derive from STIX 2.1 Relationship SRO vocabulary and OCR dependency patterns, not from the UCO core:kindOfRelationship controlled vocabulary.", + "ACES relationships carry an open `properties` map instead of UCO facet-based characterization; relationship attributes are not modeled as UCO Facets.", + "ACES relationship source and target reference SDL scenario elements (nodes, accounts, content, conditions, events), not exclusively UCO observable objects." + ] + }, + "observables": { + "concept_family": "observables", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object for conditions, metrics, and telemetry-bearing objects." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Observed digital-context event and telemetry records." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Observed blocks of digital data carrying telemetry payloads." + } + ], + "divergences": [] + }, + "actions-and-events": { + "concept_family": "actions-and-events", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "action:Action", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/Action", + "note": "Executable actions and injects (something that may be done or performed)." + }, + { + "uco_class": "action:ActionLifecycle", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionLifecycle", + "note": "Ordered sets of actions for workflow steps and sequenced injects." + }, + { + "uco_class": "action:ActionPattern", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionPattern", + "note": "Characteristic arrangements of actions." + }, + { + "uco_class": "core:Event", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Event", + "note": "A noteworthy occurrence that happens or might happen." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Recorded occurrence of an event in a digital context." + } + ], + "divergences": [] + } + } +} diff --git a/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/native-family.json b/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/native-family.json new file mode 100644 index 000000000..909ea4219 --- /dev/null +++ b/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/native-family.json @@ -0,0 +1,196 @@ +{ + "schema_version": "uco-alignment/v1", + "uco_version": "1.4.0", + "uco_reference": "https://github.com/ucoProject/UCO/releases/tag/1.4.0", + "review_scope": "Primary-source review of the Unified Cyber Ontology (UCO) at git tag 1.4.0. Reviewed ontology source: ontology/uco/core/core.ttl, ontology/uco/observable/observable.ttl, ontology/uco/identity/identity.ttl, ontology/uco/action/action.ttl, ontology/uco/tool/tool.ttl, and ontology/uco/role/role.ttl. Every uco_class cited below was confirmed declared as an owl:Class in the reviewed file at that tag. Alignment is recorded at UCO object-type granularity; UCO property-level mapping is out of scope for uco-alignment/v1. Coverage is exactly the adopted/adapted cyber-domain families in concept-families-v1.json whose declared authority is UCO. This is a concept-authority (semantic) alignment per ADR-012, not an authoring-syntax or schema-structure mapping.", + "alignments": { + "assets": { + "concept_family": "assets", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object; ACES asset-bearing scenario nodes and deployable resources are represented as observable objects." + }, + { + "uco_class": "observable:Computer", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Computer", + "note": "Host and compute nodes." + }, + { + "uco_class": "observable:Device", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Device", + "note": "Device-class deployable equipment serving a special purpose." + }, + { + "uco_class": "observable:AutonomousSystem", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/AutonomousSystem", + "note": "Network and routing infrastructure." + }, + { + "uco_class": "observable:IPAddress", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/IPAddress", + "note": "Network addressing for nodes and infrastructure." + }, + { + "uco_class": "observable:Hostname", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Hostname", + "note": "Node identification within a network." + } + ], + "divergences": [] + }, + "identities": { + "concept_family": "identities", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "identity:Identity", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Identity", + "note": "Identity-bearing participants and entities." + }, + { + "uco_class": "identity:Person", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Person", + "note": "Individual human identities." + }, + { + "uco_class": "identity:Organization", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Organization", + "note": "Organizational identities." + }, + { + "uco_class": "observable:Account", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Account", + "note": "Account arrangements enabling provision of a capability or service." + }, + { + "uco_class": "observable:DigitalAccount", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/DigitalAccount", + "note": "Accounts within the digital domain." + }, + { + "uco_class": "role:Role", + "iri": "https://ontology.unifiedcyberontology.org/uco/role/Role", + "note": "Roles attributed to identity-bearing participants." + } + ], + "divergences": [] + }, + "relationships": { + "concept_family": "relationships", + "provenance": "adapted", + "uco_types": [ + { + "uco_class": "core:Relationship", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Relationship", + "note": "UCO directed/typed assertion that objects relate to one another; the closest UCO anchor for ACES typed scenario relationships." + } + ], + "divergences": [ + "ACES relationship `type` values derive from STIX 2.1 Relationship SRO vocabulary and OCR dependency patterns, not from the UCO core:kindOfRelationship controlled vocabulary.", + "ACES relationships carry an open `properties` map instead of UCO facet-based characterization; relationship attributes are not modeled as UCO Facets.", + "ACES relationship source and target reference SDL scenario elements (nodes, accounts, content, conditions, events), not exclusively UCO observable objects." + ] + }, + "observables": { + "concept_family": "observables", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object for conditions, metrics, and telemetry-bearing objects." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Observed digital-context event and telemetry records." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Observed blocks of digital data carrying telemetry payloads." + } + ], + "divergences": [] + }, + "actions-and-events": { + "concept_family": "actions-and-events", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "action:Action", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/Action", + "note": "Executable actions and injects (something that may be done or performed)." + }, + { + "uco_class": "action:ActionLifecycle", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionLifecycle", + "note": "Ordered sets of actions for workflow steps and sequenced injects." + }, + { + "uco_class": "action:ActionPattern", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionPattern", + "note": "Characteristic arrangements of actions." + }, + { + "uco_class": "core:Event", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Event", + "note": "A noteworthy occurrence that happens or might happen." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Recorded occurrence of an event in a digital context." + } + ], + "divergences": [] + }, + "tools-and-artifacts": { + "concept_family": "tools-and-artifacts", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "tool:Tool", + "iri": "https://ontology.unifiedcyberontology.org/uco/tool/Tool", + "note": "An element of hardware and/or software utilized to carry out a particular function." + }, + { + "uco_class": "observable:File", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/File", + "note": "File artifacts recorded on a storage device." + }, + { + "uco_class": "observable:Application", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Application", + "note": "Software programs and applications designed for end users." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Content and data artifacts (blocks of digital data)." + }, + { + "uco_class": "core:Item", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Item", + "note": "A distinct article or unit; deployable artifacts as discrete items." + } + ], + "divergences": [] + }, + "scenarios": { + "concept_family": "scenarios", + "provenance": "native", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "invalid native alignment" + } + ], + "divergences": [] + } + } +} diff --git a/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/provenance-mismatch.json b/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/provenance-mismatch.json new file mode 100644 index 000000000..b714afe72 --- /dev/null +++ b/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/provenance-mismatch.json @@ -0,0 +1,186 @@ +{ + "schema_version": "uco-alignment/v1", + "uco_version": "1.4.0", + "uco_reference": "https://github.com/ucoProject/UCO/releases/tag/1.4.0", + "review_scope": "Primary-source review of the Unified Cyber Ontology (UCO) at git tag 1.4.0. Reviewed ontology source: ontology/uco/core/core.ttl, ontology/uco/observable/observable.ttl, ontology/uco/identity/identity.ttl, ontology/uco/action/action.ttl, ontology/uco/tool/tool.ttl, and ontology/uco/role/role.ttl. Every uco_class cited below was confirmed declared as an owl:Class in the reviewed file at that tag. Alignment is recorded at UCO object-type granularity; UCO property-level mapping is out of scope for uco-alignment/v1. Coverage is exactly the adopted/adapted cyber-domain families in concept-families-v1.json whose declared authority is UCO. This is a concept-authority (semantic) alignment per ADR-012, not an authoring-syntax or schema-structure mapping.", + "alignments": { + "assets": { + "concept_family": "assets", + "provenance": "adapted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object; ACES asset-bearing scenario nodes and deployable resources are represented as observable objects." + }, + { + "uco_class": "observable:Computer", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Computer", + "note": "Host and compute nodes." + }, + { + "uco_class": "observable:Device", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Device", + "note": "Device-class deployable equipment serving a special purpose." + }, + { + "uco_class": "observable:AutonomousSystem", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/AutonomousSystem", + "note": "Network and routing infrastructure." + }, + { + "uco_class": "observable:IPAddress", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/IPAddress", + "note": "Network addressing for nodes and infrastructure." + }, + { + "uco_class": "observable:Hostname", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Hostname", + "note": "Node identification within a network." + } + ], + "divergences": [ + "fabricated delta so only the catalog provenance mismatch fails" + ] + }, + "identities": { + "concept_family": "identities", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "identity:Identity", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Identity", + "note": "Identity-bearing participants and entities." + }, + { + "uco_class": "identity:Person", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Person", + "note": "Individual human identities." + }, + { + "uco_class": "identity:Organization", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Organization", + "note": "Organizational identities." + }, + { + "uco_class": "observable:Account", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Account", + "note": "Account arrangements enabling provision of a capability or service." + }, + { + "uco_class": "observable:DigitalAccount", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/DigitalAccount", + "note": "Accounts within the digital domain." + }, + { + "uco_class": "role:Role", + "iri": "https://ontology.unifiedcyberontology.org/uco/role/Role", + "note": "Roles attributed to identity-bearing participants." + } + ], + "divergences": [] + }, + "relationships": { + "concept_family": "relationships", + "provenance": "adapted", + "uco_types": [ + { + "uco_class": "core:Relationship", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Relationship", + "note": "UCO directed/typed assertion that objects relate to one another; the closest UCO anchor for ACES typed scenario relationships." + } + ], + "divergences": [ + "ACES relationship `type` values derive from STIX 2.1 Relationship SRO vocabulary and OCR dependency patterns, not from the UCO core:kindOfRelationship controlled vocabulary.", + "ACES relationships carry an open `properties` map instead of UCO facet-based characterization; relationship attributes are not modeled as UCO Facets.", + "ACES relationship source and target reference SDL scenario elements (nodes, accounts, content, conditions, events), not exclusively UCO observable objects." + ] + }, + "observables": { + "concept_family": "observables", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object for conditions, metrics, and telemetry-bearing objects." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Observed digital-context event and telemetry records." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Observed blocks of digital data carrying telemetry payloads." + } + ], + "divergences": [] + }, + "actions-and-events": { + "concept_family": "actions-and-events", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "action:Action", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/Action", + "note": "Executable actions and injects (something that may be done or performed)." + }, + { + "uco_class": "action:ActionLifecycle", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionLifecycle", + "note": "Ordered sets of actions for workflow steps and sequenced injects." + }, + { + "uco_class": "action:ActionPattern", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionPattern", + "note": "Characteristic arrangements of actions." + }, + { + "uco_class": "core:Event", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Event", + "note": "A noteworthy occurrence that happens or might happen." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Recorded occurrence of an event in a digital context." + } + ], + "divergences": [] + }, + "tools-and-artifacts": { + "concept_family": "tools-and-artifacts", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "tool:Tool", + "iri": "https://ontology.unifiedcyberontology.org/uco/tool/Tool", + "note": "An element of hardware and/or software utilized to carry out a particular function." + }, + { + "uco_class": "observable:File", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/File", + "note": "File artifacts recorded on a storage device." + }, + { + "uco_class": "observable:Application", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Application", + "note": "Software programs and applications designed for end users." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Content and data artifacts (blocks of digital data)." + }, + { + "uco_class": "core:Item", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Item", + "note": "A distinct article or unit; deployable artifacts as discrete items." + } + ], + "divergences": [] + } + } +} diff --git a/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/unknown-family.json b/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/unknown-family.json new file mode 100644 index 000000000..a7c0a76f1 --- /dev/null +++ b/contracts/fixtures/concept-authority/uco-alignment-v1/invalid/unknown-family.json @@ -0,0 +1,196 @@ +{ + "schema_version": "uco-alignment/v1", + "uco_version": "1.4.0", + "uco_reference": "https://github.com/ucoProject/UCO/releases/tag/1.4.0", + "review_scope": "Primary-source review of the Unified Cyber Ontology (UCO) at git tag 1.4.0. Reviewed ontology source: ontology/uco/core/core.ttl, ontology/uco/observable/observable.ttl, ontology/uco/identity/identity.ttl, ontology/uco/action/action.ttl, ontology/uco/tool/tool.ttl, and ontology/uco/role/role.ttl. Every uco_class cited below was confirmed declared as an owl:Class in the reviewed file at that tag. Alignment is recorded at UCO object-type granularity; UCO property-level mapping is out of scope for uco-alignment/v1. Coverage is exactly the adopted/adapted cyber-domain families in concept-families-v1.json whose declared authority is UCO. This is a concept-authority (semantic) alignment per ADR-012, not an authoring-syntax or schema-structure mapping.", + "alignments": { + "assets": { + "concept_family": "assets", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object; ACES asset-bearing scenario nodes and deployable resources are represented as observable objects." + }, + { + "uco_class": "observable:Computer", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Computer", + "note": "Host and compute nodes." + }, + { + "uco_class": "observable:Device", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Device", + "note": "Device-class deployable equipment serving a special purpose." + }, + { + "uco_class": "observable:AutonomousSystem", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/AutonomousSystem", + "note": "Network and routing infrastructure." + }, + { + "uco_class": "observable:IPAddress", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/IPAddress", + "note": "Network addressing for nodes and infrastructure." + }, + { + "uco_class": "observable:Hostname", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Hostname", + "note": "Node identification within a network." + } + ], + "divergences": [] + }, + "identities": { + "concept_family": "identities", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "identity:Identity", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Identity", + "note": "Identity-bearing participants and entities." + }, + { + "uco_class": "identity:Person", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Person", + "note": "Individual human identities." + }, + { + "uco_class": "identity:Organization", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Organization", + "note": "Organizational identities." + }, + { + "uco_class": "observable:Account", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Account", + "note": "Account arrangements enabling provision of a capability or service." + }, + { + "uco_class": "observable:DigitalAccount", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/DigitalAccount", + "note": "Accounts within the digital domain." + }, + { + "uco_class": "role:Role", + "iri": "https://ontology.unifiedcyberontology.org/uco/role/Role", + "note": "Roles attributed to identity-bearing participants." + } + ], + "divergences": [] + }, + "relationships": { + "concept_family": "relationships", + "provenance": "adapted", + "uco_types": [ + { + "uco_class": "core:Relationship", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Relationship", + "note": "UCO directed/typed assertion that objects relate to one another; the closest UCO anchor for ACES typed scenario relationships." + } + ], + "divergences": [ + "ACES relationship `type` values derive from STIX 2.1 Relationship SRO vocabulary and OCR dependency patterns, not from the UCO core:kindOfRelationship controlled vocabulary.", + "ACES relationships carry an open `properties` map instead of UCO facet-based characterization; relationship attributes are not modeled as UCO Facets.", + "ACES relationship source and target reference SDL scenario elements (nodes, accounts, content, conditions, events), not exclusively UCO observable objects." + ] + }, + "observables": { + "concept_family": "observables", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object for conditions, metrics, and telemetry-bearing objects." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Observed digital-context event and telemetry records." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Observed blocks of digital data carrying telemetry payloads." + } + ], + "divergences": [] + }, + "actions-and-events": { + "concept_family": "actions-and-events", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "action:Action", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/Action", + "note": "Executable actions and injects (something that may be done or performed)." + }, + { + "uco_class": "action:ActionLifecycle", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionLifecycle", + "note": "Ordered sets of actions for workflow steps and sequenced injects." + }, + { + "uco_class": "action:ActionPattern", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionPattern", + "note": "Characteristic arrangements of actions." + }, + { + "uco_class": "core:Event", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Event", + "note": "A noteworthy occurrence that happens or might happen." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Recorded occurrence of an event in a digital context." + } + ], + "divergences": [] + }, + "tools-and-artifacts": { + "concept_family": "tools-and-artifacts", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "tool:Tool", + "iri": "https://ontology.unifiedcyberontology.org/uco/tool/Tool", + "note": "An element of hardware and/or software utilized to carry out a particular function." + }, + { + "uco_class": "observable:File", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/File", + "note": "File artifacts recorded on a storage device." + }, + { + "uco_class": "observable:Application", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Application", + "note": "Software programs and applications designed for end users." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Content and data artifacts (blocks of digital data)." + }, + { + "uco_class": "core:Item", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Item", + "note": "A distinct article or unit; deployable artifacts as discrete items." + } + ], + "divergences": [] + }, + "made-up-family": { + "concept_family": "made-up-family", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:File", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/File", + "note": "invalid unknown family" + } + ], + "divergences": [] + } + } +} diff --git a/contracts/fixtures/concept-authority/uco-alignment-v1/valid/reference.json b/contracts/fixtures/concept-authority/uco-alignment-v1/valid/reference.json new file mode 100644 index 000000000..3426debb3 --- /dev/null +++ b/contracts/fixtures/concept-authority/uco-alignment-v1/valid/reference.json @@ -0,0 +1,184 @@ +{ + "schema_version": "uco-alignment/v1", + "uco_version": "1.4.0", + "uco_reference": "https://github.com/ucoProject/UCO/releases/tag/1.4.0", + "review_scope": "Primary-source review of the Unified Cyber Ontology (UCO) at git tag 1.4.0. Reviewed ontology source: ontology/uco/core/core.ttl, ontology/uco/observable/observable.ttl, ontology/uco/identity/identity.ttl, ontology/uco/action/action.ttl, ontology/uco/tool/tool.ttl, and ontology/uco/role/role.ttl. Every uco_class cited below was confirmed declared as an owl:Class in the reviewed file at that tag. Alignment is recorded at UCO object-type granularity; UCO property-level mapping is out of scope for uco-alignment/v1. Coverage is exactly the adopted/adapted cyber-domain families in concept-families-v1.json whose declared authority is UCO. This is a concept-authority (semantic) alignment per ADR-012, not an authoring-syntax or schema-structure mapping.", + "alignments": { + "assets": { + "concept_family": "assets", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object; ACES asset-bearing scenario nodes and deployable resources are represented as observable objects." + }, + { + "uco_class": "observable:Computer", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Computer", + "note": "Host and compute nodes." + }, + { + "uco_class": "observable:Device", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Device", + "note": "Device-class deployable equipment serving a special purpose." + }, + { + "uco_class": "observable:AutonomousSystem", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/AutonomousSystem", + "note": "Network and routing infrastructure." + }, + { + "uco_class": "observable:IPAddress", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/IPAddress", + "note": "Network addressing for nodes and infrastructure." + }, + { + "uco_class": "observable:Hostname", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Hostname", + "note": "Node identification within a network." + } + ], + "divergences": [] + }, + "identities": { + "concept_family": "identities", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "identity:Identity", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Identity", + "note": "Identity-bearing participants and entities." + }, + { + "uco_class": "identity:Person", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Person", + "note": "Individual human identities." + }, + { + "uco_class": "identity:Organization", + "iri": "https://ontology.unifiedcyberontology.org/uco/identity/Organization", + "note": "Organizational identities." + }, + { + "uco_class": "observable:Account", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Account", + "note": "Account arrangements enabling provision of a capability or service." + }, + { + "uco_class": "observable:DigitalAccount", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/DigitalAccount", + "note": "Accounts within the digital domain." + }, + { + "uco_class": "role:Role", + "iri": "https://ontology.unifiedcyberontology.org/uco/role/Role", + "note": "Roles attributed to identity-bearing participants." + } + ], + "divergences": [] + }, + "relationships": { + "concept_family": "relationships", + "provenance": "adapted", + "uco_types": [ + { + "uco_class": "core:Relationship", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Relationship", + "note": "UCO directed/typed assertion that objects relate to one another; the closest UCO anchor for ACES typed scenario relationships." + } + ], + "divergences": [ + "ACES relationship `type` values derive from STIX 2.1 Relationship SRO vocabulary and OCR dependency patterns, not from the UCO core:kindOfRelationship controlled vocabulary.", + "ACES relationships carry an open `properties` map instead of UCO facet-based characterization; relationship attributes are not modeled as UCO Facets.", + "ACES relationship source and target reference SDL scenario elements (nodes, accounts, content, conditions, events), not exclusively UCO observable objects." + ] + }, + "observables": { + "concept_family": "observables", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "observable:ObservableObject", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ObservableObject", + "note": "Base observable cyber object for conditions, metrics, and telemetry-bearing objects." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Observed digital-context event and telemetry records." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Observed blocks of digital data carrying telemetry payloads." + } + ], + "divergences": [] + }, + "actions-and-events": { + "concept_family": "actions-and-events", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "action:Action", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/Action", + "note": "Executable actions and injects (something that may be done or performed)." + }, + { + "uco_class": "action:ActionLifecycle", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionLifecycle", + "note": "Ordered sets of actions for workflow steps and sequenced injects." + }, + { + "uco_class": "action:ActionPattern", + "iri": "https://ontology.unifiedcyberontology.org/uco/action/ActionPattern", + "note": "Characteristic arrangements of actions." + }, + { + "uco_class": "core:Event", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Event", + "note": "A noteworthy occurrence that happens or might happen." + }, + { + "uco_class": "observable:EventRecord", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/EventRecord", + "note": "Recorded occurrence of an event in a digital context." + } + ], + "divergences": [] + }, + "tools-and-artifacts": { + "concept_family": "tools-and-artifacts", + "provenance": "adopted", + "uco_types": [ + { + "uco_class": "tool:Tool", + "iri": "https://ontology.unifiedcyberontology.org/uco/tool/Tool", + "note": "An element of hardware and/or software utilized to carry out a particular function." + }, + { + "uco_class": "observable:File", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/File", + "note": "File artifacts recorded on a storage device." + }, + { + "uco_class": "observable:Application", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/Application", + "note": "Software programs and applications designed for end users." + }, + { + "uco_class": "observable:ContentData", + "iri": "https://ontology.unifiedcyberontology.org/uco/observable/ContentData", + "note": "Content and data artifacts (blocks of digital data)." + }, + { + "uco_class": "core:Item", + "iri": "https://ontology.unifiedcyberontology.org/uco/core/Item", + "note": "A distinct article or unit; deployable artifacts as discrete items." + } + ], + "divergences": [] + } + } +} diff --git a/contracts/schema-publication-manifest.json b/contracts/schema-publication-manifest.json index 5ef13e6d2..be16cdb68 100644 --- a/contracts/schema-publication-manifest.json +++ b/contracts/schema-publication-manifest.json @@ -212,6 +212,12 @@ "stability": "draft", "content_hash": "ca78369c63879f21cf57b3ee29e2015163dbdc6a17d496b22ea98b1bd0b38bce" }, + { + "contract_id": "uco-alignment-v1", + "schema_path": "contracts/schemas/concept-authority/uco-alignment-v1.json", + "stability": "draft", + "content_hash": "445ef9ba99cddaae2b3308a054d26dd968d194af1c2f98710151f2d5a747f5bf" + }, { "contract_id": "workflow-cancellation-request-v1", "schema_path": "contracts/schemas/control-plane/workflow-cancellation-request-v1.json", diff --git a/contracts/schemas/README.md b/contracts/schemas/README.md index 1aec737eb..ecef66aad 100644 --- a/contracts/schemas/README.md +++ b/contracts/schemas/README.md @@ -14,6 +14,7 @@ Current published schemas cover: - processor manifests (`v1` legacy plus shared-apparatus `v2`) - concept-authority catalogs - reference model catalogs +- UCO alignment evidence - controlled vocabulary catalogs - semantic profiles - live-execution snapshots @@ -66,6 +67,20 @@ instead they must declare non-empty `extension_scope`, `relation_rules`, and provenance, and governance concepts explicit without letting them silently fork shared cyber-domain concepts. +## UCO Alignment Evidence + +The `uco-alignment-v1` schema publishes the machine-readable UCO alignment +evidence catalog. It pins the reviewed UCO version and maps each adopted and +adapted cyber-domain concept family (from `concept-families-v1`) to the UCO +object types it aligns to, enumerating adapted-family divergences explicitly. + +The catalog lives at `contracts/concept-authority/uco-alignment-v1.json`. +Coverage is catalog-derived: every adopted or adapted family whose authority is +UCO must have exactly one alignment entry. Validation uses local evidence only +and does not fetch the UCO ontology; semantic checks beyond JSON Schema enforce +coverage, provenance agreement with `concept-families-v1`, canonical UCO IRIs, +and the adapted-family divergence rule. + ## Semantic Profiles The `semantic-profile-v1` schema publishes shared semantic profile documents. diff --git a/contracts/schemas/concept-authority/uco-alignment-v1.json b/contracts/schemas/concept-authority/uco-alignment-v1.json new file mode 100644 index 000000000..dd28e576a --- /dev/null +++ b/contracts/schemas/concept-authority/uco-alignment-v1.json @@ -0,0 +1,124 @@ +{ + "$defs": { + "ConceptProvenanceCategory": { + "description": "How a concept family relates to its authority source.", + "enum": [ + "adopted", + "adapted", + "native" + ], + "title": "ConceptProvenanceCategory", + "type": "string" + }, + "UcoAlignmentTypeModel": { + "additionalProperties": false, + "properties": { + "iri": { + "minLength": 1, + "title": "Iri", + "type": "string" + }, + "note": { + "minLength": 1, + "title": "Note", + "type": "string" + }, + "uco_class": { + "pattern": "^[a-z][a-z0-9]*:[A-Z][A-Za-z0-9]*$", + "title": "Uco Class", + "type": "string" + } + }, + "required": [ + "uco_class", + "iri", + "note" + ], + "title": "UcoAlignmentTypeModel", + "type": "object" + }, + "UcoFamilyAlignmentModel": { + "additionalProperties": false, + "properties": { + "concept_family": { + "minLength": 1, + "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$", + "title": "Concept Family", + "type": "string" + }, + "divergences": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Divergences", + "type": "array" + }, + "provenance": { + "$ref": "#/$defs/ConceptProvenanceCategory" + }, + "uco_types": { + "items": { + "$ref": "#/$defs/UcoAlignmentTypeModel" + }, + "minItems": 1, + "title": "Uco Types", + "type": "array" + } + }, + "required": [ + "concept_family", + "provenance", + "uco_types", + "divergences" + ], + "title": "UcoFamilyAlignmentModel", + "type": "object" + } + }, + "$id": "https://aces.dev/schemas/uco-alignment-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "alignments": { + "additionalProperties": { + "$ref": "#/$defs/UcoFamilyAlignmentModel" + }, + "minProperties": 1, + "propertyNames": { + "minLength": 1 + }, + "title": "Alignments", + "type": "object" + }, + "review_scope": { + "minLength": 1, + "title": "Review Scope", + "type": "string" + }, + "schema_version": { + "const": "uco-alignment/v1", + "default": "uco-alignment/v1", + "title": "Schema Version", + "type": "string" + }, + "uco_reference": { + "minLength": 1, + "title": "Uco Reference", + "type": "string" + }, + "uco_version": { + "minLength": 1, + "title": "Uco Version", + "type": "string" + } + }, + "required": [ + "uco_version", + "uco_reference", + "review_scope", + "alignments" + ], + "title": "UcoAlignmentCatalogModel", + "type": "object" +} diff --git a/docs/decisions/issue-495-uco-alignment-preflight.md b/docs/decisions/issue-495-uco-alignment-preflight.md new file mode 100644 index 000000000..ae051ade9 --- /dev/null +++ b/docs/decisions/issue-495-uco-alignment-preflight.md @@ -0,0 +1,158 @@ +# Issue 495 UCO Alignment Preflight + +Date: 2026-06-14 + +Issue: #495. + +Requirement: none. The issue title, body, and acceptance criteria are the +contract. + +This note records architecture preflight guardrails for adding the UCO +alignment artifact. It is guidance for the implementation and does not add the +alignment artifact, schema, fixtures, tests, or specification references. + +## Binding Sources + +- ADR-012 defines UCO as concept authority for adopted and adapted + cyber-domain families, while keeping ACES authoring syntax and contract + structure separate from ontology structure. +- `specs/concept-authority/concept-authority.md` defines the concept-authority, + ACES concept, and artifact-binding layers. +- `contracts/concept-authority/concept-families-v1.json` is the canonical + family set and provenance source. +- `contracts/concept-authority/reference-models-v1.json` anchors recurrent + ACES structures to published ACES schemas; it is not a UCO class map. +- `contracts/profiles/semantic/reference-stack-v1.json` composes ACES concept, + contract, binding, and behavior assumptions; it must not become a UCO profile + or capability profile. +- ADR-061 and `contracts/schema-publication-manifest.json` govern any new + published schema. + +## Architecture Decisions + +- `contracts/concept-authority/uco-alignment-v1.json` is an alignment evidence + artifact for the UCO authority claim. It must not redefine concept family + scope, UCO semantics, SDL syntax, or ACES reference-model structure. +- The artifact should have one pinned UCO source/version/review scope and one + keyed family-alignment map. Per-family entries record aligned UCO object + types/properties and explicit divergence records. +- Required family coverage is catalog-derived: every `adopted` or `adapted` + family whose `authority` is `UCO` in `concept-families-v1` must have exactly + one alignment entry. Do not hard-code the current six-family set as a second + source of truth. +- Divergence must be explicit. `adapted` families require at least one stated + delta; `adopted` families still need an explicit divergence field so reviewers + can distinguish "none recorded" from "not reviewed". +- The UCO review is primary-source evidence captured in the artifact. Validators + and tests should not fetch the network at runtime; they should validate the + recorded source URI, version/ref, review scope, and local mapping shape. +- References from `reference-models.md` and `semantic-profiles.md` should point + to the mapping as alignment evidence. They should not duplicate the mapping + or imply that reference models or semantic profiles inherit UCO syntax. + +## Required Incumbents + +Reuse these repo surfaces before adding anything new: + +- Contract model base and closed-world validation: + `aces_contracts.contracts.ContractModel`, existing constrained string aliases, + `@model_validator`, and `pydantic.ValidationError`. +- Concept-authority catalog authority: `ConceptFamilyCatalogModel`, + `ConceptFamilyDefinitionModel`, `ConceptProvenanceCategory`, + `ConceptFamilyId`, and the cached catalog helpers near + `_authoritative_concept_family_ids()`. +- Schema publication: `aces_contracts.versions`, `schema_bundle()`, + `tools/generate_contract_schemas.py`, `tools/check_generated_schemas.py`, + `tools/check_schema_publication.py`, and + `contracts/schema-publication-manifest.json`. Do not hand-edit + `contracts/schemas/`. +- JSON artifact validation: `tools/check_json_artifacts.py`, including its + existing routing for `contracts/concept-authority/*.json` and + `contracts/fixtures/concept-authority//valid/*.json`. +- Fixture/test style: + `contracts/fixtures/concept-authority/concept-families-v1/`, + `contracts/fixtures/concept-authority/reference-models-v1/`, + `implementations/python/tests/test_concept_authority.py`, and + `implementations/python/tests/test_reference_models.py`. +- Specification references: `specs/concept-authority/reference-models.md`, + `specs/concept-authority/semantic-profiles.md`, and + `contracts/schemas/README.md` when a new schema is published. +- Workflow gates: `.ground-control.yaml`, `.gc/plan-rules.md`, + `tools/check_repo_policy.py`, `tools/check_requirement_governance.py`, and + `tools/verify_all.py`. + +## Cross-Cutting Layers + +- Contract shape gate: the alignment artifact must be a closed-world + `ContractModel` with generated draft 2020-12 JSON Schema, valid fixtures, and + invalid fixtures. Schema-only validation is not sufficient for catalog + coverage or adapted-family divergence rules. +- Concept-authority gate: family ids, provenance, and UCO authority status come + from `concept-families-v1`. The new artifact must not carry an independent + family registry or copied family definitions. +- Schema publication gate: adding `uco-alignment-v1` means adding the generated + schema through `schema_bundle()` and updating the publication manifest hash + under ADR-061. +- Fixture gate: valid fixtures must schema-validate through + `check_json_artifacts.py`; invalid fixtures must fail through the Pydantic + model tests that exercise semantic invariants JSON Schema cannot express. +- Documentation gate: `reference-models.md` and `semantic-profiles.md` should + explain the relationship in one direction only: ACES artifacts bind to ACES + concept families, and the UCO alignment demonstrates the external authority + review behind those families. +- Security and secret-handling gate: the design reads public ontology sources + and local JSON only. Do not place credentials, private repository URLs, tokens, + raw environment dumps, or command-line secrets in artifacts, fixtures, + validation failures, logs, or review-scope notes. +- Auth, persistence, and error-envelope gate: this change should not touch HTTP + auth, runtime persistence, control-plane stores, audit logs, or API error + envelopes. Validation failures should remain `ValidationError` or existing + policy-tool failures, not a new exception hierarchy. +- Host/OS exposure gate: any one-time source review commands used during + implementation must avoid credentialed URLs and token-bearing process argv. + The checked-in validator path must not shell out to ontology tooling or depend + on network or host-specific state. + +## Extension Boundary + +The extension seam is the catalog-derived alignment map: family id plus +authority source/version plus reviewed UCO object/property references. Adding a +new UCO-backed adopted or adapted family should require a concept-family entry +and a matching alignment entry, not a code change to a hard-coded family list. + +Future non-UCO authorities should not be forced into this artifact. The +coverage rule should select families where `authority == "UCO"`; a future +authority gets its own explicit artifact or versioned shape rather than +becoming an overloaded UCO mapping. + +## Gotchas And Anti-Patterns + +Avoid: + +- treating UCO as SDL syntax or forcing ACES schemas to mirror ontology class + hierarchy; +- treating the alignment as a reference-model catalog, semantic profile, + controlled vocabulary, or backend capability profile; +- duplicating concept-family definitions or provenance rules inside the new + artifact; +- validating only the JSON Schema shape while omitting catalog coverage and + adapted-family divergence checks; +- letting `relationships` keep an `adapted` claim without an explicit delta; +- using prose-only notes where reviewers need object/property references; +- adding live network fetches, ontology parsers, caches, or generated ontology + dumps to the validation path; +- adding a second schema generator, fixture loader, validator stack, exception + hierarchy, or policy command; +- hand-editing `contracts/schemas/` or forgetting the schema publication + manifest. + +## Non-Goals + +- Do not implement the alignment artifact, fixtures, schema, tests, or spec + references in this preflight note. +- Do not expand the concept-family catalog beyond the issue's UCO alignment + evidence need. +- Do not add UCO as an authoring format, runtime dependency, network service, + or ontology-transformation pipeline. +- Do not change ACES reference-model semantics or semantic-profile phase + semantics to make them UCO-shaped. diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index b2915a681..6dde8a181 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -73,6 +73,7 @@ RUNTIME_SNAPSHOT_SCHEMA_VERSION, SCENARIO_INSTANTIATION_REQUEST_SCHEMA_VERSION, SEMANTIC_PROFILE_SCHEMA_VERSION, + UCO_ALIGNMENT_SCHEMA_VERSION, WORKFLOW_CANCELLATION_REQUEST_SCHEMA_VERSION, WORKFLOW_STATE_SCHEMA_VERSION, ) @@ -4600,6 +4601,102 @@ def __get_pydantic_json_schema__( return json_schema +_UCO_NAMESPACE_BASE = "https://ontology.unifiedcyberontology.org/uco/" +UcoClassName = Annotated[str, Field(pattern=r"^[a-z][a-z0-9]*:[A-Z][A-Za-z0-9]*$")] + + +class UcoAlignmentTypeModel(ContractModel): + uco_class: UcoClassName + iri: NonEmptyString + note: NonEmptyString + + @model_validator(mode="after") + def _validate_canonical_iri(self) -> UcoAlignmentTypeModel: + prefix, _, local = self.uco_class.partition(":") + expected_iri = f"{_UCO_NAMESPACE_BASE}{prefix}/{local}" + if self.iri != expected_iri: + raise ValueError( + f"uco_type iri must equal the canonical UCO IRI {expected_iri} for class " + f"{self.uco_class!r}; got {self.iri!r}" + ) + return self + + +class UcoFamilyAlignmentModel(ContractModel): + concept_family: ConceptFamilyId + provenance: ConceptProvenanceCategory + uco_types: list[UcoAlignmentTypeModel] = Field(min_length=1) + divergences: list[NonEmptyString] + + @model_validator(mode="after") + def _validate_family_alignment(self) -> UcoFamilyAlignmentModel: + if self.provenance == ConceptProvenanceCategory.NATIVE: + raise ValueError(f"uco alignment family {self.concept_family!r} must be adopted or adapted, not native") + if self.provenance == ConceptProvenanceCategory.ADAPTED and not self.divergences: + raise ValueError( + f"adapted uco alignment family {self.concept_family!r} must enumerate at least one divergence" + ) + if self.provenance == ConceptProvenanceCategory.ADOPTED and self.divergences: + raise ValueError( + f"adopted uco alignment family {self.concept_family!r} must record an empty divergences list" + ) + uco_classes = [uco_type.uco_class for uco_type in self.uco_types] + if len(uco_classes) != len(set(uco_classes)): + raise ValueError(f"uco alignment family {self.concept_family!r} must not repeat a uco_class") + return self + + +class UcoAlignmentCatalogModel(ContractModel): + schema_version: Literal[UCO_ALIGNMENT_SCHEMA_VERSION] = UCO_ALIGNMENT_SCHEMA_VERSION + uco_version: NonEmptyString + uco_reference: NonEmptyString + review_scope: NonEmptyString + alignments: dict[NonEmptyString, UcoFamilyAlignmentModel] = Field(min_length=1) + + @model_validator(mode="after") + def _validate_alignment_catalog(self) -> UcoAlignmentCatalogModel: + for family_id, alignment in self.alignments.items(): + if not family_id.strip(): + raise ValueError("uco alignment family identifiers must be non-empty") + if alignment.concept_family != family_id: + raise ValueError( + f"uco alignment entry key {family_id!r} must match its concept_family {alignment.concept_family!r}" + ) + + expected_provenance = _uco_cyber_concept_family_provenance() + actual_ids = set(self.alignments) + expected_ids = set(expected_provenance) + missing = expected_ids - actual_ids + unexpected = actual_ids - expected_ids + if missing or unexpected: + raise ValueError( + "uco alignment must cover exactly the adopted/adapted UCO concept families declared in " + f"concept-families-v1; missing: {sorted(missing)}; unexpected: {sorted(unexpected)}" + ) + + for family_id, alignment in self.alignments.items(): + declared = expected_provenance[family_id] + if alignment.provenance.value != declared: + raise ValueError( + f"uco alignment provenance for family {family_id!r} is " + f"{alignment.provenance.value!r} but concept-families-v1 declares {declared!r}" + ) + return self + + @classmethod + def __get_pydantic_json_schema__( + cls, + core_schema: CoreSchema, + handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + json_schema = handler(core_schema) + json_schema = handler.resolve_ref_schema(json_schema) + alignments_schema = json_schema.get("properties", {}).get("alignments") + if isinstance(alignments_schema, dict): + alignments_schema.setdefault("propertyNames", {"minLength": 1}) + return json_schema + + class ControlledVocabularyTermModel(ContractModel): title: NonEmptyString description: NonEmptyString @@ -4765,6 +4862,26 @@ def _authoritative_concept_family_ids() -> frozenset[str]: return frozenset(catalog.families) +@lru_cache(maxsize=1) +def _uco_cyber_concept_family_provenance() -> dict[str, str]: + """Map each adopted/adapted family whose authority is UCO to its provenance. + + This is the single source of truth for UCO alignment coverage: the + ``uco-alignment-v1`` catalog must declare exactly these families, so the + cyber-domain family slice is never hard-coded in a second place. + """ + + catalog_path = _repo_root() / "contracts" / "concept-authority" / "concept-families-v1.json" + payload = json.loads(catalog_path.read_text(encoding="utf-8")) + catalog = ConceptFamilyCatalogModel.model_validate(payload) + return { + family_id: family.provenance.value + for family_id, family in catalog.families.items() + if family.provenance in {ConceptProvenanceCategory.ADOPTED, ConceptProvenanceCategory.ADAPTED} + and family.authority == "UCO" + } + + @lru_cache(maxsize=1) def _known_contract_ids() -> frozenset[str]: return frozenset(schema_bundle()) @@ -4979,6 +5096,7 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "participant-implementation-provenance-v1": ParticipantImplementationProvenanceModel.model_json_schema(), "concept-families-v1": ConceptFamilyCatalogModel.model_json_schema(), "reference-models-v1": ReferenceModelCatalogModel.model_json_schema(), + "uco-alignment-v1": UcoAlignmentCatalogModel.model_json_schema(), "controlled-vocabularies-v1": ControlledVocabularyCatalogModel.model_json_schema(), "semantic-profile-v1": SemanticProfileModel.model_json_schema(), "backend-profile-v1": _backend_profile_schema_for_bundle(), @@ -5178,6 +5296,10 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "SnapshotEntryModel", "SourcePipelineModel", "SourceStatusModel", + "UcoAlignmentCatalogModel", + "UcoAlignmentTypeModel", + "UcoFamilyAlignmentModel", + "UCO_ALIGNMENT_SCHEMA_VERSION", "WorkflowCancellationRequestModel", "WORKFLOW_CANCELLATION_REQUEST_SCHEMA_VERSION", "WorkflowExecutionStateModel", diff --git a/implementations/python/packages/aces_contracts/uco_alignment.py b/implementations/python/packages/aces_contracts/uco_alignment.py new file mode 100644 index 000000000..942b8db97 --- /dev/null +++ b/implementations/python/packages/aces_contracts/uco_alignment.py @@ -0,0 +1,22 @@ +"""Helpers for loading the UCO alignment evidence catalog.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .contracts import UcoAlignmentCatalogModel + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[4] + + +def uco_alignment_catalog_path() -> Path: + return _repo_root() / "contracts" / "concept-authority" / "uco-alignment-v1.json" + + +def load_uco_alignment_catalog() -> UcoAlignmentCatalogModel: + path = uco_alignment_catalog_path() + payload = json.loads(path.read_text(encoding="utf-8")) + return UcoAlignmentCatalogModel.model_validate(payload) diff --git a/implementations/python/packages/aces_contracts/versions.py b/implementations/python/packages/aces_contracts/versions.py index 02d3d1b22..7b0c48829 100644 --- a/implementations/python/packages/aces_contracts/versions.py +++ b/implementations/python/packages/aces_contracts/versions.py @@ -7,6 +7,7 @@ PARTICIPANT_IMPLEMENTATION_PROVENANCE_V1_SCHEMA_VERSION = "participant-implementation-provenance/v1" CONCEPT_FAMILIES_SCHEMA_VERSION = "concept-families/v1" REFERENCE_MODELS_SCHEMA_VERSION = "reference-models/v1" +UCO_ALIGNMENT_SCHEMA_VERSION = "uco-alignment/v1" CONTROLLED_VOCABULARIES_SCHEMA_VERSION = "controlled-vocabularies/v1" SEMANTIC_PROFILE_SCHEMA_VERSION = "semantic-profile/v1" BACKEND_PROFILE_SCHEMA_VERSION = "backend-profile/v1" diff --git a/implementations/python/tests/test_uco_alignment.py b/implementations/python/tests/test_uco_alignment.py new file mode 100644 index 000000000..bcaf331ef --- /dev/null +++ b/implementations/python/tests/test_uco_alignment.py @@ -0,0 +1,159 @@ +"""UCO alignment evidence catalog tests (concept-authority review CA-5). + +These tests validate the UCO alignment mapping artifact's shape, its +catalog-derived coverage of adopted/adapted cyber-domain families, and the +divergence-record discipline. They validate locally recorded evidence only and +never fetch the UCO ontology over the network (per the issue #495 preflight +note). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from aces_contracts.contracts import ConceptFamilyCatalogModel, UcoAlignmentCatalogModel +from aces_contracts.uco_alignment import ( + load_uco_alignment_catalog, + uco_alignment_catalog_path, +) +from aces_contracts.versions import UCO_ALIGNMENT_SCHEMA_VERSION +from pydantic import ValidationError + +REPO_ROOT = Path(__file__).resolve().parents[3] +CATALOG_PATH = REPO_ROOT / "contracts" / "concept-authority" / "uco-alignment-v1.json" +CONCEPT_FAMILIES_PATH = REPO_ROOT / "contracts" / "concept-authority" / "concept-families-v1.json" +FIXTURES_ROOT = REPO_ROOT / "contracts" / "fixtures" / "concept-authority" / "uco-alignment-v1" +VALID_DIR = FIXTURES_ROOT / "valid" +INVALID_DIR = FIXTURES_ROOT / "invalid" + +UCO_NAMESPACE_BASE = "https://ontology.unifiedcyberontology.org/uco/" +CYBER_DOMAIN_FAMILIES = { + "assets", + "identities", + "relationships", + "observables", + "actions-and-events", + "tools-and-artifacts", +} + + +def _uco_cyber_family_provenance() -> dict[str, str]: + payload = json.loads(CONCEPT_FAMILIES_PATH.read_text(encoding="utf-8")) + catalog = ConceptFamilyCatalogModel.model_validate(payload) + return { + family_id: family.provenance.value + for family_id, family in catalog.families.items() + if family.provenance.value in {"adopted", "adapted"} and family.authority == "UCO" + } + + +def test_uco_alignment_schema_version_constant(): + assert UCO_ALIGNMENT_SCHEMA_VERSION == "uco-alignment/v1" + + +def test_load_uco_alignment_catalog(): + catalog = load_uco_alignment_catalog() + assert catalog.schema_version == "uco-alignment/v1" + assert catalog.uco_version == "1.4.0" + assert catalog.uco_reference + assert catalog.review_scope + + +def test_uco_alignment_catalog_path_resolves(): + assert uco_alignment_catalog_path() == CATALOG_PATH + + +def test_uco_alignment_catalog_matches_valid_fixture(): + payload = json.loads((VALID_DIR / "reference.json").read_text(encoding="utf-8")) + authoritative = json.loads(CATALOG_PATH.read_text(encoding="utf-8")) + assert payload == authoritative + UcoAlignmentCatalogModel.model_validate(authoritative) + + +def test_every_adopted_adapted_uco_family_has_alignment_entry(): + """Acceptance criterion: every adopted/adapted UCO family has a checkable entry.""" + catalog = load_uco_alignment_catalog() + assert set(catalog.alignments) == set(_uco_cyber_family_provenance()) + assert set(catalog.alignments) >= CYBER_DOMAIN_FAMILIES + + +def test_pinned_uco_version_and_reference_recorded(): + catalog = load_uco_alignment_catalog() + assert catalog.uco_version == "1.4.0" + assert "1.4.0" in catalog.uco_reference + assert catalog.uco_reference.startswith("https://") + + +def test_each_alignment_entry_is_internally_consistent(): + catalog = load_uco_alignment_catalog() + expected = _uco_cyber_family_provenance() + for family_id, alignment in catalog.alignments.items(): + assert alignment.concept_family == family_id + assert alignment.provenance.value == expected[family_id] + assert alignment.uco_types, family_id + for uco_type in alignment.uco_types: + prefix, sep, local = uco_type.uco_class.partition(":") + assert sep == ":" and prefix and local, uco_type.uco_class + assert uco_type.iri == f"{UCO_NAMESPACE_BASE}{prefix}/{local}" + + +def test_relationships_is_adapted_with_enumerated_divergences(): + catalog = load_uco_alignment_catalog() + relationships = catalog.alignments["relationships"] + assert relationships.provenance.value == "adapted" + assert len(relationships.divergences) >= 1 + + +def test_adopted_families_record_explicit_empty_divergences(): + catalog = load_uco_alignment_catalog() + for family_id, alignment in catalog.alignments.items(): + if alignment.provenance.value == "adopted": + assert alignment.divergences == [], family_id + + +def test_valid_fixtures_pass_validation(): + for path in sorted(VALID_DIR.glob("*.json")): + payload = json.loads(path.read_text(encoding="utf-8")) + model = UcoAlignmentCatalogModel.model_validate(payload) + assert model.alignments, f"Valid fixture {path.name} should declare alignments" + + +def test_invalid_fixtures_fail_validation(): + paths = sorted(INVALID_DIR.glob("*.json")) + assert paths, "expected invalid fixtures to exist" + for path in paths: + payload = json.loads(path.read_text(encoding="utf-8")) + with pytest.raises(ValidationError): + UcoAlignmentCatalogModel.model_validate(payload) + + +def test_missing_family_fixture_reports_coverage_gap(): + payload = json.loads((INVALID_DIR / "missing-family.json").read_text(encoding="utf-8")) + with pytest.raises(ValidationError, match="cover|adopted|adapted|missing"): + UcoAlignmentCatalogModel.model_validate(payload) + + +def test_provenance_mismatch_fixture_rejected(): + payload = json.loads((INVALID_DIR / "provenance-mismatch.json").read_text(encoding="utf-8")) + with pytest.raises(ValidationError, match="provenance"): + UcoAlignmentCatalogModel.model_validate(payload) + + +def test_adapted_without_divergence_fixture_rejected(): + payload = json.loads((INVALID_DIR / "adapted-without-divergence.json").read_text(encoding="utf-8")) + with pytest.raises(ValidationError, match="divergence"): + UcoAlignmentCatalogModel.model_validate(payload) + + +def test_iri_mismatch_fixture_rejected(): + payload = json.loads((INVALID_DIR / "iri-mismatch.json").read_text(encoding="utf-8")) + with pytest.raises(ValidationError, match="iri|IRI|canonical"): + UcoAlignmentCatalogModel.model_validate(payload) + + +def test_native_family_fixture_rejected(): + payload = json.loads((INVALID_DIR / "native-family.json").read_text(encoding="utf-8")) + with pytest.raises(ValidationError): + UcoAlignmentCatalogModel.model_validate(payload) diff --git a/specs/concept-authority/concept-authority.md b/specs/concept-authority/concept-authority.md index 1d9043422..df269a800 100644 --- a/specs/concept-authority/concept-authority.md +++ b/specs/concept-authority/concept-authority.md @@ -26,6 +26,13 @@ UCO (Unified Cyber Ontology) is the semantic authority for cyber-domain concept families. This is a concept-authority relationship, not an authoring-syntax or schema-structure requirement. +The fidelity of this concept-authority relationship is recorded in the +machine-checkable UCO alignment artifact +`contracts/concept-authority/uco-alignment-v1.json` (schema `uco-alignment/v1`). +It pins the reviewed UCO version and maps every adopted and adapted cyber-domain +family to the UCO object types it aligns to, stating each adapted family's +divergences explicitly so the alignment can be reviewed rather than assumed. + ### 2. ACES Concept Layer Defines concepts that ACES needs beyond the cyber-domain authority. These diff --git a/specs/concept-authority/reference-models.md b/specs/concept-authority/reference-models.md index 2d798885e..598d78deb 100644 --- a/specs/concept-authority/reference-models.md +++ b/specs/concept-authority/reference-models.md @@ -102,6 +102,17 @@ The valid and invalid fixture corpus for reference models is published under: `contracts/fixtures/concept-authority/reference-models-v1/` +## UCO Alignment Evidence + +Reference models bind recurrent ACES structures to ACES concept families; they +do not inherit UCO class structure. The concept-authority relationship behind +the adopted and adapted cyber-domain families those models reference is recorded +separately as machine-checkable evidence in +`contracts/concept-authority/uco-alignment-v1.json` (schema `uco-alignment/v1`). +That artifact pins the reviewed UCO version and maps each adopted and adapted +family to the UCO object types it aligns to, enumerating adapted-family +divergences explicitly. See [ADR-012](../../docs/decisions/adrs/adr-012-shared-concept-authority-and-aces-extension-discipline.md). + ## Relationship To Other Requirements - GOV-917: canonical concept authority diff --git a/specs/concept-authority/semantic-profiles.md b/specs/concept-authority/semantic-profiles.md index b39576a45..f513f5c20 100644 --- a/specs/concept-authority/semantic-profiles.md +++ b/specs/concept-authority/semantic-profiles.md @@ -89,6 +89,16 @@ The valid and invalid fixture corpus for semantic profiles is published under: `contracts/fixtures/semantic-profile/semantic-profile-v1/` +## UCO Alignment Evidence + +Semantic profiles compose ACES concept, contract, binding, and behavior +assumptions; they are not UCO profiles and do not adopt UCO authoring syntax. +The concept-authority relationship behind the adopted and adapted cyber-domain +families a profile may require is recorded as machine-checkable evidence in +`contracts/concept-authority/uco-alignment-v1.json` (schema `uco-alignment/v1`), +which pins the reviewed UCO version and enumerates each adapted family's +divergences. See [ADR-012](../../docs/decisions/adrs/adr-012-shared-concept-authority-and-aces-extension-discipline.md). + ## Relationship To Other Requirements - GOV-917: canonical concept authority diff --git a/tools/generate_contract_schemas.py b/tools/generate_contract_schemas.py index 7d40c7f7c..19d1801ba 100644 --- a/tools/generate_contract_schemas.py +++ b/tools/generate_contract_schemas.py @@ -29,6 +29,8 @@ def _schema_output_path(schemas_dir: Path, name: str) -> Path: return schemas_dir / "concept-authority" / f"{name}.json" if name == "reference-models-v1": return schemas_dir / "concept-authority" / f"{name}.json" + if name == "uco-alignment-v1": + return schemas_dir / "concept-authority" / f"{name}.json" if name == "controlled-vocabularies-v1": return schemas_dir / "concept-authority" / f"{name}.json" if name.startswith("semantic-profile-v"): From 022e754c852740abb78830af8ec78091e600d0ab Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 18:21:42 +0200 Subject: [PATCH 44/70] Record uco-alignment-v1 schema publication ledger entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the last_change ledger entry (summary + content hash) required by the flipped schema-publication authority direction (ADR-009 §7, merged from dev) for the newly published uco-alignment-v1 schema. --- contracts/schema-publication-manifest.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/contracts/schema-publication-manifest.json b/contracts/schema-publication-manifest.json index be16cdb68..04905011c 100644 --- a/contracts/schema-publication-manifest.json +++ b/contracts/schema-publication-manifest.json @@ -216,7 +216,11 @@ "contract_id": "uco-alignment-v1", "schema_path": "contracts/schemas/concept-authority/uco-alignment-v1.json", "stability": "draft", - "content_hash": "445ef9ba99cddaae2b3308a054d26dd968d194af1c2f98710151f2d5a747f5bf" + "content_hash": "445ef9ba99cddaae2b3308a054d26dd968d194af1c2f98710151f2d5a747f5bf", + "last_change": { + "summary": "Initial publication of the UCO alignment evidence contract (concept-authority review CA-5, issue #495): maps adopted/adapted cyber-domain concept families to pinned UCO 1.4.0 object types, with adapted-family divergences enumerated.", + "content_hash": "445ef9ba99cddaae2b3308a054d26dd968d194af1c2f98710151f2d5a747f5bf" + } }, { "contract_id": "workflow-cancellation-request-v1", From 220d5eea373998e2c4b31d44508a476920700d3a Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 18:46:54 +0200 Subject: [PATCH 45/70] Author the SDL prose specification under specs/sdl/ (CT-6) Add the language-neutral normative SDL authoring specification as a catalog set under specs/sdl/: README (authority relationship and RFC 2119 conventions), document-model, the section catalog (reconciled to the live sdl-authoring-input-v1.json contract), the reference-resolution catalog, the variable/instantiation catalog, the node-scoped runtime-family index, and the error-vs-advisory diagnostics boundary (documented by reference, coordinating with review IMP-3). Register specs/sdl/ as a prose authority root in specs/authority/authority-boundary.yaml and pin the registration with a test in test_authority_boundary.py. Add the specs/ subdirectory pointer and a changelog fragment. --- changelog.d/498.added.md | 1 + .../issue-498-sdl-prose-spec-preflight.md | 160 ++++++++++++++ .../python/tests/test_authority_boundary.py | 26 +++ specs/README.md | 3 + specs/authority/authority-boundary.yaml | 10 + specs/sdl/README.md | 99 +++++++++ specs/sdl/diagnostics.md | 96 +++++++++ specs/sdl/document-model.md | 141 +++++++++++++ specs/sdl/references.md | 198 ++++++++++++++++++ specs/sdl/runtime-inventory.md | 118 +++++++++++ specs/sdl/sections.md | 101 +++++++++ specs/sdl/variables-and-instantiation.md | 127 +++++++++++ 12 files changed, 1080 insertions(+) create mode 100644 changelog.d/498.added.md create mode 100644 docs/decisions/issue-498-sdl-prose-spec-preflight.md create mode 100644 specs/sdl/README.md create mode 100644 specs/sdl/diagnostics.md create mode 100644 specs/sdl/document-model.md create mode 100644 specs/sdl/references.md create mode 100644 specs/sdl/runtime-inventory.md create mode 100644 specs/sdl/sections.md create mode 100644 specs/sdl/variables-and-instantiation.md diff --git a/changelog.d/498.added.md b/changelog.d/498.added.md new file mode 100644 index 000000000..78a2e210c --- /dev/null +++ b/changelog.d/498.added.md @@ -0,0 +1 @@ +Add the normative, language-neutral SDL authoring specification under `specs/sdl/`: a catalog set covering the document model, the top-level section catalog (reconciled to the live `sdl-authoring-input-v1.json` contract), the cross-section reference-resolution catalog, the variable/instantiation catalog, the node-scoped runtime-family index, and the error-vs-advisory diagnostics boundary. The specification is registered as a `prose` authority root in `specs/authority/authority-boundary.yaml`, giving independent implementations a structural authority that does not require reading the reference Python. diff --git a/docs/decisions/issue-498-sdl-prose-spec-preflight.md b/docs/decisions/issue-498-sdl-prose-spec-preflight.md new file mode 100644 index 000000000..b8ba8464d --- /dev/null +++ b/docs/decisions/issue-498-sdl-prose-spec-preflight.md @@ -0,0 +1,160 @@ +# Issue 498 SDL Prose Specification Preflight + +Date: 2026-06-14 + +Issue: #498. + +Requirement: none. The issue title, body, and acceptance criteria are the +contract. + +This note records architecture preflight guardrails for authoring the normative +SDL prose specification. It is guidance for the implementation and does not +write the SDL spec, update the authority manifest, change schemas, or alter +reference implementation behavior. + +## Binding Sources + +- ADR-009 and ADR-019 define the authority boundary: normative prose belongs + under `specs/`, while reference code and explanatory docs consume that + authority. +- `specs/authority/authority-boundary.yaml` is the canonical machine-readable + seam. Extend that seam for the SDL prose path; do not create a second + authority registry. +- ADR-001 defines the SDL as backend-agnostic scenario specification with 21 + named authoring sections and two-phase validation, but the current schemas and + reference implementation have grown additional top-level authoring surfaces. + The prose spec must reconcile the live contract instead of freezing the stale + section count. +- ADR-003, ADR-004, ADR-008, ADR-015, ADR-036, and ADR-053 define the current + boundaries for variables, target refs, workflows, processor/runtime layering, + and module composition. +- ADR-033, ADR-056, ADR-057, and the runtime-family ADR sequence define the + runtime-inventory boundary and observed-value/redaction constraints. +- `docs/explain/sdl/sections.md` is useful evidence and a migration aid, but it + must not become the normative source. + +## Architecture Decisions + +- Put the prose specification under `specs/sdl/`, not `specs/formal/sdl/`. + The requested artifact is the language-neutral SDL authoring authority; + `specs/formal/` remains for optional formal-methods artifacts under ADR-007. +- Register `specs/sdl/` in `specs/authority/authority-boundary.yaml` as + `prose`. Do not introduce a new authority family such as `sdl` unless the + authority-boundary ADR/checker is intentionally extended for a new family. +- Treat `contracts/schemas/sdl/*.json` as the machine-readable companion that + must agree with the prose, not as prose replacement and not as Python-owned + authority. +- Normatively state the authoring model in terms of SDL concepts, section names, + references, variables, instantiation, and diagnostics. Avoid Python class, + Pydantic, package, or validator-function names except in non-normative + implementation-evidence notes. +- Separate top-level metadata/composition fields from authoring sections, and + separate map-keyed sections from list-valued surfaces. Do not claim that every + optional top-level surface is a dict keyed by user-defined identifiers: + `forwarding_agents` is list-valued today. +- Runtime inventory should be a normative index plus shared invariants. Delegate + per-family semantics to family ADRs and lineage instead of restating every + field in the SDL authoring spec. + +## Required Incumbents + +- Authority and policy: ADR-009, ADR-019, + `specs/authority/authority-boundary.yaml`, `specs/README.md`, + `tools/check_authority_boundary.py`, `tools/check_repo_policy.py`, and + `nox -s policy`. +- Authoring structure evidence: `Scenario`, `SDLModel`, parser key + normalization, `_HASHMAP_SECTIONS`, `_NESTED_HASHMAP_FIELDS`, and the + published SDL schemas under `contracts/schemas/sdl/`. +- Reference semantics evidence: `SemanticValidator._named_ref_index`, + `_validate_named_ref`, the objective/relationship semantic helpers, and + `collect_qualified_runtime_family_refs`. +- Variables and instantiation evidence: `Variable`, `is_variable_ref`, + `extract_variable_name`, and `instantiate_scenario`. +- Runtime-inventory invariants: `_runtime_service_families.RUNTIME_SERVICE_FAMILIES`, + `runtime_values.py`, `test_runtime_family_invariants.py`, and the family ADRs + for service listeners, applications, database/DNS/mail/identity/app-auth, + scheduled jobs, datastore, platform applications, forwarding agents, and + orchestration authorities. +- Diagnostics surface: `SDLParseError`, `SDLValidationError`, + `SDLInstantiationError`, language-service diagnostics, and the existing + advisory list on `Scenario`. + +## Cross-Cutting Layers + +- YAML/config parsing: the spec must match the existing safe loading boundary: + YAML maps only, top-level string keys, normalized field keys, preserved + user-defined mapping keys, no variable placeholders in symbol-defining keys, + and `extra="forbid"` structural closure. +- Semantic validation: unresolved, missing, or ambiguous references fail closed. + Advisories stay non-fatal and must not be described as optional errors. +- Instantiation: variables are authoring placeholders until instantiation; type, + default, `allowed_values`, undeclared parameters, and unresolved placeholders + are checked before producing an instantiated scenario. Variable definitions and + unresolved `${...}` placeholders must not survive as ordinary authoring + variables in the instantiated payload. +- Runtime observed values: explicit `redacted` or `operator_secret` + classifications omit raw values. Name-based secret heuristics are advisory + only. Posture-only models must not gain raw credential fields. +- Repo-path and policy security: authority-manifest paths stay repo-relative, + no absolute paths or `..`, and policy failures use structured + `PolicyFailure` output without dumping file bodies, environment values, or + tracebacks. +- OS/process exposure: spec examples and workflow notes must not place tokens, + private keys, bearer credentials, or operator secrets in command-line argv. +- Error envelopes: do not invent a new SDL exception hierarchy or diagnostic + envelope. Document the existing parse, semantic-validation, instantiation, and + advisory boundary by reference, coordinating any future error/advisory changes + with the review IMP-3 issue. + +## Extension Boundary + +The extensibility seam is a small set of normative catalogs, not scattered prose: + +- a section catalog for top-level SDL sections, requiredness, key shape, and + identifier/ref behavior; +- a reference-resolution catalog for bare, qualified, nested, runtime-family, + workflow-step, and module-composed refs; +- a variable/instantiation catalog for allowed variable types, defaults, + substitution, and post-instantiation exclusions; +- a runtime-family index that names the family key, collection name, primary + `_id`, child-ref collections, owning ADR, and shared invariants. + +Future SDL sections or runtime families should add rows to those catalogs and +the authority/schemas/tests that consume them. They should not require rewriting +unstructured prose or adding a parallel registry. + +## Gotchas And Anti-Patterns + +Avoid: + +- copying Pydantic model docs into `specs/sdl/` as the specification; +- making `docs/explain/sdl/sections.md` normative by cross-reference; +- carrying forward the stale "21 sections, all dicts" wording without reconciling + `action_contracts`, `observation_boundaries`, + `outcome_interpretation_rules`, `forwarding_agents`, and any other published + SDL schema fields; +- adding duplicate schemas, duplicate validation helpers, duplicate exception + classes, or a new runtime-family registry; +- duplicating every runtime-family field in the SDL spec instead of delegating + family semantics to ADRs and the runtime-family index; +- resolving ambiguous references by first match or by source-file locality; +- allowing variables in identifier-defining mapping keys; +- treating authored `accounts`, runtime `local_identity`, application RBAC, + database roles, and participant identities as one identity model; +- collapsing `Node.services`, `runtime.service_listeners`, + `runtime.applications`, `runtime.database_services`, host-published ports, + and image provenance into one service concept; +- editing `docs/_build/`, implementation code, schemas, or tests as part of + this preflight. + +## Non-Goals + +- Implementing the SDL prose specification in this note. +- Changing parser, validator, instantiation, processor, runtime, schema, or MCP + behavior. +- Adding a new formal-methods artifact unless the implementation introduces new + FM1+ semantics beyond documenting the existing authoring model. +- Rewriting explanatory SDL docs or historical ADR bodies except for narrow + cross-reference updates required by policy. +- Solving the review IMP-3 error/advisory issue; this work should reference that + boundary, not decide it. diff --git a/implementations/python/tests/test_authority_boundary.py b/implementations/python/tests/test_authority_boundary.py index 064591096..52656e501 100644 --- a/implementations/python/tests/test_authority_boundary.py +++ b/implementations/python/tests/test_authority_boundary.py @@ -1332,3 +1332,29 @@ def test_artifact_family_requirement_refs_must_be_string_list(tmp_path: Path) -> def test_evaluate_authority_boundary_real_repo_is_clean() -> None: failures = evaluate_authority_boundary(REPO_ROOT) assert failures == [], "\n".join(failure.render() for failure in failures) + + +# --------------------------------------------------------------------------- # +# SDL authoring spec registration (issue #498 / review CT-6). # +# The structural checker fires `root-empty`/`root-missing` only when an # +# authority root is *registered* but absent; it does not, on its own, prove # +# that the SDL authoring spec stays registered. Removing both the manifest # +# entry and the directory would leave a valid manifest with the SDL authoring # +# authority silently dropped. This test pins the registration directly. # +# --------------------------------------------------------------------------- # + + +def test_specs_sdl_prose_root_is_registered_in_real_manifest() -> None: + import yaml + + manifest = yaml.safe_load((REPO_ROOT / AUTHORITY_BOUNDARY_RELATIVE_PATH).read_text(encoding="utf-8")) + sdl_entries = [entry for entry in manifest["authority_roots"] if entry.get("root") == "specs/sdl/"] + assert sdl_entries, "specs/sdl/ must be registered as an authority root in the authority-boundary manifest" + assert all(entry.get("family") == "prose" for entry in sdl_entries), ( + f"specs/sdl/ must be registered with family 'prose'; got {[entry.get('family') for entry in sdl_entries]}" + ) + + sdl_dir = REPO_ROOT / "specs" / "sdl" + assert sdl_dir.is_dir(), "specs/sdl/ must exist on disk" + real_artifacts = [path for path in sdl_dir.iterdir() if path.name != ".keep"] + assert real_artifacts, "specs/sdl/ must contain at least one specification artifact" diff --git a/specs/README.md b/specs/README.md index 8da2c19d1..a9a84288b 100644 --- a/specs/README.md +++ b/specs/README.md @@ -36,5 +36,8 @@ hook). `prose` family of the `specs/` root. - `concept-authority/` — concept-family and controlled-vocabulary authority artifacts (governed by ADR-012) +- `sdl/` — the language-neutral normative SDL authoring specification + (the catalog set the published `contracts/schemas/sdl/` schemas must + agree with; governed by ADR-001 and ADR-009) - `formal/` — optional formal-methods artifacts for semantic and stateful subsystems (governed by ADR-007 and ADR-018) diff --git a/specs/authority/authority-boundary.yaml b/specs/authority/authority-boundary.yaml index 571c925fd..6ed5b04f3 100644 --- a/specs/authority/authority-boundary.yaml +++ b/specs/authority/authority-boundary.yaml @@ -35,6 +35,16 @@ authority_roots: authority: normative prose specifications family: prose + # The SDL authoring specification is explicitly registered as its own prose + # root for discoverability (the language-neutral SDL authority an independent + # implementation reads). It reuses the existing `prose` family rather than + # introducing a new authority family; it is a child of `specs/` and bears the + # same authority class. + - id: normative_sdl_prose + root: specs/sdl/ + authority: normative SDL authoring specification + family: prose + - id: normative_schemas root: contracts/schemas/ authority: published JSON Schemas diff --git a/specs/sdl/README.md b/specs/sdl/README.md new file mode 100644 index 000000000..d3121e000 --- /dev/null +++ b/specs/sdl/README.md @@ -0,0 +1,99 @@ +# SDL Authoring Specification + +Status: **normative**. This directory is the language-neutral authority for the +ACES **Scenario Description Language (SDL)** authoring model. It is binding on +the ecosystem independent of any reference implementation or code-generation +pipeline, per [ADR-009](../../docs/decisions/adrs/adr-009-normative-artifact-authority-and-repository-structure.md) +and the [authority boundary manifest](../authority/authority-boundary.yaml). + +The SDL itself is established by +[ADR-001 (Scenario Description Language)](../../docs/decisions/adrs/adr-001-scenario-description-language.md): +a backend-agnostic, declarative language for specifying security experiment +scenarios. This specification states the **authoring model** — the structure of +an SDL document, how its parts reference one another, how it is parameterised +and instantiated, and how it is diagnosed — in SDL terms, so that an independent +implementation can answer structural questions without reading the reference +Python. + +## Authority relationship + +Three artifact classes describe the SDL, with distinct authority: + +1. **This prose specification (`specs/sdl/`)** is the language-neutral + normative authority. It defines externally visible meaning in SDL concepts. +2. **The published JSON Schemas (`contracts/schemas/sdl/*.json`)** are the + machine-readable normative companion. They are the hand-governed schema + authority under [ADR-009 §7](../../docs/decisions/adrs/adr-009-normative-artifact-authority-and-repository-structure.md) + and the [schema publication manifest](../../contracts/schema-publication-manifest.json). + The prose and the schemas describe the same language and **MUST** agree; a + divergence between them is a defect to be reconciled, not a license for + either to override the other. Where this prose states a structural fact + (a section's presence, requiredness, or value shape), the published + `sdl-authoring-input-v1.json` schema is the authoritative enumeration the + prose is written to match. +3. **Reference implementations (`implementations/`)** consume both. No Python + model, validator function, or generator defines ecosystem meaning; it is + evidence of one conforming realisation. This specification names + implementation symbols only in clearly marked, non-normative + *implementation-evidence* notes. + +`docs/explain/sdl/sections.md` and the rest of `docs/` are explanatory and +**non-normative**. They are useful learning and migration aids, but they are +not referenced here as authority and **MUST NOT** be cited as the normative +source. + +## Conventions + +The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, +**SHOULD**, **SHOULD NOT**, **MAY**, and **OPTIONAL** in this specification are +to be interpreted as described in RFC 2119 and RFC 8174 when, and only when, +they appear in all capitals. + +Throughout, *section* means a top-level authoring surface of an SDL document; +*identifier* means an author-chosen key that names an element; *reference* +means one element naming another by identifier; and *instantiation* means the +phase that resolves a parameterised authoring document into a concrete one. + +## Document set + +The specification is organised as a small set of normative **catalogs** so that +adding a future SDL section or runtime family is a new row plus its schema and +tests, rather than a prose rewrite. The catalogs are: + +| File | Catalog | Covers | +|------|---------|--------| +| [`document-model.md`](document-model.md) | — | Document encoding, the metadata/composition vs. authoring-section split, requiredness, identifier rules, structural closure, and the authoring → instantiated → expanded phases. | +| [`sections.md`](sections.md) | **1. Section catalog** | Every top-level field: kind, value shape (map-keyed vs. list-valued vs. scalar), requiredness, key shape, and the sections it references. | +| [`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. | +| [`diagnostics.md`](diagnostics.md) | — | The parse / semantic-validation / instantiation diagnostic stages and the error-vs-advisory boundary, documented by reference. | + +## Acceptance-question map + +An implementer can answer each structural question from the named file alone: + +- *Which sections exist, which are required, and what shape is each?* → + [`document-model.md`](document-model.md) and [`sections.md`](sections.md). +- *What is a valid identifier for a user-defined key?* → + [`document-model.md`](document-model.md). +- *How does a reference resolve, and what happens when it is dangling or + ambiguous?* → [`references.md`](references.md). +- *What is legal to instantiate, and what does instantiation reject?* → + [`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). +- *When is a problem an error versus an advisory?* → + [`diagnostics.md`](diagnostics.md). + +## Scope + +In scope: the SDL authoring model — document structure, references, variables, +instantiation, the runtime-inventory index, 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 +per-field semantics of each runtime family (owned by the family ADRs and indexed +here). Defining the precise normative classification of individual +error-vs-advisory cases is deferred to the review IMP-3 work; this specification +documents the existing boundary and does not redefine it. diff --git a/specs/sdl/diagnostics.md b/specs/sdl/diagnostics.md new file mode 100644 index 000000000..1975943d9 --- /dev/null +++ b/specs/sdl/diagnostics.md @@ -0,0 +1,96 @@ +# Diagnostics — Errors and Advisories + +This file documents the SDL diagnostic boundary: the stages at which a document +is checked, the fail-closed error semantics, and the distinction between a fatal +**error** and a non-fatal **advisory**. It documents the **existing** boundary by +reference; it does not introduce a new diagnostic mechanism, and it does not +re-decide the classification of individual cases (see §5). + +## 1. Diagnostic stages + +An SDL document is checked at three stages, in order. Each is **fail-closed**: +a problem at a stage stops the document from advancing past that stage. + +1. **Parse / structural.** YAML loading and structural shape: the root is a + mapping, keys are strings, values have the right shapes, and **no unknown key + is present** ([document-model.md §4](document-model.md)). A structural problem + is a parse error. +2. **Semantic validation.** Cross-section reference resolution + ([references.md](references.md)), uniqueness, acyclicity, control-flow + closure, and the runtime-family invariants + ([runtime-inventory.md](runtime-inventory.md)). A semantic problem is a + validation error. +3. **Instantiation.** Variable binding, type/constraint checks, undeclared + parameters, and unresolved placeholders + ([variables-and-instantiation.md §3](variables-and-instantiation.md)). A + problem here is an instantiation error. Instantiation re-runs semantic + validation on the concrete document, so semantic errors can also surface at + this stage. + +## 2. Collect-all semantics + +The semantic-validation and instantiation stages **collect all errors in a pass** +and report them together, rather than failing at the first problem. An author +fixing a document sees the full set of errors a stage found, not one error at a +time. (Parsing may stop at the first structural fault that prevents +interpretation.) + +## 3. Errors are fatal + +An **error** is fatal: it prevents the document from advancing past its stage. A +parse error prevents semantic validation; a semantic error prevents a document +from being treated as valid; an instantiation error prevents a concrete document +from being produced. There is no "warn and continue" for an error. + +> *Implementation evidence (non-normative): the reference implementation reports +> these as `SDLParseError`, `SDLValidationError`, and `SDLInstantiationError`; +> the latter two carry the full collected error list. This specification does +> not define a new exception hierarchy or diagnostic envelope.* + +## 4. Advisories are non-fatal + +An **advisory** is a non-fatal observation about a document that is otherwise +valid. Advisories are carried alongside a successfully parsed/validated +document; they do not prevent it from advancing. + +The boundary rule is symmetric and **MUST** be honoured: + +1. An advisory **MUST NOT** be described or treated as an optional error. A tool + **MUST NOT** unilaterally promote an advisory to a failure. +2. An error **MUST NOT** be demoted to an advisory to let an invalid document + pass. + +Existing advisory conditions, documented here by reference (not redefined): + +- **VM without resources.** A virtual-machine node declared without a + `resources` block is **valid** SDL; it is flagged as an advisory because it may + be undeployable unless a backend supplies defaults. It is not an error. +- **Name-based secret-classification heuristics.** A field whose *name* suggests + it carries a secret, but which is not explicitly classified + `redacted`/`operator_secret`, may raise an advisory. The heuristic is advisory + **only**: it never silently strips or rewrites a value, and an unflagged value + is not, by the heuristic alone, an error + ([ADR-057](../../docs/decisions/adrs/adr-057-runtime-secret-name-classifier-boundaries.md), + [runtime-inventory.md §3](runtime-inventory.md)). Explicit redaction + (`redacted`/`operator_secret` omitting the raw value) is the **error-enforced** + rule; the name heuristic is the advisory complement. + +> *Implementation evidence (non-normative): advisories are surfaced on the parsed +> scenario's advisory list and the language-service diagnostics, separately from +> the error channel.* + +## 5. Coordination with review IMP-3 + +The precise, case-by-case normative classification of which specific conditions +are errors and which are advisories — and any change to where the line falls — +is the subject of the review **IMP-3** work. This specification: + +- states the **principle** (errors are fatal and fail-closed; advisories are + non-fatal and not optional errors) and the **stages** at which checks run; +- documents the **existing** advisory conditions by reference; and +- **does not** re-decide individual classifications or introduce new ones. + +A future change that moves a condition between the error and advisory channels, or +that adds a new diagnostic category, **MUST** be coordinated with IMP-3 and +reflected here, in the published schemas, and in the reference implementation +together, so the boundary stays single-sourced. diff --git a/specs/sdl/document-model.md b/specs/sdl/document-model.md new file mode 100644 index 000000000..8feed241d --- /dev/null +++ b/specs/sdl/document-model.md @@ -0,0 +1,141 @@ +# SDL Document Model + +This file defines how an SDL document is encoded, how its top-level surfaces are +organised, what is required, how user-defined identifiers are formed, how the +document is structurally closed, and the phases a document passes through from +authoring to instantiation. + +See [`sections.md`](sections.md) for the per-section catalog, +[`references.md`](references.md) for reference resolution, and +[`variables-and-instantiation.md`](variables-and-instantiation.md) for variable +and instantiation rules. + +## 1. Encoding + +1. An SDL document **MUST** be a YAML 1.1/1.2 document whose top-level value is a + mapping. A document whose root is a sequence, scalar, or null is not a valid + SDL document. +2. Every top-level key **MUST** be a string. Field keys are matched + case-sensitively after enum/value normalisation (§5); a key that is not a + defined top-level field is rejected (§4). +3. A document **MUST** be loadable by a safe YAML loader. Constructor tags that + instantiate arbitrary types **MUST NOT** be honoured. + +## 2. Top-level organisation + +The top-level fields divide into two kinds, which **MUST** be kept distinct: + +- **Metadata and composition fields** describe the document itself and how it + composes with other documents. They are not authoring sections. +- **Authoring sections** carry the scenario content. Most are **maps** keyed by + user-defined identifiers; one is a **list** (see [`sections.md`](sections.md)). + +The complete, authoritative enumeration of top-level fields, their kinds, value +shapes, and requiredness is the [section catalog](sections.md), which is written +to match `contracts/schemas/sdl/sdl-authoring-input-v1.json`. + +> **Reconciliation note.** Earlier descriptions of the SDL spoke of "21 named +> sections, all dicts." That count is stale. The live authoring contract has a +> larger section set and is **not** uniformly map-keyed: `forwarding_agents` is +> list-valued, and the participant surfaces (`action_contracts`, +> `observation_boundaries`, `outcome_interpretation_rules`) are present. The +> section catalog states the live set; this specification reconciles the +> language to the published schema rather than freezing a historical count. + +## 3. Requiredness + +1. `name` is the only **REQUIRED** top-level field. A document without `name` is + invalid. +2. Every other top-level field is **OPTIONAL**. An omitted authoring section is + equivalent to an empty one (an empty map, or an empty list for the + list-valued section); an omitted metadata field takes its documented default. +3. Requiredness **within** a section's elements (for example, a field that a + `node` or a runtime family element must carry) is governed by that section's + schema and, for runtime families, by the owning family ADR + ([runtime-inventory.md](runtime-inventory.md)). + +## 4. Structural closure (fail-closed) + +1. SDL models are **closed**: an unknown key — at the top level or anywhere + within a nested model — **MUST** be rejected, not ignored. There is no + permissive "extra fields allowed" mode. +2. Rejection of an unknown or malformed key is a **parse/structural** failure + (see [`diagnostics.md`](diagnostics.md)); it occurs before semantic + validation and is fatal. +3. Closure exists so that a typo in a field name (`vulnerabilites`) fails the + document rather than silently dropping content. Authors **MUST NOT** rely on + undeclared keys to carry data. + +> *Implementation evidence (non-normative): the reference models set +> `extra="forbid"` on the shared SDL base model.* + +## 5. Value normalisation + +1. Enum-valued fields accept their value case-insensitively, and accept a hyphen + as an alias for an underscore in the value text, so that an authoring value + such as `search-index` and `search_index` denote the same enum member. This + is an authoring convenience; the normalised (canonical) form is what the + document means. +2. Normalisation applies to enum **values**, not to user-defined identifier + **keys**. A user-defined key is preserved verbatim as the element's + identifier (§6). +3. A field that holds a variable placeholder (`${…}`) is **not** normalised as an + enum value; the placeholder is preserved until instantiation + ([variables-and-instantiation.md](variables-and-instantiation.md)). + +## 6. Identifier rules for user-defined keys + +A user-defined key in a map-valued section is the **identifier** by which an +element is referenced from elsewhere ([references.md](references.md)). The +following rules govern identifiers: + +1. **Preservation.** A map key is preserved verbatim as the element identifier; + it is not lowercased, trimmed, or otherwise rewritten. +2. **Uniqueness.** An identifier **MUST** be unique within its collection. Map + semantics make duplicate keys within one section ill-formed; runtime-family + `_id` values **MUST** likewise be unique within their collection + ([runtime-inventory.md](runtime-inventory.md)). +3. **No placeholders in defining keys.** An identifier-defining key **MUST NOT** + be a variable placeholder. Variables parameterise *values*, never the + identity of an element. (`${x}: …` as a section entry is invalid.) +4. **Node identifiers** **MUST** be at most 35 characters. A node identifier + **MAY** contain `.` — dotted node identifiers such as `wazuh.manager` are + used to name service families — and reference resolution accounts for dotted + node names ([references.md](references.md)). +5. **Workflow step identifiers** **MUST NOT** contain `.`, because `.` is the + path separator used to address a step from an objective window + (`.`). +6. **Runtime `_id` values** are stable, symbol-shaped handles: they + identify an element across references and **MUST NOT** carry whitespace or + quoting that would make them unaddressable in a qualified path. + +Beyond these rules, identifier *spelling* is the author's choice; the language +does not impose a global identifier grammar on ordinary section keys. + +## 7. Document phases + +An SDL document passes through up to three forms. Each form is a superset shape +of the authored document with progressively fewer unresolved constructs: + +1. **Authored.** The document as written. It **MAY** contain module imports and + `${…}` variable placeholders. Full semantic validation + ([references.md](references.md), [diagnostics.md](diagnostics.md)) applies to + the authored document, treating unresolved placeholders per §5.3. +2. **Expanded.** If the document declares a module or imports + ([sections.md](sections.md) — `module`, `imports`), module composition is + applied **before** full semantic validation, producing an expanded document + in which imported content has been merged under its namespace + ([ADR-053](../../docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md)). +3. **Instantiated.** Instantiation resolves variables against supplied + parameters and defaults, producing a concrete document with no surviving + variable definitions or unresolved placeholders + ([variables-and-instantiation.md](variables-and-instantiation.md)). + +The authored → expanded → instantiated progression is the two-phase +authoring/instantiation model of +[ADR-001](../../docs/decisions/adrs/adr-001-scenario-description-language.md) and +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. diff --git a/specs/sdl/references.md b/specs/sdl/references.md new file mode 100644 index 000000000..7a3dde4d5 --- /dev/null +++ b/specs/sdl/references.md @@ -0,0 +1,198 @@ +# Catalog 2 — Reference-Resolution Catalog + +This catalog defines how one SDL element names another: the reference forms, the +resolution algorithm, the fail-closed ambiguity rule, the treatment of +unresolved variable placeholders, and the catalog of cross-section reference +edges with their failure semantics. + +References are resolved during **semantic validation**, after structural parsing +and module expansion ([document-model.md §7](document-model.md)). Reference +failures are fatal and are collected together ([diagnostics.md](diagnostics.md)). + +## 1. Reference forms + +A reference is a string that names a target element. Five forms exist: + +1. **Bare** — a single identifier, e.g. `web_db`. Resolved within the section(s) + the referencing field expects. +2. **Qualified** — a dotted path that names the section and identifier + explicitly, e.g. `conditions.system_online`, or a deeper path that addresses + a nested element, e.g. `nodes.web1.services.httpd`. Used to address an element + unambiguously, or to address an element that has no bare form. +3. **Nested runtime-family** — a qualified path into a node's runtime inventory: + `nodes..runtime..` and, for child collections, + `…...` to any depth the family + defines ([runtime-inventory.md](runtime-inventory.md)). +4. **Workflow-step** — `.`, naming a step within a workflow. + Used by objective windows. Because `.` separates the workflow from the step, + workflow **step** identifiers MUST NOT contain `.` + ([document-model.md §6](document-model.md)). +5. **Module-composed (namespaced)** — after a module import is expanded, imported + elements are addressed under their import namespace, and node segments are + rewritten to their namespaced form. Module-composed references resolve against + the expanded document + ([ADR-053](../../docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md)). + +### Dotted node identifiers + +A node identifier MAY itself contain `.` (e.g. `wazuh.manager`). A qualified +reference that traverses a node segment therefore resolves the **longest** +node-identifier match rather than splitting on the first `.`. Resolution MUST +account for dotted node names so that `nodes.wazuh.manager.runtime.…` addresses +the `wazuh.manager` node, not a `wazuh` node with a `manager` member. + +## 2. Resolution algorithm + +1. A reference **MUST** resolve to **exactly one** declared element of a kind the + referencing field accepts. +2. A field defines its **candidate set** — the section or sections a value may + name. Some fields accept a single section (e.g. `metric.condition_ref` → + `conditions`); others accept a set of targetable sections (e.g. an + objective's `target`, a relationship's `source`/`target`). The candidate set + is part of each field's definition and is reflected in the edge catalog (§5). +3. A **bare** reference resolves against the candidate set. A **qualified** + reference resolves against the named section/path and MUST match it exactly. +4. Some targetable candidate sets are deliberately restricted. For example, an + objective `target` excludes the `variables`, `objectives`, and `workflows` + prefixes; an agent `operating_scope` is restricted to VM nodes, + switch-backed infrastructure, services, and content. A reference outside its + field's candidate set does not resolve and fails as dangling (§4). +5. Resolution is **declaration-based**: only declared elements are resolution + targets. There is no implicit creation of a target by referencing it. + +## 3. Unresolved variable placeholders + +1. A field whose value is a variable placeholder (`${name}`) is **not** resolved + as a reference during authoring-time semantic validation; the placeholder + stands for a value not yet bound. +2. The only requirement on an unresolved placeholder at authoring time is that + `name` **MUST** be a declared variable + ([variables-and-instantiation.md](variables-and-instantiation.md)). A + placeholder naming an undeclared variable is a fatal error. +3. After instantiation substitutes a concrete value, the normal reference rules + (§2) apply to that value. A reference that becomes dangling or ambiguous only + after substitution fails at instantiation. + +## 4. Failure semantics (fail-closed) + +Reference resolution is fail-closed. Two failure modes exist, both fatal: + +1. **Dangling** — the reference names no declared element in its candidate set + (or, for a qualified reference, the path does not exist). This is an error. +2. **Ambiguous** — a bare reference matches **more than one** declared element in + its candidate set. This is an error. An ambiguous reference **MUST NOT** be + resolved by first match, by declaration order, or by source-file locality. + The author resolves the ambiguity by using a qualified reference. + +Additional structural reference constraints — uniqueness of an addressed +`_id`, acyclicity of dependency graphs (`features.dependencies`, +`objectives.depends_on`, workflow step graphs), and closure of workflow control +flow (every referenced successor/join step exists) — are likewise fatal when +violated. + +All reference failures in a document are reported together in a single +validation pass rather than one-at-a-time ([diagnostics.md](diagnostics.md)). + +## 5. Cross-section reference edge catalog + +Each row is a reference edge: a source section's field names a target. Unless +noted, an unresolved (dangling) or ambiguous reference is a fatal error. + +### Assessment pipeline + +| Source | Field | Target | +|--------|-------|--------| +| `metrics` | condition ref | `conditions` | +| `evaluations` | metric refs | `metrics` | +| `tlos` | evaluation refs | `evaluations` | +| `goals` | tlo refs | `tlos` | + +A condition referenced by a metric MUST be scored by exactly one metric; +an evaluation's minimum score MUST NOT exceed the sum of its metrics' maxima. + +### Narrative chain + +| Source | Field | Target | +|--------|-------|--------| +| `injects` | from/to entity | `entities` | +| `injects` | tlo refs | `tlos` | +| `events` | condition refs | `conditions` | +| `events` | inject refs | `injects` | +| `scripts` | event refs | `events` | +| `stories` | script refs | `scripts` | + +### Composition graph + +| Source | Field | Target | +|--------|-------|--------| +| `features` | vulnerability refs | `vulnerabilities` | +| `features` | dependencies | `features` (acyclic) | +| `entities` | tlos / vulnerabilities | `tlos` / `vulnerabilities` | +| `nodes` | feature/condition/inject/vulnerability refs | `features` / `conditions` / `injects` / `vulnerabilities` | +| `infrastructure` | node / link / dependency | `nodes` / switch-backed `infrastructure` | +| `content` | target | `nodes` (VM) | +| `accounts` | node | `nodes` (VM) | + +### Agents, objectives, participant surfaces + +| Source | Field | Target | +|--------|-------|--------| +| `agents` | entity | `entities` | +| `agents` | starting accounts | `accounts` | +| `agents` | subnets / initial-knowledge subnets | switch-backed `infrastructure` | +| `agents` | initial-knowledge hosts | `nodes` (VM) | +| `agents` | initial-knowledge services | declared services on nodes | +| `agents` | starting conditions | `conditions` | +| `agents` | actions / observation boundaries | `action_contracts` / `observation_boundaries` | +| `action_contracts` | interaction related-action | `action_contracts` | +| `observation_boundaries` | view-rule information refs | own observable/hidden/evidence refs | +| `objectives` | actor | `agents` or flattened `entities` | +| `objectives` | action | the bound agent's `action_contracts` | +| `objectives` | target | targetable elements (excl. `variables`/`objectives`/`workflows`) | +| `objectives` | success criteria | `conditions`/`metrics`/`evaluations`/`tlos`/`goals` | +| `objectives` | window | `stories`/`scripts`/`events`/`workflows` (with closure rules) | +| `objectives` | depends_on | `objectives` (acyclic) | +| `outcome_interpretation_rules` | source | `action_contracts`/`objectives`/`workflows`/`evaluations` | +| `outcome_interpretation_rules` | target | `objectives`/`workflows`/`evaluations` | + +### Workflows + +| Source | Field | Target | +|--------|-------|--------| +| `workflows` | start | own steps | +| `workflows` | step successors (`on_success`/`on_failure`) | own steps | +| `workflows` | compensation | other `workflows` | +| `workflows` | predicate assessment refs | `conditions`/`metrics`/`evaluations`/`tlos`/`goals` | +| `workflows` | predicate step refs | own steps (executable) | + +Parallel/join control flow MUST be closed: every branch reaches its join and no +join is unreferenced. + +### Typed relationships + +`relationships` carry a subtype that fixes the kinds of `source`/`target` and +any role-bearing refs +([ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md)): + +| Subtype | Endpoints / role refs resolve to | +|---------|----------------------------------| +| generic | targetable elements (`source`/`target`) | +| `database_access` | `source` → an `applications` element; `target` → a `database_services` element; role ref → a declared role on the target | +| `mail_access` | `target` → a `mail_services` element; listener/mailbox/domain refs → declared children of that mail service | +| `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 | + +### Variables + +| Source | Field | Target | +|--------|-------|--------| +| any field | `${name}` placeholder | a declared `variables` entry (name only, at authoring time) | + +## Extending the reference catalog + +A new reference edge is added by defining the field's candidate set, adding a +row here, and enforcing it in the reference implementation and tests. New +runtime-family child refs follow the nested runtime-family form (§1.3) +automatically once registered in the family index +([runtime-inventory.md](runtime-inventory.md)); they do not need bespoke prose. diff --git a/specs/sdl/runtime-inventory.md b/specs/sdl/runtime-inventory.md new file mode 100644 index 000000000..8d9c6d7fc --- /dev/null +++ b/specs/sdl/runtime-inventory.md @@ -0,0 +1,118 @@ +# Catalog 4 — Runtime-Family Index + +The runtime inventory is the node-scoped record of logical service state an SDL +document declares for a node. This file is a normative **index**: it places the +runtime inventory in the document, names each runtime family and its addressing +shape, and states the invariants every family shares **once**. It deliberately +**delegates** each family's per-field semantics to that family's ADR rather than +restating them, so the authoring spec does not duplicate the runtime ADR +sequence. + +## 1. Placement and boundary + +1. Runtime inventory lives under a node: `nodes..runtime.`. It + is **not** a top-level authoring section. +2. The runtime layer records **logical service state** — what a service is and + how it is configured — not delivery mechanics. The scenario/delivery boundary + for runtime node state is fixed by + [ADR-033](../../docs/decisions/adrs/adr-033-scenario-delivery-boundary-for-runtime-node-state.md); + the SDL/processor/runtime layering by + [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). +3. Runtime-family elements and their children are addressed by the + nested runtime-family reference form + ([references.md §1](references.md)): + `nodes..runtime..[..…]`. + +## 2. Family index + +Each registered node-scoped runtime family has a stable key, a collection name +under `runtime`, a primary `_id`, an addressable child-collection tree, +and an owning ADR. The owning ADR is the normative authority for that family's +fields, enums, and profiles; this index does not restate them. + +| Family key | `runtime.` | Primary id | Child collections (id) | Owning ADR | +|------------|------------------------|------------|------------------------|-----------| +| `service-listeners` | `service_listeners` | `service_listener_id` | — | [ADR-043](../../docs/decisions/adrs/adr-043-runtime-service-listener-surface.md) | +| `applications` | `applications` | `application_id` | — | [ADR-026](../../docs/decisions/adrs/adr-026-application-http-surface-inventory.md) | +| `database-services` | `database_services` | `database_service_id` | `databases` (`database_id`) | [ADR-029](../../docs/decisions/adrs/adr-029-database-logical-state-runtime-surface.md) | +| `dns-services` | `dns_services` | `dns_service_id` | `zones` (`zone_id`) → `rrsets` (`rrset_id`) | [ADR-039](../../docs/decisions/adrs/adr-039-dns-service-runtime-inventory.md) | +| `identity-authorities` | `identity_authorities` | `identity_authority_id` | `services`, `subjects`, `policies`, `relationships` | [ADR-032](../../docs/decisions/adrs/adr-032-directory-domain-identity-runtime-surface.md) | +| `file-services` | `file_services` | `file_service_id` | `shares`, `principals`, `access_rules`, `access_observations` | [ADR-037](../../docs/decisions/adrs/adr-037-runtime-file-service-and-filesystem-presence-semantics.md) | +| `mail-services` | `mail_services` | `mail_service_id` | `components`, `listeners`, `domains`, `mailbox_stores`, `mailboxes`, `aliases`, `routing_rules`, `queues`, `settings` | [ADR-038](../../docs/decisions/adrs/adr-038-runtime-mail-service-logical-state.md) | +| `network-sensors` | `network_sensors` | `network_sensor_id` | — | [ADR-042](../../docs/decisions/adrs/adr-042-network-sensor-runtime-monitoring.md) | +| `network-detection-engines` | `network_detection_engines` | `network_detection_engine_id` | `rule_sources`, `network_sets`, `output_streams`, `control_channels` | [ADR-044](../../docs/decisions/adrs/adr-044-network-detection-engine-runtime-inventory.md) | +| `security-monitoring-managers` | `security_monitoring_managers` | `security_monitoring_manager_id` | `listeners`, `components`, `agents`, `agent_groups`, `content_sets`, `detection_definitions`, `settings` | [ADR-040](../../docs/decisions/adrs/adr-040-security-monitoring-manager-runtime-inventory.md), [ADR-045](../../docs/decisions/adrs/adr-045-security-monitoring-detection-definition-semantics.md) | +| `ssh-servers` | `ssh_servers` | `ssh_server_id` | `match_rules` (`match_id`) | [ADR-031](../../docs/decisions/adrs/adr-031-ssh-server-configuration-surface.md) | +| `app-authorizations` | `app_authorizations` | `app_authorization_id` | `principals`, `roles`, `permission_grants`, `role_mappings`, `tenants` | [ADR-046](../../docs/decisions/adrs/adr-046-app-authorization-runtime-inventory.md) | +| `scheduled-jobs` | `scheduled_jobs` | `scheduled_job_id` | — | [ADR-047](../../docs/decisions/adrs/adr-047-scheduled-job-runtime-inventory.md) | +| `datastore-services` | `datastore_services` | `datastore_service_id` | `nodes` (`node_id`) → `plugins`, `endpoints`; `partitions`, `templates`, `mappings`, `settings` | [ADR-048](../../docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md), [ADR-058](../../docs/decisions/adrs/adr-058-datastore-node-engine-provenance-and-endpoints.md) | +| `platform-applications` | `platform_applications` | `platform_application_id` | `organizations`, `tenants`, `content_objects`, `markings`, `upstream_bindings`, `connectors`, `settings` | [ADR-049](../../docs/decisions/adrs/adr-049-platform-application-runtime-inventory.md) | +| `forwarding-agents` | `forwarding_agents` | `forwarding_agent_id` | `sources`, `transforms`, `ship_targets`, `reload_channels`, `settings` | [ADR-050](../../docs/decisions/adrs/adr-050-forwarding-agent-runtime-inventory.md) | +| `orchestration-authorities` | `orchestration_authorities` | `orchestration_authority_id` | `spawn_templates`, `realized_children` | [ADR-051](../../docs/decisions/adrs/adr-051-orchestration-authority-runtime-inventory.md) | + +The node-scoped `forwarding_agents` family is distinct from the scenario-level +`forwarding_agents` authoring section ([sections.md](sections.md)); they share +identity and invariants but occupy different document positions. + +### Other node-runtime surfaces + +A node's `runtime` also carries surfaces that are not ref-targetable families in +the index above but follow the same invariants and are governed by their own +ADRs: local identity +([ADR-024](../../docs/decisions/adrs/adr-024-local-identity-inventory-surface.md)), +container image provenance +([ADR-023](../../docs/decisions/adrs/adr-023-container-image-build-provenance-surface.md)), +software components +([ADR-034](../../docs/decisions/adrs/adr-034-runtime-software-component-inventory.md)), +service-manager units +([ADR-035](../../docs/decisions/adrs/adr-035-service-manager-unit-state-runtime-surface.md)), +and container init/reaper state +([ADR-027](../../docs/decisions/adrs/adr-027-container-init-reaper-runtime-surface.md)). +These authored identity surfaces are kept **separate** from one another: +authored `accounts`, runtime local identity, application authorization, database +roles, and participant identities are distinct models and MUST NOT be collapsed +into one. + +## 3. Shared invariants + +The following invariants hold for every runtime family and child collection. +They are stated here once; a family's ADR specifies the family's fields, but may +not contradict these. + +1. **Identity (`_id`).** Every family element and every addressable child + element carries a stable `_id`. The id **MUST** be unique within its + collection and symbol-shaped (no whitespace or quoting that would make it + unaddressable in a qualified path; [document-model.md §6](document-model.md)). + References address elements by these ids ([references.md](references.md)). +2. **Enum sentinels.** An open enum carries a closed core of well-defined values + plus the sentinels `unknown` and `other`, so an authored value can record + "not determined" or "outside the closed set" without widening the closed core. + A sentinel value is discriminator-neutral: it imposes no profile requirement. +3. **Quantity normalisation.** A human-readable byte quantity (for example + `4 GiB`, `512 MB`) is normalised to a canonical byte count on a `_bytes`-style + field. The normalised count is the value's meaning; the authored spelling is a + convenience. +4. **Required-profile guards.** Where a family is a discriminated union (for + example, a datastore's data-model spine), the discriminator value **requires** + the profile-specific fields for that value. A profile guard is fail-closed: a + discriminator that selects a profile without that profile's required fields is + an error ([diagnostics.md](diagnostics.md)). A discriminator set to a sentinel + (`unknown`/`other`) requires no profile. +5. **Observed values and redaction.** Runtime inventory records observed posture, + not live secrets. An explicit `redacted` or `operator_secret` classification + **MUST** omit the raw value + ([ADR-056](../../docs/decisions/adrs/adr-056-runtime-observed-values-and-credential-posture.md), + [ADR-057](../../docs/decisions/adrs/adr-057-runtime-secret-name-classifier-boundaries.md)). + Name-based secret-classification heuristics are **advisory only** + ([diagnostics.md](diagnostics.md)); they never silently strip or rewrite a + value. A posture-only model **MUST NOT** gain raw-credential fields. + +## Extending the runtime-family index + +A new runtime family is added by: defining its model and published schema, +authoring its owning ADR, registering it in the canonical family registry (key, +collection, primary `_id`, child-ref tree), and adding one row to the index +above. The shared invariants (§3) apply automatically; the nested runtime-family +reference form ([references.md §1](references.md)) addresses its elements without +bespoke prose. No second runtime-family registry exists or should be created. diff --git a/specs/sdl/sections.md b/specs/sdl/sections.md new file mode 100644 index 000000000..7e90704f6 --- /dev/null +++ b/specs/sdl/sections.md @@ -0,0 +1,101 @@ +# Catalog 1 — Section Catalog + +This catalog enumerates every top-level field of an SDL document: its kind, its +value shape, whether it is required, the shape of its keys, and the sections it +references. It is the normative section enumeration and is written to match the +published `contracts/schemas/sdl/sdl-authoring-input-v1.json` schema; where this +table and that schema diverge, the divergence is a defect to reconcile +([README](README.md)). + +Value-shape legend: + +- **scalar** — a single string value. +- **mapping** — a mapping with fixed keys (a structured object, not keyed by + user identifiers). +- **map** — a mapping keyed by **user-defined identifiers**, each value an + element of the named type. +- **list** — an ordered sequence of elements; element identity is carried by an + `_id` field on each element, not by a mapping key. + +"References" names the other sections an element of this section may name; the +resolution rules and full reference-edge catalog with failure semantics are in +[`references.md`](references.md). A blank "References" cell means the section is +referenced by others but does not itself reference another section. + +## Metadata and composition fields + +These describe the document and its composition. They are **not** authoring +sections. + +| Field | Shape | Required | Notes | +|-------|-------|----------|-------| +| `name` | scalar | **REQUIRED** | The scenario identity. The only required top-level field. | +| `version` | scalar | optional (default `*`) | Scenario version; `*` means unpinned. | +| `description` | scalar | optional (default empty) | Free-text description. | +| `module` | mapping \| null | optional (default null) | Published module metadata when this document is a composable module: a canonical `publisher/name` id, a `version`, declared `parameters`, and `exports` ([ADR-053](../../docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md)). | +| `imports` | list | optional (default empty) | Module imports. Each import names a module by `source` (or the deprecated `path`) and binds it under a `namespace` with `parameters`. Imports are expanded before full semantic validation ([document-model.md §7](document-model.md)). | + +## Authoring sections — map-keyed + +Each is a map keyed by a user-defined identifier ([document-model.md §6](document-model.md)) +and defaults to an empty map when omitted. + +| Section | Required | Key shape | References | +|---------|----------|-----------|------------| +| `nodes` | optional | identifier ≤ 35 chars; may contain `.` | `features`, `conditions`, `injects`, `vulnerabilities`; hosts the runtime inventory ([runtime-inventory.md](runtime-inventory.md)) | +| `infrastructure` | optional | identifier matching a node | `nodes`; switch/network nodes; other `infrastructure` (dependencies) | +| `features` | optional | identifier | `vulnerabilities`; other `features` (dependencies, acyclic) | +| `conditions` | optional | identifier | — | +| `vulnerabilities` | optional | identifier | — | +| `metrics` | optional | identifier | `conditions` | +| `evaluations` | optional | identifier | `metrics` | +| `tlos` | optional | identifier | `evaluations` | +| `goals` | optional | identifier | `tlos` | +| `entities` | optional | identifier | `tlos`, `vulnerabilities` | +| `injects` | optional | identifier | `entities`, `tlos` | +| `events` | optional | identifier | `conditions`, `injects` | +| `scripts` | optional | identifier | `events` | +| `stories` | optional | identifier | `scripts` | +| `content` | optional | identifier | `nodes` (VM target) | +| `accounts` | optional | identifier | `nodes` (VM) | +| `relationships` | optional | identifier | typed by subtype: `entities`/`accounts`/targetable elements; runtime families (`applications`, `database_services`, `mail_services`, `platform_applications`, `app_authorizations`); scenario `forwarding_agents` ([ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md)) | +| `agents` | optional | identifier | `entities`, `accounts`, `infrastructure`, `nodes`, `conditions`, `action_contracts`, `observation_boundaries`, targetable elements | +| `action_contracts` | optional | identifier | other `action_contracts` (interactions) | +| `observation_boundaries` | optional | identifier | own information refs (observable/hidden/evidence) | +| `outcome_interpretation_rules` | optional | identifier | `action_contracts`, `objectives`, `workflows`, `evaluations` | +| `objectives` | optional | identifier | `agents`/`entities` (actor), `action_contracts` (action), targetable elements (target), `conditions`/`metrics`/`evaluations`/`tlos`/`goals` (success), `stories`/`scripts`/`events`/`workflows` (window), other `objectives` (depends_on, acyclic) | +| `workflows` | optional | identifier | own steps (`start`, successors), other `workflows` (compensation), assessment sections (predicates) | +| `variables` | optional | identifier matching `[A-Za-z_][A-Za-z0-9_-]*` | referenced by `${…}` placeholders ([variables-and-instantiation.md](variables-and-instantiation.md)) | + +## Authoring section — list-valued + +| Section | Shape | Required | Element identity | Referenced by | +|---------|-------|----------|------------------|---------------| +| `forwarding_agents` | list | optional (default empty) | `forwarding_agent_id` on each element | `relationships` (`forwarding_edge.forwarder_ref`) | + +`forwarding_agents` is the **scenario-level** forwarding-agent inventory. It is +distinct from the node-scoped `forwarding_agents` runtime-family collection that +lives under `nodes..runtime` ([runtime-inventory.md](runtime-inventory.md)); +both carry `forwarding_agent_id` identity and the same family invariants, but +they occupy different positions in the document. + +## Assessment and narrative chains + +Two reference chains run through the catalog and are called out because their +ordering is normative (resolution and failure semantics in +[`references.md`](references.md)): + +- **Assessment pipeline:** `conditions` ← `metrics` ← `evaluations` ← `tlos` ← + `goals`. Each link names the prior section; the chain feeds objectives and + workflow predicates. +- **Narrative chain:** `injects` → `events` → `scripts` → `stories`, with + `injects` naming `entities`/`tlos` and `events` naming `conditions`. Objective + windows bind `stories`/`scripts`/`events`/`workflows`. + +## Extending the section set + +A new top-level authoring section is added by: defining its model and the +published schema field, adding a row to this catalog (with its shape, +requiredness, key shape, and references), adding its reference edges to +[`references.md`](references.md), and updating the reference implementation and +its tests. No parallel section registry exists or should be created. diff --git a/specs/sdl/variables-and-instantiation.md b/specs/sdl/variables-and-instantiation.md new file mode 100644 index 000000000..1a8ccfa83 --- /dev/null +++ b/specs/sdl/variables-and-instantiation.md @@ -0,0 +1,127 @@ +# Catalog 3 — Variable and Instantiation Catalog + +This catalog defines SDL variables — their types, defaults, and constraints — +the `${…}` substitution syntax, the instantiation algorithm, and what an +instantiated document is no longer permitted to contain. Variables and +instantiation are the SDL's parameterisation seam under +[ADR-001](../../docs/decisions/adrs/adr-001-scenario-description-language.md) and +[ADR-003](../../docs/decisions/adrs/adr-003-workflows-targetable-subobjects-and-enum-variables.md). + +## 1. Variable definitions + +A `variables` entry ([sections.md](sections.md)) is keyed by a variable name and +defines a typed, optionally-constrained parameter: + +| Field | Meaning | +|-------|---------| +| `type` | One of `string`, `integer`, `boolean`, `number`. **REQUIRED**. | +| `default` | The value used when no parameter is supplied. **OPTIONAL**. | +| `allowed_values` | A closed set of permitted values. **OPTIONAL**. | +| `required` | Whether a value must be supplied at instantiation when no default exists. Defaults to false. | +| `description` | Free text. **OPTIONAL**. | + +Type conformance rules, enforced when the variable is defined: + +1. If `default` is set, it **MUST** match `type`. `integer` excludes booleans; + `number` admits integers and floats but excludes booleans; `boolean` is + strictly boolean; `string` is strictly string. +2. Every member of `allowed_values` **MUST** match `type`. +3. If both `default` and `allowed_values` are set, `default` **MUST** be a member + of `allowed_values`. + +The variable **name** (the map key) **MUST** match `[A-Za-z_][A-Za-z0-9_-]*` +([document-model.md §6](document-model.md)). + +## 2. Reference syntax + +A variable is referenced by a `${name}` placeholder, where `name` matches the +variable-name grammar above. Two placeholder positions are distinguished: + +1. **Full-value placeholder** — the entire field value is `${name}`. On + instantiation it is replaced by the variable's **typed** value (an integer + stays an integer, a boolean stays a boolean). +2. **Embedded token** — `${name}` appears inside a larger string (e.g. + `host-${index}`). On instantiation each token is replaced by the **string** + form of the variable's value and the surrounding text is preserved. + +A placeholder **MUST NOT** appear in an identifier-defining map key +([document-model.md §6](document-model.md)); variables parameterise values, not +identities. + +Variables are **not** resolved at parse time. An authored document preserves +`${…}` placeholders structurally; resolution happens only at instantiation. + +## 3. Instantiation algorithm + +Instantiation turns an authored (and, if applicable, expanded) document into a +concrete one, given a mapping of parameter values and an optional profile. It is +**fail-closed**: any of the following is a fatal instantiation error +([diagnostics.md](diagnostics.md)), and all such errors are reported together. + +For each declared variable, a value is chosen in this order: + +1. the supplied parameter value, if the variable's name is a supplied parameter; +2. otherwise the variable's `default`, if set; +3. otherwise, if the variable is `required`, a **missing-required-value error**; +4. otherwise the variable is left unbound (it has no value and no default and is + not required). + +Each chosen value is then checked: + +5. its type **MUST** match the variable's `type`, else a **type-mismatch error**; +6. if `allowed_values` is set, the value **MUST** be a member, else an + **allowed-values error**. + +Across the whole parameter mapping: + +7. a supplied parameter whose name is **not** a declared variable is an + **undeclared-parameter error**. Instantiation does not silently ignore extra + parameters. + +Substitution then replaces placeholders throughout the document (§2). Finally: + +8. any placeholder that remains unresolved after substitution — because its + variable was left unbound (step 4) — is an **unresolved-placeholder error**. + A concrete document **MUST NOT** carry surviving `${…}` placeholders. + +The substituted document is re-validated structurally and **re-run through +semantic validation** ([references.md](references.md)); a reference that only +becomes dangling or ambiguous after substitution fails here. + +## 4. Post-instantiation exclusions + +An instantiated document is concrete. It **MUST NOT** contain: + +1. unresolved `${…}` placeholders (§3 step 8); and +2. variable **definitions** surviving as ordinary authoring variables. The + `variables` section parameterises the authored document; once a document is + instantiated, the bound parameter values — not the variable definitions — + are what the document means. (The resolved parameter values and any captured + pre-substitution references are retained as instantiation context for + downstream consumers, but not as live authoring variables.) + +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. + +## 5. Authoring vs. instantiated reference checks + +Reference resolution treats the two forms differently +([references.md §3](references.md)): + +- **Authored:** a field holding `${name}` is not resolved as a reference; only + `name`'s declaration is required. +- **Instantiated:** the substituted concrete value is subject to the full + reference rules. + +This lets an author parameterise a reference (e.g. a node `os` or an +infrastructure `count`) and defer its validation to the point where the concrete +value is known. + +## Extending the variable model + +New variable types or constraint kinds are added by extending the variable +model and the published schema, adding the type/constraint to this catalog, and +updating the instantiation checks (§3) and tests. The four-step instantiation +contract — choose, type-check, constraint-check, substitute-and-revalidate — +is the stable seam; new constraints slot into the check phase. From fef4064d59b62a26d202182b305e247a2a007287 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 18:57:48 +0200 Subject: [PATCH 46/70] Mark SDL-registration real-repo test as integration The specs/sdl/ prose-root registration test reads real-checkout state (the authority manifest and the specs/sdl/ directory), matching its companion real-repo test, so it carries @pytest.mark.integration. This keeps it out of unit-test selections that run without a full checkout while still running in the verify/CI integration stage. --- implementations/python/tests/test_authority_boundary.py | 1 + 1 file changed, 1 insertion(+) diff --git a/implementations/python/tests/test_authority_boundary.py b/implementations/python/tests/test_authority_boundary.py index 52656e501..eb98c99f0 100644 --- a/implementations/python/tests/test_authority_boundary.py +++ b/implementations/python/tests/test_authority_boundary.py @@ -1344,6 +1344,7 @@ def test_evaluate_authority_boundary_real_repo_is_clean() -> None: # --------------------------------------------------------------------------- # +@pytest.mark.integration def test_specs_sdl_prose_root_is_registered_in_real_manifest() -> None: import yaml From 0fe78e3caa2765c98c614742a42a09d1820b5408 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 19:44:43 +0200 Subject: [PATCH 47/70] Differentiate instantiated-scenario schema from authoring-input The instantiated-scenario-v1 contract now forbids unresolved ${var} tokens in string values (whole-string and embedded) at both the published JSON Schema and the InstantiatedScenario model. Centralize the token regex in aces_sdl._base, expose it through the package API, and record the schema change in the publication manifest ledger. --- changelog.d/500.changed.md | 1 + .../invalid/unresolved-variable.json | 9 + .../valid/minimal.json | 4 + contracts/schema-publication-manifest.json | 6 +- .../schemas/sdl/instantiated-scenario-v1.json | 4103 ++++++++++++++++- .../packages/aces_contracts/contracts.py | 64 + .../python/packages/aces_sdl/__init__.py | 3 + .../python/packages/aces_sdl/_base.py | 21 +- .../python/packages/aces_sdl/explicitness.py | 6 +- .../python/packages/aces_sdl/instantiate.py | 6 +- .../python/packages/aces_sdl/scenario.py | 46 +- .../test_instantiated_scenario_schema.py | 116 + .../python/tests/test_runtime_datastore.py | 20 +- 13 files changed, 4389 insertions(+), 16 deletions(-) create mode 100644 changelog.d/500.changed.md create mode 100644 contracts/fixtures/sdl/instantiated-scenario-v1/invalid/unresolved-variable.json create mode 100644 contracts/fixtures/sdl/instantiated-scenario-v1/valid/minimal.json create mode 100644 implementations/python/tests/test_instantiated_scenario_schema.py diff --git a/changelog.d/500.changed.md b/changelog.d/500.changed.md new file mode 100644 index 000000000..9cc48b23b --- /dev/null +++ b/changelog.d/500.changed.md @@ -0,0 +1 @@ +The published `instantiated-scenario-v1` contract now rejects unresolved `${var}` substitution tokens in string values — both whole-string placeholders (`"${os}"`) and embedded tokens (`"host-${index}"`) — differentiating it from `sdl-authoring-input-v1`, which still accepts them. The same invariant is enforced on the `InstantiatedScenario` model so directly constructed instances must be fully concrete. diff --git a/contracts/fixtures/sdl/instantiated-scenario-v1/invalid/unresolved-variable.json b/contracts/fixtures/sdl/instantiated-scenario-v1/invalid/unresolved-variable.json new file mode 100644 index 000000000..2a1aeea64 --- /dev/null +++ b/contracts/fixtures/sdl/instantiated-scenario-v1/invalid/unresolved-variable.json @@ -0,0 +1,9 @@ +{ + "name": "concrete-scenario", + "description": "deploy ${region} cluster", + "infrastructure": { + "net": { + "count": "${replicas}" + } + } +} diff --git a/contracts/fixtures/sdl/instantiated-scenario-v1/valid/minimal.json b/contracts/fixtures/sdl/instantiated-scenario-v1/valid/minimal.json new file mode 100644 index 000000000..bc5f02939 --- /dev/null +++ b/contracts/fixtures/sdl/instantiated-scenario-v1/valid/minimal.json @@ -0,0 +1,4 @@ +{ + "name": "concrete-scenario", + "description": "a fully concrete instantiated scenario with no unresolved references" +} diff --git a/contracts/schema-publication-manifest.json b/contracts/schema-publication-manifest.json index 5ef13e6d2..548de97b4 100644 --- a/contracts/schema-publication-manifest.json +++ b/contracts/schema-publication-manifest.json @@ -78,7 +78,11 @@ "contract_id": "instantiated-scenario-v1", "schema_path": "contracts/schemas/sdl/instantiated-scenario-v1.json", "stability": "draft", - "content_hash": "6849f6bf1353f2fb530b3fd4e62e854ff31d3e140fca23f975bdbc65390d62ef" + "content_hash": "fae2bab70f056a20a211b752f40e2886e1d3be9afa14c2d9d0722efc6be6e7ba", + "last_change": { + "summary": "Differentiate instantiated-scenario from authoring-input: the instantiated contract now rejects unresolved variable placeholders in string values (issue #500).", + "content_hash": "fae2bab70f056a20a211b752f40e2886e1d3be9afa14c2d9d0722efc6be6e7ba" + } }, { "contract_id": "operation-receipt-v1", diff --git a/contracts/schemas/sdl/instantiated-scenario-v1.json b/contracts/schemas/sdl/instantiated-scenario-v1.json index d226bc60b..4a6c9ebb6 100644 --- a/contracts/schemas/sdl/instantiated-scenario-v1.json +++ b/contracts/schemas/sdl/instantiated-scenario-v1.json @@ -19,6 +19,9 @@ "$ref": "#/$defs/ACLAction" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -27,21 +30,33 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "direction": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Direction", "type": "string" }, "from_net": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "From Net", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -52,6 +67,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ] @@ -61,11 +79,17 @@ }, "protocol": { "default": "any", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Protocol", "type": "string" }, "to_net": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "To Net", "type": "string" } @@ -79,11 +103,17 @@ "properties": { "auth_method": { "default": "password", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Auth Method", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -93,6 +123,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -101,6 +134,9 @@ }, "groups": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Groups", @@ -108,16 +144,25 @@ }, "home": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Home", "type": "string" }, "mail": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Mail", "type": "string" }, "node": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Node", "type": "string" }, @@ -127,6 +172,9 @@ "$ref": "#/$defs/PasswordStrength" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -135,15 +183,24 @@ }, "shell": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Shell", "type": "string" }, "spn": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Spn", "type": "string" }, "username": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Username", "type": "string" } @@ -160,6 +217,9 @@ "properties": { "actions": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Actions", @@ -167,6 +227,9 @@ }, "allowed_subnets": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Allowed Subnets", @@ -174,6 +237,9 @@ }, "authority_anchors": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Authority Anchors", @@ -181,11 +247,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "entity": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Entity", "type": "string" }, @@ -202,6 +274,9 @@ }, "observation_boundaries": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Observation Boundaries", @@ -209,6 +284,9 @@ }, "operating_scope": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Operating Scope", @@ -216,11 +294,17 @@ }, "reward_calculator": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Reward Calculator", "type": "string" }, "starting_accounts": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Starting Accounts", @@ -228,6 +312,9 @@ }, "starting_conditions": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Starting Conditions", @@ -247,6 +334,9 @@ "$ref": "#/$defs/AssetValueLevel" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -259,6 +349,9 @@ "$ref": "#/$defs/AssetValueLevel" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -271,6 +364,9 @@ "$ref": "#/$defs/AssetValueLevel" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -300,6 +396,9 @@ "command": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -311,11 +410,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "environment": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Environment", @@ -327,6 +432,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -338,6 +446,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -347,6 +458,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -373,6 +487,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -388,6 +505,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -418,11 +538,17 @@ }, "base_image": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Base Image", "type": "string" }, "base_image_digest": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Base Image Digest", "type": "string" }, @@ -453,11 +579,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "dockerfile_path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Dockerfile Path", "type": "string" }, @@ -492,16 +624,25 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "destination": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Destination", "type": "string" }, "format": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Format", "type": "string" }, @@ -514,6 +655,9 @@ }, "path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Path", "type": "string" }, @@ -523,6 +667,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -542,6 +689,9 @@ }, "tags": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Tags", @@ -549,12 +699,18 @@ }, "target": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target", "type": "string" }, "text": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -580,15 +736,24 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "tags": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Tags", @@ -616,15 +781,24 @@ "description": "An observed logical database within a database service.", "properties": { "database_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Database Id", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -634,6 +808,9 @@ "$ref": "#/$defs/DatabaseObjectOrigin" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -698,14 +875,23 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "grantee_role_ref": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Grantee Role Ref", "type": "string" }, "object_ref": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Object Ref", "type": "string" }, @@ -715,6 +901,9 @@ "$ref": "#/$defs/DatabaseObjectType" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -722,6 +911,9 @@ }, "privileges": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Privileges", @@ -733,6 +925,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -753,11 +948,17 @@ "description": "An observed listener of a database service.\n\nThis records what the database process itself listens on. It is not host\npublication \u2014 ``runtime.network.published_ports`` remains the host-exposure\nfact (ADR-029 \u00a73).", "properties": { "address": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Address", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -767,6 +968,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -829,6 +1033,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -840,10 +1047,16 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -853,6 +1066,9 @@ "$ref": "#/$defs/DatabaseObjectOrigin" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -860,6 +1076,9 @@ "title": "Origin" }, "role_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Role Id", "type": "string" }, @@ -869,6 +1088,9 @@ "$ref": "#/$defs/DatabaseRoleType" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -904,10 +1126,16 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -917,6 +1145,9 @@ "$ref": "#/$defs/DatabaseObjectOrigin" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -924,6 +1155,9 @@ "title": "Origin" }, "schema_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Schema Id", "type": "string" }, @@ -948,10 +1182,16 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -961,6 +1201,9 @@ "$ref": "#/$defs/DatabaseSettingProvenance" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -969,6 +1212,9 @@ }, "value": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Value", "type": "string" }, @@ -978,6 +1224,9 @@ "$ref": "#/$defs/RuntimeSensitivityClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1011,14 +1260,23 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "table_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Table Id", "type": "string" } @@ -1036,6 +1294,9 @@ "properties": { "allowed_clients": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Allowed Clients", @@ -1043,6 +1304,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -1052,6 +1316,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -1063,6 +1330,9 @@ }, "key_names": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Key Names", @@ -1070,6 +1340,9 @@ }, "policy": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Policy", "type": "string" } @@ -1082,11 +1355,17 @@ "description": "Recursive upstream resolver endpoint.", "properties": { "address": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Address", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -1096,6 +1375,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1104,6 +1386,9 @@ }, "tls_server_name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Tls Server Name", "type": "string" }, @@ -1113,6 +1398,9 @@ "$ref": "#/$defs/DnsForwarderTransport" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1159,6 +1447,9 @@ "description": "Typed MX RDATA fields.", "properties": { "exchange": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Exchange", "type": "string" }, @@ -1168,6 +1459,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1245,6 +1539,9 @@ "properties": { "allow_recursion": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Allow Recursion", @@ -1256,6 +1553,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -1267,6 +1567,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -1276,6 +1579,9 @@ "$ref": "#/$defs/DnssecValidationMode" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1295,6 +1601,9 @@ "$ref": "#/$defs/DnsForwardingPolicy" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1307,6 +1616,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -1322,6 +1634,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -1341,11 +1656,17 @@ "properties": { "address": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Address", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -1362,6 +1683,9 @@ }, "rdata": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Rdata", "type": "string" }, @@ -1389,11 +1713,17 @@ }, "target": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target", "type": "string" }, "text": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Text", @@ -1409,10 +1739,16 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "owner": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Owner", "type": "string" }, @@ -1422,6 +1758,9 @@ "$ref": "#/$defs/DnsRecordProvenance" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1434,6 +1773,9 @@ "$ref": "#/$defs/DnsRecordType" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1447,6 +1789,9 @@ "type": "array" }, "rrset_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Rrset Id", "type": "string" }, @@ -1456,6 +1801,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -1471,6 +1819,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -1486,6 +1837,9 @@ "$ref": "#/$defs/DnsRecordClass" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1507,10 +1861,16 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -1520,6 +1880,9 @@ "$ref": "#/$defs/DnsSettingProvenance" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1528,6 +1891,9 @@ }, "value": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Value", "type": "string" }, @@ -1537,6 +1903,9 @@ "$ref": "#/$defs/RuntimeSensitivityClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1605,6 +1974,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1616,12 +1988,18 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], "title": "Minimum" }, "mname": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Mname", "type": "string" }, @@ -1631,6 +2009,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1642,12 +2023,18 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], "title": "Retry" }, "rname": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Rname", "type": "string" }, @@ -1657,6 +2044,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1685,6 +2075,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1696,12 +2089,18 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], "title": "Priority" }, "target": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target", "type": "string" }, @@ -1711,6 +2110,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1732,6 +2134,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -1741,6 +2146,9 @@ "$ref": "#/$defs/DnsZoneKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1748,6 +2156,9 @@ "title": "Kind" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -1757,6 +2168,9 @@ "$ref": "#/$defs/DnsRecordProvenance" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1769,6 +2183,9 @@ "$ref": "#/$defs/DnsZonePurpose" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1799,6 +2216,9 @@ "$ref": "#/$defs/DnsRecordClass" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1807,12 +2227,18 @@ }, "zone_file_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Zone File Refs", "type": "array" }, "zone_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Zone Id", "type": "string" } @@ -1858,6 +2284,9 @@ "properties": { "allowed_clients": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Allowed Clients", @@ -1869,6 +2298,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -1880,6 +2312,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -1889,6 +2324,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -1900,6 +2338,9 @@ }, "primary_servers": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Primary Servers", @@ -1907,6 +2348,9 @@ }, "secondary_servers": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Secondary Servers", @@ -1935,6 +2379,9 @@ "properties": { "arguments": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Arguments", @@ -1942,6 +2389,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -1951,6 +2401,9 @@ "$ref": "#/$defs/DockerfileInstructionKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -1995,6 +2448,9 @@ "properties": { "categories": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Categories", @@ -2002,6 +2458,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -2014,6 +2473,9 @@ }, "events": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Events", @@ -2021,6 +2483,9 @@ }, "facts": { "additionalProperties": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Facts", @@ -2028,11 +2493,17 @@ }, "mission": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Mission", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -2042,6 +2513,9 @@ "$ref": "#/$defs/ExerciseRole" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -2053,6 +2527,9 @@ }, "tlos": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Tlos", @@ -2060,6 +2537,9 @@ }, "vulnerabilities": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Vulnerabilities", @@ -2075,11 +2555,17 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "metrics": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "minItems": 1, @@ -2091,6 +2577,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" } @@ -2108,6 +2597,9 @@ "properties": { "conditions": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Conditions", @@ -2115,11 +2607,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "injects": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Injects", @@ -2127,6 +2625,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -2161,18 +2662,30 @@ "description": "Loss-labeled mapping from an external vocabulary to ACES semantics.", "properties": { "identifier": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Identifier", "type": "string" }, "loss_label": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Loss Label", "type": "string" }, "rationale": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Rationale", "type": "string" }, "system": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "System", "type": "string" } @@ -2192,6 +2705,9 @@ "properties": { "dependencies": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Dependencies", @@ -2199,16 +2715,25 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "destination": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Destination", "type": "string" }, "environment": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Environment", @@ -2216,6 +2741,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -2235,6 +2763,9 @@ }, "vulnerabilities": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Vulnerabilities", @@ -2263,16 +2794,25 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "tlos": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "minItems": 1, @@ -2296,6 +2836,9 @@ "$ref": "#/$defs/ImageAttestationType" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -2304,16 +2847,25 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "evidence_reference": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Evidence Reference", "type": "string" }, "predicate_type": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Predicate Type", "type": "string" }, @@ -2323,6 +2875,9 @@ "$ref": "#/$defs/ImageAttestationStatus" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -2335,6 +2890,9 @@ "$ref": "#/$defs/ImageVerificationStatus" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -2374,15 +2932,24 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "value": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Value", "type": "string" }, @@ -2392,6 +2959,9 @@ "$ref": "#/$defs/RuntimeEnvironmentValueClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -2411,6 +2981,9 @@ "properties": { "command": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Command", @@ -2425,11 +2998,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "entrypoint": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Entrypoint", @@ -2437,6 +3016,9 @@ }, "exposed_ports": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Exposed Ports", @@ -2444,6 +3026,9 @@ }, "labels": { "additionalProperties": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Labels", @@ -2451,6 +3036,9 @@ }, "working_directory": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Working Directory", "type": "string" } @@ -2464,19 +3052,31 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "destination_path": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Destination Path", "type": "string" }, "from_stage": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "From Stage", "type": "string" }, "source_path": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source Path", "type": "string" } @@ -2494,15 +3094,24 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "value": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Value", "type": "string" }, @@ -2512,6 +3121,9 @@ "$ref": "#/$defs/RuntimeEnvironmentValueClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -2531,16 +3143,25 @@ "properties": { "created_by": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Created By", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "digest": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Digest", "type": "string" }, @@ -2550,6 +3171,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -2565,6 +3189,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -2584,30 +3211,48 @@ "properties": { "checksum": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Checksum", "type": "string" }, "checksum_algorithm": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Checksum Algorithm", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "destination_path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Destination Path", "type": "string" }, "identifier": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Identifier", "type": "string" }, "source_path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source Path", "type": "string" } @@ -2636,11 +3281,17 @@ "properties": { "digest": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Digest", "type": "string" }, "namespace": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Namespace", "type": "string" }, @@ -2651,16 +3302,25 @@ }, "path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Path", "type": "string" }, "source": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source", "type": "string" }, "version": { "default": "*", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -2685,6 +3345,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -2693,6 +3356,9 @@ }, "dependencies": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Dependencies", @@ -2700,11 +3366,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "links": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Links", @@ -2718,6 +3390,9 @@ { "items": { "additionalProperties": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "type": "object" @@ -2741,6 +3416,9 @@ "properties": { "accounts": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Accounts", @@ -2748,6 +3426,9 @@ }, "hosts": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Hosts", @@ -2755,6 +3436,9 @@ }, "services": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Services", @@ -2762,6 +3446,9 @@ }, "subnets": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Subnets", @@ -2777,11 +3464,17 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "environment": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Environment", @@ -2789,11 +3482,17 @@ }, "from_entity": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "From Entity", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -2810,6 +3509,9 @@ }, "tlos": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Tlos", @@ -2817,6 +3519,9 @@ }, "to_entities": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "To Entities", @@ -2836,6 +3541,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -2848,6 +3556,9 @@ "condition": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -2859,6 +3570,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -2868,6 +3582,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -2875,6 +3592,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -2908,6 +3628,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -2923,6 +3646,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -2942,12 +3668,18 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "exports": { "additionalProperties": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "type": "array" @@ -2956,17 +3688,26 @@ "type": "object" }, "id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Id", "type": "string" }, "parameters": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Parameters", "type": "array" }, "version": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -2995,6 +3736,9 @@ }, "conditions": { "additionalProperties": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Conditions", @@ -3002,11 +3746,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "features": { "additionalProperties": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Features", @@ -3014,6 +3764,9 @@ }, "injects": { "additionalProperties": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Injects", @@ -3025,6 +3778,9 @@ "$ref": "#/$defs/OSFamily" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -3036,6 +3792,9 @@ }, "os_version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Os Version", "type": "string" }, @@ -3091,6 +3850,9 @@ }, "vulnerabilities": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Vulnerabilities", @@ -3130,6 +3892,9 @@ "properties": { "actions": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Actions", @@ -3137,11 +3902,17 @@ }, "agent": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Agent", "type": "string" }, "depends_on": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Depends On", @@ -3149,16 +3920,25 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "entity": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Entity", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -3167,6 +3947,9 @@ }, "targets": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Targets", @@ -3196,6 +3979,9 @@ "properties": { "conditions": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Conditions", @@ -3203,6 +3989,9 @@ }, "evaluations": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evaluations", @@ -3210,6 +3999,9 @@ }, "goals": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Goals", @@ -3217,6 +4009,9 @@ }, "metrics": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Metrics", @@ -3228,6 +4023,9 @@ "$ref": "#/$defs/SuccessMode" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -3236,6 +4034,9 @@ }, "tlos": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Tlos", @@ -3251,6 +4052,9 @@ "properties": { "events": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Events", @@ -3258,6 +4062,9 @@ }, "scripts": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Scripts", @@ -3265,6 +4072,9 @@ }, "steps": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Steps", @@ -3272,6 +4082,9 @@ }, "stories": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Stories", @@ -3279,6 +4092,9 @@ }, "workflows": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Workflows", @@ -3302,6 +4118,9 @@ "properties": { "diagnostics": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Diagnostics", @@ -3309,6 +4128,9 @@ }, "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "minItems": 1, @@ -3316,11 +4138,17 @@ "type": "array" }, "interpretation_basis": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Interpretation Basis", "type": "string" }, "limitations": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "minItems": 1, @@ -3328,6 +4156,9 @@ "type": "array" }, "observation_point_basis": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Observation Point Basis", "type": "string" }, @@ -3335,6 +4166,9 @@ "$ref": "#/$defs/OutcomeInterpretationParticipantScope" }, "semantic_version": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Semantic Version", "type": "string" }, @@ -3374,6 +4208,9 @@ "properties": { "diagnostics": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Diagnostics", @@ -3381,27 +4218,42 @@ }, "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Refs", "type": "array" }, "interpretation_role": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Interpretation Role", "type": "string" }, "provenance_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Provenance Refs", "type": "array" }, "ref": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Ref", "type": "string" }, "source_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source Id", "type": "string" }, @@ -3445,6 +4297,9 @@ "properties": { "diagnostics": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Diagnostics", @@ -3452,6 +4307,9 @@ }, "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Refs", @@ -3460,6 +4318,9 @@ "governance_ref": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -3471,20 +4332,32 @@ }, "limitations": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Limitations", "type": "array" }, "ref": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Ref", "type": "string" }, "relation": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Relation", "type": "string" }, "target_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target Id", "type": "string" }, @@ -3546,6 +4419,9 @@ }, "evidence_expectations": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Expectations", @@ -3566,6 +4442,9 @@ "type": "array" }, "fidelity_claim": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Fidelity Claim", "type": "string" }, @@ -3582,6 +4461,9 @@ }, "observation_expectations": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Observation Expectations", @@ -3595,19 +4477,31 @@ "type": "array" }, "procedure_basis": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Procedure Basis", "type": "string" }, "realization_profile": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Realization Profile", "type": "string" }, "semantic_version": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Semantic Version", "type": "string" }, "state_transition_effects": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "State Transition Effects", @@ -3636,6 +4530,9 @@ "description": "Typed SEM-211 effect declaration on an action contract.", "properties": { "description": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -3643,11 +4540,17 @@ "$ref": "#/$defs/ParticipantEffectClass" }, "effect_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Effect Id", "type": "string" }, "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Refs", @@ -3655,6 +4558,9 @@ }, "target_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Target Refs", @@ -3694,11 +4600,17 @@ "description": "Typed SEM-211 applicability precondition on an action contract.", "properties": { "description": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Refs", @@ -3708,11 +4620,17 @@ "$ref": "#/$defs/ParticipantPreconditionClass" }, "precondition_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Precondition Id", "type": "string" }, "support_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Support Refs", @@ -3732,10 +4650,16 @@ "description": "Mapping from a backend diagnostic code to a portable SEM-211 failure.", "properties": { "backend_error_code": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Backend Error Code", "type": "string" }, "diagnostic": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Diagnostic", "type": "string" }, @@ -3757,6 +4681,9 @@ "properties": { "affected_temporal_ids": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "minItems": 1, @@ -3764,10 +4691,16 @@ "type": "array" }, "description": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "disclosure_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Disclosure Id", "type": "string" }, @@ -3776,6 +4709,9 @@ }, "limitations": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Limitations", @@ -3881,11 +4817,17 @@ "$ref": "#/$defs/ParticipantInteractionClass" }, "rationale": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Rationale", "type": "string" }, "related_actions": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Related Actions", @@ -3893,12 +4835,18 @@ }, "shared_state_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Shared State Refs", "type": "array" }, "target": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target", "type": "string" } @@ -3917,6 +4865,9 @@ "properties": { "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Refs", @@ -3924,17 +4875,26 @@ }, "hidden_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Hidden Refs", "type": "array" }, "latency_profile": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Latency Profile", "type": "string" }, "observable_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Observable Refs", @@ -3942,18 +4902,27 @@ }, "observer_effects": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Observer Effects", "type": "array" }, "projection_basis": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Projection Basis", "type": "string" }, "realized_view_disclosure": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -3964,6 +4933,9 @@ "title": "Realized View Disclosure" }, "redaction_policy": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Redaction Policy", "type": "string" }, @@ -4011,22 +4983,34 @@ "properties": { "backend_disclosure_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Backend Disclosure Refs", "type": "array" }, "clock_authority": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Clock Authority", "type": "string" }, "description": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "duration_ref": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4045,12 +5029,18 @@ "type": "array" }, "ordering_basis": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Ordering Basis", "type": "string" }, "randomization_basis": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4063,6 +5053,9 @@ "replay_boundary": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4075,6 +5068,9 @@ "reset_boundary": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4085,6 +5081,9 @@ "title": "Reset Boundary" }, "temporal_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Temporal Id", "type": "string" }, @@ -4097,6 +5096,9 @@ "window_ref": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4210,6 +5212,9 @@ "certainty": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4222,6 +5227,9 @@ "disclosure_rule": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4237,6 +5245,9 @@ "effective_from": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4248,18 +5259,27 @@ }, "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Refs", "type": "array" }, "information_ref": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Information Ref", "type": "string" }, "latency_profile": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4272,6 +5292,9 @@ "realized_backend_disclosure": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4282,6 +5305,9 @@ "title": "Realized Backend Disclosure" }, "visibility_basis": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Visibility Basis", "type": "string" } @@ -4302,6 +5328,9 @@ "action_instance_id": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4312,12 +5341,18 @@ "title": "Action Instance Id" }, "certainty": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Certainty", "type": "string" }, "disclosure_rule": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4328,6 +5363,9 @@ "title": "Disclosure Rule" }, "effective_from": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Effective From", "type": "string" }, @@ -4337,6 +5375,9 @@ }, "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Refs", @@ -4349,16 +5390,25 @@ "$ref": "#/$defs/ParticipantViewHistoryEventType" }, "information_ref": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Information Ref", "type": "string" }, "latency_profile": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Latency Profile", "type": "string" }, "realized_backend_disclosure": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4372,6 +5422,9 @@ "$ref": "#/$defs/ParticipantViewDisposition" }, "transition_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Transition Id", "type": "string" }, @@ -4379,6 +5432,9 @@ "$ref": "#/$defs/ParticipantViewTransitionKind" }, "trigger": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Trigger", "type": "string" } @@ -4440,6 +5496,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -4467,6 +5526,9 @@ }, "properties": { "additionalProperties": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Properties", @@ -4495,10 +5557,16 @@ "default": null }, "source": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source", "type": "string" }, "target": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target", "type": "string" }, @@ -4524,6 +5592,9 @@ "$ref": "#/$defs/DatabaseAuthMethod" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -4532,11 +5603,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "role_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Role Ref", "type": "string" } @@ -4550,11 +5627,17 @@ "properties": { "crypto_method": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Crypto Method", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -4564,6 +5647,9 @@ "$ref": "#/$defs/RuntimeForwardingEnrollmentClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -4572,10 +5658,16 @@ }, "enrollment_identity_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Enrollment Identity Ref", "type": "string" }, "forwarder_ref": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Forwarder Ref", "type": "string" }, @@ -4585,6 +5677,9 @@ "$ref": "#/$defs/RuntimeForwardingParseFormat" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -4593,6 +5688,9 @@ }, "protocol": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Protocol", "type": "string" }, @@ -4602,6 +5700,9 @@ "$ref": "#/$defs/RuntimeSecurityMonitoringListenerRole" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -4625,6 +5726,9 @@ "$ref": "#/$defs/RuntimeMailAuthMechanism" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -4633,21 +5737,33 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "domain_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Domain Ref", "type": "string" }, "listener_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Listener Ref", "type": "string" }, "mailbox_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Mailbox Ref", "type": "string" }, @@ -4657,6 +5773,9 @@ "$ref": "#/$defs/RuntimeMailProtocol" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -4669,6 +5788,9 @@ "$ref": "#/$defs/RuntimeMailTlsMode" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -4685,6 +5807,9 @@ "properties": { "body_limit": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Body Limit", "type": "string" }, @@ -4694,6 +5819,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4705,6 +5833,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -4714,6 +5845,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4724,16 +5858,25 @@ "title": "Origin Plaintext" }, "route_ref": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Route Ref", "type": "string" }, "upstream_node_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Upstream Node Ref", "type": "string" }, "upstream_service_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Upstream Service Ref", "type": "string" } @@ -4750,16 +5893,25 @@ "properties": { "auth_principal_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Auth Principal Ref", "type": "string" }, "consumer_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Consumer Ref", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -4769,6 +5921,9 @@ "$ref": "#/$defs/RelationshipServiceIntegrationDirection" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -4781,6 +5936,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4792,6 +5950,9 @@ }, "engine_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Engine Ref", "type": "string" }, @@ -4801,6 +5962,9 @@ "$ref": "#/$defs/RelationshipServiceIntegrationKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -4859,6 +6023,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -4871,6 +6038,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -4891,12 +6061,18 @@ "properties": { "entities": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Entities", "type": "array" }, "username": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Username", "type": "string" } @@ -4912,6 +6088,9 @@ "description": "Application-internal RBAC store inventory for a single owning spine.", "properties": { "app_authorization_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "App Authorization Id", "type": "string" }, @@ -4921,6 +6100,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -4932,11 +6114,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -4960,6 +6148,9 @@ "$ref": "#/$defs/RuntimeAppAuthorizationResourceVocabulary" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -5010,6 +6201,9 @@ "properties": { "actions": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Actions", @@ -5017,6 +6211,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -5026,6 +6223,9 @@ "$ref": "#/$defs/RuntimeAppAuthorizationGrantEffect" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -5033,6 +6233,9 @@ "title": "Effect" }, "grant_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Grant Id", "type": "string" }, @@ -5042,6 +6245,9 @@ "$ref": "#/$defs/RuntimeAppAuthorizationResourceVocabulary" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -5050,6 +6256,9 @@ }, "resource_patterns": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Resource Patterns", @@ -5057,6 +6266,9 @@ }, "role_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Role Ref", "type": "string" } @@ -5082,6 +6294,9 @@ "properties": { "backend_roles": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Backend Roles", @@ -5093,6 +6308,9 @@ "$ref": "#/$defs/RuntimeAppAuthorizationCredentialClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -5101,6 +6319,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -5110,6 +6331,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -5125,6 +6349,9 @@ "$ref": "#/$defs/RuntimeAppAuthorizationPrincipalKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -5133,10 +6360,16 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "principal_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Principal Id", "type": "string" }, @@ -5146,6 +6379,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -5194,15 +6430,24 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "role_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Role Id", "type": "string" } @@ -5219,6 +6464,9 @@ "properties": { "backend_roles": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Backend Roles", @@ -5226,27 +6474,42 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "hosts": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Hosts", "type": "array" }, "mapping_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Mapping Id", "type": "string" }, "role_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Role Ref", "type": "string" }, "users": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Users", @@ -5265,15 +6528,24 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "tenant_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Tenant Id", "type": "string" } @@ -5290,11 +6562,17 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "disclosure": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Disclosure", "type": "string" }, @@ -5304,6 +6582,9 @@ "$ref": "#/$defs/RuntimeSensitivityClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -5316,6 +6597,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -5327,6 +6611,9 @@ }, "trigger": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Trigger", "type": "string" } @@ -5340,10 +6627,16 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -5353,6 +6646,9 @@ "$ref": "#/$defs/RuntimeSensitivityClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -5361,6 +6657,9 @@ }, "value": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Value", "type": "string" } @@ -5377,11 +6676,17 @@ "properties": { "data_type": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Data Type", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -5391,6 +6696,9 @@ "$ref": "#/$defs/RuntimeApplicationParameterLocation" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -5398,6 +6706,9 @@ "title": "Location" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -5407,6 +6718,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -5458,11 +6772,17 @@ "properties": { "condition": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Condition", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -5472,6 +6792,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -5482,6 +6805,9 @@ "title": "Status Code" }, "target": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target", "type": "string" } @@ -5498,11 +6824,17 @@ "properties": { "content_type": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Content Type", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -5512,6 +6844,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -5534,6 +6869,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -5545,11 +6883,17 @@ }, "auth_scheme": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Auth Scheme", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -5569,6 +6913,9 @@ }, "methods": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Methods", @@ -5576,6 +6923,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -5587,6 +6937,9 @@ "type": "array" }, "path": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Path", "type": "string" }, @@ -5605,6 +6958,9 @@ "type": "array" }, "route_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Route Id", "type": "string" }, @@ -5614,6 +6970,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -5625,6 +6984,9 @@ }, "static_assets": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Static Assets", @@ -5632,6 +6994,9 @@ }, "templates": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Templates", @@ -5650,6 +7015,9 @@ }, "vulnerability_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Vulnerability Refs", @@ -5682,6 +7050,9 @@ "$ref": "#/$defs/RuntimeApplicationRouteUpstreamScheme" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -5690,11 +7061,17 @@ }, "target_node_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target Node Ref", "type": "string" }, "target_service": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target Service", "type": "string" }, @@ -5704,6 +7081,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -5722,26 +7102,41 @@ "description": "An observed application surface hosted by a transport service on a node.\n\n``service`` references the owning same-node ``Node.services[].name`` (bare\nname or the qualified ``nodes..services.`` form). The surface is\nobservation metadata; it never mutates ``Node.services``.", "properties": { "application_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Application Id", "type": "string" }, "base_path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Base Path", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "framework": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Framework", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -5751,6 +7146,9 @@ "$ref": "#/$defs/RuntimeApplicationProtocol" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -5766,6 +7164,9 @@ }, "service": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service", "type": "string" } @@ -5791,6 +7192,9 @@ "properties": { "add": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Add", @@ -5798,11 +7202,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "drop": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Drop", @@ -5810,6 +7220,9 @@ }, "effective": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Effective", @@ -5824,6 +7237,9 @@ }, "required": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Required", @@ -6106,6 +7522,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6117,11 +7536,17 @@ }, "cgroup_parent": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Cgroup Parent", "type": "string" }, "command": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Command", @@ -6129,11 +7554,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "device_cgroup_rules": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Device Cgroup Rules", @@ -6148,6 +7579,9 @@ }, "dns": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Dns", @@ -6155,6 +7589,9 @@ }, "dns_options": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Dns Options", @@ -6162,6 +7599,9 @@ }, "dns_search": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Dns Search", @@ -6169,6 +7609,9 @@ }, "entrypoint": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Entrypoint", @@ -6183,6 +7626,9 @@ }, "group_add": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Group Add", @@ -6201,11 +7647,17 @@ }, "log_driver": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Log Driver", "type": "string" }, "log_options": { "additionalProperties": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Log Options", @@ -6213,6 +7665,9 @@ }, "masked_paths": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Masked Paths", @@ -6235,6 +7690,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6250,6 +7708,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6261,6 +7722,9 @@ }, "read_only_paths": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Read Only Paths", @@ -6272,6 +7736,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6283,16 +7750,25 @@ }, "runtime_name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Runtime Name", "type": "string" }, "seccomp_profile": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Seccomp Profile", "type": "string" }, "security_opt": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Security Opt", @@ -6304,6 +7780,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6363,6 +7842,9 @@ "$ref": "#/$defs/RuntimeControlInterfaceAccess" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -6371,6 +7853,9 @@ }, "bind_source": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Bind Source", "type": "string" }, @@ -6380,6 +7865,9 @@ "$ref": "#/$defs/RuntimeSensitivityClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -6387,11 +7875,17 @@ "title": "Bind Source Sensitivity" }, "control_interface_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Control Interface Id", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -6401,6 +7895,9 @@ "$ref": "#/$defs/RuntimeControlInterfaceKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -6408,11 +7905,17 @@ "title": "Kind" }, "path": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Path", "type": "string" }, "protocol": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Protocol", "type": "string" } @@ -6452,6 +7955,9 @@ "description": "An observed database service hosted by a transport service on a node.\n\n``service`` references the owning same-node ``Node.services[].name`` (bare\nname or the qualified ``nodes..services.`` form). The inventory\nis observation metadata; it never mutates ``Node.services``.", "properties": { "database_service_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Database Service Id", "type": "string" }, @@ -6464,6 +7970,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -6473,6 +7982,9 @@ "$ref": "#/$defs/DatabaseEngine" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -6495,6 +8007,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -6504,6 +8019,9 @@ "$ref": "#/$defs/DatabaseProtocol" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -6519,6 +8037,9 @@ }, "service": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service", "type": "string" }, @@ -6531,6 +8052,9 @@ }, "version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -6546,16 +8070,25 @@ "description": "The single observed cluster posture of a datastore service.\n\nCaptures the cluster-identity facts a search/wide-column cluster exposes:\nnative UUID, aggregate cardinality and size, shard totals, health,\ndiscovery mode, partitioner, and native protocol version.", "properties": { "cluster_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Cluster Id", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "discovery_mode": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Discovery Mode", "type": "string" }, @@ -6565,6 +8098,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6576,16 +8112,25 @@ }, "health": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Health", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "native_protocol_version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Native Protocol Version", "type": "string" }, @@ -6595,6 +8140,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6606,6 +8154,9 @@ }, "partitioner": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Partitioner", "type": "string" }, @@ -6615,6 +8166,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6630,6 +8184,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6645,6 +8202,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6656,6 +8216,9 @@ }, "uuid": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Uuid", "type": "string" } @@ -6701,20 +8264,32 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "plugin_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Plugin Id", "type": "string" }, "version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -6752,6 +8327,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6763,11 +8341,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "dynamic_policy": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Dynamic Policy", "type": "string" }, @@ -6777,6 +8361,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6788,6 +8375,9 @@ }, "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Refs", @@ -6800,6 +8390,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ] @@ -6813,6 +8406,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6823,21 +8419,33 @@ "title": "Leaf Field Count" }, "mapping_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Mapping Id", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "partition_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Partition Ref", "type": "string" }, "schema_digest": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Schema Digest", "type": "string" }, @@ -6847,6 +8455,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6869,16 +8480,25 @@ "properties": { "build_hash": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Build Hash", "type": "string" }, "build_type": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Build Type", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -6891,6 +8511,9 @@ }, "engine_version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Engine Version", "type": "string" }, @@ -6900,6 +8523,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6915,6 +8541,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6930,6 +8559,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6945,6 +8577,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -6956,10 +8591,16 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "node_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Node Id", "type": "string" }, @@ -6977,6 +8618,9 @@ "$ref": "#/$defs/RuntimeDatastoreNodeRole" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ] @@ -6997,15 +8641,24 @@ "properties": { "address": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Address", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "endpoint_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Endpoint Id", "type": "string" }, @@ -7015,6 +8668,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -7026,6 +8682,9 @@ }, "protocol": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Protocol", "type": "string" }, @@ -7035,6 +8694,9 @@ "$ref": "#/$defs/RuntimeDatastoreNodeEndpointRole" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -7081,6 +8743,9 @@ "properties": { "creation_timestamp": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Creation Timestamp", "type": "string" }, @@ -7091,6 +8756,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ] @@ -7100,6 +8768,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -7109,6 +8780,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -7124,6 +8798,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -7139,6 +8816,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -7150,6 +8830,9 @@ }, "health": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Health", "type": "string" }, @@ -7159,6 +8842,9 @@ "$ref": "#/$defs/RuntimeDatastorePartitionKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -7167,15 +8853,24 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "open_closed_status": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Open Closed Status", "type": "string" }, "partition_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Partition Id", "type": "string" }, @@ -7186,6 +8881,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ] @@ -7199,6 +8897,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -7214,6 +8915,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -7229,6 +8933,9 @@ "$ref": "#/$defs/RuntimeDatastoreReplicationStrategy" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -7241,6 +8948,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -7256,6 +8966,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -7267,6 +8980,9 @@ }, "uuid": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Uuid", "type": "string" } @@ -7300,6 +9016,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -7311,6 +9030,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -7320,6 +9042,9 @@ "$ref": "#/$defs/RuntimeDatastoreEvictionPolicy" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -7328,15 +9053,24 @@ }, "maxmemory": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Maxmemory", "type": "string" }, "persistence_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Persistence Id", "type": "string" }, "rdb_save_points": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Rdb Save Points", @@ -7366,6 +9100,9 @@ "properties": { "aliases": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Aliases", @@ -7373,11 +9110,17 @@ }, "authorization_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Authorization Ref", "type": "string" }, "backup_targets": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Backup Targets", @@ -7400,6 +9143,9 @@ "$ref": "#/$defs/RuntimeDatastoreDataModel" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -7407,11 +9153,17 @@ "title": "Data Model" }, "datastore_service_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Datastore Service Id", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -7421,6 +9173,9 @@ "$ref": "#/$defs/RuntimeDatastoreEngine" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -7429,6 +9184,9 @@ }, "ingest_pipelines": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Ingest Pipelines", @@ -7436,6 +9194,9 @@ }, "lifecycle_policies": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Lifecycle Policies", @@ -7450,6 +9211,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -7480,11 +9244,17 @@ }, "protocol": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Protocol", "type": "string" }, "pubsub_channels": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Pubsub Channels", @@ -7492,6 +9262,9 @@ }, "queues_streams": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Queues Streams", @@ -7499,6 +9272,9 @@ }, "service": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service", "type": "string" }, @@ -7529,6 +9305,9 @@ }, "version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -7549,6 +9328,9 @@ "$ref": "#/$defs/RuntimeSensitivityClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -7557,11 +9339,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -7571,6 +9359,9 @@ "$ref": "#/$defs/RuntimeDatastoreSettingProvenance" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -7583,6 +9374,9 @@ "$ref": "#/$defs/RuntimeDatastoreSettingScope" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -7590,11 +9384,17 @@ "title": "Scope" }, "setting_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Setting Id", "type": "string" }, "value": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Value", "type": "string" } @@ -7636,11 +9436,17 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Refs", @@ -7648,6 +9454,9 @@ }, "index_patterns": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Index Patterns", @@ -7655,11 +9464,17 @@ }, "mapping_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Mapping Ref", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -7667,6 +9482,9 @@ "additionalProperties": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -7682,10 +9500,16 @@ }, "template_digest": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Template Digest", "type": "string" }, "template_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Template Id", "type": "string" } @@ -7706,6 +9530,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -7717,6 +9544,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -7726,6 +9556,9 @@ "$ref": "#/$defs/RuntimeDatastoreTransportSecurityMode" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -7738,6 +9571,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -7748,6 +9584,9 @@ "title": "Node Verification" }, "transport_security_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Transport Security Id", "type": "string" } @@ -7773,25 +9612,40 @@ "description": "A dependency manifest visible in the realized runtime artifact.", "properties": { "ecosystem": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Ecosystem", "type": "string" }, "format": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Format", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "path": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Path", "type": "string" }, "version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -7808,20 +9662,32 @@ "description": "A host device mapping observed in runtime configuration.", "properties": { "container_path": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Container Path", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "host_path": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Host Path", "type": "string" }, "permissions": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Permissions", "type": "string" } @@ -7839,6 +9705,9 @@ "properties": { "configuration_file_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Configuration File Refs", @@ -7846,10 +9715,16 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "dns_service_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Dns Service Id", "type": "string" }, @@ -7870,6 +9745,9 @@ "$ref": "#/$defs/DnsServerImplementation" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -7878,6 +9756,9 @@ }, "log_file_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Log File Refs", @@ -7885,6 +9766,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -7906,6 +9790,9 @@ "$ref": "#/$defs/DnsServiceRole" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ] @@ -7915,6 +9802,9 @@ }, "service": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service", "type": "string" }, @@ -7927,6 +9817,9 @@ }, "version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" }, @@ -7963,10 +9856,16 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -7976,6 +9875,9 @@ "$ref": "#/$defs/RuntimeEnvironmentVariableProvenance" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -7984,11 +9886,17 @@ }, "source": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source", "type": "string" }, "value": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Value", "type": "string" }, @@ -7998,6 +9906,9 @@ "$ref": "#/$defs/RuntimeEnvironmentValueClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8030,15 +9941,24 @@ "description": "An observed extra host mapping in runtime configuration.", "properties": { "address": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Address", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "hostname": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Hostname", "type": "string" } @@ -8070,15 +9990,24 @@ }, "backend": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Backend", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "file_service_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "File Service Id", "type": "string" }, @@ -8095,6 +10024,9 @@ "$ref": "#/$defs/RuntimeFileServiceProtocol" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8103,6 +10035,9 @@ }, "service": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service", "type": "string" }, @@ -8174,6 +10109,9 @@ "$ref": "#/$defs/RuntimeFileServiceAccessAction" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8186,6 +10124,9 @@ "$ref": "#/$defs/RuntimeFileServiceAccessBasis" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8194,10 +10135,16 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "observation_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Observation Id", "type": "string" }, @@ -8207,6 +10154,9 @@ "$ref": "#/$defs/RuntimeFileServiceAccessOutcome" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8214,6 +10164,9 @@ "title": "Outcome" }, "resource_ref": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Resource Ref", "type": "string" }, @@ -8223,6 +10176,9 @@ "$ref": "#/$defs/RuntimeSensitivityClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8230,6 +10186,9 @@ "title": "Sensitivity" }, "subject_ref": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Subject Ref", "type": "string" } @@ -8265,6 +10224,9 @@ "$ref": "#/$defs/RuntimeFileServiceAccessAction" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8277,6 +10239,9 @@ "$ref": "#/$defs/RuntimeFileServiceAccessBasis" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8285,6 +10250,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -8294,6 +10262,9 @@ "$ref": "#/$defs/RuntimeFileServiceAccessEffect" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8301,14 +10272,23 @@ "title": "Effect" }, "resource_ref": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Resource Ref", "type": "string" }, "rule_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Rule Id", "type": "string" }, "subject_ref": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Subject Ref", "type": "string" } @@ -8345,6 +10325,9 @@ "$ref": "#/$defs/RuntimeFileServiceCredentialClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8353,16 +10336,25 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "directory_subject_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Directory Subject Ref", "type": "string" }, "external_id": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "External Id", "type": "string" }, @@ -8372,6 +10364,9 @@ "$ref": "#/$defs/RuntimeFileServicePrincipalKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8380,10 +10375,16 @@ }, "local_user_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Local User Ref", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -8393,6 +10394,9 @@ "$ref": "#/$defs/RuntimeFileServicePrincipalOrigin" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8400,6 +10404,9 @@ "title": "Origin" }, "principal_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Principal Id", "type": "string" }, @@ -8409,6 +10416,9 @@ "$ref": "#/$defs/RuntimeFileServicePrincipalStatus" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8489,6 +10499,9 @@ "properties": { "backing_path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Backing Path", "type": "string" }, @@ -8498,6 +10511,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -8509,11 +10525,17 @@ }, "comment": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Comment", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -8523,6 +10545,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -8534,6 +10559,9 @@ }, "invalid_users": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Invalid Users", @@ -8545,6 +10573,9 @@ "$ref": "#/$defs/RuntimeFileShareKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8552,6 +10583,9 @@ "title": "Kind" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -8561,6 +10595,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -8571,11 +10608,17 @@ "title": "Read Only" }, "share_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Share Id", "type": "string" }, "valid_groups": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Valid Groups", @@ -8583,6 +10626,9 @@ }, "valid_users": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Valid Users", @@ -8590,6 +10636,9 @@ }, "write_users": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Write Users", @@ -8621,16 +10670,25 @@ "properties": { "content_digest": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Content Digest", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "digest_algorithm": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Digest Algorithm", "type": "string" }, @@ -8640,6 +10698,9 @@ "$ref": "#/$defs/RuntimeFilesystemEntryType" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8652,6 +10713,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -8663,20 +10727,32 @@ }, "mode": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Mode", "type": "string" }, "owner_group": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Owner Group", "type": "string" }, "owner_user": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Owner User", "type": "string" }, "path": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Path", "type": "string" }, @@ -8686,6 +10762,9 @@ "$ref": "#/$defs/RuntimeFilesystemPresence" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8694,6 +10773,9 @@ }, "provenance": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Provenance", "type": "string" }, @@ -8703,6 +10785,9 @@ "$ref": "#/$defs/RuntimeSensitivityClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8715,6 +10800,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -8726,6 +10814,9 @@ }, "source_path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source Path", "type": "string" }, @@ -8735,6 +10826,9 @@ "$ref": "#/$defs/RuntimeFilesystemStability" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8747,6 +10841,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -8815,6 +10912,9 @@ "$ref": "#/$defs/RuntimeForwardingAgentKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8834,10 +10934,16 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "forwarding_agent_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Forwarding Agent Id", "type": "string" }, @@ -8847,6 +10953,9 @@ "$ref": "#/$defs/RuntimeForwardingAgentImplementation" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8855,6 +10964,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -8895,6 +11007,9 @@ }, "version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -8952,6 +11067,9 @@ "description": "The single observed buffer / back-pressure posture of a forwarder.\n\nCaptures the ``client_buffer`` shape: queue capacity, events-per-second\nceiling, at-rest/in-transit crypto, and reconnect interval. Its presence is\nthe defining profile a ``log_forwarder`` must carry.", "properties": { "buffer_policy_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Buffer Policy Id", "type": "string" }, @@ -8961,6 +11079,9 @@ "$ref": "#/$defs/RuntimeForwardingBufferCrypto" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -8969,6 +11090,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -8978,6 +11102,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -8993,6 +11120,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -9008,6 +11138,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -9076,6 +11209,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -9085,6 +11221,9 @@ "$ref": "#/$defs/RuntimeForwardingReloadChannelKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9092,11 +11231,17 @@ "title": "Kind" }, "reload_channel_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Reload Channel Id", "type": "string" }, "target_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target Ref", "type": "string" } @@ -9131,6 +11276,9 @@ "$ref": "#/$defs/RuntimeForwardingSettingClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9139,11 +11287,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -9153,6 +11307,9 @@ "$ref": "#/$defs/RuntimeForwardingSettingProvenance" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9160,11 +11317,17 @@ "title": "Provenance" }, "setting_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Setting Id", "type": "string" }, "value": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Value", "type": "string" } @@ -9204,6 +11367,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -9213,6 +11379,9 @@ "$ref": "#/$defs/RuntimeForwardingEnrollmentClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9225,6 +11394,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -9240,6 +11412,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -9255,6 +11430,9 @@ "$ref": "#/$defs/RuntimeForwardingProtocol" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9262,16 +11440,25 @@ "title": "Protocol" }, "target_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target Id", "type": "string" }, "target_node_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target Node Ref", "type": "string" }, "target_service_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target Service Ref", "type": "string" } @@ -9288,6 +11475,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -9297,6 +11487,9 @@ "$ref": "#/$defs/RuntimeForwardingSourceKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9305,6 +11498,9 @@ }, "location": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Location", "type": "string" }, @@ -9314,6 +11510,9 @@ "$ref": "#/$defs/RuntimeForwardingParseFormat" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9322,10 +11521,16 @@ }, "selector": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Selector", "type": "string" }, "source_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source Id", "type": "string" } @@ -9354,6 +11559,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -9363,6 +11571,9 @@ "$ref": "#/$defs/RuntimeForwardingTransformKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9371,10 +11582,16 @@ }, "sid_namespace": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Sid Namespace", "type": "string" }, "transform_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Transform Id", "type": "string" } @@ -9403,6 +11620,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -9412,6 +11632,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -9434,6 +11657,9 @@ "$ref": "#/$defs/RuntimeHealthStatus" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9463,6 +11689,9 @@ "properties": { "end": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "End", "type": "string" }, @@ -9472,6 +11701,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -9483,6 +11715,9 @@ }, "output": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Output", "type": "string" }, @@ -9492,6 +11727,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9500,6 +11738,9 @@ }, "start": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Start", "type": "string" } @@ -9513,10 +11754,16 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -9526,6 +11773,9 @@ "$ref": "#/$defs/RuntimeIdentityRecordOrigin" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9538,6 +11788,9 @@ "$ref": "#/$defs/RuntimeIdentityRecordOrigin" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9550,6 +11803,9 @@ "$ref": "#/$defs/RuntimeSensitivityClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9558,6 +11814,9 @@ }, "values": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Values", @@ -9576,25 +11835,40 @@ "properties": { "base_dn": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Base Dn", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "domain_name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Domain Name", "type": "string" }, "identity_authority_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Identity Authority Id", "type": "string" }, "issuer": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Issuer", "type": "string" }, @@ -9604,6 +11878,9 @@ "$ref": "#/$defs/RuntimeIdentityAuthorityKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9612,11 +11889,17 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "namespace": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Namespace", "type": "string" }, @@ -9629,6 +11912,9 @@ }, "realm": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Realm", "type": "string" }, @@ -9655,6 +11941,9 @@ }, "tenant_id": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Tenant Id", "type": "string" } @@ -9704,11 +11993,17 @@ "properties": { "address": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Address", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -9718,6 +12013,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -9733,6 +12031,9 @@ "$ref": "#/$defs/RuntimeIdentityAuthorityProtocol" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9741,10 +12042,16 @@ }, "service": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service", "type": "string" }, "service_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service Id", "type": "string" } @@ -9761,6 +12068,9 @@ "properties": { "applies_to_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Applies To Refs", @@ -9768,15 +12078,24 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "policy_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Policy Id", "type": "string" }, @@ -9786,6 +12105,9 @@ "$ref": "#/$defs/RuntimeIdentityPolicyKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9858,15 +12180,24 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "external_target": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "External Target", "type": "string" }, "relationship_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Relationship Id", "type": "string" }, @@ -9876,6 +12207,9 @@ "$ref": "#/$defs/RuntimeIdentityRelationshipKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9883,11 +12217,17 @@ "title": "Relationship Type" }, "source_ref": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source Ref", "type": "string" }, "target_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target Ref", "type": "string" } @@ -9930,21 +12270,33 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "display_name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Display Name", "type": "string" }, "distinguished_name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Distinguished Name", "type": "string" }, "domain": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Domain", "type": "string" }, @@ -9954,6 +12306,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -9969,6 +12324,9 @@ "$ref": "#/$defs/RuntimeIdentitySubjectKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9976,6 +12334,9 @@ "title": "Kind" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -9985,6 +12346,9 @@ "$ref": "#/$defs/RuntimeIdentityRecordOrigin" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -9993,17 +12357,26 @@ }, "principal_name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Principal Name", "type": "string" }, "service_principal_names": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Service Principal Names", "type": "array" }, "subject_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Subject Id", "type": "string" } @@ -10039,6 +12412,9 @@ "properties": { "argv": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Argv", @@ -10050,6 +12426,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10058,6 +12437,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -10067,6 +12449,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -10078,11 +12463,17 @@ }, "executable_path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Executable Path", "type": "string" }, "implementation": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Implementation", "type": "string" }, @@ -10092,6 +12483,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -10156,16 +12550,25 @@ "properties": { "criteria": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Criteria", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Refs", @@ -10173,6 +12576,9 @@ }, "probe": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Probe", "type": "string" } @@ -10200,6 +12606,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -10209,6 +12618,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -10220,12 +12632,18 @@ }, "members": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Members", "type": "array" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -10235,6 +12653,9 @@ "$ref": "#/$defs/RuntimeIdentityProvenance" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10254,6 +12675,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -10288,6 +12712,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -10297,6 +12724,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10305,11 +12735,17 @@ }, "gecos": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Gecos", "type": "string" }, "home": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Home", "type": "string" }, @@ -10319,6 +12755,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10331,6 +12770,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10343,6 +12785,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -10354,6 +12799,9 @@ }, "primary_group": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Primary Group", "type": "string" }, @@ -10363,6 +12811,9 @@ "$ref": "#/$defs/RuntimeIdentityProvenance" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10371,6 +12822,9 @@ }, "shell": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Shell", "type": "string" }, @@ -10380,6 +12834,9 @@ "$ref": "#/$defs/RuntimeFilesystemStability" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10388,6 +12845,9 @@ }, "supplemental_groups": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Supplemental Groups", @@ -10399,6 +12859,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -10409,6 +12872,9 @@ "title": "Uid" }, "username": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Username", "type": "string" } @@ -10425,25 +12891,40 @@ "properties": { "address": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Address", "type": "string" }, "alias_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Alias Id", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "domain_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Domain Ref", "type": "string" }, "external_targets": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "External Targets", @@ -10451,6 +12932,9 @@ }, "target_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Target Refs", @@ -10487,11 +12971,17 @@ "description": "A mail-service engine/component such as Postfix, Dovecot, or a filter.", "properties": { "component_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Component Id", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -10501,6 +12991,9 @@ "$ref": "#/$defs/RuntimeMailComponentKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10508,11 +13001,17 @@ "title": "Kind" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -10565,14 +13064,23 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "domain_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Domain Id", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -10582,6 +13090,9 @@ "$ref": "#/$defs/RuntimeMailDomainRole" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10617,6 +13128,9 @@ "properties": { "advertised_identity": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Advertised Identity", "type": "string" }, @@ -10627,6 +13141,9 @@ "$ref": "#/$defs/RuntimeMailAuthMechanism" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ] @@ -10636,11 +13153,17 @@ }, "banner": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Banner", "type": "string" }, "capabilities": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Capabilities", @@ -10648,15 +13171,24 @@ }, "component_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Component Ref", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "listener_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Listener Id", "type": "string" }, @@ -10666,6 +13198,9 @@ "$ref": "#/$defs/RuntimeMailProtocol" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10678,6 +13213,9 @@ "$ref": "#/$defs/RuntimeMailListenerRole" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10686,6 +13224,9 @@ }, "service": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service", "type": "string" }, @@ -10695,6 +13236,9 @@ "$ref": "#/$defs/RuntimeMailTlsMode" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10703,6 +13247,9 @@ }, "tls_versions": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Tls Versions", @@ -10738,10 +13285,16 @@ "properties": { "account_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Account Ref", "type": "string" }, "address": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Address", "type": "string" }, @@ -10752,6 +13305,9 @@ "$ref": "#/$defs/RuntimeMailAuthMechanism" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ] @@ -10765,6 +13321,9 @@ "$ref": "#/$defs/RuntimeMailCredentialClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10773,25 +13332,40 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "domain_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Domain Ref", "type": "string" }, "local_part": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Local Part", "type": "string" }, "local_user_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Local User Ref", "type": "string" }, "mailbox_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Mailbox Id", "type": "string" }, @@ -10801,6 +13375,9 @@ "$ref": "#/$defs/RuntimeMailMailboxRole" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10813,6 +13390,9 @@ "$ref": "#/$defs/RuntimeMailMailboxStatus" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10821,6 +13401,9 @@ }, "store_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Store Ref", "type": "string" } @@ -10867,6 +13450,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -10876,6 +13462,9 @@ "$ref": "#/$defs/RuntimeMailMailboxStoreKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10884,10 +13473,16 @@ }, "path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Path", "type": "string" }, "store_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Store Id", "type": "string" } @@ -10937,6 +13532,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -10946,6 +13544,9 @@ "$ref": "#/$defs/RuntimeMailQueueKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -10958,6 +13559,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -10969,10 +13573,16 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "queue_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Queue Id", "type": "string" }, @@ -10982,6 +13592,9 @@ "$ref": "#/$defs/RuntimeMailQueueStability" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -11047,6 +13660,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -11056,6 +13672,9 @@ "$ref": "#/$defs/RuntimeMailRoutingKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -11064,20 +13683,32 @@ }, "relay_host": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Relay Host", "type": "string" }, "rule_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Rule Id", "type": "string" }, "source_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source Ref", "type": "string" }, "target_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target Ref", "type": "string" } @@ -11108,6 +13739,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -11120,6 +13754,9 @@ }, "engine": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Engine", "type": "string" }, @@ -11131,6 +13768,9 @@ "type": "array" }, "mail_service_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Mail Service Id", "type": "string" }, @@ -11150,6 +13790,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -11169,6 +13812,9 @@ }, "service": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service", "type": "string" }, @@ -11181,6 +13827,9 @@ }, "version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -11197,15 +13846,24 @@ "properties": { "component_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Component Ref", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -11215,6 +13873,9 @@ "$ref": "#/$defs/RuntimeMailSettingProvenance" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -11222,16 +13883,25 @@ "title": "Provenance" }, "setting_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Setting Id", "type": "string" }, "source_path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source Path", "type": "string" }, "value": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Value", "type": "string" }, @@ -11241,6 +13911,9 @@ "$ref": "#/$defs/RuntimeSensitivityClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -11364,6 +14037,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -11375,16 +14051,25 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "filesystem_type": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Filesystem Type", "type": "string" }, "options": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Options", @@ -11396,6 +14081,9 @@ "$ref": "#/$defs/RuntimeSensitivityClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -11408,6 +14096,9 @@ "$ref": "#/$defs/RuntimeMountPropagation" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -11420,6 +14111,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -11428,6 +14122,9 @@ }, "source": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source", "type": "string" }, @@ -11437,6 +14134,9 @@ "$ref": "#/$defs/RuntimeMountSourceKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -11449,6 +14149,9 @@ "$ref": "#/$defs/RuntimeSensitivityClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -11461,6 +14164,9 @@ "$ref": "#/$defs/RuntimeFilesystemStability" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -11468,6 +14174,9 @@ "title": "Stability" }, "target": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target", "type": "string" } @@ -11512,26 +14221,41 @@ "properties": { "cgroup": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Cgroup", "type": "string" }, "ipc": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Ipc", "type": "string" }, "pid": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Pid", "type": "string" }, "userns": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Userns", "type": "string" }, "uts": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Uts", "type": "string" } @@ -11545,6 +14269,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -11554,6 +14281,9 @@ "$ref": "#/$defs/RuntimeNetworkDriver" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -11562,6 +14292,9 @@ }, "driver_options": { "additionalProperties": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Driver Options", @@ -11569,11 +14302,17 @@ }, "ipam_driver": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Ipam Driver", "type": "string" }, "ipam_options": { "additionalProperties": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Ipam Options", @@ -11631,6 +14370,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -11647,6 +14389,9 @@ "$ref": "#/$defs/RuntimeNetworkDetectionControlCapability" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ] @@ -11655,11 +14400,17 @@ "type": "array" }, "channel_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Channel Id", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -11669,6 +14420,9 @@ "$ref": "#/$defs/RuntimeNetworkDetectionControlChannelKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -11677,11 +14431,17 @@ }, "path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Path", "type": "string" }, "service": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service", "type": "string" } @@ -11718,6 +14478,9 @@ "$ref": "#/$defs/RuntimeNetworkDetectionAppProtocol" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ] @@ -11727,6 +14490,9 @@ }, "configuration_file_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Configuration File Refs", @@ -11741,6 +14507,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -11750,6 +14519,9 @@ "$ref": "#/$defs/RuntimeNetworkDetectionEngineKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -11758,6 +14530,9 @@ }, "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Refs", @@ -11769,6 +14544,9 @@ "$ref": "#/$defs/RuntimeNetworkDetectionEngineImplementation" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -11777,6 +14555,9 @@ }, "log_file_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Log File Refs", @@ -11784,10 +14565,16 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "network_detection_engine_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Network Detection Engine Id", "type": "string" }, @@ -11807,11 +14594,17 @@ }, "process_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Process Ref", "type": "string" }, "revision": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Revision", "type": "string" }, @@ -11824,11 +14617,17 @@ }, "sensor_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Sensor Ref", "type": "string" }, "version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -11897,6 +14696,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -11906,6 +14708,9 @@ "$ref": "#/$defs/RuntimeNetworkDetectionNetworkSetKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -11914,11 +14719,17 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "network_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Network Refs", @@ -11926,12 +14737,18 @@ }, "selector_values": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Selector Values", "type": "array" }, "set_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Set Id", "type": "string" } @@ -11979,6 +14796,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -11988,6 +14808,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -12004,6 +14827,9 @@ "$ref": "#/$defs/RuntimeNetworkDetectionEventType" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ] @@ -12017,6 +14843,9 @@ "$ref": "#/$defs/RuntimeNetworkDetectionOutputFormat" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -12025,10 +14854,16 @@ }, "path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Path", "type": "string" }, "stream_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Stream Id", "type": "string" } @@ -12063,11 +14898,17 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "file_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "File Refs", @@ -12079,6 +14920,9 @@ "$ref": "#/$defs/RuntimeNetworkDetectionRuleFormat" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -12087,6 +14931,9 @@ }, "generated_by": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Generated By", "type": "string" }, @@ -12096,6 +14943,9 @@ "$ref": "#/$defs/RuntimeNetworkDetectionRuleSourceKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -12108,6 +14958,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -12119,6 +14972,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -12128,6 +14984,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -12138,6 +14997,9 @@ "title": "Rule Count" }, "source_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source Id", "type": "string" } @@ -12185,6 +15047,9 @@ "properties": { "aliases": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Aliases", @@ -12207,6 +15072,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -12218,11 +15086,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "dns_names": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Dns Names", @@ -12230,6 +15104,9 @@ }, "endpoint_id": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Endpoint Id", "type": "string" }, @@ -12239,6 +15116,9 @@ "$ref": "#/$defs/RuntimeNetworkIdStability" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -12247,11 +15127,17 @@ }, "gateway": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Gateway", "type": "string" }, "generated_dns_names": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Generated Dns Names", @@ -12259,6 +15145,9 @@ }, "ip_address": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Ip Address", "type": "string" }, @@ -12268,6 +15157,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -12279,15 +15171,24 @@ }, "mac_address": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Mac Address", "type": "string" }, "network": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Network", "type": "string" }, "network_id": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Network Id", "type": "string" }, @@ -12297,6 +15198,9 @@ "$ref": "#/$defs/RuntimeNetworkIdStability" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -12327,11 +15231,17 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "domainname": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Domainname", "type": "string" }, @@ -12344,6 +15254,9 @@ }, "hostname": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Hostname", "type": "string" }, @@ -12364,6 +15277,9 @@ "properties": { "capture_interfaces": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Capture Interfaces", @@ -12375,6 +15291,9 @@ "$ref": "#/$defs/RuntimeNetworkSensorCaptureMode" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -12383,6 +15302,9 @@ }, "configuration_file_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Configuration File Refs", @@ -12390,11 +15312,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Refs", @@ -12406,6 +15334,9 @@ "$ref": "#/$defs/RuntimeNetworkSensorImplementation" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -12414,6 +15345,9 @@ }, "log_file_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Log File Refs", @@ -12421,6 +15355,9 @@ }, "monitored_network_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Monitored Network Refs", @@ -12432,6 +15369,9 @@ "$ref": "#/$defs/RuntimeNetworkSensorMonitoringPosture" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -12440,20 +15380,32 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "network_sensor_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Network Sensor Id", "type": "string" }, "process_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Process Ref", "type": "string" }, "revision": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Revision", "type": "string" }, @@ -12463,6 +15415,9 @@ "$ref": "#/$defs/RuntimeNetworkSensorKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -12471,6 +15426,9 @@ }, "version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -12543,6 +15501,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -12563,6 +15524,9 @@ "$ref": "#/$defs/RuntimeRestartPolicy" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -12579,11 +15543,17 @@ "properties": { "control_interface_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Control Interface Ref", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -12593,6 +15563,9 @@ "$ref": "#/$defs/RuntimeOrchestrationEngine" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -12601,6 +15574,9 @@ }, "engine_api_version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Engine Api Version", "type": "string" }, @@ -12617,10 +15593,16 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "orchestration_authority_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Orchestration Authority Id", "type": "string" }, @@ -12630,6 +15612,9 @@ "$ref": "#/$defs/RuntimeOrchestrationPrivilegeClass" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -12688,21 +15673,33 @@ "properties": { "cleanup": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Cleanup", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "execution_timeout": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Execution Timeout", "type": "string" }, "timeout": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Timeout", "type": "string" } @@ -12731,6 +15728,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -12742,20 +15742,32 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "evidence_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Evidence Ref", "type": "string" }, "image_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Image Ref", "type": "string" }, "workload_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Workload Id", "type": "string" } @@ -12772,16 +15784,25 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "environment_name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Environment Name", "type": "string" }, "organization_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Organization Ref", "type": "string" } @@ -12795,20 +15816,32 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "image_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Image Ref", "type": "string" }, "purpose": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Purpose", "type": "string" }, "template_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Template Id", "type": "string" } @@ -12825,28 +15858,46 @@ "properties": { "architecture": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Architecture", "type": "string" }, "manager": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Manager", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "purl": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Purl", "type": "string" }, "source": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source", "type": "string" }, "version": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -12865,45 +15916,75 @@ "properties": { "advisory_url": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Advisory Url", "type": "string" }, "fixed_version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Fixed Version", "type": "string" }, "id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Id", "type": "string" }, "image_digest": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Image Digest", "type": "string" }, "installed_version": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Installed Version", "type": "string" }, "package_name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Package Name", "type": "string" }, "scan_time": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Scan Time", "type": "string" }, "scanner": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Scanner", "type": "string" }, "scanner_database": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Scanner Database", "type": "string" }, "scanner_version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Scanner Version", "type": "string" }, @@ -12913,6 +15994,9 @@ "$ref": "#/$defs/RuntimePackageVulnerabilitySeverity" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -12950,6 +16034,9 @@ "properties": { "authorization_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Authorization Ref", "type": "string" }, @@ -12969,6 +16056,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -12992,6 +16082,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -13003,6 +16096,9 @@ "type": "array" }, "platform_application_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Platform Application Id", "type": "string" }, @@ -13012,6 +16108,9 @@ "$ref": "#/$defs/RuntimePlatformApplicationKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -13020,11 +16119,17 @@ }, "product": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Product", "type": "string" }, "service": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service", "type": "string" }, @@ -13051,6 +16156,9 @@ }, "version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -13066,6 +16174,9 @@ "description": "A connector/integration wired into the platform.\n\nA connector never carries a raw credential value; its credential posture is\nrecorded purely via :attr:`credential_classification`.", "properties": { "connector_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Connector Id", "type": "string" }, @@ -13075,6 +16186,9 @@ "$ref": "#/$defs/RuntimePlatformApplicationSettingClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -13083,6 +16197,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -13092,6 +16209,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -13107,6 +16227,9 @@ "$ref": "#/$defs/RuntimePlatformApplicationConnectorKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -13115,6 +16238,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" } @@ -13149,16 +16275,25 @@ "type": "object" }, "content_object_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Content Object Id", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Refs", @@ -13170,6 +16305,9 @@ "$ref": "#/$defs/RuntimePlatformApplicationContentObjectKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -13178,6 +16316,9 @@ }, "marking_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Marking Refs", @@ -13185,11 +16326,17 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "references": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "References", @@ -13233,11 +16380,17 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "job_timeout": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Job Timeout", "type": "string" }, @@ -13247,6 +16400,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -13258,16 +16414,25 @@ }, "policy_id": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Policy Id", "type": "string" }, "rate_limit": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Rate Limit", "type": "string" }, "runner": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Runner", "type": "string" } @@ -13295,15 +16460,24 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "level": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Level", "type": "string" }, "marking_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Marking Id", "type": "string" }, @@ -13313,6 +16487,9 @@ "$ref": "#/$defs/RuntimePlatformApplicationMarkingScheme" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -13321,6 +16498,9 @@ }, "value": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Value", "type": "string" } @@ -13347,15 +16527,24 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "organization_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Organization Id", "type": "string" } @@ -13376,6 +16565,9 @@ "$ref": "#/$defs/RuntimePlatformApplicationSettingClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -13384,11 +16576,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -13398,6 +16596,9 @@ "$ref": "#/$defs/RuntimePlatformApplicationSettingProvenance" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -13406,15 +16607,24 @@ }, "redaction": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Redaction", "type": "string" }, "setting_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Setting Id", "type": "string" }, "value": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Value", "type": "string" } @@ -13455,15 +16665,24 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "tenant_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Tenant Id", "type": "string" } @@ -13479,11 +16698,17 @@ "description": "An outbound binding to an upstream node/service (data source, backend).", "properties": { "binding_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Binding Id", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -13493,6 +16718,9 @@ "$ref": "#/$defs/RuntimePlatformApplicationUpstreamBindingRole" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -13501,11 +16729,17 @@ }, "target_node_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target Node Ref", "type": "string" }, "target_service_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Target Service Ref", "type": "string" } @@ -13536,6 +16770,9 @@ "properties": { "add": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Add", @@ -13543,11 +16780,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "drop": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Drop", @@ -13555,6 +16798,9 @@ }, "effective": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Effective", @@ -13566,6 +16812,9 @@ "$ref": "#/$defs/RuntimeCapabilityOverrideScope" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -13588,6 +16837,9 @@ "properties": { "command": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Command", @@ -13599,6 +16851,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -13607,16 +16862,25 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "group": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Group", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -13626,6 +16890,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -13641,6 +16908,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -13656,6 +16926,9 @@ "$ref": "#/$defs/RuntimeProcessRole" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -13664,11 +16937,17 @@ }, "user": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "User", "type": "string" }, "working_directory": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Working Directory", "type": "string" } @@ -13700,6 +16979,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -13707,11 +16989,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "host_ip": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Host Ip", "type": "string" }, @@ -13721,6 +17009,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -13732,6 +17023,9 @@ }, "protocol": { "default": "tcp", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Protocol", "type": "string" } @@ -13752,6 +17046,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -13759,6 +17056,9 @@ }, "host_ip": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Host Ip", "type": "string" }, @@ -13768,6 +17068,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -13779,6 +17082,9 @@ }, "protocol": { "default": "tcp", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Protocol", "type": "string" } @@ -13799,6 +17105,9 @@ "type": "number" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -13810,6 +17119,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -13819,6 +17131,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -13834,6 +17149,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -13849,6 +17167,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -13864,6 +17185,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -13896,11 +17220,17 @@ "properties": { "command_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Command Ref", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -13910,6 +17240,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -13921,6 +17254,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -13947,6 +17283,9 @@ "default": null }, "scheduled_job_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Scheduled Job Id", "type": "string" } @@ -13979,6 +17318,9 @@ "$ref": "#/$defs/RuntimeScheduledJobLastResult" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -13987,11 +17329,17 @@ }, "last_run": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Last Run", "type": "string" }, "next_run": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Next Run", "type": "string" } @@ -14009,6 +17357,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -14024,6 +17375,9 @@ "$ref": "#/$defs/RuntimeScheduledJobScheduleKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -14032,6 +17386,9 @@ }, "spec": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Spec", "type": "string" } @@ -14055,36 +17412,57 @@ "properties": { "address": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Address", "type": "string" }, "agent_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Agent Id", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "group_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Group Refs", "type": "array" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "node_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Node Ref", "type": "string" }, "os": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Os", "type": "string" }, @@ -14094,6 +17472,9 @@ "$ref": "#/$defs/RuntimeSecurityMonitoringAgentStatus" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -14102,6 +17483,9 @@ }, "version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -14119,6 +17503,9 @@ "properties": { "configuration_file_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Configuration File Refs", @@ -14126,15 +17513,24 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "group_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Group Id", "type": "string" }, "member_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Member Refs", @@ -14142,6 +17538,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" } @@ -14172,11 +17571,17 @@ "description": "A manager daemon, module, or internal component.", "properties": { "component_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Component Id", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -14186,6 +17591,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -14201,6 +17609,9 @@ "$ref": "#/$defs/RuntimeSecurityMonitoringComponentKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -14208,11 +17619,17 @@ "title": "Kind" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "process_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Process Ref", "type": "string" }, @@ -14222,6 +17639,9 @@ "$ref": "#/$defs/RuntimeSecurityMonitoringComponentStatus" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -14316,11 +17736,17 @@ "description": "A manager-owned rule, decoder, policy, list, or query corpus.", "properties": { "content_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Content Id", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -14330,6 +17756,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -14341,6 +17770,9 @@ }, "file_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "File Refs", @@ -14352,6 +17784,9 @@ "$ref": "#/$defs/RuntimeSecurityMonitoringContentFormat" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -14364,6 +17799,9 @@ "$ref": "#/$defs/RuntimeSecurityMonitoringContentKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -14376,6 +17814,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -14387,6 +17828,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" } @@ -14403,11 +17847,17 @@ "properties": { "canonical_digest": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Canonical Digest", "type": "string" }, "compliance_tags": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Compliance Tags", @@ -14415,11 +17865,17 @@ }, "content_set_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Content Set Ref", "type": "string" }, "decoded_as": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Decoded As", @@ -14427,6 +17883,9 @@ }, "decoder_fields": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Decoder Fields", @@ -14434,12 +17893,18 @@ }, "decoder_names": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Decoder Names", "type": "array" }, "definition_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Definition Id", "type": "string" }, @@ -14449,6 +17914,9 @@ "$ref": "#/$defs/RuntimeSecurityMonitoringDetectionDefinitionKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -14457,11 +17925,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "digest_algorithm": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Digest Algorithm", "type": "string" }, @@ -14471,6 +17945,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -14486,6 +17963,9 @@ "$ref": "#/$defs/RuntimeSecurityMonitoringDetectionEngine" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -14494,6 +17974,9 @@ }, "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Refs", @@ -14512,6 +17995,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -14523,6 +18009,9 @@ }, "groups": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Groups", @@ -14530,6 +18019,9 @@ }, "if_matched_sid_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "If Matched Sid Refs", @@ -14537,6 +18029,9 @@ }, "if_sid_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "If Sid Refs", @@ -14548,6 +18043,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -14563,6 +18061,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -14574,6 +18075,9 @@ }, "match_strings": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Match Strings", @@ -14581,6 +18085,9 @@ }, "mitre_attack_ids": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Mitre Attack Ids", @@ -14588,16 +18095,25 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "native_id": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Native Id", "type": "string" }, "parent_definition_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Parent Definition Refs", @@ -14609,6 +18125,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -14620,6 +18139,9 @@ }, "regex_patterns": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Regex Patterns", @@ -14627,6 +18149,9 @@ }, "same_source_constraints": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Same Source Constraints", @@ -14634,11 +18159,17 @@ }, "severity": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Severity", "type": "string" }, "source_artifact_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source Artifact Ref", "type": "string" }, @@ -14648,6 +18179,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -14659,6 +18193,9 @@ }, "source_file_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source File Ref", "type": "string" }, @@ -14668,6 +18205,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -14679,6 +18219,9 @@ }, "tactic_labels": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Tactic Labels", @@ -14686,6 +18229,9 @@ }, "tags": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Tags", @@ -14693,6 +18239,9 @@ }, "target_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Target Refs", @@ -14700,6 +18249,9 @@ }, "technique_labels": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Technique Labels", @@ -14711,6 +18263,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -14769,10 +18324,16 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "field": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Field", "type": "string" }, @@ -14782,6 +18343,9 @@ "$ref": "#/$defs/RuntimeSecurityMonitoringFieldPredicateOperator" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -14790,6 +18354,9 @@ }, "value": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Value", "type": "string" } @@ -14842,6 +18409,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -14853,15 +18423,24 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "listener_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Listener Id", "type": "string" }, "protocol": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Protocol", "type": "string" }, @@ -14871,6 +18450,9 @@ "$ref": "#/$defs/RuntimeSecurityMonitoringListenerRole" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -14879,6 +18461,9 @@ }, "service": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service", "type": "string" }, @@ -14888,6 +18473,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -14947,6 +18535,9 @@ }, "configuration_file_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Configuration File Refs", @@ -14961,6 +18552,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -14973,6 +18567,9 @@ }, "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Refs", @@ -14984,6 +18581,9 @@ "$ref": "#/$defs/RuntimeSecurityMonitoringImplementation" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -14999,6 +18599,9 @@ }, "log_file_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Log File Refs", @@ -15010,6 +18613,9 @@ "$ref": "#/$defs/RuntimeSecurityMonitoringManagerKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15018,20 +18624,32 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "revision": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Revision", "type": "string" }, "security_monitoring_manager_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Security Monitoring Manager Id", "type": "string" }, "service": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service", "type": "string" }, @@ -15044,6 +18662,9 @@ }, "version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -15076,15 +18697,24 @@ "properties": { "component_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Component Ref", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -15094,6 +18724,9 @@ "$ref": "#/$defs/RuntimeSecurityMonitoringSettingProvenance" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15101,16 +18734,25 @@ "title": "Provenance" }, "setting_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Setting Id", "type": "string" }, "source_path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Source Path", "type": "string" }, "value": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Value", "type": "string" }, @@ -15120,6 +18762,9 @@ "$ref": "#/$defs/RuntimeSensitivityClassification" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15168,6 +18813,9 @@ "properties": { "address": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Address", "type": "string" }, @@ -15177,6 +18825,9 @@ "$ref": "#/$defs/RuntimeListenerAddressFamily" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15185,16 +18836,25 @@ }, "bind_interface": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Bind Interface", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "evidence_refs": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evidence Refs", @@ -15206,6 +18866,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -15217,11 +18880,17 @@ }, "process_name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Process Name", "type": "string" }, "process_ref": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Process Ref", "type": "string" }, @@ -15231,6 +18900,9 @@ "$ref": "#/$defs/RuntimeListenerProtocol" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15243,6 +18915,9 @@ "$ref": "#/$defs/RuntimeListenerProvenance" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15273,6 +18948,9 @@ "$ref": "#/$defs/RuntimeListenerScope" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15281,15 +18959,24 @@ }, "service": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service", "type": "string" }, "service_listener_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service Listener Id", "type": "string" }, "socket_path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Socket Path", "type": "string" } @@ -15305,6 +18992,9 @@ "description": "A software component observed as part of a runtime node's state.", "properties": { "component_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Component Id", "type": "string" }, @@ -15314,6 +19004,9 @@ "$ref": "#/$defs/RuntimeSoftwareComponentType" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15322,16 +19015,25 @@ }, "cpe": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Cpe", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "ecosystem": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Ecosystem", "type": "string" }, @@ -15344,6 +19046,9 @@ }, "installed_paths": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Installed Paths", @@ -15351,25 +19056,40 @@ }, "manifest_path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Manifest Path", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "package_manager": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Package Manager", "type": "string" }, "package_name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Package Name", "type": "string" }, "package_version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Package Version", "type": "string" }, @@ -15379,6 +19099,9 @@ "$ref": "#/$defs/RuntimeSoftwareComponentProvenance" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15387,11 +19110,17 @@ }, "purl": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Purl", "type": "string" }, "version": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -15408,10 +19137,16 @@ "description": "Digest attached to an observed runtime software component.", "properties": { "algorithm": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Algorithm", "type": "string" }, "value": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Value", "type": "string" } @@ -15467,6 +19202,9 @@ "properties": { "accept_env": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Accept Env", @@ -15474,6 +19212,9 @@ }, "allow_groups": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Allow Groups", @@ -15481,6 +19222,9 @@ }, "allow_users": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Allow Users", @@ -15488,6 +19232,9 @@ }, "authentication_methods": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Authentication Methods", @@ -15495,16 +19242,25 @@ }, "authorized_keys_file": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Authorized Keys File", "type": "string" }, "chroot_directory": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Chroot Directory", "type": "string" }, "deny_groups": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Deny Groups", @@ -15512,6 +19268,9 @@ }, "deny_users": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Deny Users", @@ -15519,6 +19278,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -15546,6 +19308,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -15561,6 +19326,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -15576,6 +19344,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -15586,10 +19357,16 @@ "title": "Pubkey Authentication" }, "service": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service", "type": "string" }, "ssh_server_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Ssh Server Id", "type": "string" } @@ -15622,6 +19399,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15630,6 +19410,9 @@ }, "commands": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Commands", @@ -15637,11 +19420,17 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "host_scope": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Host Scope", "type": "string" }, @@ -15651,6 +19440,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15658,6 +19450,9 @@ "title": "Nopasswd" }, "principal": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Principal", "type": "string" }, @@ -15667,6 +19462,9 @@ "$ref": "#/$defs/RuntimeSudoPrincipalKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15675,11 +19473,17 @@ }, "raw_entry": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Raw Entry", "type": "string" }, "run_as_groups": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Run As Groups", @@ -15687,6 +19491,9 @@ }, "run_as_users": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Run As Users", @@ -15705,6 +19512,9 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -15714,6 +19524,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15726,6 +19539,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ] @@ -15736,6 +19552,9 @@ }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -15745,6 +19564,9 @@ "type": "number" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15756,6 +19578,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15791,6 +19616,9 @@ "$ref": "#/$defs/ServiceUnitActiveState" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15799,6 +19627,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -15808,6 +19639,9 @@ "$ref": "#/$defs/ServiceUnitEnabledState" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15831,6 +19665,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -15846,6 +19683,9 @@ "$ref": "#/$defs/ServiceUnitLoadState" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15858,6 +19698,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -15873,6 +19716,9 @@ "$ref": "#/$defs/ServiceManagerKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15885,6 +19731,9 @@ "$ref": "#/$defs/ServiceUnitResult" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15893,29 +19742,47 @@ }, "service": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Service", "type": "string" }, "status_text": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Status Text", "type": "string" }, "sub_state": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Sub State", "type": "string" }, "unit_file_path": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Unit File Path", "type": "string" }, "unit_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Unit Id", "type": "string" }, "unit_name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Unit Name", "type": "string" }, @@ -15925,6 +19792,9 @@ "$ref": "#/$defs/ServiceUnitKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15945,11 +19815,17 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -15959,6 +19835,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -15966,6 +19845,9 @@ }, "protocol": { "default": "tcp", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Protocol", "type": "string" } @@ -16015,6 +19897,9 @@ "properties": { "command": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Command", "type": "string" }, @@ -16024,6 +19909,9 @@ "$ref": "#/$defs/ServiceUnitExecStartKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -16036,6 +19924,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -16044,6 +19935,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" } @@ -16120,10 +20014,16 @@ "description": "Network properties for a switch/subnet: CIDR, gateway, and flags.", "properties": { "cidr": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Cidr", "type": "string" }, "gateway": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Gateway", "type": "string" }, @@ -16133,6 +20033,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -16163,11 +20066,17 @@ "default": null }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "version": { "default": "*", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" } @@ -16184,6 +20093,9 @@ "properties": { "command": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Command", "type": "string" }, @@ -16193,6 +20105,9 @@ "$ref": "#/$defs/SshForcedCommandKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -16205,6 +20120,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -16213,6 +20131,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" } @@ -16240,12 +20161,18 @@ "$ref": "#/$defs/SshMatchCriterionKind" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], "title": "Kind" }, "pattern": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Pattern", "type": "string" } @@ -16278,6 +20205,9 @@ "properties": { "accept_env": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Accept Env", @@ -16285,6 +20215,9 @@ }, "allow_groups": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Allow Groups", @@ -16292,6 +20225,9 @@ }, "allow_users": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Allow Users", @@ -16299,6 +20235,9 @@ }, "authentication_methods": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Authentication Methods", @@ -16306,11 +20245,17 @@ }, "authorized_keys_file": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Authorized Keys File", "type": "string" }, "chroot_directory": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Chroot Directory", "type": "string" }, @@ -16323,6 +20268,9 @@ }, "deny_groups": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Deny Groups", @@ -16330,6 +20278,9 @@ }, "deny_users": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Deny Users", @@ -16337,6 +20288,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -16352,6 +20306,9 @@ "default": null }, "match_id": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Match Id", "type": "string" }, @@ -16361,6 +20318,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -16376,6 +20336,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -16391,6 +20354,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -16413,16 +20379,25 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, "scripts": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "minItems": 1, @@ -16435,6 +20410,9 @@ "type": "number" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -16463,15 +20441,24 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "evaluation": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Evaluation", "type": "string" }, "name": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" } @@ -16490,6 +20477,9 @@ "items": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -16509,6 +20499,9 @@ "default": { "anyOf": [ { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -16529,6 +20522,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -16563,14 +20559,23 @@ "description": "A named vulnerability with CWE classification.", "properties": { "class": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Class", "type": "string" }, "description": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -16580,6 +20585,9 @@ "type": "boolean" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -16612,10 +20620,16 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "start": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Start", "type": "string" }, @@ -16685,6 +20699,9 @@ }, "order": { "default": "reverse_completion", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Order", "type": "string" } @@ -16708,6 +20725,9 @@ "properties": { "conditions": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Conditions", @@ -16715,6 +20735,9 @@ }, "evaluations": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Evaluations", @@ -16722,6 +20745,9 @@ }, "goals": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Goals", @@ -16729,6 +20755,9 @@ }, "metrics": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Metrics", @@ -16736,6 +20765,9 @@ }, "objectives": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Objectives", @@ -16750,6 +20782,9 @@ }, "tlos": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Tlos", @@ -16765,6 +20800,9 @@ "properties": { "branches": { "items": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, "title": "Branches", @@ -16779,26 +20817,41 @@ }, "compensate-with": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Compensate-With", "type": "string" }, "default": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Default", "type": "string" }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "else": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Else", "type": "string" }, "join": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Join", "type": "string" }, @@ -16808,6 +20861,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -16819,31 +20875,49 @@ }, "next": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Next", "type": "string" }, "objective": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Objective", "type": "string" }, "on-exhausted": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "On-Exhausted", "type": "string" }, "on-failure": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "On-Failure", "type": "string" }, "on-success": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "On-Success", "type": "string" }, "then": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Then", "type": "string" }, @@ -16863,6 +20937,9 @@ }, "workflow": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Workflow", "type": "string" } @@ -16893,6 +20970,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" }, { @@ -16911,6 +20991,9 @@ "type": "array" }, "step": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Step", "type": "string" } @@ -16943,10 +21026,16 @@ "properties": { "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, "next": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Next", "type": "string" }, @@ -16971,6 +21060,9 @@ "type": "integer" }, { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "type": "string" } ], @@ -16987,7 +21079,7 @@ "$id": "https://aces.dev/schemas/instantiated-scenario-v1.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, - "description": "Scenario with all `${var}` references resolved to concrete values.", + "description": "Scenario with all ``${var}`` references resolved to concrete values.\n\nUnlike the authoring-input contract, an instantiated scenario MUST NOT\ncontain any unresolved ``${name}`` substitution token in any string value,\nwhether a whole-string placeholder (``\"${os}\"``) or embedded\n(``\"host-${index}\"``). The invariant is enforced both by the model\nvalidator below and by the published ``instantiated-scenario-v1`` JSON\nSchema, which forbids the token in every string field. The schema is\nmarginally stricter than the runtime instantiation engine in one\npathological case: if a resolved variable *value* itself re-introduces a\nliteral ``${name}`` sequence (single-pass substitution does not re-scan\nit), the schema and this validator treat it as non-concrete and reject it.", "properties": { "accounts": { "additionalProperties": { @@ -17026,6 +21118,9 @@ }, "description": { "default": "", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Description", "type": "string" }, @@ -17111,6 +21206,9 @@ "default": null }, "name": { + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Name", "type": "string" }, @@ -17179,6 +21277,9 @@ }, "version": { "default": "*", + "not": { + "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + }, "title": "Version", "type": "string" }, diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index b2915a681..707558f4d 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Annotated, Any, Literal +from aces_sdl import VARIABLE_TOKEN_PATTERN from aces_sdl.participant_attribution_semantics import ( ParticipantAttributionCandidateKind, ParticipantAttributionOrderingBasisKind, @@ -366,6 +367,68 @@ def _attach_experiment_datetime_invariants(contract_id: str, json_schema: dict[s ) +_INSTANTIATION_INVARIANT_CONTRACT_ID = "instantiated-scenario-v1" +_SCHEMA_MAP_KEYS = ("properties", "patternProperties", "$defs") +_SCHEMA_SUBSCHEMA_KEYS = ( + "additionalProperties", + "items", + "contains", + "anyOf", + "allOf", + "oneOf", + "prefixItems", +) + + +def _forbid_variable_tokens_in_strings(node: Any) -> None: + """Add a ``${name}``-rejecting constraint to every free string subschema. + + Walks the JSON Schema applicator keywords and, on each subschema that + declares a scalar ``"type": "string"`` and is not a fixed ``enum`` / ``const`` + value, forbids the SDL substitution token. Restricting to a scalar ``type`` + (not a type-list) and skipping ``enum`` / ``const`` avoids the nullable-string + and fixed-value pitfalls; the ``${var}`` branch of every ``*_or_var`` field + (``InfraNode.count``, ``ACLRule.ports``, ``SimpleProperties.internal`` …) is a + bare ``{"type": "string"}`` branch, so it is covered. Mapping keys are not + substitution sites, so ``propertyNames`` is intentionally not constrained. + """ + if isinstance(node, list): + for item in node: + _forbid_variable_tokens_in_strings(item) + return + if not isinstance(node, dict): + return + if node.get("type") == "string" and "enum" not in node and "const" not in node: + constraint = {"pattern": VARIABLE_TOKEN_PATTERN} + if "not" in node: + node.setdefault("allOf", []).append({"not": constraint}) + else: + node["not"] = constraint + for key in _SCHEMA_MAP_KEYS: + child = node.get(key) + if isinstance(child, dict): + for sub in child.values(): + _forbid_variable_tokens_in_strings(sub) + for key in _SCHEMA_SUBSCHEMA_KEYS: + if key in node: + _forbid_variable_tokens_in_strings(node[key]) + + +def _attach_instantiation_invariants(contract_id: str, json_schema: dict[str, Any]) -> None: + """Differentiate the instantiated-scenario contract from authoring-input. + + The authoring (``Scenario``) and instantiated (``InstantiatedScenario``) + models share every field, so their generated schemas are identical apart + from metadata. An instantiated scenario is fully concrete, so the + instantiated schema additionally forbids unresolved ``${var}`` tokens in + string values — both whole-string placeholders and embedded tokens (issue + #500). The matching model-level invariant lives on ``InstantiatedScenario``. + """ + if contract_id != _INSTANTIATION_INVARIANT_CONTRACT_ID: + return + _forbid_variable_tokens_in_strings(json_schema) + + def _schema_id_for_contract_id(contract_id: str) -> str: if contract_id == "aces-semantic-invariants-v1": return _ACES_SEMANTIC_INVARIANT_PROFILE_URI @@ -5021,6 +5084,7 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "operation-status-v1": OperationStatusModel.model_json_schema(), } for contract_id, json_schema in bundle.items(): + _attach_instantiation_invariants(contract_id, json_schema) _attach_experiment_datetime_invariants(contract_id, json_schema) _attach_json_schema_metadata(contract_id, json_schema) _attach_aces_semantic_profile(contract_id, json_schema) diff --git a/implementations/python/packages/aces_sdl/__init__.py b/implementations/python/packages/aces_sdl/__init__.py index 42c3bcc6b..d8dd902bf 100644 --- a/implementations/python/packages/aces_sdl/__init__.py +++ b/implementations/python/packages/aces_sdl/__init__.py @@ -17,6 +17,7 @@ "SDLInstantiationError", "SDLParseError", "SDLValidationError", + "VARIABLE_TOKEN_PATTERN", ] @@ -28,6 +29,8 @@ def __getattr__(name: str): "SDLValidationError", }: module = import_module("aces_sdl._errors") + elif name == "VARIABLE_TOKEN_PATTERN": + module = import_module("aces_sdl._base") elif name == "instantiate_scenario": module = import_module("aces_sdl.instantiate") elif name in {"parse_sdl", "parse_sdl_file"}: diff --git a/implementations/python/packages/aces_sdl/_base.py b/implementations/python/packages/aces_sdl/_base.py index 2eecea7ad..467992f90 100644 --- a/implementations/python/packages/aces_sdl/_base.py +++ b/implementations/python/packages/aces_sdl/_base.py @@ -16,7 +16,17 @@ class SDLModel(BaseModel): ) -_VARIABLE_REF_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_-]*)\}$") +_VARIABLE_NAME_PATTERN = r"[A-Za-z_][A-Za-z0-9_-]*" +# Single source of truth for the ``${name}`` substitution token, shared by the +# instantiation engine, SEM-218 explicitness analysis, the InstantiatedScenario +# model validator, and the published instantiated-scenario JSON Schema +# constraint (issue #500). The capturing group yields the variable name for +# ``.findall`` / ``match.group(1)`` consumers. ``VARIABLE_TOKEN_RE`` matches a +# token embedded anywhere in a string; ``_VARIABLE_REF_RE`` matches a +# whole-string placeholder. +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"$") def is_variable_ref(v: Any) -> bool: @@ -24,6 +34,15 @@ def is_variable_ref(v: Any) -> bool: return isinstance(v, str) and _VARIABLE_REF_RE.fullmatch(v) is not None +def contains_variable_token(v: Any) -> bool: + """Return whether ``v`` is a string containing any ``${name}`` token. + + Unlike :func:`is_variable_ref` (whole-string placeholder only), this also + matches tokens embedded within a larger string, e.g. ``"host-${index}"``. + """ + return isinstance(v, str) and VARIABLE_TOKEN_RE.search(v) is not None + + def extract_variable_name(v: str) -> str | None: """Return the referenced variable name, if ``v`` is a placeholder.""" match = _VARIABLE_REF_RE.fullmatch(v) if isinstance(v, str) else None diff --git a/implementations/python/packages/aces_sdl/explicitness.py b/implementations/python/packages/aces_sdl/explicitness.py index c5dcc265b..d611a3791 100644 --- a/implementations/python/packages/aces_sdl/explicitness.py +++ b/implementations/python/packages/aces_sdl/explicitness.py @@ -2,14 +2,13 @@ from __future__ import annotations -import re from collections.abc import Iterable from dataclasses import dataclass from enum import Enum from pydantic import BaseModel -from ._base import extract_variable_name +from ._base import VARIABLE_TOKEN_RE, extract_variable_name from .variables import Variable __all__ = [ @@ -21,7 +20,6 @@ "derive_instantiated_explicitness", ] -_VARIABLE_TOKEN_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_-]*)\}") _OPEN_ENUM_SENTINELS = frozenset({"unknown", "other"}) _EXPLICITNESS_ORDER: dict[ExplicitnessClass, int] = {} @@ -203,7 +201,7 @@ def _variable_names(value: object) -> tuple[str, ...]: if full_name is not None: names.append(full_name) else: - names.extend(dict.fromkeys(_VARIABLE_TOKEN_RE.findall(value))) + names.extend(dict.fromkeys(VARIABLE_TOKEN_RE.findall(value))) return tuple(names) diff --git a/implementations/python/packages/aces_sdl/instantiate.py b/implementations/python/packages/aces_sdl/instantiate.py index 008f11f17..65f9f64f5 100644 --- a/implementations/python/packages/aces_sdl/instantiate.py +++ b/implementations/python/packages/aces_sdl/instantiate.py @@ -13,15 +13,13 @@ from pydantic import ValidationError -from ._base import extract_variable_name +from ._base import VARIABLE_TOKEN_RE, extract_variable_name from ._errors import SDLInstantiationError, SDLValidationError from .explicitness import derive_instantiated_explicitness from .scenario import InstantiatedScenario, Scenario from .validator import SemanticValidator from .variables import Variable, VariableType -_VARIABLE_TOKEN_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_-]*)\}") - JSONScalar = str | int | float | bool | None JSONLike = JSONScalar | list["JSONLike"] | dict[str, "JSONLike"] @@ -112,7 +110,7 @@ def replace_token(match: re.Match[str]) -> str: return match.group(0) return str(variable_values[variable_name]) - return _VARIABLE_TOKEN_RE.sub(replace_token, value) + return VARIABLE_TOKEN_RE.sub(replace_token, value) def _capture_node_variable_refs( diff --git a/implementations/python/packages/aces_sdl/scenario.py b/implementations/python/packages/aces_sdl/scenario.py index 9b25acee5..f2c6e9aaf 100644 --- a/implementations/python/packages/aces_sdl/scenario.py +++ b/implementations/python/packages/aces_sdl/scenario.py @@ -10,9 +10,11 @@ outside the SDL. """ +from collections.abc import Mapping + from pydantic import Field, PrivateAttr, model_validator -from ._base import SDLModel +from ._base import VARIABLE_TOKEN_RE, SDLModel from .accounts import Account from .agents import Agent from .conditions import Condition @@ -33,6 +35,25 @@ from .vulnerabilities import Vulnerability +def _collect_variable_tokens(value: object) -> list[str]: + """Return the names of every ``${name}`` token found in string *values*. + + Mirrors ``instantiate._substitute_value``: every string is a substitution + site and mapping keys are not. An :class:`InstantiatedScenario` is fully + concrete, so no token may survive in any string value. + """ + found: list[str] = [] + if isinstance(value, Mapping): + for nested in value.values(): + found.extend(_collect_variable_tokens(nested)) + elif isinstance(value, (list, tuple)): + for item in value: + found.extend(_collect_variable_tokens(item)) + elif isinstance(value, str): + found.extend(VARIABLE_TOKEN_RE.findall(value)) + return found + + class ModuleDescriptor(SDLModel): """Published module metadata for SDL composition.""" @@ -184,7 +205,19 @@ def _set_module_node_variable_refs(self, refs: dict[str, dict[str, str | None]]) class InstantiatedScenario(Scenario): - """Scenario with all `${var}` references resolved to concrete values.""" + """Scenario with all ``${var}`` references resolved to concrete values. + + Unlike the authoring-input contract, an instantiated scenario MUST NOT + contain any unresolved ``${name}`` substitution token in any string value, + whether a whole-string placeholder (``"${os}"``) or embedded + (``"host-${index}"``). The invariant is enforced both by the model + validator below and by the published ``instantiated-scenario-v1`` JSON + Schema, which forbids the token in every string field. The schema is + marginally stricter than the runtime instantiation engine in one + pathological case: if a resolved variable *value* itself re-introduces a + literal ``${name}`` sequence (single-pass substitution does not re-scan + it), the schema and this validator treat it as non-concrete and reject it. + """ _instantiation_parameters: dict[str, object] = PrivateAttr(default_factory=dict) _instantiation_profile: str | None = PrivateAttr(default=None) @@ -222,6 +255,15 @@ def _set_instantiation_context( def _set_node_variable_refs(self, refs: dict[str, dict[str, str | None]]) -> None: self._node_variable_refs = {name: dict(entry) for name, entry in refs.items()} + @model_validator(mode="after") + def _reject_unresolved_variable_references(self) -> "InstantiatedScenario": + payload = self.model_dump(mode="python", by_alias=True) + tokens = sorted(set(_collect_variable_tokens(payload))) + if tokens: + joined = ", ".join(tokens) + raise ValueError(f"InstantiatedScenario must not contain unresolved variable references: {joined}") + return self + class ExpandedScenario(Scenario): """Scenario produced by module/import expansion.""" diff --git a/implementations/python/tests/test_instantiated_scenario_schema.py b/implementations/python/tests/test_instantiated_scenario_schema.py new file mode 100644 index 000000000..ca8d00a72 --- /dev/null +++ b/implementations/python/tests/test_instantiated_scenario_schema.py @@ -0,0 +1,116 @@ +"""Issue #500 — instantiated-scenario contract differentiated from authoring-input. + +`sdl-authoring-input-v1` and `instantiated-scenario-v1` were byte-identical +apart from `$id`/title/description: the authoring-vs-instantiated distinction +lived only in `InstantiatedScenario` private attributes that never reached the +JSON Schema. These tests pin the differentiation: the instantiated contract +rejects unresolved ``${var}`` tokens (embedded and full-string) at both the +Pydantic model boundary and the published JSON Schema boundary, while the +authoring contract still accepts them. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from aces_contracts.contracts import schema_bundle +from aces_sdl._base import VARIABLE_TOKEN_PATTERN +from aces_sdl.scenario import InstantiatedScenario, Scenario +from jsonschema import Draft202012Validator +from pydantic import ValidationError + +REPO_ROOT = Path(__file__).resolve().parents[3] +SDL_SCHEMA_DIR = REPO_ROOT / "contracts" / "schemas" / "sdl" +FIXTURE_DIR = REPO_ROOT / "contracts" / "fixtures" / "sdl" / "instantiated-scenario-v1" + +_CONCRETE = {"name": "concrete-scenario", "description": "a fully concrete scenario"} +_EMBEDDED_VAR = {"name": "concrete-scenario", "description": "deploy ${region} cluster"} +_FULL_VAR = {"name": "concrete-scenario", "description": "${environment}"} +_COUNT_VAR = {"name": "concrete-scenario", "infrastructure": {"net": {"count": "${replicas}"}}} + +_VAR_PAYLOADS = [_EMBEDDED_VAR, _FULL_VAR, _COUNT_VAR] + + +def _load(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +# The token pattern's backslashes are escaped once serialized into a schema's +# `not.pattern`; match the serialized form, not the raw Python string. +_PATTERN_IN_JSON = json.dumps(VARIABLE_TOKEN_PATTERN)[1:-1] + + +# --- Model boundary ------------------------------------------------------- + + +def test_authoring_model_accepts_unresolved_variables() -> None: + """No regression: the authoring model still accepts ``${var}`` placeholders.""" + for payload in _VAR_PAYLOADS: + Scenario.model_validate(payload) # must not raise + + +def test_instantiated_model_accepts_concrete_scenario() -> None: + instantiated = InstantiatedScenario.model_validate(_CONCRETE) + assert instantiated.name == "concrete-scenario" + + +@pytest.mark.parametrize("payload", _VAR_PAYLOADS) +def test_instantiated_model_rejects_unresolved_variables(payload: dict) -> None: + with pytest.raises(ValidationError): + InstantiatedScenario.model_validate(payload) + + +# --- JSON Schema boundary (live bundle) ----------------------------------- + + +def test_bundle_instantiated_schema_constraints_differ_from_authoring() -> None: + """Acceptance (a): the schemas differ in *constraints*, not just metadata.""" + 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 + + +@pytest.mark.parametrize("payload", _VAR_PAYLOADS) +def test_bundle_instantiated_schema_rejects_unresolved_variables(payload: dict) -> None: + """Acceptance (b): a ``${var}`` payload fails instantiated-schema validation.""" + bundle = schema_bundle() + assert not Draft202012Validator(bundle["instantiated-scenario-v1"]).is_valid(payload) + # The same payload is valid against the authoring contract. + assert Draft202012Validator(bundle["sdl-authoring-input-v1"]).is_valid(payload) + + +def test_bundle_instantiated_schema_accepts_concrete_scenario() -> None: + bundle = schema_bundle() + Draft202012Validator(bundle["instantiated-scenario-v1"]).validate(_CONCRETE) + + +# --- Published artifacts + fixtures --------------------------------------- + + +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 + + +def test_published_valid_fixture_passes() -> None: + schema = _load(SDL_SCHEMA_DIR / "instantiated-scenario-v1.json") + fixture = _load(FIXTURE_DIR / "valid" / "minimal.json") + Draft202012Validator(schema).validate(fixture) + + +def test_published_invalid_fixture_fails() -> None: + """Acceptance (b), fixture-proven (check_json_artifacts only checks valid/).""" + schema = _load(SDL_SCHEMA_DIR / "instantiated-scenario-v1.json") + fixture = _load(FIXTURE_DIR / "invalid" / "unresolved-variable.json") + assert not Draft202012Validator(schema).is_valid(fixture) + # The invalid fixture is otherwise well-formed: it only fails because of the + # unresolved ${var}, so it still validates against the authoring contract. + authoring = _load(SDL_SCHEMA_DIR / "sdl-authoring-input-v1.json") + Draft202012Validator(authoring).validate(fixture) diff --git a/implementations/python/tests/test_runtime_datastore.py b/implementations/python/tests/test_runtime_datastore.py index 61df108f7..e70a1a0eb 100644 --- a/implementations/python/tests/test_runtime_datastore.py +++ b/implementations/python/tests/test_runtime_datastore.py @@ -12,6 +12,7 @@ import json import pytest +from aces_sdl import VARIABLE_TOKEN_PATTERN from aces_sdl._runtime_service_families import collect_qualified_runtime_family_refs from aces_sdl.runtime_datastore import ( RuntimeDatastoreCluster, @@ -663,12 +664,25 @@ def test_mapping_and_template_refs_are_targetable() -> None: assert "nodes.indexer.runtime.datastore_services.wazuh-indexer.templates.wazuh-template" in refs +def _string_branch(schema_name: str) -> dict: + """Shape of a free string subschema in the given published SDL schema. + + The instantiated-scenario contract forbids unresolved ``${var}`` tokens in + string values (issue #500); the authoring contract still accepts them, so + the two schemas now differ on every string branch. + """ + if schema_name == "instantiated-scenario-v1": + return {"type": "string", "not": {"pattern": VARIABLE_TOKEN_PATTERN}} + return {"type": "string"} + + def test_published_sdl_schemas_include_mapping_and_template_manifests() -> None: for schema_name in _PUBLISHED_SDL_SCHEMA_NAMES: schema_path = REPO_ROOT / "contracts" / "schemas" / "sdl" / f"{schema_name}.json" schema = json.loads(schema_path.read_text(encoding="utf-8")) defs = schema["$defs"] service_properties = defs["RuntimeDatastoreService"]["properties"] + string_branch = _string_branch(schema_name) assert service_properties["mappings"]["items"]["$ref"] == "#/$defs/RuntimeDatastoreMapping" assert service_properties["templates"]["items"]["$ref"] == "#/$defs/RuntimeDatastoreTemplate" @@ -679,11 +693,11 @@ def test_published_sdl_schemas_include_mapping_and_template_manifests() -> None: assert mapping_schema["required"] == ["mapping_id"] assert mapping_schema["properties"]["field_type_census"]["additionalProperties"]["anyOf"] == [ {"type": "integer"}, - {"type": "string"}, + string_branch, ] assert mapping_schema["properties"]["date_detection"]["anyOf"] == [ {"type": "boolean"}, - {"type": "string"}, + string_branch, {"type": "null"}, ] @@ -692,7 +706,7 @@ def test_published_sdl_schemas_include_mapping_and_template_manifests() -> None: assert set(template_schema["properties"]) == _DATASTORE_TEMPLATE_SCHEMA_FIELDS assert template_schema["required"] == ["template_id"] assert template_schema["properties"]["settings_summary"]["additionalProperties"]["anyOf"] == [ - {"type": "string"}, + string_branch, {"type": "integer"}, {"type": "boolean"}, ] From fdd26d0d259dd968c4df99fbe430e8625dab0522 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 20:07:59 +0200 Subject: [PATCH 48/70] Fix SonarCloud findings (cycle 1) Refactor _forbid_variable_tokens_in_strings into _apply_string_token_constraint + _child_subschemas to drop cyclomatic/cognitive complexity below thresholds, use object instead of Any on the token helpers, and share a _DEFS_KEY constant for the duplicated $defs literal. Schema output is unchanged. --- .../packages/aces_contracts/contracts.py | 71 +++++++++++-------- .../python/packages/aces_sdl/_base.py | 2 +- 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index 707558f4d..03e8ba562 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -367,8 +367,9 @@ def _attach_experiment_datetime_invariants(contract_id: str, json_schema: dict[s ) +_DEFS_KEY = "$defs" _INSTANTIATION_INVARIANT_CONTRACT_ID = "instantiated-scenario-v1" -_SCHEMA_MAP_KEYS = ("properties", "patternProperties", "$defs") +_SCHEMA_MAP_KEYS = ("properties", "patternProperties", _DEFS_KEY) _SCHEMA_SUBSCHEMA_KEYS = ( "additionalProperties", "items", @@ -380,38 +381,52 @@ def _attach_experiment_datetime_invariants(contract_id: str, json_schema: dict[s ) -def _forbid_variable_tokens_in_strings(node: Any) -> None: - """Add a ``${name}``-rejecting constraint to every free string subschema. +def _apply_string_token_constraint(node: dict[str, Any]) -> None: + """Forbid the ``${name}`` token on a single free string subschema. - Walks the JSON Schema applicator keywords and, on each subschema that - declares a scalar ``"type": "string"`` and is not a fixed ``enum`` / ``const`` - value, forbids the SDL substitution token. Restricting to a scalar ``type`` - (not a type-list) and skipping ``enum`` / ``const`` avoids the nullable-string - and fixed-value pitfalls; the ``${var}`` branch of every ``*_or_var`` field - (``InfraNode.count``, ``ACLRule.ports``, ``SimpleProperties.internal`` …) is a - bare ``{"type": "string"}`` branch, so it is covered. Mapping keys are not - substitution sites, so ``propertyNames`` is intentionally not constrained. + Targets scalar ``"type": "string"`` only, skipping fixed ``enum`` / ``const`` + values, which avoids the nullable-string and fixed-value pitfalls. The + ``${var}`` branch of every ``*_or_var`` field (``InfraNode.count``, + ``ACLRule.ports``, ``SimpleProperties.internal`` …) is a bare + ``{"type": "string"}`` branch, so it is covered. """ - if isinstance(node, list): - for item in node: - _forbid_variable_tokens_in_strings(item) - return - if not isinstance(node, dict): + if node.get("type") != "string" or "enum" in node or "const" in node: return - if node.get("type") == "string" and "enum" not in node and "const" not in node: - constraint = {"pattern": VARIABLE_TOKEN_PATTERN} - if "not" in node: - node.setdefault("allOf", []).append({"not": constraint}) - else: - node["not"] = constraint + constraint = {"pattern": VARIABLE_TOKEN_PATTERN} + if "not" in node: + node.setdefault("allOf", []).append({"not": constraint}) + else: + node["not"] = constraint + + +def _child_subschemas(node: dict[str, Any]) -> list[Any]: + """Return the applicator subschemas reachable from ``node``. + + Mapping keys are not substitution sites, so ``propertyNames`` is + intentionally excluded. + """ + children: list[Any] = [] for key in _SCHEMA_MAP_KEYS: child = node.get(key) if isinstance(child, dict): - for sub in child.values(): - _forbid_variable_tokens_in_strings(sub) + children.extend(child.values()) for key in _SCHEMA_SUBSCHEMA_KEYS: if key in node: - _forbid_variable_tokens_in_strings(node[key]) + children.append(node[key]) + return children + + +def _forbid_variable_tokens_in_strings(node: object) -> None: + """Recursively forbid the ``${var}`` token on every free string subschema.""" + if isinstance(node, list): + for item in node: + _forbid_variable_tokens_in_strings(item) + return + if not isinstance(node, dict): + return + _apply_string_token_constraint(node) + for child in _child_subschemas(node): + _forbid_variable_tokens_in_strings(child) def _attach_instantiation_invariants(contract_id: str, json_schema: dict[str, Any]) -> None: @@ -495,7 +510,7 @@ class AcesSemanticInvariantProfileReferenceModel(ContractModel): def _aces_semantic_invariant_profile_schema_for_bundle() -> dict[str, Any]: json_schema = AcesSemanticInvariantProfileModel.model_json_schema() - json_schema.setdefault("$defs", {})["AcesSemanticInvariantProfileReferenceModel"] = ( + json_schema.setdefault(_DEFS_KEY, {})["AcesSemanticInvariantProfileReferenceModel"] = ( AcesSemanticInvariantProfileReferenceModel.model_json_schema() ) return json_schema @@ -5016,7 +5031,7 @@ def _backend_profile_schema_for_bundle() -> dict[str, Any]: def _event_stream_schema(title: str, item_schema: dict[str, Any]) -> dict[str, Any]: item_schema = dict(item_schema) - defs = item_schema.pop("$defs", None) + defs = item_schema.pop(_DEFS_KEY, None) schema = { _JSON_SCHEMA_KEY: _JSON_SCHEMA_DRAFT_2020_12, "title": title, @@ -5024,7 +5039,7 @@ def _event_stream_schema(title: str, item_schema: dict[str, Any]) -> dict[str, A "items": item_schema, } if defs: - schema["$defs"] = defs + schema[_DEFS_KEY] = defs return schema diff --git a/implementations/python/packages/aces_sdl/_base.py b/implementations/python/packages/aces_sdl/_base.py index 467992f90..a6c576924 100644 --- a/implementations/python/packages/aces_sdl/_base.py +++ b/implementations/python/packages/aces_sdl/_base.py @@ -34,7 +34,7 @@ def is_variable_ref(v: Any) -> bool: return isinstance(v, str) and _VARIABLE_REF_RE.fullmatch(v) is not None -def contains_variable_token(v: Any) -> bool: +def contains_variable_token(v: object) -> bool: """Return whether ``v`` is a string containing any ``${name}`` token. Unlike :func:`is_variable_ref` (whole-string placeholder only), this also From 9a0a8b299c63eaf0219a84dd0b2949d7ff0752be Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 20:45:49 +0200 Subject: [PATCH 49/70] Ship contract corpus as package data and add a versioned release line Bundle the published contract corpus (backend/semantic profiles, fixture conformance corpus, concept-authority catalogs, schemas) into the aces-sdl wheel and sdist via a hatchling build hook, and resolve it through a single importlib.resources seam (aces_contracts.corpus) so 'aces conformance backend' and SDL semantic validation work from an installed wheel with no source checkout. Collapse the six per-loader parents[N] heuristics onto the seam; contracts/ stays the normative authority. Declare the previously-undeclared 'packaging' runtime dependency: it was on the CLI import path but only present transitively, so any 'aces' command failed in a clean wheel install. Bump to 0.3.0 and add a v* release workflow + runbook so downstream backends can pin a version instead of a dev commit SHA. --- .github/workflows/release.yml | 100 ++++++++++ changelog.d/537.added.md | 1 + changelog.d/537.fixed.md | 1 + .../backend-manifest-v2/valid/stub.json | 2 +- ...537-contract-corpus-packaging-preflight.md | 179 ++++++++++++++++++ docs/explain/releasing.md | 70 +++++++ docs/index.md | 7 + implementations/python/hatch_build.py | 52 +++++ .../packages/aces_conformance/conformance.py | 7 +- .../aces_contracts/backend_profiles.py | 7 +- .../packages/aces_contracts/contracts.py | 8 +- .../aces_contracts/controlled_vocabularies.py | 7 +- .../python/packages/aces_contracts/corpus.py | 128 +++++++++++++ .../aces_contracts/reference_models.py | 7 +- .../aces_contracts/semantic_profiles.py | 7 +- implementations/python/pyproject.toml | 20 +- .../python/tests/test_corpus_packaging.py | 170 +++++++++++++++++ .../python/tests/test_corpus_resources.py | 155 +++++++++++++++ implementations/python/uv.lock | 4 +- 19 files changed, 898 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 changelog.d/537.added.md create mode 100644 changelog.d/537.fixed.md create mode 100644 docs/decisions/issue-537-contract-corpus-packaging-preflight.md create mode 100644 docs/explain/releasing.md create mode 100644 implementations/python/hatch_build.py create mode 100644 implementations/python/packages/aces_contracts/corpus.py create mode 100644 implementations/python/tests/test_corpus_packaging.py create mode 100644 implementations/python/tests/test_corpus_resources.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..6f2c346e8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,100 @@ +name: Release + +# Cut a release by pushing a version tag, e.g. `git tag v0.3.0 && git push origin v0.3.0`. +# Builds the corpus-bundled wheel + sdist and publishes a GitHub Release with the +# artifacts attached, so downstream backends can pin a real version instead of a +# `dev` commit SHA. See docs/explain/releasing.md for the full runbook. +# +# Release integrity (issue #537 codex review): +# * The only trigger is a `v*` tag push — there is no `workflow_dispatch`, so a +# manual run can never publish a Release named after a branch from untagged +# code. +# * The build job checks the tag commit is reachable from the protected default +# branch (`main`) BEFORE it runs any repository-controlled build code +# (`uv build` runs the hatch build hook), and checks out with +# `persist-credentials: false` so no write-scoped token sits in the git config +# while that build code executes. +# * Publishing happens in a separate job that only consumes the already-built +# artifacts. The `contents: write` token is scoped to that job alone and is +# used only by `gh release create`, so tag-controlled build hooks never run in +# a context that holds the write token. +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + persist-credentials: false + - name: Verify the tag is reachable from the protected default branch + run: | + set -euo pipefail + git fetch --no-tags origin main:refs/remotes/origin/main + tag_sha="$(git rev-parse HEAD)" + if ! git merge-base --is-ancestor "${tag_sha}" origin/main; then + echo "::error::tag ${GITHUB_REF_NAME} (${tag_sha}) is not reachable from origin/main; refusing to build or publish a release from unreviewed code" + exit 1 + fi + echo "tag ${GITHUB_REF_NAME} (${tag_sha}) is an ancestor of origin/main" + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.12" + - name: Install uv + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8 + - name: Build wheel + sdist + run: uv build --out-dir dist implementations/python + - name: Verify the contract corpus is bundled in the wheel + run: | + python - <<'PY' + import glob + import sys + import zipfile + + wheels = glob.glob("dist/aces_sdl-*.whl") + if len(wheels) != 1: + sys.exit(f"expected exactly one wheel, found {wheels}") + names = zipfile.ZipFile(wheels[0]).namelist() + required = [ + "aces_contracts/_corpus/profiles/backend/provisioning-only.json", + "aces_contracts/_corpus/fixtures/", + "aces_contracts/_corpus/concept-authority/controlled-vocabularies-v1.json", + "aces_contracts/_corpus/schemas/", + ] + missing = [r for r in required if not any(n == r or n.startswith(r) for n in names)] + if missing: + sys.exit(f"wheel is missing corpus payload: {missing}") + print(f"corpus payload present: {sum(n.startswith('aces_contracts/_corpus/') for n in names)} files") + PY + - name: Upload built artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-dist + path: dist/* + if-no-files-found: error + + publish: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download built artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-dist + path: dist + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${GITHUB_REF_NAME}" dist/* \ + --repo "${GITHUB_REPOSITORY}" \ + --title "${GITHUB_REF_NAME}" \ + --generate-notes diff --git a/changelog.d/537.added.md b/changelog.d/537.added.md new file mode 100644 index 000000000..899c807a2 --- /dev/null +++ b/changelog.d/537.added.md @@ -0,0 +1 @@ +The published contract corpus (backend/semantic profiles, the fixture conformance corpus, concept-authority catalogs, and schemas) now ships as package data in the `aces-sdl` wheel and sdist and is resolved through a single `importlib.resources`-backed seam (`aces_contracts.corpus`), so `aces conformance backend` and SDL semantic validation work from an installed distribution with no source checkout. The top-level `contracts/` tree remains the normative authority; `--fixtures-root` / `--profiles-root` overrides are unchanged. Added a `v*`-tag release workflow that builds the corpus-bundled artifacts and publishes a GitHub Release so downstream backends can pin a version instead of a `dev` commit SHA. diff --git a/changelog.d/537.fixed.md b/changelog.d/537.fixed.md new file mode 100644 index 000000000..87b96192f --- /dev/null +++ b/changelog.d/537.fixed.md @@ -0,0 +1 @@ +Declared `packaging` as a runtime dependency of `aces-sdl`. It was imported by `aces_sdl.module_registry` on the CLI import path but only present transitively, so any `aces` command failed with `ModuleNotFoundError: No module named 'packaging'` in a clean wheel install. 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 9f544ffe6..86912d74a 100644 --- a/contracts/fixtures/backend-manifest/backend-manifest-v2/valid/stub.json +++ b/contracts/fixtures/backend-manifest/backend-manifest-v2/valid/stub.json @@ -2,7 +2,7 @@ "schema_version": "backend-manifest/v2", "identity": { "name": "stub", - "version": "0.2.0" + "version": "0.3.0" }, "supported_contract_versions": [ "backend-manifest-v2", diff --git a/docs/decisions/issue-537-contract-corpus-packaging-preflight.md b/docs/decisions/issue-537-contract-corpus-packaging-preflight.md new file mode 100644 index 000000000..e92eab11e --- /dev/null +++ b/docs/decisions/issue-537-contract-corpus-packaging-preflight.md @@ -0,0 +1,179 @@ +# Issue 537 Contract Corpus Packaging Preflight + +Date: 2026-06-14 + +Issue: #537. + +Requirement: none. The issue title, body, and acceptance criteria are the +contract. + +This note records architecture preflight guardrails for shipping the published +contract corpus with the `aces-sdl` Python distribution. It is guidance for the +implementation and does not implement package-data wiring, resource loaders, +tests, release automation, or the release itself. + +## Binding Sources + +- ADR-009 defines `contracts/` as the normative machine-readable artifact + boundary and implementations as consumers of those artifacts. +- ADR-019 and `specs/authority/authority-boundary.yaml` are the canonical + machine-readable authority-boundary seam: `contracts/schemas/`, + `contracts/fixtures/`, `contracts/profiles/`, and + `contracts/concept-authority/` are separate normative families. +- ADR-061 and `contracts/schema-publication-manifest.json` govern published + schema inventory and schema compatibility. Package-data work must not add a + second schema registry or change-ledger. +- The Python distribution is declared in + `implementations/python/pyproject.toml`; release notes are towncrier + fragments under `changelog.d/`, and CI already treats `v*` tags as release + events. + +## Architecture Decisions + +- Keep the top-level `contracts/` tree as the normative source of truth. A + packaged corpus is a distribution copy of that authority, not a second + authority root and not an implementation-owned schema set. +- Resolve default corpus reads through a single `importlib.resources`-backed + seam in `aces_contracts`. The seam should expose package resources for + `schemas`, `fixtures`, `profiles`, and `concept-authority`; callers should + not each reconstruct repository paths. +- Preserve explicit local-development overrides such as `--fixtures-root` and + `--profiles-root`. Overrides are caller-controlled filesystem inputs and must + stay distinct from packaged-resource defaults. +- Backend profile identity remains artifact-driven: the JSON payload and file + stem are still checked by `load_backend_profile_from_path()`, and adding a + new profile JSON must not require a Python enum edit for fixture conformance. +- The conformance CLI report envelope remains stable. Missing or malformed + packaged resources should surface through existing structured diagnostics + such as `conformance.profile-load-failed` or `conformance.fixture-missing`, + with sanitized messages. +- SDL semantic validation should consume the same packaged concept-authority + artifacts as backend conformance. Do not create a separate SDL-only + vocabulary loader or duplicate concept-authority cache. +- Versioned releases bind Python code and packaged corpus by artifact version. + Do not conflate the package version/tag (`pyproject.toml`, Git tag, release + notes) with JSON Schema lineage suffixes such as `*-v1`. + +## Required Incumbents + +- Package metadata and release conventions: + `implementations/python/pyproject.toml`, `README.md` versioning guidance, + `towncrier.toml`, `changelog.d/`, `.github/workflows/ci.yml`, and the + `noxfile.py` verification graph. +- Authority and validation gates: ADR-009, ADR-019, ADR-061, + `contracts/README.md`, `specs/authority/authority-boundary.yaml`, + `tools/check_authority_boundary.py`, `tools/check_schema_publication.py`, + `tools/check_generated_schemas.py`, and `tools/check_json_artifacts.py`. +- Corpus loaders to converge on the shared seam: + `aces_contracts.backend_profiles`, `controlled_vocabularies`, + `semantic_profiles`, `reference_models`, and the concept-family loader in + `aces_contracts.contracts`. +- Conformance surfaces: + `aces_conformance.conformance.fixtures_root()`, + `backend_profiles_root()`, `required_contracts()`, `run_fixture_suite()`, + `run_target_conformance()`, and `aces_cli.conformance`. +- Validation surfaces: + closed-world `ContractModel` descendants, `schema_bundle()`, + `manifest_authority` contract-id allowlists, + `validate_controlled_vocabulary_scope_values()`, `Diagnostic`/`Severity`, + and existing SDL `SDLParseError` / `SDLValidationError` behavior. +- Tests to extend instead of bypass: + `test_backend_profiles.py`, `test_backend_conformance_cli.py`, + `test_runtime_conformance.py`, `test_controlled_vocabularies.py`, + `test_concept_authority.py`, `test_reference_models.py`, + `test_semantic_profiles.py`, and policy tests for package/build metadata if + new verification tooling is added. + +## Cross-Cutting Layers + +- Package-data config: `pyproject.toml` should include the corpus in both wheel + and sdist. Include the four normative families as data; do not package + generated caches, tests, secrets, `.git`, build output, or ad hoc copies from + outside the declared corpus. +- Resource loading: use `importlib.resources.files()` for default reads and + `as_file()` only at boundaries that truly need a concrete `Path`. Do not + assume resources are ordinary directories; zipped wheels and non-editable + installs must stay viable. +- Filesystem override security: keep profile ids and similar caller-controlled + names behind `_validate_backend_profile_id()` and root-confinement checks such + as `_path_is_within()`. Reject absolute paths, `..`, separators in ids, and + symlink escapes before file reads when an override root is involved. +- JSON/config parsing: continue using `json.loads()` followed by the existing + Pydantic closed-world models. Do not evaluate JSON, fetch remote refs, or + coerce malformed package metadata or corpus payloads into empty defaults. +- Contract/vocabulary validation: reuse `BackendProfileModel`, + `ControlledVocabularyCatalogModel`, `ReferenceModelCatalogModel`, + `SemanticProfileModel`, `schema_bundle()`, and + `manifest_authority` allowlists. Do not duplicate schemas or validators to + make packaged-resource tests pass. +- Error envelopes and leakage control: keep conformance failures in + `Diagnostic` envelopes and SDL failures in the established SDL errors. Do + not dump file contents, rejected payloads, environment variables, absolute + site-packages paths, raw tracebacks, or build backend internals into CLI + JSON. +- Workflow gates: `nox -s contracts`, `nox -s policy`, `nox -s tests`, and + `nox -s verify` remain the canonical gates. Any installed-wheel smoke test + should be added to the existing nox/test graph, not a separate release-only + script with different semantics. +- Secret and OS exposure: runtime loaders should read packaged data only. They + should not shell out, read auth tokens, inspect Git remotes, or require + environment variables. Release publishing may need credentials, but those + belong to maintainer/CI release workflow state and must not appear in argv, + logs, package metadata, fixtures, or diagnostics. +- Auth, persistence, and HTTP layers: this issue does not add or change + control-plane auth, persistence, APIs, audit logs, or request handling. If a + later user-visible API exposes corpus inspection, it must separately reuse + the existing control-plane security and redacted error patterns. + +## Extension Boundary + +The extension seam is one package-resource corpus resolver, parameterized by +normative family and relative artifact path. Adding a future corpus family +should require one include-pattern update, one resolver family entry, and the +existing artifact validation gates, not edits to every loader. + +Tests should keep override roots as parameters for local corpus development. +The default installed-distribution test must exercise the package-resource path +with no repository `contracts/` tree present, so a source-checkout fallback +cannot mask a missing wheel payload. + +Release extensibility belongs to the package version and changelog path: +future releases should bump `pyproject.toml`, add a `changelog.d/` fragment, +build wheel/sdist artifacts containing the corpus, and create a `v*` tag or +GitHub Release. Schema stability promotions remain ADR-061 manifest changes, +not package-version side effects. + +## Gotchas And Anti-Patterns + +Avoid: + +- leaving any default loader anchored on `Path(__file__).parents[N]`; +- adding a second `contracts/` copy to source control without declaring which + copy is authoritative; +- moving normative authority under `implementations/python/` as an + implementation-owned package directory; +- making tests pass by setting `--fixtures-root`, `--profiles-root`, or + `PYTHONPATH` in the installed-wheel acceptance path; +- using `importlib.resources` in one loader while concept-authority or + semantic-profile loaders keep source-tree heuristics; +- introducing environment variables as hidden corpus-root configuration; +- swallowing missing packaged resources and returning empty contract/profile + sets; +- exposing full validation errors that include rejected corpus payloads; +- duplicating backend profile tables, schema registries, vocabulary catalogs, + fixture loaders, exception hierarchies, nox sessions, or release scripts; +- treating a Git tag as evidence that the wheel actually contains the corpus; +- editing accepted ADR bodies in place instead of following ADR-059 amendment + rules when architecture text needs to change. + +## Non-Goals + +- Changing contract payload shapes, schema stability classes, backend profile + semantics, controlled vocabulary terms, or SDL semantic rules. +- Redesigning conformance, profile capability inference, live target probing, + or the conformance JSON report format. +- Adding runtime persistence, control-plane endpoints, authentication, + authorization, audit logging, or network access. +- Publishing credentials or requiring live GitHub/PyPI access from runtime + code. +- Cutting the actual release in this preflight note. diff --git a/docs/explain/releasing.md b/docs/explain/releasing.md new file mode 100644 index 000000000..087c08128 --- /dev/null +++ b/docs/explain/releasing.md @@ -0,0 +1,70 @@ +# Releasing aces-sdl + +`aces-sdl` ships the published contract corpus (backend/semantic profiles, the +fixture conformance corpus, the concept-authority catalogs, and the schemas) as +package data so that `aces conformance backend` and SDL semantic validation work +from an installed wheel — no source checkout required. Releases bind the Python +code and the corpus together in one versioned artifact, so downstream backends +(e.g. APTL) can pin a real version instead of a `dev` commit SHA. + +## How the corpus is bundled + +The corpus is the normative authority at the repository-root `contracts/` tree +(ADR-009). It is **not** moved or duplicated in source control. At build time a +hatchling build hook (`implementations/python/hatch_build.py`) force-includes it +into the wheel at `aces_contracts/_corpus`, and the sdist vendors it at +top-level `_corpus/` so a wheel built from the sdist finds it too. At runtime, +`aces_contracts.corpus` resolves the corpus via `importlib.resources`, falling +back to the in-repo `contracts/` tree only for source/editable checkouts. + +Build artifacts locally with: + +```sh +uv build --out-dir dist implementations/python +``` + +Both the wheel and the sdist contain the corpus and are independently +installable. + +## Cutting a release + +1. Bump `version` in `implementations/python/pyproject.toml` (and run + `uv lock` so the lockfile records the new version). +2. Collate the changelog fragments into `CHANGELOG.md`: + + ```sh + uvx towncrier build --version --date $(date -u +%F) + ``` + +3. Land the version bump + changelog on the default branch via the normal PR + flow (CI must be green). +4. Tag the merged commit and push the tag: + + ```sh + git tag v + git push origin v + ``` + + The `Release` workflow (`.github/workflows/release.yml`) runs on `v*` tags: + it builds the wheel + sdist, asserts the corpus payload is present in the + wheel, and publishes a GitHub Release with the artifacts attached. The push + to `v*` also runs the normal CI `verify`/`fuzz`/`sonar` jobs. + +## Pinning from a downstream backend + +Once a release is published, pin the tag instead of a `dev` commit SHA: + +``` +aces-sdl @ git+https://github.com/Brad-Edwards/aces.git@v#subdirectory=implementations/python +``` + +or install the release wheel directly. + +## PyPI (future) + +The release workflow publishes a GitHub Release using the built-in +`GITHUB_TOKEN`; no extra secrets are required. Publishing to PyPI is a separate, +maintainer-owned step that requires configuring +[trusted publishing](https://docs.pypi.org/trusted-publishers/) for the project; +it is intentionally not wired into this workflow so the release path needs no +long-lived publishing credentials. diff --git a/docs/index.md b/docs/index.md index dff575f85..facb52972 100644 --- a/docs/index.md +++ b/docs/index.md @@ -58,6 +58,13 @@ for advisory in scenario.advisories: explain/getting-started ``` +```{toctree} +:maxdepth: 2 +:caption: Maintainer Guide + +explain/releasing +``` + ```{toctree} :maxdepth: 2 :caption: SDL Guide diff --git a/implementations/python/hatch_build.py b/implementations/python/hatch_build.py new file mode 100644 index 000000000..4c9a3301d --- /dev/null +++ b/implementations/python/hatch_build.py @@ -0,0 +1,52 @@ +"""Hatchling build hook that bundles the published contract corpus (#537). + +The corpus is the ADR-009 normative authority at the repository-root +``contracts/`` tree, which lives *outside* this Python project directory. A +static ``force-include`` of ``../../contracts`` works when the wheel is built +directly from the source checkout, but breaks when the wheel is built from an +unpacked sdist — the ``../../contracts`` parent path is not present inside the +sdist, so ``uv build`` (which builds the wheel from the sdist) fails with +``Forced include not found``. + +This hook resolves the corpus from whichever layout is being built and +force-includes it into the wheel at ``aces_contracts/_corpus``: + +* **source checkout** — the corpus is the authority at ``/contracts``; +* **sdist** — the sdist target vendors the corpus at top-level ``_corpus/`` + (see ``[tool.hatch.build.targets.sdist.force-include]``), so a wheel built + from the sdist still finds it. + +The runtime resolver (``aces_contracts.corpus``) reads the bundled corpus via +``importlib.resources``; this hook only governs what lands in the wheel. +""" + +from __future__ import annotations + +from pathlib import Path + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + +_WHEEL_DESTINATION = "aces_contracts/_corpus" + + +class CustomBuildHook(BuildHookInterface): + PLUGIN_NAME = "custom" + + def initialize(self, version: str, build_data: dict) -> None: + root = Path(self.root) + source_checkout_corpus = (root.parent.parent / "contracts").resolve() + vendored_sdist_corpus = (root / "_corpus").resolve() + + if source_checkout_corpus.is_dir(): + corpus = source_checkout_corpus + elif vendored_sdist_corpus.is_dir(): + corpus = vendored_sdist_corpus + else: + raise FileNotFoundError( + "contract corpus not found for packaging: looked for " + f"{source_checkout_corpus} (source checkout) and " + f"{vendored_sdist_corpus} (sdist). The wheel cannot ship " + "without the corpus." + ) + + build_data.setdefault("force_include", {})[str(corpus)] = _WHEEL_DESTINATION diff --git a/implementations/python/packages/aces_conformance/conformance.py b/implementations/python/packages/aces_conformance/conformance.py index 9605325f1..76fe1591a 100644 --- a/implementations/python/packages/aces_conformance/conformance.py +++ b/implementations/python/packages/aces_conformance/conformance.py @@ -37,6 +37,7 @@ WorkflowHistoryEventModel, schema_bundle, ) +from aces_contracts.corpus import FIXTURES, corpus_family_root from aces_contracts.diagnostics import Diagnostic, Severity from aces_contracts.evaluation import EvaluationExecutionState from aces_contracts.participant_episode import ( @@ -177,12 +178,8 @@ class BackendConformanceReport: } -def _repo_root() -> Path: - return Path(__file__).resolve().parents[4] - - def fixtures_root() -> Path: - return _repo_root() / "contracts" / "fixtures" + return corpus_family_root(FIXTURES) def profiles_root() -> Path: diff --git a/implementations/python/packages/aces_contracts/backend_profiles.py b/implementations/python/packages/aces_contracts/backend_profiles.py index c27b29272..8a7c11076 100644 --- a/implementations/python/packages/aces_contracts/backend_profiles.py +++ b/implementations/python/packages/aces_contracts/backend_profiles.py @@ -18,6 +18,7 @@ from pydantic import Field, WithJsonSchema, model_validator from .contracts import ContractModel +from .corpus import PROFILES, corpus_family_root from .manifest_authority import ( BACKEND_SUPPORTED_CONTRACT_IDS, validate_backend_supported_contract_versions, @@ -27,12 +28,8 @@ _BACKEND_PROFILE_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") -def _repo_root() -> Path: - return Path(__file__).resolve().parents[4] - - def backend_profiles_root() -> Path: - return _repo_root() / "contracts" / "profiles" / "backend" + return corpus_family_root(PROFILES) / "backend" def _validate_backend_profile_id(profile_id: str) -> None: diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index b2915a681..a6410a36e 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -8,7 +8,6 @@ from collections.abc import Mapping from datetime import UTC, datetime, timedelta from functools import lru_cache -from pathlib import Path from typing import Annotated, Any, Literal from aces_sdl.participant_attribution_semantics import ( @@ -35,6 +34,7 @@ from pydantic.json_schema import JsonSchemaValue from pydantic_core import CoreSchema +from .corpus import CONCEPT_AUTHORITY, corpus_family_root from .manifest_authority import ( BACKEND_SUPPORTED_CONTRACT_IDS, PARTICIPANT_IMPLEMENTATION_SUPPORTED_CONTRACT_IDS, @@ -4753,13 +4753,9 @@ def _validate_phase_binding_scopes(self) -> SemanticProfileModel: return self -def _repo_root() -> Path: - return Path(__file__).resolve().parents[4] - - @lru_cache(maxsize=1) def _authoritative_concept_family_ids() -> frozenset[str]: - catalog_path = _repo_root() / "contracts" / "concept-authority" / "concept-families-v1.json" + catalog_path = corpus_family_root(CONCEPT_AUTHORITY) / "concept-families-v1.json" payload = json.loads(catalog_path.read_text(encoding="utf-8")) catalog = ConceptFamilyCatalogModel.model_validate(payload) return frozenset(catalog.families) diff --git a/implementations/python/packages/aces_contracts/controlled_vocabularies.py b/implementations/python/packages/aces_contracts/controlled_vocabularies.py index 218d132cd..e9e171a91 100644 --- a/implementations/python/packages/aces_contracts/controlled_vocabularies.py +++ b/implementations/python/packages/aces_contracts/controlled_vocabularies.py @@ -9,14 +9,11 @@ from pathlib import Path from .contracts import ControlledVocabularyCatalogModel - - -def _repo_root() -> Path: - return Path(__file__).resolve().parents[4] +from .corpus import CONCEPT_AUTHORITY, corpus_family_root def controlled_vocabulary_catalog_path() -> Path: - return _repo_root() / "contracts" / "concept-authority" / "controlled-vocabularies-v1.json" + return corpus_family_root(CONCEPT_AUTHORITY) / "controlled-vocabularies-v1.json" @lru_cache(maxsize=1) diff --git a/implementations/python/packages/aces_contracts/corpus.py b/implementations/python/packages/aces_contracts/corpus.py new file mode 100644 index 000000000..fc809075f --- /dev/null +++ b/implementations/python/packages/aces_contracts/corpus.py @@ -0,0 +1,128 @@ +"""Single seam for resolving the published ACES contract corpus. + +The ``contracts/`` tree at the repository root is the hand-governed normative +authority (ADR-009): published schemas, the fixture conformance corpus, backend +and semantic capability profiles, and the concept-authority catalogs. Every +loader that reads that corpus resolves through this module rather than +reconstructing a repository path, so: + +* an **installed distribution** finds the corpus shipped as package data under + ``aces_contracts/_corpus`` (see the wheel/sdist ``force-include`` in + ``implementations/python/pyproject.toml``), resolved via + ``importlib.resources`` so it is install-layout independent; and +* a **source / editable checkout** finds the in-repo ``contracts/`` authority. + +This is the single extension seam: adding a future corpus family needs one +``force-include`` pattern and one call site through :func:`corpus_family_root`, +not a new ``Path(__file__).parents[N]`` heuristic in every loader. + +Caller-controlled override roots (``--fixtures-root`` / ``--profiles-root``) are +deliberately *not* handled here; they stay explicit inputs at the loader +boundary, distinct from the packaged-resource default. +""" + +from __future__ import annotations + +import importlib.resources as resources +from functools import cache +from pathlib import Path + +_CORPUS_PACKAGE = "aces_contracts" +_BUNDLED_DIRNAME = "_corpus" +_REPO_MARKER = ".ground-control.yaml" +_CONTRACTS_DIRNAME = "contracts" + +# Normative corpus families (mirror specs/authority/authority-boundary.yaml and +# ADR-019). Loaders reference these constants so the family names live in one +# place. +PROFILES = "profiles" +FIXTURES = "fixtures" +CONCEPT_AUTHORITY = "concept-authority" +SCHEMAS = "schemas" + + +def _bundled_corpus_root() -> Path | None: + """Return the packaged corpus root when the distribution bundles it. + + For an installed (unpacked) wheel/sdist the ``force-include`` lands the + corpus at ``aces_contracts/_corpus``. ``importlib.resources.files`` returns + a concrete filesystem path for such installs, which the corpus loaders need + for directory traversal (``glob``). Returns ``None`` when the package ships + without bundled data (the source/editable layout), so the caller can fall + back to the in-repo authority. + """ + + try: + candidate = resources.files(_CORPUS_PACKAGE).joinpath(_BUNDLED_DIRNAME) + except (ModuleNotFoundError, TypeError): + return None + try: + if not candidate.is_dir(): + return None + except OSError: + return None + path = Path(str(candidate)) + return path if path.is_dir() else None + + +def _source_checkout_corpus_root() -> Path | None: + """Return the in-repo ``contracts/`` authority for a source/editable checkout. + + Located by walking up to the repository marker (``.ground-control.yaml``) + rather than a fragile ``Path(__file__).resolve().parents[N]`` index, so + relocating this package within the source tree cannot silently break + resolution. Returns ``None`` when no marked checkout is found above this + module (the installed-distribution layout). + """ + + for parent in Path(__file__).resolve().parents: + contracts = parent / _CONTRACTS_DIRNAME + if contracts.is_dir() and (parent / _REPO_MARKER).is_file(): + return contracts + return None + + +@cache +def corpus_root() -> Path: + """Resolve the published contract-corpus root directory. + + Prefers the packaged corpus (the default for an installed distribution) and + falls back to the in-repo ``contracts/`` authority for editable/source + checkouts. Raises :class:`RuntimeError` when neither is present rather than + returning an empty corpus, so a wheel built without the corpus payload fails + loudly instead of letting conformance or semantic validation pass vacuously. + """ + + bundled = _bundled_corpus_root() + if bundled is not None: + return bundled + source = _source_checkout_corpus_root() + if source is not None: + return source + raise RuntimeError( + "ACES contract corpus is unavailable: the installed distribution does " + f"not bundle '{_CORPUS_PACKAGE}/{_BUNDLED_DIRNAME}' and no source " + f"checkout ('{_REPO_MARKER}' + '{_CONTRACTS_DIRNAME}/') was found above " + f"{__file__}. Reinstall a wheel built with the corpus force-include, or " + "run from a source checkout." + ) + + +def corpus_family_root(family: str) -> Path: + """Return the root directory of a normative corpus family. + + ``family`` is one of :data:`PROFILES`, :data:`FIXTURES`, + :data:`CONCEPT_AUTHORITY`, or :data:`SCHEMAS`. + """ + + return corpus_root() / family + + +__all__ = [ + "CONCEPT_AUTHORITY", + "FIXTURES", + "PROFILES", + "SCHEMAS", + "corpus_family_root", + "corpus_root", +] diff --git a/implementations/python/packages/aces_contracts/reference_models.py b/implementations/python/packages/aces_contracts/reference_models.py index 51001708e..844e24b90 100644 --- a/implementations/python/packages/aces_contracts/reference_models.py +++ b/implementations/python/packages/aces_contracts/reference_models.py @@ -6,14 +6,11 @@ from pathlib import Path from .contracts import ReferenceModelCatalogModel - - -def _repo_root() -> Path: - return Path(__file__).resolve().parents[4] +from .corpus import CONCEPT_AUTHORITY, corpus_family_root def reference_model_catalog_path() -> Path: - return _repo_root() / "contracts" / "concept-authority" / "reference-models-v1.json" + return corpus_family_root(CONCEPT_AUTHORITY) / "reference-models-v1.json" def load_reference_model_catalog() -> ReferenceModelCatalogModel: diff --git a/implementations/python/packages/aces_contracts/semantic_profiles.py b/implementations/python/packages/aces_contracts/semantic_profiles.py index 2cdc6f40d..679a8ad51 100644 --- a/implementations/python/packages/aces_contracts/semantic_profiles.py +++ b/implementations/python/packages/aces_contracts/semantic_profiles.py @@ -6,14 +6,11 @@ from pathlib import Path from .contracts import SemanticProfileModel - - -def _repo_root() -> Path: - return Path(__file__).resolve().parents[4] +from .corpus import PROFILES, corpus_family_root def semantic_profiles_root() -> Path: - return _repo_root() / "contracts" / "profiles" / "semantic" + return corpus_family_root(PROFILES) / "semantic" def semantic_profile_path(profile_id: str) -> Path: diff --git a/implementations/python/pyproject.toml b/implementations/python/pyproject.toml index a99edb2b6..f56130aa4 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.2.0" +version = "0.3.0" description = "Backend-agnostic cyber range scenario description language and runtime." requires-python = ">=3.11" dependencies = [ @@ -18,6 +18,7 @@ dependencies = [ "sse-starlette>=2.0.0", "asyncssh>=2.17.0", "mcp>=1.0.0", + "packaging>=23.0", ] [project.optional-dependencies] @@ -54,6 +55,23 @@ packages = [ "packages/aces_sdl", ] +# Ship the published contract corpus (the ADR-009 normative authority at the +# repo-root `contracts/` tree) as package data so conformance + SDL semantic +# validation work from an installed wheel. The corpus is resolved at runtime +# via `importlib.resources` in `aces_contracts.corpus`; `contracts/` stays the +# single source of truth — this is a build-time distribution copy only. +# +# A custom build hook (hatch_build.py) force-includes the corpus into the wheel +# at `aces_contracts/_corpus`, resolving it from the source checkout +# (`../../contracts`) or from the corpus vendored into the sdist below — so both +# a direct wheel build and `uv build`'s sdist→wheel path produce a wheel that +# contains the corpus. The sdist vendors the corpus at top-level `_corpus/`. +[tool.hatch.build.targets.wheel.hooks.custom] +path = "hatch_build.py" + +[tool.hatch.build.targets.sdist.force-include] +"../../contracts" = "_corpus" + [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src", "packages", "tests"] diff --git a/implementations/python/tests/test_corpus_packaging.py b/implementations/python/tests/test_corpus_packaging.py new file mode 100644 index 000000000..5067345c2 --- /dev/null +++ b/implementations/python/tests/test_corpus_packaging.py @@ -0,0 +1,170 @@ +"""Installed-distribution acceptance tests for the bundled contract corpus (#537). + +These build the real ``aces-sdl`` wheel, install it into a throwaway virtualenv, +and exercise conformance + SDL semantic validation **with no repository +``contracts/`` tree on the path** — so a source-checkout fallback cannot mask a +missing wheel payload (the failure mode the issue exists to prevent). A passing +unit suite or a Git tag is not evidence that the wheel actually contains the +corpus; building and installing it is. + +Marked ``integration`` because they build/install artifacts and read the real +repo on disk; they run in ``nox -s integration`` and the ``verify`` graph, not +the default fast unit sweep. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.integration + +PROJECT_ROOT = Path(__file__).resolve().parents[1] # implementations/python +REPO_ROOT = PROJECT_ROOT.parents[1] +REPO_CONTRACTS = REPO_ROOT / "contracts" + +_CORPUS_PREFIX = "aces_contracts/_corpus/" +_FAMILY_PROBES = { + "profiles": "aces_contracts/_corpus/profiles/backend/provisioning-only.json", + "fixtures": "aces_contracts/_corpus/fixtures/", + "concept-authority": "aces_contracts/_corpus/concept-authority/controlled-vocabularies-v1.json", + "schemas": "aces_contracts/_corpus/schemas/", +} + +_UV = shutil.which("uv") +requires_uv = pytest.mark.skipif(_UV is None, reason="uv toolchain not available") + + +def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, text=True, capture_output=True, timeout=600, **kwargs) + + +def _sanitized_runtime_env(home: Path) -> dict[str, str]: + """A minimal env for invoking the *installed* distribution. + + Deliberately omits ``PYTHONPATH`` so the repo's ``packages/`` source tree + cannot leak onto ``sys.path`` and mask a broken wheel payload. + """ + + return {"PATH": os.environ.get("PATH", ""), "HOME": str(home)} + + +@pytest.fixture(scope="module") +def built_wheel(tmp_path_factory: pytest.TempPathFactory) -> Path: + out_dir = tmp_path_factory.mktemp("wheel") + result = _run([_UV, "build", "--wheel", "--out-dir", str(out_dir)], cwd=PROJECT_ROOT) + assert result.returncode == 0, f"wheel build failed:\n{result.stdout}\n{result.stderr}" + wheels = list(out_dir.glob("aces_sdl-*.whl")) + assert len(wheels) == 1, f"expected exactly one wheel, found {wheels}" + return wheels[0] + + +@pytest.fixture(scope="module") +def installed_python(built_wheel: Path, tmp_path_factory: pytest.TempPathFactory) -> Path: + venv_dir = tmp_path_factory.mktemp("venv") + create = _run([_UV, "venv", str(venv_dir)]) + assert create.returncode == 0, f"venv create failed:\n{create.stdout}\n{create.stderr}" + py = ( + venv_dir + / ("Scripts" if sys.platform == "win32" else "bin") + / ("python.exe" if sys.platform == "win32" else "python") + ) + install = _run([_UV, "pip", "install", "--python", str(py), str(built_wheel)]) + assert install.returncode == 0, f"wheel install failed:\n{install.stdout}\n{install.stderr}" + # The throwaway venv must not contain the source-tree corpus. + assert not (venv_dir / "contracts").exists() + return py + + +@requires_uv +@pytest.mark.parametrize("family", sorted(_FAMILY_PROBES)) +def test_built_wheel_bundles_each_corpus_family(built_wheel: Path, family: str): + """The built wheel must physically contain every normative corpus family.""" + + with zipfile.ZipFile(built_wheel) as zf: + names = zf.namelist() + corpus_names = [n for n in names if n.startswith(_CORPUS_PREFIX)] + assert corpus_names, "wheel ships no aces_contracts/_corpus payload at all" + probe = _FAMILY_PROBES[family] + assert any(n == probe or n.startswith(probe) for n in names), ( + f"corpus family {family!r} ({probe}) missing from wheel" + ) + + +@requires_uv +def test_corpus_discoverable_via_importlib_resources_from_installed_wheel(installed_python: Path, tmp_path: Path): + """Acceptance: the corpus is discoverable via ``importlib.resources`` from + the installed distribution, resolved out of site-packages — NOT the repo + ``contracts/`` tree.""" + + script = ( + "import json\n" + "from aces_contracts.corpus import corpus_root, corpus_family_root\n" + "root = corpus_root()\n" + "print(json.dumps({\n" + " 'root': str(root),\n" + " 'backend_profile': (corpus_family_root('profiles')/'backend'/'provisioning-only.json').exists(),\n" + " 'controlled_vocab': (corpus_family_root('concept-authority')/'controlled-vocabularies-v1.json').exists(),\n" + " 'fixtures_dir': corpus_family_root('fixtures').is_dir(),\n" + " 'schemas_dir': corpus_family_root('schemas').is_dir(),\n" + "}))\n" + ) + result = _run( + [str(installed_python), "-c", script], + cwd=tmp_path, + env=_sanitized_runtime_env(tmp_path), + ) + assert result.returncode == 0, f"discovery failed:\n{result.stdout}\n{result.stderr}" + payload = json.loads(result.stdout) + resolved = Path(payload["root"]).resolve() + assert "site-packages" in resolved.parts, f"corpus resolved outside site-packages: {resolved}" + assert resolved != REPO_CONTRACTS.resolve(), "installed dist fell back to the repo contracts/ tree" + assert payload["backend_profile"] is True + assert payload["controlled_vocab"] is True + assert payload["fixtures_dir"] is True + assert payload["schemas_dir"] is True + + +@requires_uv +def test_conformance_backend_passes_from_installed_wheel(installed_python: Path, tmp_path: Path): + """Acceptance: ``aces conformance backend --profile provisioning-only`` exits + 0 from a fresh wheel install with no source tree present.""" + + aces = installed_python.parent / ("aces.exe" if sys.platform == "win32" else "aces") + result = _run( + [str(aces), "conformance", "backend", "--profile", "provisioning-only"], + cwd=tmp_path, + env=_sanitized_runtime_env(tmp_path), + ) + assert result.returncode == 0, f"conformance CLI failed:\n{result.stdout}\n{result.stderr}" + payload = json.loads(result.stdout) + assert payload["profile"] == "provisioning-only" + assert payload["passed"] is True, payload + assert payload["cases"], "conformance ran zero cases — corpus fixtures not bundled" + + +@requires_uv +def test_sdl_semantic_validation_loads_corpus_from_installed_wheel(installed_python: Path, tmp_path: Path): + """Acceptance: SDL semantic validation reads the concept-authority corpus + from the installed wheel with no source checkout.""" + + script = ( + "from aces_contracts.controlled_vocabularies import load_controlled_vocabulary_catalog\n" + "catalog = load_controlled_vocabulary_catalog()\n" + "assert catalog.vocabularies, 'no controlled vocabularies loaded'\n" + "print(len(catalog.vocabularies))\n" + ) + result = _run( + [str(installed_python), "-c", script], + cwd=tmp_path, + env=_sanitized_runtime_env(tmp_path), + ) + assert result.returncode == 0, f"semantic-validation load failed:\n{result.stdout}\n{result.stderr}" + assert int(result.stdout.strip()) > 0 diff --git a/implementations/python/tests/test_corpus_resources.py b/implementations/python/tests/test_corpus_resources.py new file mode 100644 index 000000000..d3f9bca13 --- /dev/null +++ b/implementations/python/tests/test_corpus_resources.py @@ -0,0 +1,155 @@ +"""Tests for the shared contract-corpus resolution seam (issue #537). + +The published ``contracts/`` tree is the hand-governed normative authority +(ADR-009). Every corpus loader must resolve through the single +``aces_contracts.corpus`` seam so the corpus is reachable from an installed +distribution (where it ships as package data) without each loader +reconstructing a ``Path(__file__).parents[N]`` repository path. +""" + +from __future__ import annotations + +import importlib +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +CONTRACTS_ROOT = REPO_ROOT / "contracts" + + +def test_corpus_root_resolves_to_an_existing_directory(): + from aces_contracts.corpus import corpus_root + + root = corpus_root() + assert isinstance(root, Path) + assert root.is_dir() + + +def test_corpus_root_matches_repo_contracts_in_source_checkout(): + """In a source checkout the seam resolves to the in-repo authority tree.""" + + from aces_contracts.corpus import corpus_root + + assert corpus_root() == CONTRACTS_ROOT + + +@pytest.mark.parametrize( + ("family", "probe"), + [ + ("profiles", "backend/provisioning-only.json"), + ("fixtures", None), + ("concept-authority", "controlled-vocabularies-v1.json"), + ("schemas", None), + ], +) +def test_corpus_family_root_resolves_each_normative_family(family: str, probe: str | None): + from aces_contracts.corpus import corpus_family_root + + family_root = corpus_family_root(family) + assert family_root == CONTRACTS_ROOT / family + assert family_root.is_dir() + if probe is not None: + assert (family_root / probe).exists() + + +def test_family_constants_match_authority_boundary_families(): + from aces_contracts import corpus + + assert corpus.PROFILES == "profiles" + assert corpus.FIXTURES == "fixtures" + assert corpus.CONCEPT_AUTHORITY == "concept-authority" + assert corpus.SCHEMAS == "schemas" + + +def test_backend_profiles_loader_routes_through_seam(): + from aces_contracts.backend_profiles import backend_profiles_root + from aces_contracts.corpus import corpus_family_root + + assert backend_profiles_root() == corpus_family_root("profiles") / "backend" + + +def test_semantic_profiles_loader_routes_through_seam(): + from aces_contracts.corpus import corpus_family_root + from aces_contracts.semantic_profiles import semantic_profiles_root + + assert semantic_profiles_root() == corpus_family_root("profiles") / "semantic" + + +def test_controlled_vocabulary_loader_routes_through_seam(): + from aces_contracts.controlled_vocabularies import controlled_vocabulary_catalog_path + from aces_contracts.corpus import corpus_family_root + + assert controlled_vocabulary_catalog_path() == ( + corpus_family_root("concept-authority") / "controlled-vocabularies-v1.json" + ) + + +def test_reference_model_loader_routes_through_seam(): + from aces_contracts.corpus import corpus_family_root + from aces_contracts.reference_models import reference_model_catalog_path + + assert reference_model_catalog_path() == (corpus_family_root("concept-authority") / "reference-models-v1.json") + + +def test_fixtures_root_routes_through_seam(): + from aces_conformance.conformance import fixtures_root + from aces_contracts.corpus import corpus_family_root + + assert fixtures_root() == corpus_family_root("fixtures") + + +@pytest.mark.parametrize( + "module_name", + [ + "aces_contracts.backend_profiles", + "aces_contracts.semantic_profiles", + "aces_contracts.controlled_vocabularies", + "aces_contracts.reference_models", + "aces_contracts.contracts", + "aces_conformance.conformance", + ], +) +def test_no_loader_keeps_a_parents_based_repo_root(module_name: str): + """Preflight gotcha: no default loader may stay anchored on + ``Path(__file__).parents[N]``. The fragile per-module ``_repo_root`` helper + must be gone once the loader routes through the corpus seam.""" + + module = importlib.import_module(module_name) + assert not hasattr(module, "_repo_root"), ( + f"{module_name} still defines a parents[N]-based _repo_root; " + "resolve the corpus through aces_contracts.corpus instead." + ) + + +def test_bundled_corpus_takes_precedence_over_source_checkout(monkeypatch, tmp_path): + """The packaged-resource path is the default for an installed distribution; + when both a bundled corpus and a source checkout are visible, bundled wins, + so the installed wheel is what gets exercised.""" + + from aces_contracts import corpus + + bundled = tmp_path / "_corpus" + bundled.mkdir() + monkeypatch.setattr(corpus, "_bundled_corpus_root", lambda: bundled) + corpus.corpus_root.cache_clear() + try: + assert corpus.corpus_root() == bundled + finally: + corpus.corpus_root.cache_clear() + + +def test_corpus_root_raises_when_no_corpus_is_available(monkeypatch): + """A missing packaged corpus must surface as a hard error, never a silent + empty corpus that would make conformance/validation pass vacuously.""" + + from aces_contracts import corpus + + monkeypatch.setattr(corpus, "_bundled_corpus_root", lambda: None) + monkeypatch.setattr(corpus, "_source_checkout_corpus_root", lambda: None) + corpus.corpus_root.cache_clear() + try: + with pytest.raises(RuntimeError, match="contract corpus is unavailable"): + corpus.corpus_root() + finally: + corpus.corpus_root.cache_clear() diff --git a/implementations/python/uv.lock b/implementations/python/uv.lock index a74c989aa..e03c3ab59 100644 --- a/implementations/python/uv.lock +++ b/implementations/python/uv.lock @@ -20,13 +20,14 @@ wheels = [ [[package]] name = "aces-sdl" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "asyncssh" }, { name = "cryptography" }, { name = "fastapi" }, { name = "mcp" }, + { name = "packaging" }, { name = "pydantic" }, { name = "pyyaml" }, { name = "rich" }, @@ -63,6 +64,7 @@ requires-dist = [ { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.0.0" }, { name = "mcp", specifier = ">=1.0.0" }, { name = "myst-parser", marker = "extra == 'docs'", specifier = ">=3.0.0" }, + { name = "packaging", specifier = ">=23.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" }, From 07ae6f3fa010b18ac8295858c1828bef44672e5d Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 20:52:38 +0200 Subject: [PATCH 50/70] Route merged uco-alignment corpus loaders through the importlib.resources seam Merging dev brought in two new readers of the concept-authority corpus that still used the Path(__file__).parents[4] heuristic: aces_contracts.uco_alignment (uco-alignment-v1.json) and the _uco_cyber_concept_family_provenance loader in aces_contracts.contracts (concept-families-v1.json). Route both through aces_contracts.corpus.corpus_family_root so UCO alignment validation also works from an installed wheel, and extend the no-parents[N] structural guard + a routing test to cover uco_alignment. --- .../python/packages/aces_contracts/contracts.py | 2 +- .../python/packages/aces_contracts/uco_alignment.py | 7 ++----- implementations/python/tests/test_corpus_resources.py | 8 ++++++++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index 357def5ff..4e2361bce 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -4867,7 +4867,7 @@ def _uco_cyber_concept_family_provenance() -> dict[str, str]: cyber-domain family slice is never hard-coded in a second place. """ - catalog_path = _repo_root() / "contracts" / "concept-authority" / "concept-families-v1.json" + catalog_path = corpus_family_root(CONCEPT_AUTHORITY) / "concept-families-v1.json" payload = json.loads(catalog_path.read_text(encoding="utf-8")) catalog = ConceptFamilyCatalogModel.model_validate(payload) return { diff --git a/implementations/python/packages/aces_contracts/uco_alignment.py b/implementations/python/packages/aces_contracts/uco_alignment.py index 942b8db97..7bce1170e 100644 --- a/implementations/python/packages/aces_contracts/uco_alignment.py +++ b/implementations/python/packages/aces_contracts/uco_alignment.py @@ -6,14 +6,11 @@ from pathlib import Path from .contracts import UcoAlignmentCatalogModel - - -def _repo_root() -> Path: - return Path(__file__).resolve().parents[4] +from .corpus import CONCEPT_AUTHORITY, corpus_family_root def uco_alignment_catalog_path() -> Path: - return _repo_root() / "contracts" / "concept-authority" / "uco-alignment-v1.json" + return corpus_family_root(CONCEPT_AUTHORITY) / "uco-alignment-v1.json" def load_uco_alignment_catalog() -> UcoAlignmentCatalogModel: diff --git a/implementations/python/tests/test_corpus_resources.py b/implementations/python/tests/test_corpus_resources.py index d3f9bca13..83dfb0fcb 100644 --- a/implementations/python/tests/test_corpus_resources.py +++ b/implementations/python/tests/test_corpus_resources.py @@ -92,6 +92,13 @@ def test_reference_model_loader_routes_through_seam(): assert reference_model_catalog_path() == (corpus_family_root("concept-authority") / "reference-models-v1.json") +def test_uco_alignment_loader_routes_through_seam(): + from aces_contracts.corpus import corpus_family_root + from aces_contracts.uco_alignment import uco_alignment_catalog_path + + assert uco_alignment_catalog_path() == (corpus_family_root("concept-authority") / "uco-alignment-v1.json") + + def test_fixtures_root_routes_through_seam(): from aces_conformance.conformance import fixtures_root from aces_contracts.corpus import corpus_family_root @@ -106,6 +113,7 @@ def test_fixtures_root_routes_through_seam(): "aces_contracts.semantic_profiles", "aces_contracts.controlled_vocabularies", "aces_contracts.reference_models", + "aces_contracts.uco_alignment", "aces_contracts.contracts", "aces_conformance.conformance", ], From f66f7d2d94d28fc118982dd1070ce6b34183cdee Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 14 Jun 2026 21:12:23 +0200 Subject: [PATCH 51/70] Fix SonarCloud findings (cycle 1) Consolidate aces_contracts.corpus._bundled_corpus_root from 4 returns to 3 (SonarCloud S1142): merge the two try/except blocks into one and gate on a single is_dir result. Behavior-preserving. --- implementations/python/packages/aces_contracts/corpus.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/implementations/python/packages/aces_contracts/corpus.py b/implementations/python/packages/aces_contracts/corpus.py index fc809075f..c19d7d5aa 100644 --- a/implementations/python/packages/aces_contracts/corpus.py +++ b/implementations/python/packages/aces_contracts/corpus.py @@ -54,12 +54,10 @@ def _bundled_corpus_root() -> Path | None: try: candidate = resources.files(_CORPUS_PACKAGE).joinpath(_BUNDLED_DIRNAME) - except (ModuleNotFoundError, TypeError): + candidate_is_dir = candidate.is_dir() + except (ModuleNotFoundError, TypeError, OSError): return None - try: - if not candidate.is_dir(): - return None - except OSError: + if not candidate_is_dir: return None path = Path(str(candidate)) return path if path.is_dir() else None From 7cb20f7de7a266abc13d756c3ac98c1d94008e37 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 01:30:18 +0200 Subject: [PATCH 52/70] added: concept-authority catalog governance gate (ADR-062) --- changelog.d/496.added.md | 1 + .../concept-families-v1.json | 20 +- docs/decisions/adrs/README.md | 2 + ...ncept-authority-catalog-governance-gate.md | 148 ++++++++ docs/decisions/adrs/adr-index.yaml | 3 + .../issue-496-catalog-governance-preflight.md | 154 ++++++++ .../test_concept_authority_governance.py | 343 ++++++++++++++++++ noxfile.py | 9 + tools/check_concept_authority_governance.py | 333 +++++++++++++++++ tools/policy/adr_policy.yaml | 1 + 10 files changed, 1004 insertions(+), 10 deletions(-) create mode 100644 changelog.d/496.added.md create mode 100644 docs/decisions/adrs/adr-062-concept-authority-catalog-governance-gate.md create mode 100644 docs/decisions/issue-496-catalog-governance-preflight.md create mode 100644 implementations/python/tests/test_concept_authority_governance.py create mode 100644 tools/check_concept_authority_governance.py diff --git a/changelog.d/496.added.md b/changelog.d/496.added.md new file mode 100644 index 000000000..2da952c79 --- /dev/null +++ b/changelog.d/496.added.md @@ -0,0 +1 @@ +Added a concept-authority catalog governance gate (ADR-062, `tools/check_concept_authority_governance.py`, wired into the `policy` nox session): every concept family in `concept-families-v1.json` must be ADR-linked, and inline-code cross-references in a family's `relation_rules` must resolve to a known concept family or controlled vocabulary. diff --git a/contracts/concept-authority/concept-families-v1.json b/contracts/concept-authority/concept-families-v1.json index c60aaf1a8..a613a7e94 100644 --- a/contracts/concept-authority/concept-families-v1.json +++ b/contracts/concept-authority/concept-families-v1.json @@ -63,7 +63,7 @@ "provenance": "native", "extension_scope": "ACES experiment workflow concepts for task framing, run lifecycle, study grouping, and analysis organization.", "relation_rules": [ - "May reference scenarios as the authoring input under evaluation and provenance-and-evidence concepts as recorded outputs.", + "May reference `scenarios` as the authoring input under evaluation and `provenance-and-evidence` concepts as recorded outputs.", "Must describe experiment organization and lifecycle rather than the cyber-domain assets or actions inside a scenario." ], "non_ambiguity_constraints": [ @@ -77,9 +77,9 @@ "provenance": "native", "extension_scope": "ACES participant runtime episode identity, lifecycle state, state/history contracts, reset/restart/terminate semantics, and participant-scoped behavior sequencing.", "relation_rules": [ - "May relate to tasks-runs-studies as the participant-level runtime segment that occurs within a task, run, or study without becoming that task, run, or study.", - "May relate to scenarios as the runtime execution instance of a participant admitted from scenario context, while scenario authoring remains governed by scenarios.", - "May relate to actions-and-events and observables through behavior or history records emitted inside an episode without classifying those records as the episode itself." + "May relate to `tasks-runs-studies` as the participant-level runtime segment that occurs within a task, run, or study without becoming that task, run, or study.", + "May relate to `scenarios` as the runtime execution instance of a participant admitted from scenario context, while scenario authoring remains governed by `scenarios`.", + "May relate to `actions-and-events` and `observables` through behavior or history records emitted inside an episode without classifying those records as the episode itself." ], "non_ambiguity_constraints": [ "Must not be used as a synonym for tasks, runs, studies, scenarios, workflow steps, operation receipts, or backend process restarts.", @@ -93,9 +93,9 @@ "provenance": "native", "extension_scope": "ACES observed and declared runtime inventory of scenario node state under nodes.*.runtime: services, platforms, packages and software components, controls and security posture, filesystem and mounts, processes and scheduled jobs, and other node-scoped apparatus-state facts.", "relation_rules": [ - "May relate to assets as the observed runtime configuration state of an asset-bearing scenario node without becoming the node or its asset identity.", - "May relate to observables when an inventory fact is surfaced as a condition, metric, or telemetry signal, while the structured inventory record itself remains runtime-inventory.", - "May relate to identities, tools-and-artifacts, relationships, and actions-and-events through individual inventory fields that denote those narrower cyber-domain objects, which bind to the narrower family rather than to the inventory record as a whole." + "May relate to `assets` as the observed runtime configuration state of an asset-bearing scenario node without becoming the node or its asset identity.", + "May relate to `observables` when an inventory fact is surfaced as a condition, metric, or telemetry signal, while the structured inventory record itself remains `runtime-inventory`.", + "May relate to `identities`, `tools-and-artifacts`, `relationships`, and `actions-and-events` through individual inventory fields that denote those narrower cyber-domain objects, which bind to the narrower family rather than to the inventory record as a whole." ], "non_ambiguity_constraints": [ "Must record observed or declared node runtime state and must not be used as a synonym for the actions or events that produce, change, or react to that state.", @@ -123,7 +123,7 @@ "provenance": "native", "extension_scope": "ACES realization semantics for turning underspecified authoring inputs into executable, planned, or disclosed artifacts.", "relation_rules": [ - "May relate scenarios to apparatus-declarations by describing what an apparatus can realize or disclose.", + "May relate `scenarios` to `apparatus-declarations` by describing what an apparatus can realize or disclose.", "Must distinguish realization support from the cyber-domain objects being realized." ], "non_ambiguity_constraints": [ @@ -137,7 +137,7 @@ "provenance": "native", "extension_scope": "ACES provenance, evidence expectation, evidence collection, and audit-record semantics for experiments and execution.", "relation_rules": [ - "May record observations about scenarios, tasks, runs, studies, apparatus, and cyber-domain objects without redefining those objects.", + "May record observations about `scenarios`, tasks, runs, studies, apparatus, and cyber-domain objects without redefining those objects.", "Must separate evidence/provenance records from the observed asset, identity, observable, action, relationship, or artifact." ], "non_ambiguity_constraints": [ @@ -151,7 +151,7 @@ "provenance": "native", "extension_scope": "ACES timing, clock, scheduling, synchronization, and apparatus-time semantics that govern execution rather than cyber-domain object identity.", "relation_rules": [ - "May constrain scenarios, tasks, runs, realization, and apparatus behavior through time or clock declarations.", + "May constrain `scenarios`, tasks, runs, realization, and apparatus behavior through time or clock declarations.", "Must keep timing and clock semantics separate from the cyber-domain event or action whose occurrence is being timed." ], "non_ambiguity_constraints": [ diff --git a/docs/decisions/adrs/README.md b/docs/decisions/adrs/README.md index cf465bab0..2d4cc693f 100644 --- a/docs/decisions/adrs/README.md +++ b/docs/decisions/adrs/README.md @@ -106,6 +106,7 @@ adr-058-datastore-node-engine-provenance-and-endpoints adr-059-adr-amendment-policy-and-pin-gate adr-060-participant-backend-facing-contract-surface adr-061-published-schema-evolution-policy +adr-062-concept-authority-catalog-governance-gate ``` | ADR | Title | Status | Date | @@ -172,3 +173,4 @@ adr-061-published-schema-evolution-policy | [059](adr-059-adr-amendment-policy-and-pin-gate.md) | ADR Amendment Policy and Acceptance-Content Pin Gate | accepted | 2026-06-10 | | [060](adr-060-participant-backend-facing-contract-surface.md) | Participant Backend-Facing Contract Surface | proposed | 2026-06-11 | | [061](adr-061-published-schema-evolution-policy.md) | Published Schema Evolution Policy | accepted | 2026-06-14 | +| [062](adr-062-concept-authority-catalog-governance-gate.md) | Concept-Authority Catalog Governance Gate | accepted | 2026-06-14 | diff --git a/docs/decisions/adrs/adr-062-concept-authority-catalog-governance-gate.md b/docs/decisions/adrs/adr-062-concept-authority-catalog-governance-gate.md new file mode 100644 index 000000000..0102b6452 --- /dev/null +++ b/docs/decisions/adrs/adr-062-concept-authority-catalog-governance-gate.md @@ -0,0 +1,148 @@ +# ADR-062: Concept-Authority Catalog Governance Gate + +## Status + +accepted + +## Date + +2026-06-14 + +## Classification + +Classification: FM0 +Required artifacts: ADR, policy checker gate, unit tests +Waivers: none + +The gate is a deterministic, filesystem-only structural checker (whole-token ADR +matching and catalog set-membership resolution); its correctness is established +structurally and verified by unit tests, with no semantic, graph, or stateful +reasoning that would warrant a higher level. + +## Context + +[ADR-012](adr-012-shared-concept-authority-and-aces-extension-discipline.md) §3 +establishes the ACES extension discipline: an ACES-native concept family must be +an explicit, disciplined extension over the shared concept authority, declaring +`extension_scope`, `relation_rules`, and `non_ambiguity_constraints`. The +machine-readable catalog +`contracts/concept-authority/concept-families-v1.json` and its published JSON +Schema validate that those fields are *present and well-shaped*. Nothing +structural validates the *governance linkages* around the catalog: + +- a family can be added to the catalog with no ADR deciding it exists — the + "explicit extension" requirement is then satisfied on paper (the fields are + filled in) but not in the architectural record; +- a native family's `relation_rules` can name another concept family or a + controlled vocabulary that does not exist (a rename or typo leaves a dangling + reference), and no gate notices. + +[ADR-009](adr-009-normative-artifact-authority-and-repository-structure.md) / +[ADR-019](adr-019-normative-authority-boundary-manifest.md) already make the +*authority-boundary* machine-checkable (`tools/check_authority_boundary.py`): +which repository roots bear authority, and that every authority family is named +in the immutable ADR pair. That gate governs where authority lives; it does not +reach inside the concept-family catalog to check that each family is decided by +an ADR or that the catalog's internal cross-references resolve. Review finding +CA-6 (issue #496) records exactly this gap. + +## Decision + +Add one filesystem-only, deterministic `policy` gate — +`tools/check_concept_authority_governance.py`, wired into the `policy` nox +session beside `check_authority_boundary.py` — that enforces concept-authority +catalog governance over the existing catalog. It introduces no new +concept-authority schema, registry, nox session, or runtime validation path, and +derives family and vocabulary identity from the authoritative catalogs and their +existing Pydantic models (`ConceptFamilyCatalogModel`, +`ControlledVocabularyCatalogModel`) rather than from a hard-coded id list. + +### 1. ADR linkage is governance proof + +Every family id in `concept-families-v1.json` must appear as a **whole token** in +at least one ADR under `docs/decisions/adrs/`. The match is word-boundary +(reusing the `check_authority_boundary.py` precedent), so `prosecution` cannot +satisfy `prose` and a hyphenated id matches only as a complete token. +Specifications, explanatory docs, preflight notes, fixtures, and tests do **not** +satisfy ADR linkage — only ADRs do. Adding a new concept family therefore +requires one catalog entry **and** at least one ADR that names it. + +### 2. Catalog cross-references use an explicit token convention + +A cross-reference from a family's `relation_rules` to another concept family or +to a controlled vocabulary is written as an **inline-code (Markdown backtick) +token** — `` `runtime-inventory` ``, `` `processor-features` `` — and the gate +validates only those explicit tokens, never bare prose words. Each inline-code +token shaped like a concept identifier +(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`, the `ConceptFamilyId` grammar) must resolve to +a known concept family (`concept-families-v1.json`) or controlled vocabulary +(`controlled-vocabularies-v1.json`); a token that resolves to neither is a +dangling reference and fails the gate. Inline-code spans that are not +identifier-shaped — field names (`concept_bindings`), model names +(`RuntimeConfiguration`), instance paths (`nodes.*.runtime`) — are not references +and are ignored. Reference validation is deterministic and does not infer +references from natural-language prose. + +### 3. Governed concept-family set + +The canonical concept-family identifiers under this gate's governance are: +`assets`, `identities`, `relationships`, `observables`, `actions-and-events`, +`tools-and-artifacts`, `scenarios`, `tasks-runs-studies`, `episodes`, +`runtime-inventory`, `apparatus-declarations`, `realization-and-disclosure`, +`provenance-and-evidence`, and `time-and-apparatus`. Enumerating them here +records their ADR linkage and documents the gate's domain; the catalog remains +the authority for which families exist, and a family added later must earn its +own ADR mention (this accepted ADR is immutable and cannot be the home for a +future family). + +This gate operationalises GOV-918 (cross-artifact concept binding — references +bind to canonical concepts that exist) and GOV-919 (extension discipline for +adding new concepts). It does not change concept-binding, semantic-profile, +reference-model, or UCO-alignment semantics. + +## Alternatives Considered + +- **Amend ADR-012 to spell the canonical hyphenated family ids.** Rejected: + ADR-012 is accepted and immutable under + [ADR-059](adr-059-adr-amendment-policy-and-pin-gate.md), and amending an ADR + solely to satisfy a new gate's initial green state is the wrong shape. A + dedicated governance ADR that enumerates the governed set is the honest home + for the linkage and for the gate decision. +- **Add a structured `governed_vocabularies` (or similar) field to family + entries.** Rejected: adding a structured reference field to a published + catalog is a schema-evolution change subject to + [ADR-061](adr-061-published-schema-evolution-policy.md), the schema + publication manifest, and the generated-schema gate. The inline-code prose + convention carries machine-checkable references without changing the published + schema. +- **Infer family/vocabulary references from `relation_rules` prose by matching + any id-grammar-shaped word.** Rejected: natural-language inference is + non-deterministic and false-positives on domain adjectives the prose uses + (for example `cyber-domain`). The explicit inline-code convention makes "this + token is a reference" an authoring decision, not a guess. + +## Consequences + +**Positive** + +- The extension discipline ADR-012 §3 describes becomes mechanically enforced: + a family with no deciding ADR, or a relation rule with a dangling reference, + fails `nox -s policy`. +- The catalog and the ADR corpus cannot silently drift apart. +- The gate is filesystem-only and never calls Ground Control or the network, so + it cannot become flaky in CI. + +**Negative / costs** + +- A new concept family now requires an ADR mention as well as a catalog entry — + intended friction that keeps the architectural record honest. +- Machine-checkable catalog cross-references must adopt the inline-code token + convention; bare-prose references are documentation only and are not validated. + +**Risks** + +- The gate validates only references that opt into the inline-code convention, so + a bare-prose reference to a non-existent family is not caught. This is the + deliberate trade for determinism; the convention is the seam future references + use, and the authoritative catalogs remain the single source of family and + vocabulary identity. diff --git a/docs/decisions/adrs/adr-index.yaml b/docs/decisions/adrs/adr-index.yaml index a723802d7..2b542880e 100644 --- a/docs/decisions/adrs/adr-index.yaml +++ b/docs/decisions/adrs/adr-index.yaml @@ -253,3 +253,6 @@ adrs: - id: ADR-061 path: docs/decisions/adrs/adr-061-published-schema-evolution-policy.md pin: 08849a676cdaaf21bd1ac425f552d6347032d3f545b9cb5618090d22c56cd5dd + - id: ADR-062 + path: docs/decisions/adrs/adr-062-concept-authority-catalog-governance-gate.md + pin: b5a15b467977e433fbfff55a1de2b4744faada7dd54ea4806496e8293bedae91 diff --git a/docs/decisions/issue-496-catalog-governance-preflight.md b/docs/decisions/issue-496-catalog-governance-preflight.md new file mode 100644 index 000000000..3a6e6d41d --- /dev/null +++ b/docs/decisions/issue-496-catalog-governance-preflight.md @@ -0,0 +1,154 @@ +# Issue 496 Concept Authority Governance Preflight + +Date: 2026-06-14 + +Issue: #496. + +Requirement: GOV-918. + +This note records architecture preflight guardrails for adding the concept +authority governance gate. It is guidance for the implementation and does not +implement the checker, tests, nox wiring, catalog edits, or ADR linkage. + +## Binding Sources + +- ADR-012 defines shared concept authority, ACES-native extension discipline, + artifact concept bindings, and controlled-vocabulary authority. +- `specs/concept-authority/concept-authority.md` is the normative concept + authority prose for families, extension discipline, and the surface model. +- `contracts/concept-authority/concept-families-v1.json` is the authoritative + family catalog. Family ids are authoritative at the keyed map. +- `contracts/concept-authority/controlled-vocabularies-v1.json` is the + authoritative vocabulary catalog. Vocabulary ids are authoritative at the + keyed map. +- `contracts/concept-authority/reference-models-v1.json` and + `contracts/profiles/semantic/reference-stack-v1.json` consume family ids; + they must not become fallback authorities for what families exist. +- `tools/check_authority_boundary.py` is the closest policy-tool precedent for + ADR drift checks, word-boundary matching, `--json`, exception handling, and + focused mutation tests. + +## Architecture Decisions + +- Add one policy checker for concept-authority governance. It should validate + governance linkages around the existing catalog; it should not create a new + concept-authority schema, registry, exception hierarchy, nox session, or + runtime validation path. +- Treat the concept-family catalog and controlled-vocabulary catalog as the + only machine-readable authorities for family and vocabulary identity. The + checker derives known ids from those files and from their existing Pydantic + models rather than hard-coding the current family or vocabulary set. +- ADR linkage is a governance proof, not concept definition. Each family id in + the catalog should be mentioned as a whole token in at least one ADR under + `docs/decisions/adrs/`; issue preflight notes, explanatory docs, specs, and + tests do not satisfy that linkage. +- Use the existing word-boundary pattern from `check_authority_boundary.py`. + Substring matches such as `prose` inside `prosecution`, or `ADR-0120` for + `ADR-012`, must not satisfy the gate. +- Relation and vocabulary cross-reference validation must be deterministic. + Do not infer references from every word in prose. If free-text fields need + machine-checkable references, use an explicit token convention, such as + inline-code tokens containing `ConceptFamilyId` or controlled-vocabulary ids, + and validate only those explicit tokens against the authoritative catalogs. +- Current-catalog green state must be achieved by real governance linkage or + catalog prose cleanup, not by special-casing existing family ids or skipping + native families. + +## Required Incumbents + +- Policy CLI and failure surface: + `tools.policy.common.PolicyFailure`, `failures_to_json`, + `load_exceptions`, and `apply_exceptions`. +- Policy workflow: `noxfile.py` `_run_policy`, `TARGETED_POLICY_TESTS`, and + the existing `policy` session. Do not add a parallel verification command. +- Concept catalog validation: + `ConceptFamilyCatalogModel`, `ConceptFamilyDefinitionModel`, + `ConceptProvenanceCategory`, `ConceptFamilyId`, and the cached helpers near + `_authoritative_concept_family_ids()` in + `implementations/python/packages/aces_contracts/contracts.py`. +- Vocabulary catalog validation: + `ControlledVocabularyCatalogModel` and + `implementations/python/packages/aces_contracts/controlled_vocabularies.py`. +- Test style: `implementations/python/tests/test_authority_boundary.py` for + temp-repo seeding and drift mutations, plus + `implementations/python/tests/test_concept_authority.py` for catalog model + invariants. +- Repo workflow guards: `.ground-control.yaml`, `.gc/plan-rules.md`, + `tools/check_repo_policy.py`, `tools/check_requirement_governance.py`, and + `tools/verify_all.py`. + +## Cross-Cutting Layers + +- JSON/config parsing: read checked-in JSON with `json.loads` and validate + through existing contract models. Do not add permissive ad hoc parsing or + coerce malformed catalog fields to empty structures. +- Markdown/ADR scanning: scan only repository ADR files from the canonical + `docs/decisions/adrs/` directory. Treat file text as inert text; do not + evaluate Markdown, links, code fences, or front matter. +- Repo-path security: checker inputs should be constant repo-relative paths or + the existing `--repo-root` pattern. If future config introduces paths, route + them through `safe_repo_path` or an equivalent absolute-path, `..`, and + symlink-escape guard before reading. +- Policy error envelope: emit concise `PolicyFailure` records and preserve + `--json`. Do not dump full catalog bodies, ADR text, environment variables, + tracebacks, or raw exception chains into policy output. +- Auth and secret-handling: this gate should read local public repo files only. + It must not use GitHub, Ground Control, network fetches, credentials, private + repository URLs, bearer tokens, or environment-bound secrets. +- OS/runtime exposure: do not shell out for ADR scans or catalog parsing. If + subprocess use is later required for workflow integration, use fixed argv + lists and keep tokens or secrets out of process argv and logs. +- Schema/publication gate: this issue should not require published schema + changes. If the implementation chooses to add structured reference fields, + that is a separate schema-evolution change subject to ADR-061, + `contracts/schema-publication-manifest.json`, + `tools/check_schema_publication.py`, and `tools/check_generated_schemas.py`. + +## Extension Boundary + +The extension seam is catalog-derived identity plus explicit governance tokens: + +- adding a concept family requires one catalog entry and at least one ADR + whole-token mention of that family id; +- adding a relation from native-family prose to another family should use the + same explicit family-id token convention and resolve against the catalog; +- adding a vocabulary reference from family prose should use the same explicit + vocabulary-id token convention and resolve against + `controlled-vocabularies-v1.json`; +- adding future authority catalogs should add a new checker parameter or helper + over an authoritative catalog file, not hard-code another current-id list. + +## Gotchas And Anti-Patterns + +Avoid: + +- treating specs, issue preflight notes, READMEs, fixtures, or tests as ADR + linkage for a family id; +- assuming the current ADR corpus already spells every catalog id exactly. A + strict whole-token scan should treat missing exact-id linkage, such as for + `actions-and-events`, `tools-and-artifacts`, or + `realization-and-disclosure`, as initial green-state work rather than a + reason for checker exceptions; +- special-casing `episodes`, `runtime-inventory`, or any current family to get + the initial catalog passing; +- validating relation rules by natural-language guesses or by matching every + lowercase word that happens to fit `ConceptFamilyId`; +- duplicating concept-family or vocabulary definitions in the checker, tests, + noxfile, or policy YAML; +- turning controlled vocabularies, reference models, semantic profiles, or UCO + alignment into alternate sources of family existence; +- adding a new nox session, policy exception format, schema generator, or + duplicate JSON artifact validator; +- weakening the existing schema, concept-binding, semantic-profile, or + reference-model validators while adding this governance gate. + +## Non-Goals + +- Implementing `tools/check_concept_authority_governance.py`, nox wiring, or + checker tests in this preflight note. +- Adding, removing, or renaming concept families or controlled vocabularies. +- Amending ADRs to satisfy the initial green state. +- Adding new SDL, manifest, provenance, reporting, runtime, API, persistence, + auth, logging, or schema behavior. +- Changing semantic profiles, reference models, UCO alignment, or manifest + concept-binding semantics. diff --git a/implementations/python/tests/test_concept_authority_governance.py b/implementations/python/tests/test_concept_authority_governance.py new file mode 100644 index 000000000..84e46cb01 --- /dev/null +++ b/implementations/python/tests/test_concept_authority_governance.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from tools.check_concept_authority_governance import ( # noqa: E402 + ADR_DIR_RELATIVE_PATH, + CONCEPT_FAMILIES_RELATIVE_PATH, + CONTROLLED_VOCABULARIES_RELATIVE_PATH, + EXTENSION_DISCIPLINE_ADR_REF, + GOVERNANCE_ADR_REF, + REQUIREMENT_REFS, + evaluate_concept_authority_governance, + main, +) + +# --------------------------------------------------------------------------- # +# Canonical, well-formed concept-authority catalogs used as the positive case # +# and the starting point for every mutation test. The families catalog mirrors # +# the real contracts/concept-authority/concept-families-v1.json shape (one # +# adopted + one native family); the native family's relation_rules use the # +# inline-code (backtick) token convention to reference a known family and a # +# known controlled vocabulary, so the positive case exercises both resolution # +# paths. # +# --------------------------------------------------------------------------- # +_GOOD_FAMILIES: dict = { + "schema_version": "concept-families/v1", + "families": { + "assets": { + "title": "Assets", + "description": "Nodes, infrastructure, networks, and deployable resources.", + "provenance": "adopted", + "authority": "UCO", + "authority_reference": "https://github.com/ucoProject/UCO", + }, + "episodes": { + "title": "Episodes", + "description": "Participant runtime episode identity and lifecycle state.", + "provenance": "native", + "extension_scope": "ACES participant runtime episode identity and lifecycle state.", + "relation_rules": [ + "May relate to `assets` as the asset-bearing scenario node an episode runs on.", + "May bind enumerated terms through the `sample-vocab` controlled vocabulary.", + ], + "non_ambiguity_constraints": [ + "Must not be used as a synonym for tasks, runs, or studies.", + ], + }, + }, +} + +_GOOD_VOCABULARIES: dict = { + "schema_version": "controlled-vocabularies/v1", + "vocabularies": { + "sample-vocab": { + "title": "Sample Vocabulary", + "description": "A closed enumeration used only by the test fixture.", + "kind": "enumeration", + "governed_scopes": [], + "extension_policy": "closed", + "terms": { + "alpha": {"title": "Alpha", "description": "The alpha term."}, + }, + }, + }, +} + +# A single ADR that mentions every fixture family id as a whole token. ADR +# linkage is governance proof: the gate is satisfied when a family id appears +# word-boundary in at least one ADR under docs/decisions/adrs/. +_GOOD_ADR = """# ADR-001: Concept Families + +## Status +accepted + +## Decision + +The catalog governs the `assets` family and the `episodes` family. +""" + + +def _seed_repo( + tmp_path: Path, + *, + families: dict | str | None = None, + vocabularies: dict | str | None = None, + adrs: dict[str, str] | None = None, + extra_files: dict[str, str] | None = None, +) -> Path: + """Seed a temp repo with concept-authority catalogs and ADR files. + + ``families`` / ``vocabularies`` accept a dict (serialized to JSON) or a raw + string (to plant malformed JSON). ``adrs`` maps ADR file names to bodies; + when omitted the canonical single ADR is written. + """ + if families is None: + families = _GOOD_FAMILIES + if vocabularies is None: + vocabularies = _GOOD_VOCABULARIES + if adrs is None: + adrs = {"adr-001-concept-families.md": _GOOD_ADR} + + if families is not None: + families_path = tmp_path / CONCEPT_FAMILIES_RELATIVE_PATH + families_path.parent.mkdir(parents=True, exist_ok=True) + families_path.write_text( + families if isinstance(families, str) else json.dumps(families, indent=2), + encoding="utf-8", + ) + + if vocabularies is not None: + vocab_path = tmp_path / CONTROLLED_VOCABULARIES_RELATIVE_PATH + vocab_path.parent.mkdir(parents=True, exist_ok=True) + vocab_path.write_text( + vocabularies if isinstance(vocabularies, str) else json.dumps(vocabularies, indent=2), + encoding="utf-8", + ) + + adr_dir = tmp_path / ADR_DIR_RELATIVE_PATH + adr_dir.mkdir(parents=True, exist_ok=True) + for name, body in adrs.items(): + (adr_dir / name).write_text(body, encoding="utf-8") + + if extra_files: + for rel, body in extra_files.items(): + extra_path = tmp_path / rel + extra_path.parent.mkdir(parents=True, exist_ok=True) + extra_path.write_text(body, encoding="utf-8") + + return tmp_path + + +def _flagged(failures, marker: str) -> bool: + needle = marker.lower() + return any(f.rule_id == marker or needle in f.render().lower() for f in failures) + + +# --------------------------------------------------------------------------- # +# Module-level invariants. # +# --------------------------------------------------------------------------- # + + +def test_requirement_refs_include_gov_918() -> None: + assert "GOV-918" in REQUIREMENT_REFS + + +def test_governance_adr_ref_is_adr_062() -> None: + assert GOVERNANCE_ADR_REF == "ADR-062" + + +def test_extension_discipline_adr_ref_is_adr_012() -> None: + assert EXTENSION_DISCIPLINE_ADR_REF == "ADR-012" + + +# --------------------------------------------------------------------------- # +# Positive case. # +# --------------------------------------------------------------------------- # + + +def test_good_catalog_has_no_failures(tmp_path: Path) -> None: + assert evaluate_concept_authority_governance(_seed_repo(tmp_path)) == [] + + +def test_real_repo_catalog_passes() -> None: + # Acceptance criterion: the live catalog passes the gate without + # special-casing. Runs the checker against the actual repository. + assert evaluate_concept_authority_governance(REPO_ROOT) == [] + + +# --------------------------------------------------------------------------- # +# Check 1 -- family ADR linkage. # +# --------------------------------------------------------------------------- # + + +def test_family_absent_from_all_adrs_is_flagged(tmp_path: Path) -> None: + # The ADR mentions only `assets`; `episodes` is unlinked. + adr = "# ADR-001\n\n## Decision\n\nThe catalog governs the `assets` family.\n" + failures = evaluate_concept_authority_governance(_seed_repo(tmp_path, adrs={"adr-001.md": adr})) + assert _flagged(failures, "concept-authority-family-adr-missing") + assert any("episodes" in f.message for f in failures) + + +def test_substring_adr_mention_does_not_satisfy_linkage(tmp_path: Path) -> None: + # `subepisodes` must not satisfy the `episodes` family id (word-boundary). + adr = "# ADR-001\n\n## Decision\n\nGoverns `assets` and subepisodes handling.\n" + failures = evaluate_concept_authority_governance(_seed_repo(tmp_path, adrs={"adr-001.md": adr})) + assert _flagged(failures, "concept-authority-family-adr-missing") + assert any("episodes" in f.message for f in failures) + + +def test_hyphen_delimited_superset_does_not_satisfy_linkage(tmp_path: Path) -> None: + # A hyphen-delimited superset like `pre-episodes-handling` must not satisfy + # the `episodes` family id: concept ids use `-` as an identifier character, + # so the boundary excludes `-` and the id matches only as a complete token. + adr = "# ADR-001\n\n## Decision\n\nGoverns `assets` and pre-episodes-handling.\n" + failures = evaluate_concept_authority_governance(_seed_repo(tmp_path, adrs={"adr-001.md": adr})) + assert _flagged(failures, "concept-authority-family-adr-missing") + assert any("episodes" in f.message for f in failures) + + +def test_readme_in_adr_dir_does_not_count_as_adr(tmp_path: Path) -> None: + # A README under the ADR dir is not an ADR file; it must not satisfy linkage. + seeded = _seed_repo( + tmp_path, + adrs={"adr-001.md": "# ADR-001\n\n## Decision\n\nGoverns `assets`.\n"}, + extra_files={f"{ADR_DIR_RELATIVE_PATH}/README.md": "Mentions `episodes` here.\n"}, + ) + failures = evaluate_concept_authority_governance(seeded) + assert _flagged(failures, "concept-authority-family-adr-missing") + assert any("episodes" in f.message for f in failures) + + +def test_missing_adr_dir_is_flagged(tmp_path: Path) -> None: + seeded = _seed_repo(tmp_path) + for child in (seeded / ADR_DIR_RELATIVE_PATH).iterdir(): + child.unlink() + (seeded / ADR_DIR_RELATIVE_PATH).rmdir() + failures = evaluate_concept_authority_governance(seeded) + assert _flagged(failures, "concept-authority-adr-dir-missing") + + +# --------------------------------------------------------------------------- # +# Check 2 -- relation/vocabulary reference resolution (explicit-token only). # +# --------------------------------------------------------------------------- # + + +def test_relation_rule_unknown_family_reference_is_flagged(tmp_path: Path) -> None: + families = json.loads(json.dumps(_GOOD_FAMILIES)) + families["families"]["episodes"]["relation_rules"][0] = "May relate to `ghost-family` nodes." + failures = evaluate_concept_authority_governance(_seed_repo(tmp_path, families=families)) + assert _flagged(failures, "concept-authority-dangling-reference") + assert any("ghost-family" in f.message for f in failures) + + +def test_relation_rule_unknown_vocabulary_like_reference_is_flagged(tmp_path: Path) -> None: + families = json.loads(json.dumps(_GOOD_FAMILIES)) + families["families"]["episodes"]["relation_rules"][1] = "Bind through the `made-up-vocab` vocabulary." + failures = evaluate_concept_authority_governance(_seed_repo(tmp_path, families=families)) + assert _flagged(failures, "concept-authority-dangling-reference") + assert any("made-up-vocab" in f.message for f in failures) + + +def test_known_vocabulary_reference_resolves(tmp_path: Path) -> None: + # `sample-vocab` is a known controlled vocabulary id -> no dangling failure. + failures = evaluate_concept_authority_governance(_seed_repo(tmp_path)) + assert not _flagged(failures, "concept-authority-dangling-reference") + + +def test_non_id_inline_code_token_is_ignored(tmp_path: Path) -> None: + # Backtick tokens that are not concept-family-id shaped (caps, underscores, + # dotted paths) are not references and must not be validated. + families = json.loads(json.dumps(_GOOD_FAMILIES)) + families["families"]["episodes"]["relation_rules"][0] = ( + "Bound via `concept_bindings` on `RuntimeConfiguration` at `nodes.*.runtime`." + ) + failures = evaluate_concept_authority_governance(_seed_repo(tmp_path, families=families)) + assert not _flagged(failures, "concept-authority-dangling-reference") + + +def test_bare_prose_family_like_word_is_not_validated(tmp_path: Path) -> None: + # Without inline-code delimiters, a family-id-shaped word is prose, not a + # reference: no natural-language inference. + families = json.loads(json.dumps(_GOOD_FAMILIES)) + families["families"]["episodes"]["relation_rules"][0] = "May relate to ghost-family nodes." + failures = evaluate_concept_authority_governance(_seed_repo(tmp_path, families=families)) + assert not _flagged(failures, "concept-authority-dangling-reference") + + +# --------------------------------------------------------------------------- # +# Catalog load failures. # +# --------------------------------------------------------------------------- # + + +def test_missing_families_catalog_is_flagged(tmp_path: Path) -> None: + seeded = _seed_repo(tmp_path) + (seeded / CONCEPT_FAMILIES_RELATIVE_PATH).unlink() + failures = evaluate_concept_authority_governance(seeded) + assert _flagged(failures, "concept-authority-families-catalog-missing") + + +def test_invalid_families_catalog_is_flagged(tmp_path: Path) -> None: + failures = evaluate_concept_authority_governance(_seed_repo(tmp_path, families="{ not valid json")) + assert _flagged(failures, "concept-authority-families-catalog-invalid") + + +def test_schema_invalid_families_catalog_is_flagged(tmp_path: Path) -> None: + # A native family missing its required relation_rules fails model validation. + families = json.loads(json.dumps(_GOOD_FAMILIES)) + del families["families"]["episodes"]["relation_rules"] + failures = evaluate_concept_authority_governance(_seed_repo(tmp_path, families=families)) + assert _flagged(failures, "concept-authority-families-catalog-invalid") + + +def test_missing_vocabularies_catalog_is_flagged(tmp_path: Path) -> None: + seeded = _seed_repo(tmp_path) + (seeded / CONTROLLED_VOCABULARIES_RELATIVE_PATH).unlink() + failures = evaluate_concept_authority_governance(seeded) + assert _flagged(failures, "concept-authority-vocabularies-catalog-missing") + + +def test_invalid_vocabularies_catalog_is_flagged(tmp_path: Path) -> None: + failures = evaluate_concept_authority_governance(_seed_repo(tmp_path, vocabularies="{ not valid json")) + assert _flagged(failures, "concept-authority-vocabularies-catalog-invalid") + + +def test_schema_invalid_vocabularies_catalog_is_flagged(tmp_path: Path) -> None: + # A vocabulary missing its required `kind` field fails model validation. + vocabularies = json.loads(json.dumps(_GOOD_VOCABULARIES)) + del vocabularies["vocabularies"]["sample-vocab"]["kind"] + failures = evaluate_concept_authority_governance(_seed_repo(tmp_path, vocabularies=vocabularies)) + assert _flagged(failures, "concept-authority-vocabularies-catalog-invalid") + + +# --------------------------------------------------------------------------- # +# CLI surface -- --json output and exception waiver. # +# --------------------------------------------------------------------------- # + + +def test_main_json_output_lists_failures(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + seeded = _seed_repo(tmp_path, adrs={"adr-001.md": "Governs `assets` only.\n"}) + exit_code = main(["--repo-root", str(seeded), "--json"]) + assert exit_code == 1 + payload = json.loads(capsys.readouterr().out) + assert any(item["rule_id"] == "concept-authority-family-adr-missing" for item in payload) + + +def test_exception_waiver_suppresses_failure(tmp_path: Path) -> None: + seeded = _seed_repo(tmp_path, adrs={"adr-001.md": "Governs `assets` only.\n"}) + exceptions = 'exceptions:\n - rule_id: concept-authority-family-adr-missing\n reason: "test waiver"\n' + (seeded / "tools" / "policy").mkdir(parents=True, exist_ok=True) + (seeded / "tools" / "policy" / "exceptions.yaml").write_text(exceptions, encoding="utf-8") + assert main(["--repo-root", str(seeded)]) == 0 + + +def test_main_passes_on_good_catalog(tmp_path: Path) -> None: + assert main(["--repo-root", str(_seed_repo(tmp_path))]) == 0 diff --git a/noxfile.py b/noxfile.py index 44022b907..0bf843b3b 100644 --- a/noxfile.py +++ b/noxfile.py @@ -26,6 +26,7 @@ "implementations/python/tests/test_semantic_coverage.py", "implementations/python/tests/test_assurance_policy.py", "implementations/python/tests/test_authority_boundary.py", + "implementations/python/tests/test_concept_authority_governance.py", "implementations/python/tests/test_agent_guidance_policy.py", "implementations/python/tests/test_example_library_policy.py", ] @@ -517,6 +518,10 @@ def _run_policy(session: nox.Session, reporter: SessionReporter, *args: str) -> "policy / authority boundary ADR", "skipped on staged check; runs on push and verify", ) + reporter.skip( + "policy / concept authority governance", + "skipped on staged check; runs on push and verify", + ) reporter.skip( "policy / agent guidance profile", "skipped on staged check; runs on push and verify", @@ -542,6 +547,10 @@ def _run_policy(session: nox.Session, reporter: SessionReporter, *args: str) -> "policy / authority boundary ADR", lambda: _run_project_python(session, "tools/check_authority_boundary.py"), ) + reporter.run( + "policy / concept authority governance", + lambda: _run_project_python(session, "tools/check_concept_authority_governance.py"), + ) reporter.run( "policy / agent guidance profile", lambda: _run_project_python(session, "tools/check_agent_guidance.py"), diff --git a/tools/check_concept_authority_governance.py b/tools/check_concept_authority_governance.py new file mode 100644 index 000000000..aeaef24f7 --- /dev/null +++ b/tools/check_concept_authority_governance.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +# ruff: noqa: E402, I001 +"""Structural gate for concept-authority catalog governance (ADR-062). + +ADR-012 ("Shared Concept Authority and ACES Extension Discipline") §3 demands +that ACES-native concept families be explicit, disciplined extensions over the +shared concept authority. The JSON Schema for ``concept-families-v1.json`` +validates field *presence* (a native family must declare ``extension_scope``, +``relation_rules``, and ``non_ambiguity_constraints``), but nothing structural +prevents a family being added to the catalog with no governing ADR, or a +relation rule that names a family or vocabulary that does not exist. The +discipline is otherwise process-gated, not enforced. + +This gate (ADR-062, operationalising GOV-918 / GOV-919) closes that gap with +filesystem-only, deterministic checks around the existing catalog — it does +not introduce a new concept schema, registry, runtime validator, or vocabulary: + +* **ADR linkage.** Every family id in + ``contracts/concept-authority/concept-families-v1.json`` must be matched as a + whole token in at least one ADR under ``docs/decisions/adrs/``. ADR linkage + is governance proof; specs, explanatory docs, the preflight note, and tests + do not satisfy it. The word-boundary match mirrors + ``check_authority_boundary.py`` so ``prosecution`` cannot satisfy ``prose``. +* **Reference resolution.** Cross-references from a family's ``relation_rules`` + to another concept family or to a controlled vocabulary use an explicit + inline-code (Markdown backtick) token convention; the gate validates only + those explicit tokens — never bare prose words — and each must resolve to a + known concept family (``concept-families-v1.json``) or controlled vocabulary + (``controlled-vocabularies-v1.json``). A backtick token shaped like an id but + resolving to neither is a dangling reference. + +Family and vocabulary identity derive from the authoritative catalogs and their +existing Pydantic models (``ConceptFamilyCatalogModel`` / +``ControlledVocabularyCatalogModel``); the gate hard-codes no family or +vocabulary id list. Failures use ``tools.policy.common.PolicyFailure`` and the +CLI honours ``--json`` and the shared ``tools/policy/exceptions.yaml`` waiver +mechanism, matching the other ``policy`` nox-stage gates. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections.abc import Iterable, Iterator +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from pydantic import ValidationError + +from aces_contracts.contracts import ( + ConceptFamilyCatalogModel, + ControlledVocabularyCatalogModel, +) + +from tools.policy.common import PolicyFailure, apply_exceptions, failures_to_json, load_exceptions + +# --------------------------------------------------------------------------- # +# Canonical paths and references. Test code imports these directly so a rename # +# surfaces in the test suite rather than silently in production. # +# --------------------------------------------------------------------------- # + +CONCEPT_FAMILIES_RELATIVE_PATH = "contracts/concept-authority/concept-families-v1.json" +CONTROLLED_VOCABULARIES_RELATIVE_PATH = "contracts/concept-authority/controlled-vocabularies-v1.json" +ADR_DIR_RELATIVE_PATH = "docs/decisions/adrs" + +# ADR-012 establishes the extension discipline this gate enforces; ADR-062 is +# the decision record that governs THIS file and enumerates the governed set. +EXTENSION_DISCIPLINE_ADR_REF = "ADR-012" +GOVERNANCE_ADR_REF = "ADR-062" +# The requirements this gate operationalises (cross-artifact concept binding and +# extension discipline). Pinned for the module-invariant tests. +REQUIREMENT_REFS: tuple[str, ...] = ("GOV-918", "GOV-919") + +# The shared concept-family / controlled-vocabulary identifier grammar — the +# same pattern as aces_contracts.vocabulary.ConceptFamilyId and the keys of +# controlled-vocabularies-v1.json. Only inline-code spans whose full content +# matches this grammar are treated as catalog references. +_ID_TOKEN_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$") +# A Markdown inline-code span with no embedded backtick or newline. This is the +# explicit reference-token convention: a family/vocabulary cross-reference in +# free-text prose is written as `the-id`, never inferred from bare words. +_INLINE_CODE_RE = re.compile(r"`([^`\n]+)`") + +RULE_FAMILIES_MISSING = "concept-authority-families-catalog-missing" +RULE_FAMILIES_INVALID = "concept-authority-families-catalog-invalid" +RULE_VOCAB_MISSING = "concept-authority-vocabularies-catalog-missing" +RULE_VOCAB_INVALID = "concept-authority-vocabularies-catalog-invalid" +RULE_ADR_DIR_MISSING = "concept-authority-adr-dir-missing" +RULE_FAMILY_ADR_MISSING = "concept-authority-family-adr-missing" +RULE_DANGLING_REFERENCE = "concept-authority-dangling-reference" + + +def _fail(rule_id: str, message: str, path: str | None = None) -> PolicyFailure: + return PolicyFailure(rule_id, message, path) + + +def _read_text_or_none(path: Path) -> str | None: + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return None + + +# --------------------------------------------------------------------------- # +# Catalog loading. The authoritative catalogs are read from disk and validated # +# through the existing Pydantic models; a malformed catalog is surfaced as a # +# clean failure rather than coerced to an empty structure. # +# --------------------------------------------------------------------------- # + + +def _load_families(repo_root: Path) -> tuple[ConceptFamilyCatalogModel | None, list[PolicyFailure]]: + path = repo_root / CONCEPT_FAMILIES_RELATIVE_PATH + if not path.is_file(): + return None, [ + _fail( + RULE_FAMILIES_MISSING, + f"concept-family catalog not found: {CONCEPT_FAMILIES_RELATIVE_PATH}", + CONCEPT_FAMILIES_RELATIVE_PATH, + ) + ] + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return None, [ + _fail( + RULE_FAMILIES_INVALID, + f"{CONCEPT_FAMILIES_RELATIVE_PATH} is not valid JSON: {exc}", + CONCEPT_FAMILIES_RELATIVE_PATH, + ) + ] + try: + catalog = ConceptFamilyCatalogModel.model_validate(payload) + except ValidationError as exc: + return None, [ + _fail( + RULE_FAMILIES_INVALID, + f"{CONCEPT_FAMILIES_RELATIVE_PATH} failed concept-family catalog validation " + f"({exc.error_count()} error(s))", + CONCEPT_FAMILIES_RELATIVE_PATH, + ) + ] + return catalog, [] + + +def _load_vocabularies(repo_root: Path) -> tuple[ControlledVocabularyCatalogModel | None, list[PolicyFailure]]: + path = repo_root / CONTROLLED_VOCABULARIES_RELATIVE_PATH + if not path.is_file(): + return None, [ + _fail( + RULE_VOCAB_MISSING, + f"controlled-vocabulary catalog not found: {CONTROLLED_VOCABULARIES_RELATIVE_PATH}", + CONTROLLED_VOCABULARIES_RELATIVE_PATH, + ) + ] + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return None, [ + _fail( + RULE_VOCAB_INVALID, + f"{CONTROLLED_VOCABULARIES_RELATIVE_PATH} is not valid JSON: {exc}", + CONTROLLED_VOCABULARIES_RELATIVE_PATH, + ) + ] + try: + catalog = ControlledVocabularyCatalogModel.model_validate(payload) + except ValidationError as exc: + return None, [ + _fail( + RULE_VOCAB_INVALID, + f"{CONTROLLED_VOCABULARIES_RELATIVE_PATH} failed controlled-vocabulary catalog validation " + f"({exc.error_count()} error(s))", + CONTROLLED_VOCABULARIES_RELATIVE_PATH, + ) + ] + return catalog, [] + + +# --------------------------------------------------------------------------- # +# Check 1: every family id is ADR-linked. # +# --------------------------------------------------------------------------- # + + +def _check_family_adr_linkage(repo_root: Path, family_ids: Iterable[str]) -> list[PolicyFailure]: + adr_dir = repo_root / ADR_DIR_RELATIVE_PATH + if not adr_dir.is_dir(): + return [ + _fail( + RULE_ADR_DIR_MISSING, + f"ADR directory not found: {ADR_DIR_RELATIVE_PATH}", + ADR_DIR_RELATIVE_PATH, + ) + ] + + # The README in the ADR directory is not an ADR; the `adr-*.md` glob already + # excludes it. Treat each ADR's bytes as inert text — no Markdown parsing. + texts = [text for adr_file in sorted(adr_dir.glob("adr-*.md")) if (text := _read_text_or_none(adr_file))] + union_text = "\n".join(texts) + + failures: list[PolicyFailure] = [] + for family_id in sorted(family_ids): + # Whole-token match: a substring like `subepisodes` AND a hyphen- + # delimited superset like `pre-episodes` / `actions-and-events-post` + # must not satisfy the family id. Concept ids use `-` as an identifier + # character (the `ConceptFamilyId` grammar), so the boundary excludes + # word characters AND `-` on both sides — `check_authority_boundary` + # excludes only `\w`, which suffices there but would let a hyphen- + # extended superset satisfy a hyphenated concept-family id here. + pattern = rf"(? Iterator[str]: + """Yield inline-code spans whose full content is a concept-id-shaped token. + + Backtick spans that are not id-shaped — a field name like ``concept_bindings`` + (underscore), a model like ``RuntimeConfiguration`` (caps), or an instance + path like ``nodes.*.runtime`` (dots) — are not catalog references and are + skipped, so the gate never flags them. + """ + for match in _INLINE_CODE_RE.finditer(text): + token = match.group(1) + if _ID_TOKEN_RE.match(token): + yield token + + +def _check_reference_resolution( + catalog: ConceptFamilyCatalogModel, + family_ids: frozenset[str], + vocabulary_ids: frozenset[str], +) -> list[PolicyFailure]: + known = family_ids | vocabulary_ids + failures: list[PolicyFailure] = [] + for family_id, definition in sorted(catalog.families.items()): + seen: set[str] = set() + for rule in definition.relation_rules: + for token in _inline_code_id_tokens(rule): + if token in known or token in seen: + continue + seen.add(token) + failures.append( + _fail( + RULE_DANGLING_REFERENCE, + f"concept family '{family_id}' relation_rules reference '{token}', which is not a known " + "concept family or controlled vocabulary", + CONCEPT_FAMILIES_RELATIVE_PATH, + ) + ) + return failures + + +# --------------------------------------------------------------------------- # +# Top-level entry point. # +# --------------------------------------------------------------------------- # + + +def evaluate_concept_authority_governance(repo_root: Path) -> list[PolicyFailure]: + """Return the list of concept-authority governance failures (empty = OK).""" + failures: list[PolicyFailure] = [] + + catalog, families_failures = _load_families(repo_root) + failures.extend(families_failures) + if catalog is None: + return failures + + family_ids = frozenset(catalog.families) + failures.extend(_check_family_adr_linkage(repo_root, family_ids)) + + vocabulary_catalog, vocab_failures = _load_vocabularies(repo_root) + failures.extend(vocab_failures) + if vocabulary_catalog is not None: + vocabulary_ids = frozenset(vocabulary_catalog.vocabularies) + failures.extend(_check_reference_resolution(catalog, family_ids, vocabulary_ids)) + + return failures + + +# --------------------------------------------------------------------------- # +# CLI. # +# --------------------------------------------------------------------------- # + + +def parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate concept-authority catalog governance (ADR-012 extension discipline / ADR-062)." + ) + parser.add_argument( + "--repo-root", + type=Path, + default=REPO_ROOT, + help="Repository root (defaults to the repo containing this file).", + ) + parser.add_argument("--json", action="store_true", help="Emit JSON failures.") + return parser.parse_args(list(argv) if argv is not None else None) + + +def main(argv: Iterable[str] | None = None) -> int: + args = parse_args(argv) + failures = evaluate_concept_authority_governance(args.repo_root) + exceptions_file = args.repo_root / "tools" / "policy" / "exceptions.yaml" + if exceptions_file.is_file(): + failures = apply_exceptions(failures, load_exceptions(args.repo_root)) + if failures: + if args.json: + print(failures_to_json(failures)) + else: + for failure in failures: + print(failure.render(), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/policy/adr_policy.yaml b/tools/policy/adr_policy.yaml index e9d50de70..ff4a3d8d3 100644 --- a/tools/policy/adr_policy.yaml +++ b/tools/policy/adr_policy.yaml @@ -38,6 +38,7 @@ concept_authority: - contracts/fixtures/concept-authority - docs/explain/reference/shared-concept-model.md - docs/decisions/adrs/adr-012-shared-concept-authority-and-aces-extension-discipline.md + - docs/decisions/adrs/adr-062-concept-authority-catalog-governance-gate.md - implementations/python/tests/test_concept_authority.py - implementations/python/packages/aces_contracts - implementations/python/packages/aces_backend_protocols From fa8786ed7508eff75517a914ec59e9dbae9ce3e6 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 02:30:00 +0200 Subject: [PATCH 53/70] Validate worked examples against published JSON Schema (CT-4) Add tests/test_example_schema_conformance.py: serialize each examples/scenarios/*.sdl.yaml via load_scenario + model_dump(mode=json, by_alias=True) and validate against the checked-in contracts/schemas/sdl/sdl-authoring-input-v1.json with Draft202012Validator. Includes a non-vacuity corpus guard and a negative control proving the validator is engaged. Strengthens the example/negative-path leg of ASR-504. --- ...-501-validation-corpus-schema-preflight.md | 134 +++++++++++++++ .../tests/test_example_schema_conformance.py | 153 ++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 docs/decisions/issue-501-validation-corpus-schema-preflight.md create mode 100644 implementations/python/tests/test_example_schema_conformance.py diff --git a/docs/decisions/issue-501-validation-corpus-schema-preflight.md b/docs/decisions/issue-501-validation-corpus-schema-preflight.md new file mode 100644 index 000000000..5bfbb9c32 --- /dev/null +++ b/docs/decisions/issue-501-validation-corpus-schema-preflight.md @@ -0,0 +1,134 @@ +# Issue 501 Validation Corpus Schema Preflight + +Date: 2026-06-15 + +Issue: #501. + +Requirement: ASR-504. + +This note records architecture preflight guardrails for validating shipped SDL +examples against the published JSON Schema surface. It is guidance for the +implementation and does not implement tests, change examples, edit schemas, or +alter parser behavior. + +## Binding Sources + +- ADR-009 makes `contracts/schemas/` the normative machine-readable schema + authority. Python models and `schema_bundle()` are compatibility evidence. +- ADR-061 and `contracts/schema-publication-manifest.json` govern published + schema inventory and evolution. This issue should not need a schema edit. +- ADR-014 and `.ground-control.yaml` keep `nox -s verify` as the canonical + verification graph. +- `docs/explain/sdl/testing.md` defines `examples/scenarios/*.sdl.yaml` as the + reusable positive example corpus. +- `implementations/python/tests/paths.py` is the existing marker-based + repo-root and example-corpus path seam. +- `load_scenario()` / `parse_sdl()` are the SDL parsing and semantic-validation + boundary for example files. + +## Architecture Decisions + +- Keep worked examples under repo-root `examples/scenarios/`. They are positive, + reusable SDL authoring examples, not invalid fixtures and not normative + contract fixtures. +- Validate examples through the existing loading boundary first, then validate + the serialized model payload against the published schema file + `contracts/schemas/sdl/sdl-authoring-input-v1.json`. +- Treat Pydantic acceptance and published-schema conformance as two separate + claims. The new evidence must prove both; it must not replace parser, + semantic-validator, or schema-publication tests. +- Load the published schema artifact, not a freshly generated schema, when + proving example conformance. `schema_bundle()` remains the drift/compatibility + proof used by existing contract gates. +- Keep the serialization choice explicit and centralized in test support: + `model_dump(mode="json", by_alias=True)` is the contract-shaped payload. Any + exclusions must be documented at that seam, not repeated at each assertion. +- Add a non-vacuity guard around example enumeration. A stale corpus root must + fail loudly rather than collecting zero parametrized cases. +- If instantiated-scenario examples are added later, cover them through the same + contract-id/serialization seam with `instantiated-scenario-v1`; do not + conflate authoring examples with concrete instantiated artifacts. + +## Required Incumbents + +- Corpus discovery: `implementations/python/tests/paths.py` and + `EXAMPLES_DIR`. +- Scenario loading: `aces_sdl.scenarios.load_scenario`, + `aces_sdl.parser.parse_sdl`, `yaml.safe_load`, `SDLModel.extra="forbid"`, + `SemanticValidator`, and the existing SDL error types. +- Schema authority and publication: `contracts/schemas/sdl/`, + `contracts/schema-publication-manifest.json`, ADR-009, ADR-061, + `tools/check_schema_publication.py`, `tools/check_generated_schemas.py`, and + `tools/check_json_artifacts.py`. +- Schema validation idiom: `jsonschema.Draft202012Validator`, already used by + contract and SDL schema tests. +- Contract-corpus resolution, when a schema path helper is needed: + `aces_contracts.corpus.corpus_family_root("schemas")` rather than new + `Path(__file__).parents[N]` heuristics. +- Existing tests to extend or mirror: `test_scenarios.py`, + `test_instantiated_scenario_schema.py`, `test_runtime_contracts.py`, and + `test_mcp_server.py`. + +## Cross-Cutting Layers + +- YAML/config parsing: examples must pass through `yaml.safe_load`, key + normalization, shorthand expansion, top-level string-key checks, mapping-key + variable rejection, and Pydantic closed-world validation. +- Semantic validation: unresolved references, ambiguity, dependency failures, + and runtime-family reference errors must continue to fail closed through + `SemanticValidator`. Advisories remain non-fatal but visible. +- Published schema validation: the JSON Schema validator checks the serialized + authoring payload against the checked-in schema. It must not fetch remote + refs, generate schemas in place, or mutate the manifest. +- Repo-path security: corpus and schema paths stay repo-relative or flow through + existing marker/corpus seams. Do not add absolute-path, `..`, symlink, or + environment-variable based discovery. +- Secret handling and OS exposure: tests should run in process and pass file + paths only. Do not place scenario contents, tokens, operator secrets, or + payload JSON in subprocess argv or logs. +- Error envelopes and leakage: keep existing `SDLParseError`, + `SDLValidationError`, `ScenarioValidationError`, and pytest assertion + surfaces. Failures should identify the example path and schema error, not dump + whole scenario payloads or environment state. +- Auth, persistence, network, and control-plane layers: this work does not add + or change runtime auth, HTTP handlers, database state, audit logging, or live + network access. + +## Extension Boundary + +The extension seam is a small table of validation-corpus entries: +example root, filename glob, model loader, contract id, schema path, and +serialization options. Today that table has the authoring corpus +`examples/scenarios/*.sdl.yaml` against `sdl-authoring-input-v1`. A future +instantiated corpus should add a row for `instantiated-scenario-v1`; it should +not require a new schema loader, new path convention, or new validator wrapper. + +## Gotchas And Anti-Patterns + +Avoid: + +- validating zero examples because `EXAMPLES_DIR` points at the old + `implementations/python/examples` location; +- using `schema_bundle()["sdl-authoring-input-v1"]` as the primary proof of + published-schema conformance; +- adding duplicate schema files, schema registries, validation helpers, or + exception hierarchies; +- placing invalid controls under `examples/scenarios/`; +- proving only Pydantic acceptance and calling it published-contract + conformance; +- serializing with field names instead of aliases, which misses YAML-facing + fields such as `on-success`, `max-attempts`, and `class`; +- hiding all defaults/exclusions inline until the test no longer resembles the + published contract payload; +- editing `implementations/python/src/aces/`, generated schema outputs, or + accepted ADR text for this issue. + +## Non-Goals + +- Changing SDL semantics, parser normalization, instantiation behavior, or MCP + example content. +- Editing published schemas or the schema publication manifest. +- Adding new corpus roots, package-data behavior, release workflow, control + plane endpoints, persistence, or authentication behavior. +- Turning examples into conformance fixtures or making invalid examples part of + the reusable example corpus. diff --git a/implementations/python/tests/test_example_schema_conformance.py b/implementations/python/tests/test_example_schema_conformance.py new file mode 100644 index 000000000..eb6e87c4e --- /dev/null +++ b/implementations/python/tests/test_example_schema_conformance.py @@ -0,0 +1,153 @@ +"""Issue #501 (review CT-4) — worked examples conform to the *published* JSON Schema. + +``test_scenarios.py`` proves every ``examples/scenarios/*.sdl.yaml`` loads through the +Pydantic parser, but Pydantic acceptance is not published-schema conformance, and the +worked examples are the proof artifacts third parties read. This suite serializes each +example with the canonical, contract-shaped publication serialization +(``model_dump(mode="json", by_alias=True)`` — the same flags ``aces_processor/compiler.py`` +uses) and validates the result against the *checked-in* published schema +``contracts/schemas/sdl/sdl-authoring-input-v1.json`` with ``Draft202012Validator``. + +Validating the shipped schema artifact rather than ``schema_bundle()`` is deliberate: per +ADR-009 the published ``contracts/schemas/`` JSON is the normative authority, while +``schema_bundle()`` remains the drift/compatibility proof used by other gates. + +A non-vacuity guard and a deliberate negative control keep the suite from passing +vacuously — a stale corpus path that collects zero cases, or a validator that accepts +everything, both fail loudly. +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from dataclasses import dataclass, field +from functools import cache +from pathlib import Path + +import pytest +from aces_contracts.corpus import corpus_family_root +from aces_sdl.scenario import Scenario +from aces_sdl.scenarios import load_scenario +from jsonschema import Draft202012Validator +from paths import EXAMPLES_DIR + +# ``by_alias=True`` is load-bearing: the published schema is generated from the model with +# ``model_json_schema()`` (aliases on), so a field-name dump fails on YAML-facing aliases +# such as ``class``, ``on-success``, and ``max-attempts``. ``mode="json"`` yields JSON-native +# scalars (enum values, not enum members). These are the same flags the runtime compiler +# uses for its contract-shaped serialization. +_PUBLICATION_DUMP_KWARGS = {"mode": "json", "by_alias": True} + + +@dataclass(frozen=True) +class CorpusEntry: + """One validation-corpus leg: a glob of example files checked against one published schema. + + The extension seam (issue #501 preflight): a future instantiated-example corpus adds a + row to ``VALIDATION_CORPUS`` — root, glob, loader, contract id, schema file, and + serialization kwargs — rather than a new loader, path convention, or validator wrapper. + """ + + contract_id: str + root: Path + glob: str + loader: Callable[[Path], Scenario] + schema_path: Path + dump_kwargs: dict = field(default_factory=lambda: dict(_PUBLICATION_DUMP_KWARGS)) + + def examples(self) -> list[Path]: + return sorted(self.root.glob(self.glob)) + + def validator(self) -> Draft202012Validator: + return _validator_for(self.schema_path) + + +@cache +def _validator_for(schema_path: Path) -> Draft202012Validator: + """Load the *checked-in* published schema artifact and build its validator once.""" + schema = json.loads(schema_path.read_text(encoding="utf-8")) + return Draft202012Validator(schema) + + +_SDL_SCHEMA_DIR = corpus_family_root("schemas") / "sdl" + +# Today the table has exactly one leg: the authoring example corpus against the published +# authoring-input contract. No instantiated-scenario *example* artifacts exist under +# ``examples/`` — the instantiated schema's fixtures under ``contracts/fixtures/sdl/`` are +# covered by ``test_instantiated_scenario_schema.py`` — so a future instantiated corpus adds +# a row here for ``instantiated-scenario-v1``. +VALIDATION_CORPUS = [ + CorpusEntry( + contract_id="sdl-authoring-input-v1", + root=EXAMPLES_DIR, + glob="*.sdl.yaml", + loader=load_scenario, + schema_path=_SDL_SCHEMA_DIR / "sdl-authoring-input-v1.json", + ), +] + + +def _example_cases() -> list[tuple[CorpusEntry, Path]]: + return [(entry, path) for entry in VALIDATION_CORPUS for path in entry.examples()] + + +def _case_id(value: object) -> str: + if isinstance(value, Path): + return value.name + if isinstance(value, CorpusEntry): + return value.contract_id + return str(value) + + +@pytest.mark.parametrize(("entry", "path"), _example_cases(), ids=_case_id) +def test_example_conforms_to_published_schema(entry: CorpusEntry, path: Path) -> None: + """Every shipped example provably conforms to the published contract surface in CI. + + The serialization mirrors the runtime compiler's publication serialization, so the JSON + validated here is the shape a downstream consumer reads — not an internal Pydantic-only + projection. + """ + scenario = entry.loader(path) + payload = scenario.model_dump(**entry.dump_kwargs) + + errors = sorted(entry.validator().iter_errors(payload), key=lambda error: error.json_path) + + assert not errors, f"{path.name} violates {entry.contract_id}:\n" + "\n".join( + f" {error.json_path}: {error.message}" for error in errors + ) + + +@pytest.mark.parametrize("entry", VALIDATION_CORPUS, ids=_case_id) +def test_corpus_leg_is_nonempty(entry: CorpusEntry) -> None: + """Non-vacuity guard: a stale/relocated corpus root must fail loudly, not collect zero cases.""" + assert entry.examples(), f"No examples for {entry.contract_id} under {entry.root} (glob {entry.glob!r})" + + +# --- Negative control: the validator must be engaged (the suite cannot pass vacuously) --- + +_AUTHORING_ENTRY = VALIDATION_CORPUS[0] + +_MINIMAL_VALID_PAYLOAD = {"name": "negative-control-baseline"} + +# Each payload violates exactly one published-schema rule: ``required: ["name"]``, +# ``name`` typed ``string``, and top-level ``additionalProperties: false``. Kept inline, +# never as a file under ``examples/scenarios/``, so an invalid control never leaks into the +# reusable positive example corpus. +_INVALID_PAYLOADS = { + "missing-required-name": {"description": "no name field"}, + "wrong-type-name": {"name": 123}, + "additional-top-level-property": {"name": "neg-control", "not_a_real_section": True}, +} + + +def test_negative_control_baseline_is_accepted() -> None: + """A minimal valid document validates, so the rejections below indicate the injected defect, + not a validator that rejects everything.""" + _AUTHORING_ENTRY.validator().validate(_MINIMAL_VALID_PAYLOAD) + + +@pytest.mark.parametrize("payload", _INVALID_PAYLOADS.values(), ids=list(_INVALID_PAYLOADS)) +def test_negative_control_invalid_payload_is_rejected(payload: dict) -> None: + """A known-invalid payload must fail published-schema validation, proving the validator is engaged.""" + assert not _AUTHORING_ENTRY.validator().is_valid(payload) From 19b115031720515a10075f78aa188b8e9ced543d Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 02:33:52 +0200 Subject: [PATCH 54/70] Add changelog fragment for example schema-conformance tests (#501) --- changelog.d/501.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/501.added.md diff --git a/changelog.d/501.added.md b/changelog.d/501.added.md new file mode 100644 index 000000000..bd351acd8 --- /dev/null +++ b/changelog.d/501.added.md @@ -0,0 +1 @@ +The worked SDL examples under `examples/scenarios/` are now validated against the published `sdl-authoring-input-v1` JSON Schema in CI, proving the shipped example corpus conforms to the contract surface downstream consumers read (previously only Pydantic-parser acceptance was checked). Includes a non-vacuity corpus guard and a negative control so the suite cannot pass vacuously. From 86b1ea0c5ac65ba95277a643dbb251cd7dbe69aa Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 03:13:28 +0200 Subject: [PATCH 55/70] Add determinism witness for SDL parse/instantiate/compile pipeline Two-pass byte-identity tests over representative shipped scenarios (multiple agents/features/relationships + variable substitution) and a local module-import scenario, plus a cross-process PYTHONHASHSEED pass to catch hash-order dependence. Cite the witness from parser.md's determinism claim. Witnesses review finding IMP-4 (#506). --- ...issue-506-determinism-witness-preflight.md | 163 +++++++++++ docs/explain/sdl/parser.md | 6 +- .../python/tests/test_pipeline_determinism.py | 253 ++++++++++++++++++ 3 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/issue-506-determinism-witness-preflight.md create mode 100644 implementations/python/tests/test_pipeline_determinism.py diff --git a/docs/decisions/issue-506-determinism-witness-preflight.md b/docs/decisions/issue-506-determinism-witness-preflight.md new file mode 100644 index 000000000..1f8a16486 --- /dev/null +++ b/docs/decisions/issue-506-determinism-witness-preflight.md @@ -0,0 +1,163 @@ +# Issue 506 Determinism Witness Preflight + +Date: 2026-06-15 + +Issue: #506. + +Requirement: none. The issue title, body, and acceptance criteria are the +contract. + +This note records architecture preflight guardrails for adding a narrow +determinism witness for parse -> instantiate -> compile. It is guidance for the +implementation and does not add the witness test, change parser/compiler +behavior, or update the parser documentation citation. + +## Binding Sources + +- `docs/explain/sdl/parser.md` makes the documented claim: file-backed import + expansion is deterministic and in-memory parsing rejects imports. +- ADR-053 fixes the module-composition model: imports are typed SDL + composition, resolved through lock/trust/digest/version checks, expanded + before semantic validation, and compiled as one canonical scenario. +- ADR-016 fixes the lifecycle order: authoring, validation, instantiation, + compilation, planning, execution, observation. This issue witnesses only the + first four phases. +- ADR-004 fixes the compiler boundary: `compile_runtime_model()` is a pure + normalization pass from an instantiated scenario into canonical runtime + resources. +- `.ground-control.yaml`, `noxfile.py`, and `implementations/python/pyproject.toml` + define the verification graph. The witness must live under + `implementations/python/tests/` so CI and `nox -s verify` run it. + +## Architecture Decisions + +- Test the public pipeline, not internals: `parse_sdl_file(...)`, + `instantiate_scenario(...)`, and `compile_runtime_model(...)`. + `parse_sdl(...)`, `expand_sdl_modules(...)`, direct `Scenario(...)` + construction, and compiler helper calls are the wrong witness surface. +- Build the representative scenario as temp files through `tmp_path`, including + at least one local module import, explicit namespace, module exports, variables + with defaults or provided parameters, multiple agents, multiple features, and + multiple relationships. This exercises import expansion, map-key preservation, + reference rewriting, variable substitution, and ordered collection surfaces. +- Compare the compiled artifact with one canonical serializer local to the + witness. The serializer should convert dataclasses and Pydantic/contract + models to plain JSON-compatible data, then use + `json.dumps(..., sort_keys=True, separators=(",", ":"))`. Do not compare + `repr(...)`, object ids, dict iteration text, or pretty JSON. +- Enumerate explicitly timestamp-typed compiled fields in the test. Today the + parse/instantiate/compile path does not generate timestamps; timestamp-shaped + values are authored data. If the witness fixture avoids timestamp-bearing + runtime inventory, the exclusion set should be empty and asserted as such. +- The `PYTHONHASHSEED` variation must be a subprocess pass over the same + temp-file scenario graph. Use fixed argv, `sys.executable`, a controlled + `env`, and captured stdout containing only the canonical serialization or a + digest of it. Do not use `shell=True` or embed secrets in argv. +- After the test exists, `docs/explain/sdl/parser.md` should cite the test by + stable test name or file path. Do not pre-cite a non-existent test. + +## Required Incumbents + +- Parser and composition: `aces_sdl.parser.parse_sdl_file`, + `_load_normalized_data`, `ImportDecl`, `ModuleDescriptor`, + `aces_sdl.composition.expand_sdl_modules`, and + `aces_sdl.module_registry.resolve_import`. +- Validation and instantiation: `SDLModel(extra="forbid")`, + `SemanticValidator`, `instantiate_scenario`, `InstantiatedScenario`, and + `SDLInstantiationError` / `SDLParseError` / `SDLValidationError`. +- Compilation: `aces_processor.compiler.compile_runtime_model`, + `RuntimeModel`, existing compiler dataclasses, and `resource_payload(...)` + where backend-facing resource shape is relevant. +- Existing test patterns: `test_sdl_module_registry.py` for temp-file module + imports and lock/trust behavior, `test_runtime_models.py` for compile + assertions, `test_instantiated_scenario_schema.py` for concrete instantiated + payload constraints, and `test_corpus_packaging.py` for fixed-argv subprocess + style. +- Verification workflow: `nox -s tests` for the default CI pytest sweep and + `nox -s verify` for the full repository gate. + +## Cross-Cutting Layers + +- YAML/config parsing: the witness must enter through `parse_sdl_file(...)`, + which uses `yaml.safe_load`, top-level mapping checks, key normalization, + shorthand expansion, variable-key rejection, import expansion, and + `extra="forbid"` Pydantic construction. +- Module supply-chain validation: local imports still pass through + `ImportDecl`, module descriptor validation, version checks, digest pins when + authored, optional lockfile checks, allowed-parameter checks, namespace + collision rejection, reserved `__private` namespace rejection, and import-cycle + detection. The test should not bypass those layers with direct payload merges. +- Semantic validation: the root expanded scenario must pass the existing + `SemanticValidator`; missing, ambiguous, or unexported references must remain + hard errors rather than being normalized away by the witness. +- Instantiated-shape validation: variable substitution must go through + `instantiate_scenario(...)`, including variable type checks, undeclared + parameter rejection, unresolved-placeholder rejection, `InstantiatedScenario` + revalidation, and post-substitution semantic validation. +- Compiler contracts: compilation must use the existing `RuntimeModel` and + dataclass resources. The witness must not introduce a second compiled schema, + duplicate DTO, or alternate address generator. +- Secret-handling surface: fixture data should avoid real credentials. If an + environment/runtime secret-shaped value is needed, use existing redaction + classifications and assert only the classification, not raw secret material. +- Environment and OS exposure: the subprocess should set only + `PYTHONHASHSEED` and any minimal import-path/cwd variables needed to import + the project under pytest. Pass scenario paths as argv values, never shell + fragments, and never place tokens, private keys, or operator secrets in argv + or stdout. +- Error envelopes: failures should be ordinary pytest assertion failures or the + existing SDL exception types. Do not add a new exception hierarchy, logging + channel, CLI wrapper, or diagnostic envelope for the witness. +- Auth, persistence, and network layers: this issue should touch none of them. + No control-plane auth, HTTP API, database, runtime manager, backend registry, + OCI network fetch, or audit-log behavior is in scope. + +## Extension Boundary + +The extensibility seam is the canonical serializer plus a small fixture builder +parameterized by scenario root path, parameters, and hash seed. Future +determinism witnesses can reuse that seam for other pipeline phases or broader +ASR-514 coverage without editing production parser, composition, instantiation, +or compiler code. + +If future compiled models add genuinely generated timestamp fields, add those +field paths to the explicit exclusion list with a comment naming the producer. +Do not introduce broad key-name scrubbing such as dropping every field whose +name contains `time`. + +## Gotchas And Anti-Patterns + +Avoid: + +- comparing `repr(model)`, object identity, insertion-order pretty JSON, or + unsorted dict text; +- mutating production code only to make the witness deterministic unless the + test exposes a real nondeterminism bug that the implementation deliberately + fixes; +- constructing `Scenario` or `RuntimeModel` objects directly instead of passing + through parse, import resolution, instantiation, and compilation; +- adding a duplicate schema, duplicate validator, duplicate exception hierarchy, + or duplicate module-import workflow for test convenience; +- using sets in fixture construction where the witness is supposed to exercise + authored order and stable compiler order; +- masking nondeterminism by sorting away ordered collection values that are + semantically ordered, such as authored list fields, workflow steps, or + compiled tuples; +- making the subprocess test depend on the developer's ambient environment, + home directory, network, installed package, or shell; +- running an OCI registry or generating signing keys for this narrow witness + when a local file import is enough to exercise composition order; +- updating `docs/explain/sdl/parser.md` with a citation before the executable + test lands. + +## Non-Goals + +- Proving all ASR-514 determinism and stability properties. +- Redesigning module composition, lockfile semantics, trust policy, parser + normalization, variable substitution, compiler ordering, or runtime planning. +- Adding a public canonical-serialization API or published compiled-runtime + JSON schema. +- Changing contract schemas, generated schema bundles, formal specs, or the + compatibility-only `implementations/python/src/aces/` wrappers. +- Adding control-plane, backend, persistence, auth, logging, or network + behavior. diff --git a/docs/explain/sdl/parser.md b/docs/explain/sdl/parser.md index e52797592..5d9d81873 100644 --- a/docs/explain/sdl/parser.md +++ b/docs/explain/sdl/parser.md @@ -106,7 +106,11 @@ scenario = parse_sdl(yaml_string, skip_semantic_validation=True) Use `parse_sdl_file(...)` for SDL that uses top-level `imports:`. Import expansion is file-backed and deterministic, so in-memory `parse_sdl(...)` -rejects module/import composition by design. +rejects module/import composition by design. This determinism is witnessed by +`implementations/python/tests/test_pipeline_determinism.py`, which runs the +`parse → instantiate → compile` pipeline twice over representative scenarios +(including a module-import scenario) and under varied `PYTHONHASHSEED`, and +asserts the compiled output is byte-identical. Top-level composition supports: diff --git a/implementations/python/tests/test_pipeline_determinism.py b/implementations/python/tests/test_pipeline_determinism.py new file mode 100644 index 000000000..da4d548b3 --- /dev/null +++ b/implementations/python/tests/test_pipeline_determinism.py @@ -0,0 +1,253 @@ +"""Determinism witness for the SDL ``parse -> instantiate -> compile`` pipeline. + +Records the executable witness that review finding IMP-4 (issue #506) found +missing: ``docs/explain/sdl/parser.md`` claims "Import expansion is file-backed +and deterministic", and the pipeline design (lockfile-backed imports, no ambient +state) is plausibly deterministic, but no test ran the pipeline twice and +asserted identical output. This is also the narrow witness for the broader +ASR-514 determinism-verification surface. + +Three checks: + +1. ``test_pipeline_is_byte_identical_across_two_passes`` -- run the public + pipeline twice in one process over the shipped complex example scenarios + (multiple agents / features / relationships + variable substitution + + ordered collections) and assert the canonical serializations are + byte-identical. +2. ``test_module_import_pipeline_is_byte_identical_across_two_passes`` -- same + two-pass assertion for a local module-import scenario (explicit namespace, + module ``exports``, multiple imported nodes) so import-expansion order is + exercised. +3. ``test_pipeline_is_hash_seed_independent`` -- compile the same scenario in + two fixed-argv ``sys.executable`` subprocesses under different + ``PYTHONHASHSEED`` values and assert the canonical serializations are + byte-identical. A single process pins one hash seed, so cross-process + variation is the only way to catch hash-order dependence (set/dict iteration + order leaking into the output). + +Canonical serializer notes: + +* Keys are intentionally **not** sorted. Determinism means the pipeline must + reproduce identical ordering on its own; sorting keys would canonicalize away + the hash-seed-dependent ordering that check (3) exists to detect. The pipeline + is verified hash-stable, so the strict assertion is not flaky. (This is a + deliberate refinement over the Step 2.5 preflight's ``sort_keys=True`` + serializer suggestion, justified by the hash-order acceptance criterion.) +* The generated-timestamp exclusion set is **empty by design**: the + ``parse -> instantiate -> compile`` path injects no wall-clock / uuid / random + values, so the compiled ``RuntimeModel`` carries no per-run fields (verified: + no ``datetime.now`` / ``time.time`` / ``uuid4`` / ``random`` / ``secrets`` in + ``aces_sdl`` or ``aces_processor``). Author-provided time-typed values + (``start_time``, OCR durations, script ``time``) are deterministic functions + of the input and need no exclusion. The strip mechanism is retained and + check (1) is the tripwire: a future generated timestamp would differ between + two same-process passes and fail loudly, prompting its field name to be added + to ``_GENERATED_TIMESTAMP_FIELDS``. +""" + +from __future__ import annotations + +import dataclasses +import json +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest +from paths import EXAMPLES_DIR + +from aces.core.runtime.compiler import compile_runtime_model +from aces.core.sdl import instantiate_scenario, parse_sdl_file + +# This whole witness is integration-style: it reads shipped scenarios from the +# real repo on disk and spawns subprocesses, so it is excluded from the default +# fast unit sweep and runs in the `integration` session (which `nox -s verify` +# and CI both execute). A module-level mark keeps every current and future test +# in this file inside that boundary -- per the repo's pytest marker taxonomy. +pytestmark = pytest.mark.integration + +# Shipped scenarios that exercise ordered-collection surfaces (multiple agents, +# features, and relationships) and -- for the first two -- variable +# substitution. These are the representative inputs the witness compiles. +COMPLEX_EXAMPLES = [ + EXAMPLES_DIR / "hospital-ransomware-surgery-day.sdl.yaml", + EXAMPLES_DIR / "satcom-release-poisoning.sdl.yaml", + EXAMPLES_DIR / "port-authority-surge-response.sdl.yaml", +] + +# Compiled RuntimeModel field names carrying per-run wall-clock / generated +# values to exclude from the determinism comparison. Empty by design -- see the +# module docstring. Adding a name here is the documented escape hatch if the +# compiler ever emits a genuinely generated timestamp. +_GENERATED_TIMESTAMP_FIELDS: frozenset[str] = frozenset() + + +def _strip_excluded(value: object) -> object: + """Recursively drop excluded (generated-timestamp) keys from a payload.""" + if isinstance(value, dict): + return {k: _strip_excluded(v) for k, v in value.items() if k not in _GENERATED_TIMESTAMP_FIELDS} + if isinstance(value, list): + return [_strip_excluded(item) for item in value] + return value + + +def _canonical_json(model: object) -> str: + """Serialize a compiled ``RuntimeModel`` deterministically for byte compare. + + ``sort_keys`` is intentionally ``False`` (see the module docstring). + ``default=str`` renders any enum or non-JSON-native leaf deterministically. + """ + payload = _strip_excluded(dataclasses.asdict(model)) + return json.dumps(payload, ensure_ascii=False, sort_keys=False, separators=(",", ":"), default=str) + + +def _compile_canonical(path: Path, *, substitute_variables: bool) -> str: + """Run the full public pipeline for ``path`` and return canonical JSON.""" + scenario = parse_sdl_file(path) + parameters: dict[str, object] = {} + if substitute_variables: + # Exercise variable substitution through the parameters path (rather + # than relying on implicit defaults) by passing each declared default + # back in as a provided value. + parameters = {name: var.default for name, var in scenario.variables.items() if var.default is not None} + instantiated = instantiate_scenario(scenario, parameters=parameters) + return _canonical_json(compile_runtime_model(instantiated)) + + +@pytest.mark.parametrize("scenario_path", COMPLEX_EXAMPLES, ids=lambda path: path.stem) +def test_pipeline_is_byte_identical_across_two_passes(scenario_path: Path) -> None: + first = _compile_canonical(scenario_path, substitute_variables=True) + # Non-vacuous guard: a serializer that collapsed to empty/constant output + # would make byte-identity trivially true. Require real compiled content. + assert len(first) > 1000 + assert '"node_deployments"' in first + second = _compile_canonical(scenario_path, substitute_variables=True) + assert first == second + + +def test_canonical_serializer_distinguishes_distinct_scenarios() -> None: + # Proves the comparison is meaningful (sensitive to input), so the two-pass + # equality assertions above are not tautological. + first = _compile_canonical(COMPLEX_EXAMPLES[0], substitute_variables=True) + second = _compile_canonical(COMPLEX_EXAMPLES[1], substitute_variables=True) + assert first != second + + +def _write(path: Path, content: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(content).strip() + "\n", encoding="utf-8") + return path + + +def _module_import_root(tmp_path: Path) -> Path: + """Write a local module + an importing root, returning the root path. + + The module exports multiple nodes and infrastructure with switch links so + import expansion and ordered-collection rewriting are exercised. A ``path:`` + import needs no lockfile/trust material (it is the backward-compatible + direct-path source). + """ + _write( + tmp_path / "shared.yaml", + """ + name: shared + version: 1.2.3 + module: + id: acme/shared + version: 1.2.3 + exports: + nodes: [web, db, edge] + infrastructure: [web, db, edge] + nodes: + web: {type: vm, os: linux, resources: {ram: 1 gib, cpu: 1}} + db: {type: vm, os: linux, resources: {ram: 2 gib, cpu: 2}} + edge: {type: switch} + infrastructure: + web: {count: 1, links: [edge]} + db: {count: 1, links: [edge]} + edge: 1 + """, + ) + return _write( + tmp_path / "root.yaml", + """ + name: root + imports: + - path: shared.yaml + namespace: shared + """, + ) + + +def test_module_import_pipeline_is_byte_identical_across_two_passes(tmp_path: Path) -> None: + root = _module_import_root(tmp_path) + first = _compile_canonical(root, substitute_variables=False) + # Confirm the namespaced import actually expanded into the compiled model. + assert "shared.web" in first + second = _compile_canonical(root, substitute_variables=False) + assert first == second + + +# Fixed-argv subprocess driver: read a scenario path (argv[1]), run the public +# pipeline, and print the sha256 of the canonical serialization. The exclusion +# set is passed as a JSON argv (argv[2]) so the driver mirrors `_canonical_json` +# exactly with a single source of truth. No shell, no secrets in argv/stdout. +_SUBPROCESS_DRIVER = textwrap.dedent( + """ + import dataclasses + import hashlib + import json + import sys + from pathlib import Path + + from aces.core.runtime.compiler import compile_runtime_model + from aces.core.sdl import instantiate_scenario, parse_sdl_file + + exclude = set(json.loads(sys.argv[2])) + + def strip(value): + if isinstance(value, dict): + return {k: strip(v) for k, v in value.items() if k not in exclude} + if isinstance(value, list): + return [strip(item) for item in value] + return value + + scenario = parse_sdl_file(Path(sys.argv[1])) + parameters = {n: v.default for n, v in scenario.variables.items() if v.default is not None} + model = compile_runtime_model(instantiate_scenario(scenario, parameters=parameters)) + canonical = json.dumps( + strip(dataclasses.asdict(model)), + ensure_ascii=False, + sort_keys=False, + separators=(",", ":"), + default=str, + ) + sys.stdout.write(hashlib.sha256(canonical.encode("utf-8")).hexdigest()) + """ +) + + +def _subprocess_digest(scenario_path: Path, hash_seed: str) -> str: + env = dict(os.environ) + env["PYTHONHASHSEED"] = hash_seed + exclude_arg = json.dumps(sorted(_GENERATED_TIMESTAMP_FIELDS)) + result = subprocess.run( + [sys.executable, "-c", _SUBPROCESS_DRIVER, str(scenario_path), exclude_arg], + capture_output=True, + text=True, + env=env, + check=False, + ) + assert result.returncode == 0, f"determinism driver failed (seed={hash_seed}): {result.stderr}" + digest = result.stdout.strip() + assert len(digest) == 64, f"unexpected driver output (seed={hash_seed}): {result.stdout!r} / {result.stderr!r}" + return digest + + +@pytest.mark.parametrize("scenario_path", COMPLEX_EXAMPLES, ids=lambda path: path.stem) +def test_pipeline_is_hash_seed_independent(scenario_path: Path) -> None: + digest_seed_0 = _subprocess_digest(scenario_path, "0") + digest_seed_1 = _subprocess_digest(scenario_path, "1") + assert digest_seed_0 == digest_seed_1 From b390fd5e5af8b59f63b84607a6c8d1375ddaf846 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 03:15:49 +0200 Subject: [PATCH 56/70] Add changelog fragment for determinism witness (#506) --- changelog.d/506.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/506.added.md diff --git a/changelog.d/506.added.md b/changelog.d/506.added.md new file mode 100644 index 000000000..25d7e7c86 --- /dev/null +++ b/changelog.d/506.added.md @@ -0,0 +1 @@ +Add a determinism witness for the SDL parse/instantiate/compile pipeline: it compiles representative scenarios (including a module-import scenario) twice, and once more under a varied PYTHONHASHSEED in a subprocess, and asserts the compiled output is byte-identical. docs/explain/sdl/parser.md now cites it. From 38789b1c14ee8ab415d75ecb447d841ca9236219 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 03:18:30 +0200 Subject: [PATCH 57/70] Specify SDL error-vs-advisory boundary normatively (review IMP-3) State the classification criterion in specs/sdl/diagnostics.md section 5 as the single normative source, resolving the deferral to review IMP-3: errors affect SDL meaning (structural/semantic invariants); advisories are deployability or quality heuristics that leave meaning intact, with a fail-closed default for borderline cases. Update specs/sdl/README.md to point at it, and cite that source from the non-normative docs/explain/sdl/validation.md instead of restating the rule. Add an AST drift-guard test enforcing that the reference SemanticValidator keeps the advisory (_warn_*/_collect_advisories) and error (_verify_*/_err) channels separate, including indirect advisory emission from an error pass. The audit of existing passes is a clean bill: one advisory (VM-without-resources) against 156 error sites, consistent with the criterion. --- changelog.d/505.changed.md | 1 + ...e-505-error-advisory-boundary-preflight.md | 123 +++++++++++++ docs/explain/sdl/validation.md | 13 ++ .../tests/test_sdl_diagnostic_boundary.py | 164 ++++++++++++++++++ specs/sdl/README.md | 8 +- specs/sdl/diagnostics.md | 65 +++++-- 6 files changed, 354 insertions(+), 20 deletions(-) create mode 100644 changelog.d/505.changed.md create mode 100644 docs/decisions/issue-505-error-advisory-boundary-preflight.md create mode 100644 implementations/python/tests/test_sdl_diagnostic_boundary.py diff --git a/changelog.d/505.changed.md b/changelog.d/505.changed.md new file mode 100644 index 000000000..23d1825a9 --- /dev/null +++ b/changelog.d/505.changed.md @@ -0,0 +1 @@ +State the SDL error-vs-advisory boundary normatively in `specs/sdl/diagnostics.md` §5, resolving the classification previously deferred to review IMP-3. The criterion is meaning preservation: an error affects SDL meaning (structural/semantic invariants — reference resolution, uniqueness, ambiguity, acyclicity, required-profile guards, instantiation, explicit redaction), while an advisory is a deployability or quality heuristic that leaves SDL meaning intact, with a fail-closed default for borderline cases. `docs/explain/sdl/validation.md` now cites that single normative source instead of restating the rule, and a new AST drift-guard test enforces that the reference `SemanticValidator` keeps the advisory (`_warn_*`/`_collect_advisories`) and error (`_verify_*`/`_err`) channels separate. diff --git a/docs/decisions/issue-505-error-advisory-boundary-preflight.md b/docs/decisions/issue-505-error-advisory-boundary-preflight.md new file mode 100644 index 000000000..f5348fdca --- /dev/null +++ b/docs/decisions/issue-505-error-advisory-boundary-preflight.md @@ -0,0 +1,123 @@ +# Issue 505 Error/Advisory Boundary Preflight + +Date: 2026-06-15 + +Issue: #505. + +Requirement: none. The issue title, body, and acceptance criteria are the +contract. + +This note records architecture guardrails for specifying the SDL +error-vs-advisory boundary. It is non-normative preflight guidance only: it does +not amend the SDL specification, reclassify validator passes, add lint, or +update explanatory documentation. + +## Architecture Decisions + +- Use `specs/sdl/diagnostics.md` as the single normative source for the + boundary. `specs/sdl/` is already registered as normative SDL prose in + `specs/authority/authority-boundary.yaml`, so adding a separate ADR for the + same rule would create a second authority surface. +- Collapse the current diagnostics text that defers case-by-case + classification to IMP-3 into one normative boundary statement. The + explanatory `docs/explain/sdl/validation.md` page should cite that statement, + not restate it. +- Keep the boundary conceptual, not implementation-shaped: errors cover + structural and semantic invariants that affect SDL meaning; advisories cover + deployability or quality heuristics that leave SDL meaning intact. +- Decide borderline cases by meaning preservation. If a condition changes parse + shape, reference resolution, uniqueness, ambiguity, cycle safety, required + profile guards, variable instantiation, or explicit redaction semantics, it is + an error. If it only warns that a valid SDL document may be hard to deploy, + may need backend defaults, or may benefit from author review, it is an + advisory. + +## Required Incumbents + +- Normative authority: ADR-009, ADR-019, `specs/sdl/README.md`, + `specs/sdl/diagnostics.md`, and `specs/authority/authority-boundary.yaml`. +- Explanatory docs: `docs/explain/sdl/validation.md` remains non-normative and + should cite the spec instead of carrying a competing rule. +- Runtime/value security lineage: ADR-056, ADR-057, `runtime_values.py`, and + `enforce_observed_value_redaction()`. Explicit redaction remains an + error-enforced invariant; name-shaped secret heuristics remain advisory-only. +- Parser and diagnostic envelopes: `parse_sdl()`, `parse_sdl_file()`, + `SemanticValidator`, `SDLParseError`, `SDLValidationError`, + `SDLInstantiationError`, `Scenario.advisories`, `instantiate_scenario()`, + `language_diagnostics()`, and `load_scenario()` advisory logging. +- Policy and verification: `.ground-control.yaml`, `.gc/plan-rules.md`, + `tools/check_repo_policy.py`, `tools/check_authority_boundary.py`, and the + canonical `nox -s verify` graph. + +## Audit Guidance + +- Treat `_verify_*` methods in `SemanticValidator` as error passes unless the + audit finds a method that only describes deployability or quality without + affecting SDL meaning. +- Treat `_collect_advisories()` and `_warn_*` methods as the advisory pass + surface. The current concrete advisory, VM nodes without `resources`, fits the + deployability-heuristic side of the boundary. +- Do not classify `runtime_values.name_indicates_secret()` as a validation + error source. It is explicitly advisory helper logic after ADR-057. +- Record the audit result in the PR even if it is a clean bill. The acceptance + criterion asks for the result, not just code changes. + +## Lint/Checklist Seam + +Prefer a lightweight executable guard in tests if it stays simple: + +- `_collect_advisories()` is the only place that invokes advisory pass methods. +- Advisory pass method names use `_warn_*`. +- Validation pass registration in `validate()` continues to use `_verify_*` for + error-producing passes and invokes `_collect_advisories()` once after those + passes. + +If an AST/introspection lint becomes brittle, use a documented review checklist +hook instead. Do not introduce a new validator framework or diagnostic registry +for one advisory pass. + +## Security And Cross-Cutting Layers + +- YAML/config parsing: preserve the safe loader, string top-level keys, + user-defined key preservation, variable-key rejection, and Pydantic + `extra="forbid"` structural closure. This issue should not loosen any parser + gate. +- Semantic validation: keep fail-closed reference resolution, ambiguity, + uniqueness, cycle, visibility, profile-guard, and explicit-redaction failures + on `SDLValidationError` or the existing Pydantic `ValidationError` path. +- Secret handling: advisory text must not echo raw values, credentials, or host + operator secrets. If a future secret-name advisory is surfaced, report field + paths or names only and keep explicit `redacted`/`operator_secret` raw-value + omission as the enforced rule. +- Error envelopes: do not add a new SDL exception hierarchy or diagnostics + envelope. Language-service behavior should continue to route errors through + the existing structured diagnostics; adding warning diagnostics is out of + scope unless separately required and tested. +- OS/process exposure: no command examples or tooling added for this issue + should require tokens, private keys, credentials, or scenario secrets in + process argv. + +## Gotchas And Anti-Patterns + +Avoid: + +- creating both an ADR and a spec section that each state the boundary; +- leaving `docs/explain/sdl/validation.md` as an uncited duplicate authority; +- treating advisories as optional errors or allowing tools to promote them to + failures by default; +- demoting structural or semantic validity failures to advisories for backend + convenience; +- inventing a second warning list, exception type, schema, validator registry, + or language-service diagnostic channel; +- editing compatibility-only wrappers under `implementations/python/src/aces/`; +- expanding this issue into backend deployability policy or resource-default + selection. + +## Non-Goals + +- Implementing the normative text, lint, audit, or reclassification. +- Changing parser, validator, instantiation, language-service, schema, runtime, + or backend behavior during this preflight. +- Adding a changelog fragment for this preflight note. +- Deciding backend defaults for VM resources or changing whether missing + resources are valid SDL. diff --git a/docs/explain/sdl/validation.md b/docs/explain/sdl/validation.md index bc2de6240..840001bc8 100644 --- a/docs/explain/sdl/validation.md +++ b/docs/explain/sdl/validation.md @@ -499,6 +499,15 @@ the current validator surface. ## Advisories +The normative boundary between a fatal **error** and a non-fatal **advisory** — +including the classification criterion that decides which channel a condition +belongs to — is stated in +[`specs/sdl/diagnostics.md` §5](../../../specs/sdl/diagnostics.md). This page is +non-normative explanation and cites that criterion rather than restating it: an +**error** affects SDL meaning (structural/semantic invariants), while an +**advisory** is a deployability or quality heuristic that leaves SDL meaning +intact. + Successful parses may still carry non-fatal advisories on `Scenario.advisories`. These are not validation errors and do not block parsing. Current advisory coverage: @@ -507,6 +516,10 @@ Current advisory coverage: ## Error Reporting +The fatal, fail-closed error semantics and the collect-all behaviour described +here are the explanatory companion to the normative diagnostic boundary in +[`specs/sdl/diagnostics.md`](../../../specs/sdl/diagnostics.md). + All passes run to completion. Errors are collected into a list and raised as a single `SDLValidationError`: ```python diff --git a/implementations/python/tests/test_sdl_diagnostic_boundary.py b/implementations/python/tests/test_sdl_diagnostic_boundary.py new file mode 100644 index 000000000..95585a207 --- /dev/null +++ b/implementations/python/tests/test_sdl_diagnostic_boundary.py @@ -0,0 +1,164 @@ +"""Executable drift guard for the SDL error-vs-advisory diagnostic boundary. + +``specs/sdl/diagnostics.md`` §5 states the normative classification criterion: +an **error** is a condition that affects SDL *meaning* (structural/semantic +invariants), while an **advisory** is a non-fatal deployability or quality +heuristic that leaves SDL meaning intact. The reference ``SemanticValidator`` +keeps the two diagnostic channels structurally separate: + +- error passes are ``_verify_*`` methods that call ``self._err(...)``; +- advisory passes are ``_warn_*`` methods that call ``self._warn(...)``, + registered exactly once each in ``_collect_advisories()``, which + ``validate()`` invokes after the error passes. + +This lint makes that convention executable so a new pass cannot silently emit an +advisory from an error pass (or an error from the advisory seam) -- the +inconsistency review IMP-3 (issue #505) set out to prevent. It mirrors the +AST-introspection style of ``test_runtime_family_invariants.py`` and keeps its +negative path executable via a synthetic violating fixture, rather than +inventing a new validator framework or diagnostic registry for one advisory +pass. +""" + +from __future__ import annotations + +import ast +import inspect + +import aces_sdl.validator as validator_module + + +def _validator_class_node(source: str) -> ast.ClassDef: + tree = ast.parse(source) + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and node.name == "SemanticValidator": + return node + raise AssertionError("SemanticValidator class definition not found in source") + + +def _calls_self_method(func: ast.AST, name: str) -> bool: + """True when ``func`` contains a ``self.(...)`` call.""" + + for sub in ast.walk(func): + if ( + isinstance(sub, ast.Call) + and isinstance(sub.func, ast.Attribute) + and sub.func.attr == name + and isinstance(sub.func.value, ast.Name) + and sub.func.value.id == "self" + ): + return True + return False + + +def _self_methods_called(func: ast.AST) -> set[str]: + """Names of every ``self.(...)`` call made inside ``func``.""" + + called: set[str] = set() + for sub in ast.walk(func): + if ( + isinstance(sub, ast.Call) + and isinstance(sub.func, ast.Attribute) + and isinstance(sub.func.value, ast.Name) + and sub.func.value.id == "self" + ): + called.add(sub.func.attr) + return called + + +def find_advisory_boundary_violations(source: str) -> set[str]: + """Return the advisory/error channel-separation violations in ``source``. + + Pure over ``source`` so the negative path is executable against synthetic + input (see ``test_lint_detects_synthetic_violations``). Each violation is a + short ``:`` key so a failure names the offending site. + """ + + cls = _validator_class_node(source) + methods = {node.name: node for node in cls.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))} + violations: set[str] = set() + + for name, func in methods.items(): + is_advisory_pass = name == "_collect_advisories" or name.startswith("_warn_") + emits_advisory = _calls_self_method(func, "_warn") + emits_error = _calls_self_method(func, "_err") + + # Rule 1: advisories may only be emitted from a ``_warn_*`` advisory pass. + if emits_advisory and not name.startswith("_warn_"): + violations.add(f"warn-outside-advisory-pass:{name}") + # Rule 2: advisory passes never emit errors. + if is_advisory_pass and emits_error: + violations.add(f"err-in-advisory-pass:{name}") + + # Rule 3: every ``_warn_*`` pass is registered in ``_collect_advisories()``. + collect = methods.get("_collect_advisories") + if collect is None: + violations.add("missing-collect-advisories") + else: + registered = _self_methods_called(collect) + for name in methods: + if name.startswith("_warn_") and name not in registered: + violations.add(f"unregistered-advisory-pass:{name}") + + # Rule 4: ``validate()`` invokes ``_collect_advisories()`` after the passes. + validate = methods.get("validate") + if validate is None or not _calls_self_method(validate, "_collect_advisories"): + violations.add("collect-advisories-not-invoked-in-validate") + + # Rule 5: a ``_warn_*`` advisory pass may only be *invoked* from + # ``_collect_advisories()``. An error pass that calls a ``_warn_*`` method + # emits an advisory indirectly -- Rule 1 only sees a direct ``self._warn`` + # call, so without this rule a ``_verify_*`` pass could route an advisory + # through a helper and slip past the guard. + for name, func in methods.items(): + if name == "_collect_advisories": + continue + if any(called.startswith("_warn_") for called in _self_methods_called(func)): + violations.add(f"advisory-pass-invoked-outside-collect:{name}") + + return violations + + +def test_validator_advisory_error_channels_are_separated() -> None: + """The live ``SemanticValidator`` honours the diagnostics.md boundary.""" + + source = inspect.getsource(validator_module) + assert find_advisory_boundary_violations(source) == set() + + +# A synthetic class that violates every rule, so the negative path of the lint +# stays executable (cf. ``GuardlessProfileKind`` in test_runtime_family_invariants). +_SYNTHETIC_VIOLATING_SOURCE = """ +class SemanticValidator: + def validate(self): + self._verify_nodes() + self._verify_indirect() + # _collect_advisories() intentionally not invoked here. + + def _verify_nodes(self): + self._warn("advisory emitted directly from an error pass") + + def _verify_indirect(self): + self._warn_registered() # advisory pass invoked outside _collect_advisories + + def _collect_advisories(self): + self._warn_registered() + self._err("error emitted from the advisory seam") + + def _warn_registered(self): + self._warn("registered advisory, but also reached from an error pass") + + def _warn_orphan(self): + self._warn("never registered in _collect_advisories") +""" + + +def test_lint_detects_synthetic_violations() -> None: + """Each rule fires on a known-bad fixture (the lint has teeth).""" + + violations = find_advisory_boundary_violations(_SYNTHETIC_VIOLATING_SOURCE) + assert "warn-outside-advisory-pass:_verify_nodes" in violations + assert "err-in-advisory-pass:_collect_advisories" in violations + assert "unregistered-advisory-pass:_warn_orphan" in violations + assert "collect-advisories-not-invoked-in-validate" in violations + assert "advisory-pass-invoked-outside-collect:_verify_indirect" in violations diff --git a/specs/sdl/README.md b/specs/sdl/README.md index d3121e000..6aab64781 100644 --- a/specs/sdl/README.md +++ b/specs/sdl/README.md @@ -67,7 +67,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. | -| [`diagnostics.md`](diagnostics.md) | — | The parse / semantic-validation / instantiation diagnostic stages and the error-vs-advisory boundary, documented by reference. | +| [`diagnostics.md`](diagnostics.md) | — | The parse / semantic-validation / instantiation diagnostic stages and the normative error-vs-advisory classification criterion. | ## Acceptance-question map @@ -94,6 +94,6 @@ instantiation, the runtime-inventory index, 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 per-field semantics of each runtime family (owned by the family ADRs and indexed -here). Defining the precise normative classification of individual -error-vs-advisory cases is deferred to the review IMP-3 work; this specification -documents the existing boundary and does not redefine it. +here). The normative error-vs-advisory classification criterion is stated in +[`diagnostics.md` §5](diagnostics.md) (resolving review IMP-3); the +classification of an individual condition follows from that criterion. diff --git a/specs/sdl/diagnostics.md b/specs/sdl/diagnostics.md index 1975943d9..8cdeff3a0 100644 --- a/specs/sdl/diagnostics.md +++ b/specs/sdl/diagnostics.md @@ -2,9 +2,9 @@ This file documents the SDL diagnostic boundary: the stages at which a document is checked, the fail-closed error semantics, and the distinction between a fatal -**error** and a non-fatal **advisory**. It documents the **existing** boundary by -reference; it does not introduce a new diagnostic mechanism, and it does not -re-decide the classification of individual cases (see §5). +**error** and a non-fatal **advisory**. It states the normative criterion that +classifies a condition as an error or an advisory (§5); it does not introduce a +new diagnostic mechanism, and it does not reclassify any existing condition. ## 1. Diagnostic stages @@ -79,18 +79,51 @@ Existing advisory conditions, documented here by reference (not redefined): > scenario's advisory list and the language-service diagnostics, separately from > the error channel.* -## 5. Coordination with review IMP-3 - -The precise, case-by-case normative classification of which specific conditions -are errors and which are advisories — and any change to where the line falls — -is the subject of the review **IMP-3** work. This specification: - -- states the **principle** (errors are fatal and fail-closed; advisories are - non-fatal and not optional errors) and the **stages** at which checks run; -- documents the **existing** advisory conditions by reference; and -- **does not** re-decide individual classifications or introduce new ones. - -A future change that moves a condition between the error and advisory channels, or -that adds a new diagnostic category, **MUST** be coordinated with IMP-3 and +## 5. Classification criterion + +This section states the normative rule that decides whether a condition is an +**error** or an **advisory**. It resolves the classification deferred by review +**IMP-3**: the boundary is single-sourced here, and the classification of an +individual condition follows from this criterion rather than from each pass's +implementation. + +The criterion is **meaning preservation**: + +1. A condition is an **error** if and only if it affects the *meaning* of the SDL + document — whether the document denotes a well-defined scenario at all. The + meaning-affecting categories are: structural shape and closure (the §1 stage-1 + checks, including unknown-key rejection); cross-section reference resolution + ([references.md](references.md)); identifier uniqueness; reference ambiguity; + dependency and control-flow acyclicity; control-flow reachability, + convergence, and "known-before-evaluation" visibility; required-profile guards + on discriminated runtime spines ([runtime-inventory.md](runtime-inventory.md)); + variable binding, type, and constraint checks at instantiation + ([variables-and-instantiation.md §3](variables-and-instantiation.md)); and + explicit-redaction enforcement ([runtime-inventory.md §3](runtime-inventory.md)). + A document that violates one of these has no single well-defined meaning, so + the stage **MUST** fail closed (§3). +2. A condition is an **advisory** if and only if the document still has a single + well-defined meaning and the condition reports a *deployability* or *quality* + heuristic that does not change what the scenario means — for example, a + construct a backend may be unable to realise without defaults, or a non-binding + observation an author may wish to review. Advisories are non-fatal (§4). + +**Borderline rule.** SDL diagnostics are fail-closed, so the default for a +condition whose classification is genuinely unclear is **error**. A condition is +classified as an advisory **only** when it is clearly a deployability or quality +heuristic that leaves SDL meaning intact; if a violation could leave the +document's meaning undefined, or if two conforming tools could legitimately +disagree about whether the document is valid, it is an error. This rule is +directional with §4: it governs how a *new* condition is classified, while §4 +forbids re-labelling an *already-classified* condition to change its severity. + +The existing conditions in §4 are consistent with this criterion. "VM without +resources" and the name-based secret heuristic are deployability/quality +heuristics that leave meaning intact, so they are advisories; reference +resolution, uniqueness, acyclicity, ambiguity, required-profile guards, and +explicit redaction are meaning-affecting, so they are errors. + +A future change that moves a condition between the error and advisory channels, +or that adds a new diagnostic category, **MUST** apply this criterion and be reflected here, in the published schemas, and in the reference implementation together, so the boundary stays single-sourced. From 214045db44d6db325075b3c20ac017e5b833f81d Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 03:24:20 +0200 Subject: [PATCH 58/70] Realize SEM-218 runtime non-approximation gate and snapshot provenance ledger --- changelog.d/491.added.md | 1 + ...ization-provenance-missing-provenance.json | 21 ++ .../valid/realization-provenance.json | 30 +++ contracts/schema-publication-manifest.json | 6 +- .../snapshots/runtime-snapshot-v1.json | 69 ++++++ ...1-sem-218-runtime-realization-preflight.md | 178 +++++++++++++++ .../explicitness-realization-semantics.md | 20 +- .../reference/shared-semantic-integrity.md | 2 +- .../packages/aces_conformance/conformance.py | 25 ++- .../packages/aces_contracts/contracts.py | 21 ++ .../packages/aces_contracts/runtime_state.py | 49 ++++ .../python/packages/aces_processor/models.py | 3 + .../python/packages/aces_processor/planner.py | 1 + .../aces_processor/semantics/realization.py | 124 +++++++++- .../packages/aces_runtime/backend_calls.py | 44 +++- .../aces_runtime/control_plane_store.py | 28 +++ .../python/packages/aces_runtime/manager.py | 2 + .../tests/test_sem_218_runtime_realization.py | 211 ++++++++++++++++++ .../explicitness-and-realization.md | 84 ++++--- 19 files changed, 859 insertions(+), 60 deletions(-) create mode 100644 changelog.d/491.added.md create mode 100644 contracts/fixtures/snapshots/runtime-snapshot-v1/invalid/realization-provenance-missing-provenance.json create mode 100644 contracts/fixtures/snapshots/runtime-snapshot-v1/valid/realization-provenance.json create mode 100644 docs/decisions/issue-491-sem-218-runtime-realization-preflight.md create mode 100644 implementations/python/tests/test_sem_218_runtime_realization.py diff --git a/changelog.d/491.added.md b/changelog.d/491.added.md new file mode 100644 index 000000000..02060d964 --- /dev/null +++ b/changelog.d/491.added.md @@ -0,0 +1 @@ +Added the SEM-218 runtime non-approximation gate (invariant I2) at the backend-adapter boundary, which rejects a backend that silently realizes an exact author declaration with a weaker value, and the `realization_provenance` ledger (invariant I5) on the runtime snapshot envelope, which records each realized concern's explicitness class and author-declared / processor-derived / backend-realized origin in published schemas, fixtures, and the schema-publication manifest. diff --git a/contracts/fixtures/snapshots/runtime-snapshot-v1/invalid/realization-provenance-missing-provenance.json b/contracts/fixtures/snapshots/runtime-snapshot-v1/invalid/realization-provenance-missing-provenance.json new file mode 100644 index 000000000..5e11c776c --- /dev/null +++ b/contracts/fixtures/snapshots/runtime-snapshot-v1/invalid/realization-provenance-missing-provenance.json @@ -0,0 +1,21 @@ +{ + "schema_version": "runtime-snapshot/v1", + "entries": {}, + "orchestration_results": {}, + "orchestration_history": {}, + "evaluation_results": {}, + "evaluation_history": {}, + "participant_episode_results": {}, + "participant_episode_history": {}, + "participant_behavior_history": {}, + "realization_provenance": [ + { + "address": "node.web", + "field_path": "nodes.web.os", + "domain": "runtime-realization", + "requirement_kind": "os-family", + "explicitness": "exact" + } + ], + "metadata": {} +} diff --git a/contracts/fixtures/snapshots/runtime-snapshot-v1/valid/realization-provenance.json b/contracts/fixtures/snapshots/runtime-snapshot-v1/valid/realization-provenance.json new file mode 100644 index 000000000..96c5163d4 --- /dev/null +++ b/contracts/fixtures/snapshots/runtime-snapshot-v1/valid/realization-provenance.json @@ -0,0 +1,30 @@ +{ + "schema_version": "runtime-snapshot/v1", + "entries": {}, + "orchestration_results": {}, + "orchestration_history": {}, + "evaluation_results": {}, + "evaluation_history": {}, + "participant_episode_results": {}, + "participant_episode_history": {}, + "participant_behavior_history": {}, + "realization_provenance": [ + { + "address": "node.web", + "field_path": "nodes.web.os", + "domain": "runtime-realization", + "requirement_kind": "os-family", + "explicitness": "exact", + "provenance": "author-declared" + }, + { + "address": "node.web", + "field_path": "nodes.web.type", + "domain": "runtime-realization", + "requirement_kind": "node-type", + "explicitness": "exact", + "provenance": "author-declared" + } + ], + "metadata": {} +} diff --git a/contracts/schema-publication-manifest.json b/contracts/schema-publication-manifest.json index 95ab0daf8..12bd2b1b7 100644 --- a/contracts/schema-publication-manifest.json +++ b/contracts/schema-publication-manifest.json @@ -196,7 +196,11 @@ "contract_id": "runtime-snapshot-v1", "schema_path": "contracts/schemas/snapshots/runtime-snapshot-v1.json", "stability": "draft", - "content_hash": "189e1fe99a31e64853238cea4423942577ceb0a86d2edf72ccf7031b6b1d7361" + "content_hash": "348a25b49e30081797164f77cb344dbf27e85007fcf5d9e93c2e57e33795074a", + "last_change": { + "summary": "Add SEM-218 realization_provenance ledger (RealizationProvenanceEntryModel) to the runtime snapshot envelope: per-concern author-declared/processor-derived/backend-realized provenance for realized realization concerns, enforcing invariant I5 (issue #491).", + "content_hash": "348a25b49e30081797164f77cb344dbf27e85007fcf5d9e93c2e57e33795074a" + } }, { "contract_id": "scenario-instantiation-request-v1", diff --git a/contracts/schemas/snapshots/runtime-snapshot-v1.json b/contracts/schemas/snapshots/runtime-snapshot-v1.json index b845d66f6..89fadca95 100644 --- a/contracts/schemas/snapshots/runtime-snapshot-v1.json +++ b/contracts/schemas/snapshots/runtime-snapshot-v1.json @@ -185,6 +185,26 @@ "title": "EvaluationResultStateModel", "type": "object" }, + "ExplicitnessClass": { + "description": "SEM-218 author-intent class for a declaration.", + "enum": [ + "exact", + "constrained", + "open" + ], + "title": "ExplicitnessClass", + "type": "string" + }, + "ExplicitnessProvenance": { + "description": "Where the classified value came from in the SDL lifecycle.", + "enum": [ + "author-declared", + "processor-derived", + "backend-realized" + ], + "title": "ExplicitnessProvenance", + "type": "string" + }, "OutcomeInterpretationSourceLayer": { "description": "Semantic layers that may feed a SEM-215 interpretation rule.", "enum": [ @@ -1588,6 +1608,48 @@ "title": "ParticipantTimeDomain", "type": "string" }, + "RealizationProvenanceEntryModel": { + "additionalProperties": false, + "description": "SEM-218 invariant I5: provenance for one realized realization concern.\n\nDistinguishes ``author-declared`` / ``processor-derived`` / ``backend-realized``\norigins for a realized concern recorded on the snapshot's result / history\nsurfaces. Carries field-path and kind references only (never the realized\nvalue, per the SEM-218 host-exposure gate). Kept distinct from ADR-054\nlifecycle ``phase_realization`` and API-407 participant feature support.", + "properties": { + "address": { + "minLength": 1, + "title": "Address", + "type": "string" + }, + "domain": { + "minLength": 1, + "title": "Domain", + "type": "string" + }, + "explicitness": { + "$ref": "#/$defs/ExplicitnessClass" + }, + "field_path": { + "minLength": 1, + "title": "Field Path", + "type": "string" + }, + "provenance": { + "$ref": "#/$defs/ExplicitnessProvenance" + }, + "requirement_kind": { + "minLength": 1, + "title": "Requirement Kind", + "type": "string" + } + }, + "required": [ + "address", + "field_path", + "domain", + "requirement_kind", + "explicitness", + "provenance" + ], + "title": "RealizationProvenanceEntryModel", + "type": "object" + }, "SnapshotEntryModel": { "additionalProperties": false, "properties": { @@ -1909,6 +1971,13 @@ "title": "Participant Episode Results", "type": "object" }, + "realization_provenance": { + "items": { + "$ref": "#/$defs/RealizationProvenanceEntryModel" + }, + "title": "Realization Provenance", + "type": "array" + }, "schema_version": { "const": "runtime-snapshot/v1", "default": "runtime-snapshot/v1", diff --git a/docs/decisions/issue-491-sem-218-runtime-realization-preflight.md b/docs/decisions/issue-491-sem-218-runtime-realization-preflight.md new file mode 100644 index 000000000..bd7c1dd89 --- /dev/null +++ b/docs/decisions/issue-491-sem-218-runtime-realization-preflight.md @@ -0,0 +1,178 @@ +# Issue 491 SEM-218 Runtime Realization Preflight + +Date: 2026-06-15 + +Issue: #491. + +Requirement: none. The issue title, body, and acceptance criteria are the +contract. + +This note records architecture preflight guardrails for closing the last +SEM-218 realization gaps: the runtime non-approximation gate and provenance +fields on runtime envelopes. It is guidance for the implementation and does +not implement the gate, schemas, fixtures, or spec updates. + +## Binding Sources + +- `specs/formal/realization/explicitness-and-realization.md` is the normative + SEM-218 authority for I1-I5. +- ADR-008 separates processor work from backend realization and live runtime + state; the processor compiles and plans, while the backend realizes. +- ADR-009, ADR-019, ADR-061, `contracts/schema-publication-manifest.json`, and + `contracts/README.md` define schema authority and publication review. +- ADR-012 and `contracts/concept-authority/concept-families-v1.json` define + the `realization-and-disclosure` concept family and extension discipline. +- ADR-054 and ADR-060 define participant runtime envelope discipline and keep + participant support strength distinct from SEM-218 realization support. +- The existing issue #76 preflight note under + `docs/research/participant-backend-contracts/preflight-guardrails.md` + remains the guardrail for participant backend carriers and control-plane + security. + +## Architecture Decisions + +- The runtime non-approximation gate belongs at the backend adapter contract + boundary, before a backend-returned `ApplyResult.snapshot` is accepted into + `RuntimeManager` state or control-plane persistence. +- The gate must build on the compiled requirements already emitted by + `RuntimeModel.realization_requirements` and matched by + `realization_support_diagnostics()`. Do not reclassify SDL declarations, + reinterpret authored values, or add a second exactness taxonomy at runtime. +- The gate should consume a runtime realization context passed down from the + `ExecutionPlan`, not global state. That context should be parameterized by + realization `domain`, `requirement_kind`, `field_path`, `address`, and + `explicitness` so future SEM-218 concern kinds can reuse the same boundary. +- Missing runtime provenance for a compiled exact requirement is a contract + failure, not a best-effort warning. A backend that reports a weaker + realization for an exact declaration must fail through the existing + `runtime.backend-contract-invalid` diagnostic surface. +- Provenance is per realized concern or value, not a scalar property of a whole + snapshot. If multiple envelopes need the same fields, define one shared + contract component in `aces_contracts.contracts` and reuse it instead of + adding divergent per-envelope shapes. +- The provenance fields must distinguish SEM-218 explicitness/provenance from + ADR-054 lifecycle `phase_realization`, API-407 participant feature support, + actor provenance, mapping loss, evidence refs, and control-plane operation + state. Those concepts are adjacent but not interchangeable. +- Published schemas remain the language-neutral contract. Any envelope shape + change must update `contracts/schemas/`, `schema_bundle()`, + fixtures, and the publication manifest through the existing schema + authority pipeline. + +## Required Incumbents + +- SEM-218 classifier and compiler: `aces_sdl.explicitness`, + `aces_processor.compiler._compile_realization_requirements`, + `aces_processor.models.RuntimeModel.realization_requirements`, and + `aces_processor.semantics.realization`. +- Backend manifest declaration: `RealizationSupportMode`, + `RealizationSupportDeclaration`, + `RealizationSupportDeclarationModel`, `BackendManifestV2Model`, and backend + manifest fixtures. +- Runtime adapter boundary: `aces_runtime.backend_calls._call_backend_apply`, + `_snapshot_contract_diagnostics()`, `RuntimeManager.apply()`, and + `ApplyResult` / `RuntimeSnapshot`. +- Existing result validators: + `workflow_result_contract_diagnostics()`, + `evaluation_result_contract_diagnostics()`, + `participant_runtime_state_contract_diagnostics()`, and + `participant_runtime_history_transition_diagnostics()`. +- Published contract models and schemas: + `ContractModel`, `RuntimeSnapshotEnvelopeModel`, + `WorkflowExecutionStateModel`, `WorkflowHistoryEventModel`, + `EvaluationResultStateModel`, `EvaluationHistoryEventModel`, + participant episode/behavior history models, participant runtime base + envelopes, `schema_bundle()`, and `tools/generate_contract_schemas.py`. +- Control-plane serialization and exposure: `ControlPlaneStore`, + `_snapshot_payload()`, `_snapshot_from_payload()`, `_snapshot_model()`, + `RuntimeSnapshotEnvelopeModel`, and `aces_conformance.conformance`. +- Verification and policy: `.ground-control.yaml`, `.gc/plan-rules.md`, + `tools/check_repo_policy.py`, `tools/check_requirement_governance.py`, + `tools/check_generated_schemas.py`, `tools/check_schema_publication.py`, + `tools/check_json_artifacts.py`, and `tools/verify_all.py`. + +## Cross-Cutting Layers + +- SDL validation layer: exact/constrained/open classification must come from + the existing closed-model parser, `SemanticValidator`, and + `aces_sdl.explicitness`. Runtime code must not infer explicitness from raw + payload values. +- Manifest/config layer: backend realization support still passes through + `RealizationSupportDeclaration` shape validation, controlled-vocabulary and + concept-binding checks, manifest version allowlists, and backend profile + conformance. Do not add backend-local realization flags outside the manifest + contract. +- Backend adapter layer: `_call_backend_apply()` is the fail-closed point for + invalid backend return values. New SEM-218 failures should be ordinary + `Diagnostic` instances with code `runtime.backend-contract-invalid`, leaving + rollback and manager behavior on the existing path. +- Persistence layer: snapshots and histories move through `ControlPlaneStore` + JSON-like payloads and the published runtime snapshot schema. New fields must + round-trip through those serializers; do not create a sidecar persistence + store for realization provenance. +- HTTP/auth layer: if the fields are visible through the control-plane API, + they inherit `ControlPlaneSecurityConfig`, role checks, request-size limits, + audit recording, and redacted FastAPI 500 envelopes. No new unauthenticated + read surface is part of this issue. +- Error-envelope layer: diagnostics should name addresses, field paths, + requirement kinds, and disclosure refs. They must not echo exact authored + values, backend-native objects, raw payload bodies, environment dumps, + bearer tokens, private keys, or stack traces. +- Schema and fixture layer: valid fixtures must exercise the new provenance + fields on every affected published contract; invalid fixtures must fail + schema/model validation for missing or contradictory provenance. Use the + existing `contracts/fixtures///valid|invalid` layout. +- OS/process exposure layer: this work should not introduce command-line + passing of exact values or secrets. Use fixed-argv repo tools and existing + nox/uv commands; do not put tokens or exact declaration values in process + argv, logs, audit details, fixtures, or publication metadata. + +## Extension Boundary + +The extensibility seam is the runtime realization context plus a shared +provenance entry shape keyed by contract-local field/ref paths and realization +domain/kind strings. Future exact-requirement kinds, constraint kinds, or +governed vocabularies should extend those parameters and the concept-authority +catalog, not add new adapter-specific gates or envelope-specific provenance +schemas. + +If the implementation needs a temporary string field before a governed +vocabulary exists, keep it opaque, non-empty, and disclosure-focused. Do not +pretend the field is a closed portable vocabulary until the concept-authority +artifact owns it. + +## Gotchas And Anti-Patterns + +Avoid: + +- duplicating SEM-218 classification logic in runtime code; +- treating a successful planner gate as sufficient proof that the backend + realized exact requirements exactly; +- treating `OPEN_REALIZATION`, `CONSTRAINED`, API-407 support strength, or + ADR-054 `phase_realization` as permission to weaken an exact declaration; +- putting provenance only in `RuntimeSnapshot.metadata` or generic `details` + maps when a first-class envelope field is required; +- adding per-envelope field names with subtly different meanings; +- adding a second schema registry, fixture loader, conformance runner, + exception hierarchy, audit log, or persistence store; +- hand-waving schema changes with generated Python output but no publication + manifest `last_change` ledger; +- exposing exact values, secrets, hidden truth, backend-private object + representations, or raw validation payloads in diagnostics or fixtures; +- rewriting accepted ADRs in place for this issue without ADR-059 amendment + handling. + +## Non-Goals + +- Implementing the runtime gate, provenance contract fields, schema + regeneration, fixtures, or tests in this preflight note. +- Adding SDL authoring syntax or new realization concern authorities beyond + what SEM-218 and the issue require. +- Redesigning backend manifests, processor manifests, participant feature + support, participant episode lifecycle, workflow/evaluation result + semantics, or control-plane authentication. +- Creating new archival experiment semantics beyond carrying the SEM-218 + provenance fields on any evidence envelopes the implementation explicitly + changes. +- Merging or closing the PR, changing `main`, or bypassing existing Ground + Control policy gates. diff --git a/docs/explain/reference/explicitness-realization-semantics.md b/docs/explain/reference/explicitness-realization-semantics.md index 448f81407..7c087036d 100644 --- a/docs/explain/reference/explicitness-realization-semantics.md +++ b/docs/explain/reference/explicitness-realization-semantics.md @@ -176,12 +176,14 @@ This note is implementation guidance for the SEM-218 normative spec at It does not itself add SDL syntax, define exact-requirement-kinds, or change manifest payloads — those are governed by the spec and by the controlled-vocabulary / reference-model authorities. The PR that -introduced the spec also promoted the SEM-218 row in the SEM-200 -coverage table to `partial` and transitioned the requirement from -`DRAFT` to `ACTIVE` in Ground Control; the staged work that lifts the -row from `partial` to `active` (the SEM-218 classifier in -`SemanticValidator`, the typed compiler emission, the planner gate, the -runtime non-approximation envelope, the SEM-218 provenance fields) is -tracked under the SEM-218 coverage row and is the subject of follow-on -`/implement` runs. Treat the prose above as architecture guidance for -that staged work; treat the spec as the binding contract. +introduced the spec promoted the SEM-218 row in the SEM-200 coverage +table to `partial` and transitioned the requirement from `DRAFT` to +`ACTIVE` in Ground Control. The staged work that lifted the row from +`partial` to `active` — the SEM-218 classifier in `SemanticValidator`, +the typed compiler emission, the planner gate, the runtime +non-approximation gate (`aces_processor.semantics.realization`, invoked +from the `aces_runtime` adapter boundary), and the +SEM-218 `realization_provenance` fields on the runtime snapshot envelope — +is now complete, and the coverage row reads `active`. Treat the prose +above as architecture guidance for that work; treat the spec as the +binding contract. diff --git a/docs/explain/reference/shared-semantic-integrity.md b/docs/explain/reference/shared-semantic-integrity.md index beac492b8..9f12e6c8b 100644 --- a/docs/explain/reference/shared-semantic-integrity.md +++ b/docs/explain/reference/shared-semantic-integrity.md @@ -252,5 +252,5 @@ so they are tracked by their own requirements, not here. | Derived operational context views (portable meaning and comparability) | SEM-214 | — | — | planned | | Evidence, evaluation, view-boundary, and observability-plane semantics | SEM-216, SEM-224, SEM-225 | — | — | planned | | External knowledge bindings semantics | SEM-217 | — | — | planned | -| Explicitness and realization semantics (binding declarations vs processor/backend realization) | SEM-218 | validation, instantiation, compilation, planning | `specs/formal/realization/explicitness-and-realization.md`, `specs/formal/realization/README.md`, `docs/explain/reference/explicitness-realization-semantics.md`, `implementations/python/packages/aces_sdl/explicitness.py`, `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/packages/aces_contracts/apparatus.py`, `implementations/python/packages/aces_contracts/vocabulary.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/packages/aces_backend_protocols/manifest.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_processor/planner.py`, `implementations/python/packages/aces_processor/semantics/realization.py`, `implementations/python/tests/test_sem_218_explicitness.py`, `implementations/python/tests/test_sem_218_realization.py`, `implementations/python/tests/test_runtime_planner.py`, `implementations/python/tests/test_backend_manifest.py`, `implementations/python/tests/test_processor_manifest.py`, `implementations/python/tests/test_runtime_contracts.py` | partial | +| Explicitness and realization semantics (binding declarations vs processor/backend realization) | SEM-218 | validation, instantiation, compilation, planning, execution, observation | `specs/formal/realization/explicitness-and-realization.md`, `specs/formal/realization/README.md`, `docs/explain/reference/explicitness-realization-semantics.md`, `implementations/python/packages/aces_sdl/explicitness.py`, `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/packages/aces_contracts/apparatus.py`, `implementations/python/packages/aces_contracts/vocabulary.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/packages/aces_contracts/runtime_state.py`, `implementations/python/packages/aces_backend_protocols/manifest.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_processor/planner.py`, `implementations/python/packages/aces_processor/semantics/realization.py`, `implementations/python/packages/aces_runtime/backend_calls.py`, `implementations/python/packages/aces_runtime/manager.py`, `implementations/python/packages/aces_runtime/control_plane_store.py`, `implementations/python/tests/test_sem_218_explicitness.py`, `implementations/python/tests/test_sem_218_realization.py`, `implementations/python/tests/test_sem_218_runtime_realization.py`, `implementations/python/tests/test_runtime_planner.py`, `implementations/python/tests/test_backend_manifest.py`, `implementations/python/tests/test_processor_manifest.py`, `implementations/python/tests/test_runtime_contracts.py` | active | | Clock, time-domain, advancement/pacing/synchronization, and temporal ordering/causality semantics | SEM-227, SEM-228, SEM-229 | — | — | planned | diff --git a/implementations/python/packages/aces_conformance/conformance.py b/implementations/python/packages/aces_conformance/conformance.py index 9605325f1..71e2df966 100644 --- a/implementations/python/packages/aces_conformance/conformance.py +++ b/implementations/python/packages/aces_conformance/conformance.py @@ -758,6 +758,21 @@ def _semantic_diagnostics(contract_name: str, payload: Any) -> list[Diagnostic]: return _runtime_snapshot_semantic_diagnostics(payload) +def _fixture_case_diagnostics(contract_name: str, payload: Any) -> list[Diagnostic]: + """Schema-gate first; only run semantic analysis on a schema-valid payload. + + Semantic checks (for example ``_runtime_snapshot_semantic_diagnostics``) + deserialize the payload through its closed-world contract model and assume it + is already schema-valid. Running them on a schema-invalid fixture would raise + instead of reporting a diagnostic, so a schema failure short-circuits. + """ + + schema_diagnostics = _validate_payload(contract_name, payload) + if schema_diagnostics: + return schema_diagnostics + return _semantic_diagnostics(contract_name, payload) + + def run_fixture_suite( *, profile: BackendProfileSelector, @@ -794,10 +809,7 @@ def run_fixture_suite( for path in sorted(valid_dir.glob("*.json")): payload = _load_json(path) - case_diagnostics = [ - *_validate_payload(contract_name, payload), - *_semantic_diagnostics(contract_name, payload), - ] + case_diagnostics = _fixture_case_diagnostics(contract_name, payload) cases.append( ConformanceCaseResult( name=path.stem, @@ -811,10 +823,7 @@ def run_fixture_suite( if invalid_dir.exists(): for path in sorted(invalid_dir.glob("*.json")): payload = _load_json(path) - case_diagnostics = [ - *_validate_payload(contract_name, payload), - *_semantic_diagnostics(contract_name, payload), - ] + case_diagnostics = _fixture_case_diagnostics(contract_name, payload) cases.append( ConformanceCaseResult( name=path.stem, diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index b8e9d5fbd..2f3691d48 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -12,6 +12,7 @@ from typing import Annotated, Any, Literal from aces_sdl import VARIABLE_TOKEN_PATTERN +from aces_sdl.explicitness import ExplicitnessClass, ExplicitnessProvenance from aces_sdl.participant_attribution_semantics import ( ParticipantAttributionCandidateKind, ParticipantAttributionOrderingBasisKind, @@ -1370,6 +1371,24 @@ class SnapshotEntryModel(ContractModel): status: str = "ready" +class RealizationProvenanceEntryModel(ContractModel): + """SEM-218 invariant I5: provenance for one realized realization concern. + + Distinguishes ``author-declared`` / ``processor-derived`` / ``backend-realized`` + origins for a realized concern recorded on the snapshot's result / history + surfaces. Carries field-path and kind references only (never the realized + value, per the SEM-218 host-exposure gate). Kept distinct from ADR-054 + lifecycle ``phase_realization`` and API-407 participant feature support. + """ + + address: NonEmptyString + field_path: NonEmptyString + domain: NonEmptyString + requirement_kind: NonEmptyString + explicitness: ExplicitnessClass + provenance: ExplicitnessProvenance + + class RuntimeSnapshotEnvelopeModel(ContractModel): """Published envelope for a live runtime snapshot. @@ -1393,6 +1412,7 @@ class RuntimeSnapshotEnvelopeModel(ContractModel): participant_episode_results: dict[str, ParticipantEpisodeStateModel] = Field(default_factory=dict) participant_episode_history: dict[str, list[ParticipantEpisodeHistoryEventModel]] = Field(default_factory=dict) participant_behavior_history: dict[str, list[ParticipantBehaviorHistoryEventModel]] = Field(default_factory=dict) + realization_provenance: list[RealizationProvenanceEntryModel] = Field(default_factory=list) metadata: dict[str, Any] = Field(default_factory=dict) @@ -5358,6 +5378,7 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "ProvisionerCapabilitiesModel", "ProvisioningPlanModel", "RawDataIntegrityModel", + "RealizationProvenanceEntryModel", "RealizationSupportDeclarationModel", "RealizationSupportMode", "ReferenceModelCatalogModel", diff --git a/implementations/python/packages/aces_contracts/runtime_state.py b/implementations/python/packages/aces_contracts/runtime_state.py index 1e54a4512..177fdb3f0 100644 --- a/implementations/python/packages/aces_contracts/runtime_state.py +++ b/implementations/python/packages/aces_contracts/runtime_state.py @@ -7,6 +7,8 @@ from enum import Enum from typing import Any +from aces_sdl.explicitness import ExplicitnessClass, ExplicitnessProvenance + from aces_contracts.diagnostics import Diagnostic from aces_contracts.planning import RuntimeDomain from aces_contracts.versions import OPERATION_SCHEMA_VERSION, RUNTIME_SNAPSHOT_SCHEMA_VERSION @@ -35,6 +37,28 @@ class SnapshotEntry: status: str = "ready" +@dataclass(frozen=True) +class RealizationProvenanceEntry: + """SEM-218 provenance for one realized realization concern (invariant I5). + + Records, for a single realization concern that entered a runtime snapshot / + result / history surface, its SEM-218 explicitness class and the origin of + the realized value: ``author-declared`` (honoured exactly as the author + wrote it), ``processor-derived`` (produced by deterministic processor + activity), or ``backend-realized`` (picked by the backend from an open or + constrained surface admitted by I3). It carries field-path and kind + references only — never the realized value itself, which may carry sensitive + material (SEM-218 host-exposure gate). + """ + + address: str + field_path: str + domain: str + requirement_kind: str + explicitness: ExplicitnessClass + provenance: ExplicitnessProvenance + + @dataclass class RuntimeSnapshot: """Current runtime snapshot.""" @@ -47,6 +71,9 @@ class RuntimeSnapshot: participant_episode_results: dict[str, dict[str, Any]] = field(default_factory=dict) participant_episode_history: dict[str, list[dict[str, Any]]] = field(default_factory=dict) participant_behavior_history: dict[str, list[dict[str, Any]]] = field(default_factory=dict) + # SEM-218 invariant I5: per-concern provenance for realized realization + # concerns recorded across this snapshot's result / history surfaces. + realization_provenance: tuple[RealizationProvenanceEntry, ...] = () metadata: dict[str, Any] = field(default_factory=dict) def get(self, address: str) -> SnapshotEntry | None: @@ -90,6 +117,11 @@ def with_entries( "participant_behavior_history", self.participant_behavior_history, ), + realization_provenance=_provenance_update( + updates, + "realization_provenance", + self.realization_provenance, + ), metadata=_mapping_update(updates, "metadata", self.metadata), ) @@ -102,6 +134,7 @@ def with_entries( "participant_episode_results", "participant_episode_history", "participant_behavior_history", + "realization_provenance", "metadata", } @@ -138,6 +171,19 @@ def _history_update( return {str(address): list(events) for address, events in raw.items()} +def _provenance_update( + updates: Mapping[str, object], + key: str, + current: tuple[RealizationProvenanceEntry, ...], +) -> tuple[RealizationProvenanceEntry, ...]: + raw = updates.get(key) + if raw is None: + return tuple(current) + if not isinstance(raw, tuple) or any(not isinstance(entry, RealizationProvenanceEntry) for entry in raw): + raise TypeError(f"{key} must be a tuple of RealizationProvenanceEntry") + return raw + + @dataclass class ApplyResult: """Result of applying or starting a runtime plan.""" @@ -185,9 +231,12 @@ class RuntimeSnapshotEnvelope: __all__ = ( "ApplyResult", + "ExplicitnessClass", + "ExplicitnessProvenance", "OperationReceipt", "OperationState", "OperationStatus", + "RealizationProvenanceEntry", "RuntimeSnapshot", "RuntimeSnapshotEnvelope", "SnapshotEntry", diff --git a/implementations/python/packages/aces_processor/models.py b/implementations/python/packages/aces_processor/models.py index 4b52f3c1a..0bb2f36c2 100644 --- a/implementations/python/packages/aces_processor/models.py +++ b/implementations/python/packages/aces_processor/models.py @@ -149,6 +149,9 @@ from aces_contracts.runtime_state import ( OperationStatus as OperationStatus, ) +from aces_contracts.runtime_state import ( + RealizationProvenanceEntry as RealizationProvenanceEntry, +) from aces_contracts.runtime_state import ( RuntimeSnapshot as RuntimeSnapshot, ) diff --git a/implementations/python/packages/aces_processor/planner.py b/implementations/python/packages/aces_processor/planner.py index 1474b98d8..9fafa9eb6 100644 --- a/implementations/python/packages/aces_processor/planner.py +++ b/implementations/python/packages/aces_processor/planner.py @@ -31,6 +31,7 @@ resource_dependency_cycles, resource_topological_order, ) +from .semantics.realization import realization_disclosure as realization_disclosure from .semantics.realization import realization_support_diagnostics diff --git a/implementations/python/packages/aces_processor/semantics/realization.py b/implementations/python/packages/aces_processor/semantics/realization.py index fa6893958..002c2fa58 100644 --- a/implementations/python/packages/aces_processor/semantics/realization.py +++ b/implementations/python/packages/aces_processor/semantics/realization.py @@ -24,16 +24,23 @@ from aces_backend_protocols.capabilities import BackendManifest from aces_contracts.diagnostics import Diagnostic, Severity -from aces_sdl.explicitness import ExplicitnessClass +from aces_contracts.planning import ChangeAction, ProvisioningPlan +from aces_contracts.runtime_state import RealizationProvenanceEntry, RuntimeSnapshot +from aces_sdl.explicitness import ExplicitnessClass, ExplicitnessProvenance __all__ = [ + "CONCERN_PAYLOAD_PATH", "EXACT_REQUIREMENT_KIND", "REALIZATION_DOMAIN", "CompiledRealizationRequirement", + "realization_disclosure", "realization_support_diagnostics", "resolve_realization_concern", ] +_BACKEND_CONTRACT_INVALID = "runtime.backend-contract-invalid" +_MISSING_CONCERN_VALUE = object() + # The single coarse realization domain string already published by backend # manifests (see ``aces_backend_stubs.stubs``). Kept opaque per the SEM-218 # extensibility seam. @@ -56,6 +63,18 @@ ("content", "type"): "content-type", } +# Where each realization concern's realized value lives inside the backend's +# provisioning resource payload (``resource_payload``). The runtime +# non-approximation gate uses this to locate the value the backend realized for +# an exact concern and compare it against the author declaration. Mirrors the +# concern set in ``_CONCERN_KIND_BY_PATH``; a concern absent here is not gated at +# runtime (no published payload slot to compare). +CONCERN_PAYLOAD_PATH: dict[str, tuple[str, ...]] = { + "os-family": ("os_family",), + "node-type": ("node_type",), + "content-type": ("spec", "type"), +} + @dataclass(frozen=True) class CompiledRealizationRequirement: @@ -151,3 +170,106 @@ def realization_support_diagnostics( ) ) return diagnostics + + +def realization_disclosure( + requirements: tuple[CompiledRealizationRequirement, ...], + declared_plan: ProvisioningPlan, + returned_snapshot: RuntimeSnapshot, +) -> tuple[list[Diagnostic], tuple[RealizationProvenanceEntry, ...]]: + """SEM-218 runtime non-approximation gate (I2) + provenance disclosure (I5). + + Compares each compiled realization concern's author-declared value (from the + provisioning plan the processor emitted) against the value the backend + realized in its returned snapshot. For an exact concern, a backend that + realizes a *different* value — or *omits* the value entirely (a returned + snapshot with no entry for the resource, or an entry missing the concern + field) — is a silent approximation and yields a rejecting + ``runtime.backend-contract-invalid`` diagnostic. Absent *backend* evidence is + not a non-event: an exact declaration the backend never realized is exactly + the I2 failure this gate exists to catch. This is the spec's Execution-phase + non-approximation rule. + + Absent *plan-side* evidence is different and is not a backend fault: when the + plan declares no provisioning op for the resource, removes it (a ``DELETE`` + op — expected absence), or carries no value for the concern, there is no + author baseline for this run to enforce, so the requirement is skipped. + + Every honoured/realized concern is recorded as a + ``RealizationProvenanceEntry``: ``author-declared`` when the backend honoured + the declaration, ``backend-realized`` when it realized a different value for + a constrained surface. Diagnostics and entries name the field path and kind + only, never the realized value (SEM-218 host-exposure gate). + + This is the runtime sibling of ``realization_support_diagnostics``: the + planner gate rejects an unrealizable exact requirement before deployment; + this gate rejects a backend that realized one dishonestly. The runtime + adapter (``aces_runtime``) invokes it at the backend-call boundary. + """ + + diagnostics: list[Diagnostic] = [] + provenance: list[RealizationProvenanceEntry] = [] + declared_ops = {op.address: op for op in declared_plan.operations} + for requirement in requirements: + path = CONCERN_PAYLOAD_PATH.get(requirement.requirement_kind) + if path is None: + continue + op = declared_ops.get(requirement.address) + if op is None or op.action is ChangeAction.DELETE: + # No realization is owed: the plan declares no op for this resource, + # or it removes it (expected absence). Not a backend contract fault. + continue + declared_value = _concern_value(op.payload, path) + if declared_value is _MISSING_CONCERN_VALUE: + # The plan op carries no value for this concern, so there is no author + # baseline to enforce (an upstream processor invariant, not a backend + # contract). Nothing to gate or disclose. + continue + entry = returned_snapshot.entries.get(requirement.address) + realized_value = _concern_value(entry.payload, path) if entry is not None else _MISSING_CONCERN_VALUE + honoured = realized_value == declared_value + if requirement.explicitness is ExplicitnessClass.EXACT and not honoured: + # The backend realized the exact concern with a different value or + # omitted it entirely; both are forbidden silent approximation (I2). + diagnostics.append(_silent_approximation_diagnostic(requirement)) + continue + if realized_value is _MISSING_CONCERN_VALUE: + # A non-exact concern the backend left unrealized: nothing to disclose. + continue + provenance.append( + RealizationProvenanceEntry( + address=requirement.address, + field_path=requirement.field_path, + domain=requirement.domain, + requirement_kind=requirement.requirement_kind, + explicitness=requirement.explicitness, + provenance=( + ExplicitnessProvenance.AUTHOR_DECLARED if honoured else ExplicitnessProvenance.BACKEND_REALIZED + ), + ) + ) + return diagnostics, tuple(provenance) + + +def _silent_approximation_diagnostic(requirement: CompiledRealizationRequirement) -> Diagnostic: + return Diagnostic( + code=_BACKEND_CONTRACT_INVALID, + domain=requirement.domain, + address=requirement.address, + message=( + f"Backend did not realize the exact '{requirement.requirement_kind}' requirement at " + f"'{requirement.field_path}' as the author declared it (the realized value is absent " + f"or differs); silent approximation or omission of an exact declaration is forbidden " + f"(SEM-218 I2)." + ), + severity=Severity.ERROR, + ) + + +def _concern_value(payload: dict[str, object], path: tuple[str, ...]) -> object: + current: object = payload + for key in path: + if not isinstance(current, dict) or key not in current: + return _MISSING_CONCERN_VALUE + current = current[key] + return current diff --git a/implementations/python/packages/aces_runtime/backend_calls.py b/implementations/python/packages/aces_runtime/backend_calls.py index aca870091..d402c28f5 100644 --- a/implementations/python/packages/aces_runtime/backend_calls.py +++ b/implementations/python/packages/aces_runtime/backend_calls.py @@ -6,7 +6,10 @@ from copy import deepcopy from aces_contracts.diagnostics import Diagnostic -from aces_contracts.runtime_state import ApplyResult, RuntimeSnapshot +from aces_contracts.planning import ProvisioningPlan +from aces_contracts.runtime_state import ApplyResult, RealizationProvenanceEntry, RuntimeSnapshot +from aces_processor.models import CompiledRealizationRequirement +from aces_processor.planner import realization_disclosure from .diagnostics import _failure_diagnostic from .evaluation_result_contracts import evaluation_result_contract_diagnostics @@ -45,6 +48,8 @@ def _call_backend_apply( *args: object, address: str, snapshot: RuntimeSnapshot, + realization_requirements: tuple[CompiledRealizationRequirement, ...] = (), + realization_plan: ProvisioningPlan | None = None, ) -> ApplyResult: baseline_snapshot = deepcopy(snapshot) backend_snapshot = deepcopy(snapshot) @@ -62,14 +67,41 @@ def _call_backend_apply( contract_diagnostics = _snapshot_contract_diagnostics(result.snapshot) if not contract_diagnostics: contract_diagnostics = _snapshot_transition_contract_diagnostics(baseline_snapshot, result.snapshot) - apply_result = ( - ApplyResult(success=False, snapshot=baseline_snapshot, diagnostics=contract_diagnostics) - if contract_diagnostics - else result - ) + realization_provenance: tuple[RealizationProvenanceEntry, ...] = () + if not contract_diagnostics and realization_requirements and realization_plan is not None: + # SEM-218 I2 non-approximation gate + I5 provenance disclosure. + contract_diagnostics, realization_provenance = realization_disclosure( + realization_requirements, + realization_plan, + result.snapshot, + ) + if contract_diagnostics: + apply_result = ApplyResult(success=False, snapshot=baseline_snapshot, diagnostics=contract_diagnostics) + elif realization_provenance: + apply_result = _with_realization_provenance(result, realization_provenance) + else: + apply_result = result return apply_result +def _with_realization_provenance( + result: ApplyResult, + provenance: tuple[RealizationProvenanceEntry, ...], +) -> ApplyResult: + """Attach the SEM-218 provenance ledger to a successful apply's snapshot.""" + + return ApplyResult( + success=result.success, + snapshot=result.snapshot.with_entries( + dict(result.snapshot.entries), + realization_provenance=provenance, + ), + diagnostics=result.diagnostics, + changed_addresses=result.changed_addresses, + details=result.details, + ) + + def _backend_call_failed(address: str, exc: Exception) -> Diagnostic: return _failure_diagnostic( "runtime.backend-call-failed", diff --git a/implementations/python/packages/aces_runtime/control_plane_store.py b/implementations/python/packages/aces_runtime/control_plane_store.py index d2fa8fce6..ef77293d4 100644 --- a/implementations/python/packages/aces_runtime/control_plane_store.py +++ b/implementations/python/packages/aces_runtime/control_plane_store.py @@ -10,9 +10,12 @@ from aces_contracts.diagnostics import Diagnostic, Severity from aces_contracts.planning import RuntimeDomain from aces_contracts.runtime_state import ( + ExplicitnessClass, + ExplicitnessProvenance, OperationReceipt, OperationState, OperationStatus, + RealizationProvenanceEntry, RuntimeSnapshot, RuntimeSnapshotEnvelope, SnapshotEntry, @@ -92,6 +95,17 @@ def _snapshot_payload(snapshot: RuntimeSnapshot) -> dict[str, Any]: participant_address: list(events) for participant_address, events in snapshot.participant_behavior_history.items() }, + "realization_provenance": [ + { + "address": entry.address, + "field_path": entry.field_path, + "domain": entry.domain, + "requirement_kind": entry.requirement_kind, + "explicitness": entry.explicitness.value, + "provenance": entry.provenance.value, + } + for entry in snapshot.realization_provenance + ], "metadata": dict(snapshot.metadata), } @@ -128,6 +142,20 @@ def _snapshot_from_payload(payload: dict[str, Any]) -> RuntimeSnapshot: participant_address: list(events) for participant_address, events in payload.get("participant_behavior_history", {}).items() }, + realization_provenance=tuple( + RealizationProvenanceEntry( + address=str(item.get("address", "")), + field_path=str(item.get("field_path", "")), + domain=str(item.get("domain", "")), + requirement_kind=str(item.get("requirement_kind", "")), + explicitness=ExplicitnessClass(str(item.get("explicitness", ExplicitnessClass.EXACT.value))), + provenance=ExplicitnessProvenance( + str(item.get("provenance", ExplicitnessProvenance.AUTHOR_DECLARED.value)) + ), + ) + for item in payload.get("realization_provenance", []) + if isinstance(item, dict) + ), metadata=dict(payload.get("metadata", {})), ) diff --git a/implementations/python/packages/aces_runtime/manager.py b/implementations/python/packages/aces_runtime/manager.py index e59335fed..010a66a26 100644 --- a/implementations/python/packages/aces_runtime/manager.py +++ b/implementations/python/packages/aces_runtime/manager.py @@ -206,6 +206,8 @@ def _apply_provisioning_phase( state.working_snapshot, address="runtime.apply.provisioning", snapshot=state.working_snapshot, + realization_requirements=execution_plan.model.realization_requirements, + realization_plan=execution_plan.provisioning, ) self._record_phase_result(state, provision_result) if not provision_result.success: diff --git a/implementations/python/tests/test_sem_218_runtime_realization.py b/implementations/python/tests/test_sem_218_runtime_realization.py new file mode 100644 index 000000000..d441b7639 --- /dev/null +++ b/implementations/python/tests/test_sem_218_runtime_realization.py @@ -0,0 +1,211 @@ +"""SEM-218 part 3: runtime non-approximation gate + provenance fields. + +These tests exercise the last two enforcement points of the SEM-218 spec +``specs/formal/realization/explicitness-and-realization.md``: + +- the runtime non-approximation gate on backend adapters (invariant I2 / + Execution phase): a backend that silently realizes an exact declaration with + a weaker value is rejected at the runtime adapter boundary; and +- the SEM-218 provenance fields on the runtime snapshot envelope (invariant + I5 / Observation phase): realized concerns are recorded with their + explicitness class and author-declared / processor-derived / backend-realized + origin. + +The gate compares the backend-returned realized value against the +author-declared value the planner emitted; it does not re-classify SDL at +runtime (it consumes ``RuntimeModel.realization_requirements``). +""" + +from __future__ import annotations + +import textwrap +from dataclasses import replace + +from aces_contracts.runtime_state import ( + ApplyResult, + RealizationProvenanceEntry, + RuntimeSnapshot, +) +from aces_contracts.versions import RUNTIME_SNAPSHOT_SCHEMA_VERSION +from aces_sdl.explicitness import ExplicitnessClass, ExplicitnessProvenance + +from aces.backends.stubs import StubProvisioner, create_stub_target +from aces.core.runtime.control_plane_store import _snapshot_from_payload, _snapshot_payload +from aces.core.runtime.manager import RuntimeManager +from aces.core.runtime.registry import RuntimeTarget +from aces.core.sdl import parse_sdl + +_EXACT_SCENARIO = """ +name: sem-218-runtime-exact +nodes: + web: + type: vm + os: linux + resources: {ram: 1 gib, cpu: 1} +""" + + +def _plan_exact(manager: RuntimeManager): + return manager.plan(parse_sdl(textwrap.dedent(_EXACT_SCENARIO))) + + +def _target_with_provisioner(provisioner) -> RuntimeTarget: + base = create_stub_target() + return RuntimeTarget( + name=base.name, + manifest=base.manifest, + provisioner=provisioner, + orchestrator=base.orchestrator, + evaluator=base.evaluator, + participant_runtime=base.participant_runtime, + ) + + +class _WeakeningProvisioner: + """A provisioner that silently downgrades an exact ``os`` declaration. + + It honours the plan structurally (delegating to the reference stub) but + realizes the exact ``linux`` os family as the weaker ``other`` sentinel — + the silent-approximation failure mode the runtime gate must reject. + """ + + def validate(self, plan) -> list: + return [] + + def apply(self, plan, snapshot: RuntimeSnapshot) -> ApplyResult: + honest = StubProvisioner().apply(plan, snapshot) + entries = dict(honest.snapshot.entries) + for address, entry in entries.items(): + if entry.payload.get("os_family") == "linux": + weakened = dict(entry.payload) + weakened["os_family"] = "other" + entries[address] = replace(entry, payload=weakened) + return ApplyResult( + success=True, + snapshot=honest.snapshot.with_entries(entries), + changed_addresses=honest.changed_addresses, + ) + + +class _OmittingProvisioner: + """A provisioner that realizes the node but silently omits its exact ``os``. + + It drops the ``os_family`` key from the realized entry payload — the + "absent backend evidence" failure mode, where the exact declaration is + neither honoured nor visibly weakened, just missing. The gate must treat + an unrealized exact declaration as an I2 violation, not a non-event. + """ + + def validate(self, plan) -> list: + return [] + + def apply(self, plan, snapshot: RuntimeSnapshot) -> ApplyResult: + honest = StubProvisioner().apply(plan, snapshot) + entries = dict(honest.snapshot.entries) + for address, entry in entries.items(): + if "os_family" in entry.payload: + stripped = {key: value for key, value in entry.payload.items() if key != "os_family"} + entries[address] = replace(entry, payload=stripped) + return ApplyResult( + success=True, + snapshot=honest.snapshot.with_entries(entries), + changed_addresses=honest.changed_addresses, + ) + + +def test_runtime_gate_rejects_silently_weakened_exact_value(): + """I2: a backend that weakens an exact realization fails at the adapter boundary.""" + + manager = RuntimeManager(_target_with_provisioner(_WeakeningProvisioner())) + result = manager.apply(_plan_exact(manager)) + + assert not result.success + assert "runtime.backend-contract-invalid" in {diag.code for diag in result.diagnostics} + # Fail-closed: the weakened snapshot is rejected, baseline (empty) is kept. + assert result.snapshot.entries == {} + + +def test_runtime_gate_diagnostic_does_not_leak_exact_value(): + """Security gate: the rejection names the field path and kind, never the value.""" + + manager = RuntimeManager(_target_with_provisioner(_WeakeningProvisioner())) + result = manager.apply(_plan_exact(manager)) + + messages = [diag.message for diag in result.diagnostics if diag.code == "runtime.backend-contract-invalid"] + assert messages + for message in messages: + assert "linux" not in message + assert "other" not in message + assert "nodes.web.os" in message + assert "os-family" in message + + +def test_runtime_gate_rejects_silently_omitted_exact_value(): + """I2: a backend that omits an exact concern (absent evidence) is rejected, + not silently accepted as a non-event.""" + + manager = RuntimeManager(_target_with_provisioner(_OmittingProvisioner())) + result = manager.apply(_plan_exact(manager)) + + assert not result.success + assert "runtime.backend-contract-invalid" in {diag.code for diag in result.diagnostics} + messages = [diag.message for diag in result.diagnostics if diag.code == "runtime.backend-contract-invalid"] + assert any("nodes.web.os" in message for message in messages) + + +def test_honest_apply_records_realization_provenance(): + """I5: an honoured exact realization is recorded as author-declared/exact.""" + + manager = RuntimeManager(create_stub_target()) + result = manager.apply(_plan_exact(manager)) + + assert result.success + by_field = {entry.field_path: entry for entry in result.snapshot.realization_provenance} + assert by_field["nodes.web.os"].provenance is ExplicitnessProvenance.AUTHOR_DECLARED + assert by_field["nodes.web.os"].explicitness is ExplicitnessClass.EXACT + assert by_field["nodes.web.os"].requirement_kind == "os-family" + assert by_field["nodes.web.type"].provenance is ExplicitnessProvenance.AUTHOR_DECLARED + assert by_field["nodes.web.type"].explicitness is ExplicitnessClass.EXACT + + +def test_realization_provenance_round_trips_through_control_plane_store(): + """I5: the provenance ledger survives snapshot persistence serialization.""" + + entry = RealizationProvenanceEntry( + address="node.web", + field_path="nodes.web.os", + domain="runtime-realization", + requirement_kind="os-family", + explicitness=ExplicitnessClass.EXACT, + provenance=ExplicitnessProvenance.AUTHOR_DECLARED, + ) + snapshot = RuntimeSnapshot(realization_provenance=(entry,)) + + restored = _snapshot_from_payload(_snapshot_payload(snapshot)) + + assert restored.realization_provenance == (entry,) + + +def test_runtime_snapshot_envelope_schema_accepts_realization_provenance(): + """The published runtime-snapshot envelope carries the provenance field.""" + + from aces_contracts.contracts import RuntimeSnapshotEnvelopeModel + + model = RuntimeSnapshotEnvelopeModel.model_validate( + { + "schema_version": RUNTIME_SNAPSHOT_SCHEMA_VERSION, + "realization_provenance": [ + { + "address": "node.web", + "field_path": "nodes.web.os", + "domain": "runtime-realization", + "requirement_kind": "os-family", + "explicitness": "exact", + "provenance": "author-declared", + } + ], + } + ) + + assert model.realization_provenance[0].provenance.value == "author-declared" + assert model.realization_provenance[0].explicitness.value == "exact" diff --git a/specs/formal/realization/explicitness-and-realization.md b/specs/formal/realization/explicitness-and-realization.md index f4912185a..3b5492073 100644 --- a/specs/formal/realization/explicitness-and-realization.md +++ b/specs/formal/realization/explicitness-and-realization.md @@ -33,9 +33,9 @@ boundary. Those are governed elsewhere. The semantic boundary defined in §"Required Semantics" is normative immediately on landing. Its enforcement across the seven `SEM-200` -lifecycle phases is staged. The current realization, recorded in the +lifecycle phases is now complete. The current realization, recorded in the SEM-200 coverage table at `docs/explain/reference/shared-semantic-integrity.md`, -is `partial`. What is *enforced today* is narrow and structural: +is `active`. What is *enforced today* spans authoring through observation: - the apparatus-contract shape gates on backend `RealizationSupportDeclaration` (the `EXACT_ONLY` ⇒ no constraint @@ -62,18 +62,22 @@ is `partial`. What is *enforced today* is narrow and structural: - the planner realization-support gate in `aces_processor.planner.plan`, which matches each compiled exact / constrained requirement kind against the selected backend's `realization_support` and rejects an unsupported - kind with a structured `Diagnostic` before deployment. - -What is *normative but not yet realized*: - -- the runtime non-approximation gate on backend adapters; -- the SEM-218 provenance fields on snapshot / result / history / - evidence envelopes. - -The spec is the source of truth for those rules; the implementation -work that closes each gap is tracked under the SEM-218 coverage row in -`shared-semantic-integrity.md` and is staged across follow-on -`/implement` runs. + kind with a structured `Diagnostic` before deployment; +- the runtime non-approximation gate on backend adapters in + `aces_processor.semantics.realization.realization_disclosure` (invoked from + `aces_runtime.backend_calls._call_backend_apply`, the runtime adapter + boundary), which compares each realized exact concern against the author + declaration and rejects a silent approximation with a + `runtime.backend-contract-invalid` diagnostic before the backend snapshot is + accepted into runtime state; +- the SEM-218 provenance ledger (`realization_provenance`) on the runtime + snapshot envelope, which records each realized concern with its explicitness + class and author-declared / processor-derived / backend-realized origin. + +With those last two enforcement points realized, every `SEM-200` lifecycle +phase boundary is now enforced by named, tested code; no rule in this spec +remains normative-but-unrealized. The SEM-200 coverage row in +`shared-semantic-integrity.md` records the realization as `active`. ## Canonical Inputs @@ -248,19 +252,18 @@ contracts is staged work tracked under the SEM-218 coverage row. The seven canonical `SEM-200` lifecycle phases interact with the invariants as follows. The list is normative for the phase boundary, not for the engineering layout of any one phase. The **Status** column -records what is enforced in the repository today; rows marked *normative -(future)* state binding rules that the realizing code does not yet -implement end-to-end. +records what is enforced in the repository today; every phase boundary is +now enforced by named, tested code. | Phase | Responsibility | Status | | --- | --- | --- | | Authoring | Source of declarations. The authoring layer is the only authority that may classify a construct as exact, constrained, or open; downstream stages MUST treat that classification as immutable input. | partial — closed Pydantic SDL models (`extra="forbid"`), the apparatus-contract type system, and the `aces_sdl.explicitness` classifier carry the exact / constrained / open classification for authored SDL declarations. | -| Validation | At the apparatus-contract layer: the shape gates on backend `RealizationSupportDeclaration` (I4 structural floor), the JSON-schema conditional gate, and `ProcessorManifestV2Model`'s asymmetric rejection of `realization_support` are enforced. At the SDL scenario layer: `SemanticValidator` enforces fail-closed validation on the *existing* closed SDL models and attaches SEM-218 classifier output to the validated scenario. | partial — apparatus-contract validation (manifest shape) and SDL-scenario classifier output are enforced; compiler/planner/runtime consumers remain staged. | +| Validation | At the apparatus-contract layer: the shape gates on backend `RealizationSupportDeclaration` (I4 structural floor), the JSON-schema conditional gate, and `ProcessorManifestV2Model`'s asymmetric rejection of `realization_support` are enforced. At the SDL scenario layer: `SemanticValidator` enforces fail-closed validation on the *existing* closed SDL models and attaches SEM-218 classifier output to the validated scenario. | partial — apparatus-contract validation (manifest shape) and SDL-scenario classifier output are enforced; the compiler, planner, and runtime consumers of that output are realized (see those rows), and only the per-field SDL classification authority for the remaining concerns stays staged. | | Instantiation | Parameter and default substitution may resolve open concerns and constrained surfaces. Substitution MUST NOT downgrade an exact declaration into a constrained or open one, and MUST NOT introduce an exact declaration that the author did not write. The concrete scenario MUST be revalidated after substitution. | partial — `instantiate_scenario` revalidates after substitution and derives instantiated explicitness from the authored classification so substituted values do not become false exact declarations. | | Compilation | Lowers each declaration into a typed runtime requirement preserving class. Exact requirements carry their declared kind into the compiled representation; constrained requirements carry the typed constraint surface; open requirements are emitted as realizable slots tagged with the realization-and-disclosure family. | partial — `compile_runtime_model` emits `CompiledRealizationRequirement` metadata on the `RuntimeModel` preserving the exact / constrained / open class for the authored realization concerns the planner validates; per-field designation for the remaining concerns stays staged. | -| Planning | Matches every compiled requirement against the candidate backend manifest. An unsupported exact-requirement-kind MUST cause plan rejection through a structured `Diagnostic` before deployment; an unsupported constraint-kind MUST cause the same outcome. An open realizable slot MAY be left for the backend only when its manifest declares matching support. | partial — `aces_processor.planner.plan` matches each compiled exact / constrained requirement kind against the backend's `realization_support` and emits a rejecting `Diagnostic` for an unsupported kind; the runtime non-approximation gate on backend adapters remains future. | -| Execution | Backend realizers honor the compiled class. A runtime adapter MUST NOT silently broaden an exact requirement, MUST NOT silently narrow an open realization beyond its declared constraints, and MUST surface incompatibilities through the existing runtime error envelope rather than approximate. | normative (future) | -| Observation | Realized values land in plan, result, snapshot, history, and evidence surfaces with provenance per I5. Realization choices are observation data, not private backend state. | normative (future) | +| Planning | Matches every compiled requirement against the candidate backend manifest. An unsupported exact-requirement-kind MUST cause plan rejection through a structured `Diagnostic` before deployment; an unsupported constraint-kind MUST cause the same outcome. An open realizable slot MAY be left for the backend only when its manifest declares matching support. | partial — `aces_processor.planner.plan` matches each compiled exact / constrained requirement kind against the backend's `realization_support` and emits a rejecting `Diagnostic` for an unsupported kind; the Execution-phase runtime non-approximation gate it hands off to is realized in `aces_processor.semantics.realization` (invoked from the `aces_runtime` adapter boundary). | +| Execution | Backend realizers honor the compiled class. A runtime adapter MUST NOT silently broaden an exact requirement, MUST NOT silently narrow an open realization beyond its declared constraints, and MUST surface incompatibilities through the existing runtime error envelope rather than approximate. | active — the runtime non-approximation gate `aces_processor.semantics.realization.realization_disclosure` (invoked from `aces_runtime.backend_calls._call_backend_apply`) compares each realized exact concern against the author declaration and rejects a silent approximation with a `runtime.backend-contract-invalid` diagnostic before the backend snapshot is accepted. | +| Observation | Realized values land in plan, result, snapshot, history, and evidence surfaces with provenance per I5. Realization choices are observation data, not private backend state. | active — realized concerns are recorded on the runtime snapshot envelope's `realization_provenance` ledger with their explicitness class and author-declared / processor-derived / backend-realized origin; the snapshot is the aggregate observation surface that carries result and history data. | ## Cross-Cutting Gates @@ -310,9 +313,11 @@ realization status, are: - **Persistence and observation gate** — values entering snapshots, results, history, and evidence MUST carry provenance distinguishing author-declared, processor-derived, and backend-realized origins (I5). - *Future*; runtime contracts carry plan / result / snapshot data - today, but the SEM-218-specific provenance fields are not yet - realized. + *Enforced today* by the `realization_provenance` ledger on the runtime + snapshot envelope (`RealizationProvenanceEntry` / + `RealizationProvenanceEntryModel`), which records each realized concern's + explicitness class and origin and round-trips through the control-plane + snapshot serializers. - **Host / OS exposure gate** — exact values, credentials, and backend tokens MUST NOT be passed through process argv, logs, audit details, diagnostics, JSON fixtures, or semantic-profile artifacts when they @@ -390,9 +395,8 @@ A SEM-218 realization MUST NOT: ## Implementation Mapping -The rules above are realized today by these existing surfaces. Lines -marked *(future)* state where a binding rule is normative but its -realization is staged work tracked under the SEM-218 coverage row. +The rules above are realized today by these existing surfaces; every +invariant I1–I5 is enforced by named code. - I4 shape floor (backend manifests) — apparatus contract: `implementations/python/packages/aces_contracts/apparatus.py` @@ -433,9 +437,20 @@ realization is staged work tracked under the SEM-218 coverage row. (called from `aces_processor.planner.plan`) matches them against the backend's `realization_support`, rejecting unsupported kinds with a `Diagnostic`. -- I2, I5 runtime envelopes — *future*; runtime plan / result / - snapshot / history contracts exist, but SEM-218-specific provenance - fields are not yet added. +- I2 runtime non-approximation gate — + `aces_processor.semantics.realization.realization_disclosure` (re-exported + through `aces_processor.planner`), invoked from + `aces_runtime.backend_calls._call_backend_apply`, compares each realized + exact concern against the author declaration and rejects a silent + approximation with a `runtime.backend-contract-invalid` diagnostic before the + backend snapshot is accepted. +- I5 runtime provenance — the `realization_provenance` ledger + (`RealizationProvenanceEntry` in + `implementations/python/packages/aces_contracts/runtime_state.py`, published as + `RealizationProvenanceEntryModel` on the `runtime-snapshot-v1` envelope) + records each realized concern's explicitness class and author-declared / + processor-derived / backend-realized origin, and round-trips through the + control-plane snapshot serializers. ## Tests @@ -458,10 +473,11 @@ realization is staged work tracked under the SEM-218 coverage row. typed compiler emission preserving the class through compilation, and the planner realization-support gate rejecting an unsupported exact or constrained requirement kind. - -Tests for the runtime non-approximation envelope and SEM-218 runtime -provenance fields are *future* and will land with the implementations -they exercise. +- `implementations/python/tests/test_sem_218_runtime_realization.py` — + the runtime non-approximation gate rejecting a silently-weakened exact + realization (I2), the `realization_provenance` ledger recorded for honoured + concerns (I5), the rejection diagnostic naming the field path and kind but + not the value, and the ledger's round-trip through snapshot persistence. ## Non-Goals From 41ebcdf0e45d16bcff6c7dcb65d00ecbae994f48 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 04:01:23 +0200 Subject: [PATCH 59/70] Fix SonarCloud findings (cycle 1) --- .../packages/aces_conformance/conformance.py | 2 +- .../python/packages/aces_processor/planner.py | 5 +- .../aces_processor/semantics/realization.py | 100 +++++++++++------- .../packages/aces_runtime/backend_calls.py | 71 ++++++++----- 4 files changed, 112 insertions(+), 66 deletions(-) diff --git a/implementations/python/packages/aces_conformance/conformance.py b/implementations/python/packages/aces_conformance/conformance.py index 71e2df966..a901d3598 100644 --- a/implementations/python/packages/aces_conformance/conformance.py +++ b/implementations/python/packages/aces_conformance/conformance.py @@ -758,7 +758,7 @@ def _semantic_diagnostics(contract_name: str, payload: Any) -> list[Diagnostic]: return _runtime_snapshot_semantic_diagnostics(payload) -def _fixture_case_diagnostics(contract_name: str, payload: Any) -> list[Diagnostic]: +def _fixture_case_diagnostics(contract_name: str, payload: object) -> list[Diagnostic]: """Schema-gate first; only run semantic analysis on a schema-valid payload. Semantic checks (for example ``_runtime_snapshot_semantic_diagnostics``) diff --git a/implementations/python/packages/aces_processor/planner.py b/implementations/python/packages/aces_processor/planner.py index 9fafa9eb6..d47f05dec 100644 --- a/implementations/python/packages/aces_processor/planner.py +++ b/implementations/python/packages/aces_processor/planner.py @@ -31,8 +31,9 @@ resource_dependency_cycles, resource_topological_order, ) -from .semantics.realization import realization_disclosure as realization_disclosure -from .semantics.realization import realization_support_diagnostics +from .semantics.realization import realization_disclosure, realization_support_diagnostics + +__all__ = ["plan", "realization_disclosure", "snapshot_delete_order"] def _planned_resource(address: str, domain: RuntimeDomain, resource_type: str, resource) -> PlannedResource: diff --git a/implementations/python/packages/aces_processor/semantics/realization.py b/implementations/python/packages/aces_processor/semantics/realization.py index 002c2fa58..363067bc8 100644 --- a/implementations/python/packages/aces_processor/semantics/realization.py +++ b/implementations/python/packages/aces_processor/semantics/realization.py @@ -24,7 +24,7 @@ from aces_backend_protocols.capabilities import BackendManifest from aces_contracts.diagnostics import Diagnostic, Severity -from aces_contracts.planning import ChangeAction, ProvisioningPlan +from aces_contracts.planning import ChangeAction, ProvisioningPlan, ProvisionOp from aces_contracts.runtime_state import RealizationProvenanceEntry, RuntimeSnapshot from aces_sdl.explicitness import ExplicitnessClass, ExplicitnessProvenance @@ -211,46 +211,70 @@ def realization_disclosure( provenance: list[RealizationProvenanceEntry] = [] declared_ops = {op.address: op for op in declared_plan.operations} for requirement in requirements: - path = CONCERN_PAYLOAD_PATH.get(requirement.requirement_kind) - if path is None: - continue - op = declared_ops.get(requirement.address) - if op is None or op.action is ChangeAction.DELETE: - # No realization is owed: the plan declares no op for this resource, - # or it removes it (expected absence). Not a backend contract fault. - continue - declared_value = _concern_value(op.payload, path) - if declared_value is _MISSING_CONCERN_VALUE: - # The plan op carries no value for this concern, so there is no author - # baseline to enforce (an upstream processor invariant, not a backend - # contract). Nothing to gate or disclose. - continue - entry = returned_snapshot.entries.get(requirement.address) - realized_value = _concern_value(entry.payload, path) if entry is not None else _MISSING_CONCERN_VALUE - honoured = realized_value == declared_value - if requirement.explicitness is ExplicitnessClass.EXACT and not honoured: - # The backend realized the exact concern with a different value or - # omitted it entirely; both are forbidden silent approximation (I2). - diagnostics.append(_silent_approximation_diagnostic(requirement)) - continue - if realized_value is _MISSING_CONCERN_VALUE: - # A non-exact concern the backend left unrealized: nothing to disclose. - continue - provenance.append( - RealizationProvenanceEntry( - address=requirement.address, - field_path=requirement.field_path, - domain=requirement.domain, - requirement_kind=requirement.requirement_kind, - explicitness=requirement.explicitness, - provenance=( - ExplicitnessProvenance.AUTHOR_DECLARED if honoured else ExplicitnessProvenance.BACKEND_REALIZED - ), - ) - ) + diagnostic, entry = _evaluate_realization(requirement, declared_ops, returned_snapshot) + if diagnostic is not None: + diagnostics.append(diagnostic) + if entry is not None: + provenance.append(entry) return diagnostics, tuple(provenance) +def _evaluate_realization( + requirement: CompiledRealizationRequirement, + declared_ops: dict[str, ProvisionOp], + returned_snapshot: RuntimeSnapshot, +) -> tuple[Diagnostic | None, RealizationProvenanceEntry | None]: + """Gate one compiled requirement against its realized value. + + Returns ``(diagnostic, entry)`` where at most one is non-None: a diagnostic + for an exact requirement the backend realized dishonestly, or a provenance + entry for a located realized concern. Both are None when there is no author + baseline to enforce (no plan op / a ``DELETE`` op / no declared value) or + when a non-exact concern was left unrealized. + """ + + path = CONCERN_PAYLOAD_PATH.get(requirement.requirement_kind) + if path is None: + return None, None + op = declared_ops.get(requirement.address) + # No realization is owed when the plan declares no op for this resource or + # removes it (a DELETE op — expected absence); neither is a backend fault. + if op is None or op.action is ChangeAction.DELETE: + return None, None + declared_value = _concern_value(op.payload, path) + if declared_value is _MISSING_CONCERN_VALUE: + # The plan op carries no value for this concern: no author baseline to + # enforce (an upstream processor invariant, not a backend contract). + return None, None + snapshot_entry = returned_snapshot.entries.get(requirement.address) + realized_value = ( + _concern_value(snapshot_entry.payload, path) if snapshot_entry is not None else _MISSING_CONCERN_VALUE + ) + honoured = realized_value == declared_value + if requirement.explicitness is ExplicitnessClass.EXACT and not honoured: + # The backend realized the exact concern with a different value or omitted + # it entirely; both are forbidden silent approximation (I2). + return _silent_approximation_diagnostic(requirement), None + if realized_value is _MISSING_CONCERN_VALUE: + # A non-exact concern the backend left unrealized: nothing to disclose. + return None, None + return None, _realization_provenance_entry(requirement, honoured) + + +def _realization_provenance_entry( + requirement: CompiledRealizationRequirement, + honoured: bool, +) -> RealizationProvenanceEntry: + return RealizationProvenanceEntry( + address=requirement.address, + field_path=requirement.field_path, + domain=requirement.domain, + requirement_kind=requirement.requirement_kind, + explicitness=requirement.explicitness, + provenance=(ExplicitnessProvenance.AUTHOR_DECLARED if honoured else ExplicitnessProvenance.BACKEND_REALIZED), + ) + + def _silent_approximation_diagnostic(requirement: CompiledRealizationRequirement) -> Diagnostic: return Diagnostic( code=_BACKEND_CONTRACT_INVALID, diff --git a/implementations/python/packages/aces_runtime/backend_calls.py b/implementations/python/packages/aces_runtime/backend_calls.py index d402c28f5..94e339e8f 100644 --- a/implementations/python/packages/aces_runtime/backend_calls.py +++ b/implementations/python/packages/aces_runtime/backend_calls.py @@ -57,31 +57,52 @@ def _call_backend_apply( try: result = method(*backend_args) except Exception as exc: - apply_result = _failed_apply_result(baseline_snapshot, _backend_call_failed(address, exc)) - else: - invalid_message = _apply_result_contract_violation(result, address) - if invalid_message is not None: - apply_result = _failed_apply_result(baseline_snapshot, _backend_contract_invalid(address, invalid_message)) - else: - assert isinstance(result, ApplyResult) - contract_diagnostics = _snapshot_contract_diagnostics(result.snapshot) - if not contract_diagnostics: - contract_diagnostics = _snapshot_transition_contract_diagnostics(baseline_snapshot, result.snapshot) - realization_provenance: tuple[RealizationProvenanceEntry, ...] = () - if not contract_diagnostics and realization_requirements and realization_plan is not None: - # SEM-218 I2 non-approximation gate + I5 provenance disclosure. - contract_diagnostics, realization_provenance = realization_disclosure( - realization_requirements, - realization_plan, - result.snapshot, - ) - if contract_diagnostics: - apply_result = ApplyResult(success=False, snapshot=baseline_snapshot, diagnostics=contract_diagnostics) - elif realization_provenance: - apply_result = _with_realization_provenance(result, realization_provenance) - else: - apply_result = result - return apply_result + return _failed_apply_result(baseline_snapshot, _backend_call_failed(address, exc)) + return _finalize_backend_apply( + result, + address=address, + baseline_snapshot=baseline_snapshot, + realization_requirements=realization_requirements, + realization_plan=realization_plan, + ) + + +def _finalize_backend_apply( + result: object, + *, + address: str, + baseline_snapshot: RuntimeSnapshot, + realization_requirements: tuple[CompiledRealizationRequirement, ...], + realization_plan: ProvisioningPlan | None, +) -> ApplyResult: + """Validate a backend's apply result and gate its realized snapshot. + + Rejects (returning the baseline snapshot, ``success=False``) on a malformed + result, a snapshot-contract violation, or a SEM-218 non-approximation + violation; otherwise returns the backend result, augmented with the + realization-provenance ledger when the gate disclosed one. + """ + + invalid_message = _apply_result_contract_violation(result, address) + if invalid_message is not None: + return _failed_apply_result(baseline_snapshot, _backend_contract_invalid(address, invalid_message)) + assert isinstance(result, ApplyResult) + contract_diagnostics = _snapshot_contract_diagnostics(result.snapshot) + if not contract_diagnostics: + contract_diagnostics = _snapshot_transition_contract_diagnostics(baseline_snapshot, result.snapshot) + realization_provenance: tuple[RealizationProvenanceEntry, ...] = () + if not contract_diagnostics and realization_requirements and realization_plan is not None: + # SEM-218 I2 non-approximation gate + I5 provenance disclosure. + contract_diagnostics, realization_provenance = realization_disclosure( + realization_requirements, + realization_plan, + result.snapshot, + ) + if contract_diagnostics: + return ApplyResult(success=False, snapshot=baseline_snapshot, diagnostics=contract_diagnostics) + if realization_provenance: + return _with_realization_provenance(result, realization_provenance) + return result def _with_realization_provenance( From e05f4d0dd411652ec31cba379d5a9d29a810ba5f Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 04:17:38 +0200 Subject: [PATCH 60/70] Fix SonarCloud findings (cycle 2) --- .../aces_processor/semantics/realization.py | 22 ++++++++++--------- .../packages/aces_runtime/backend_calls.py | 4 +--- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/implementations/python/packages/aces_processor/semantics/realization.py b/implementations/python/packages/aces_processor/semantics/realization.py index 363067bc8..9905c78d8 100644 --- a/implementations/python/packages/aces_processor/semantics/realization.py +++ b/implementations/python/packages/aces_processor/semantics/realization.py @@ -234,12 +234,11 @@ def _evaluate_realization( """ path = CONCERN_PAYLOAD_PATH.get(requirement.requirement_kind) - if path is None: - return None, None op = declared_ops.get(requirement.address) - # No realization is owed when the plan declares no op for this resource or - # removes it (a DELETE op — expected absence); neither is a backend fault. - if op is None or op.action is ChangeAction.DELETE: + # No realization is owed when the concern is unmapped, the plan declares no op + # for this resource, or it removes it (a DELETE op — expected absence); none is + # a backend fault, so there is no baseline to gate or disclose. + if path is None or op is None or op.action is ChangeAction.DELETE: return None, None declared_value = _concern_value(op.payload, path) if declared_value is _MISSING_CONCERN_VALUE: @@ -251,14 +250,17 @@ def _evaluate_realization( _concern_value(snapshot_entry.payload, path) if snapshot_entry is not None else _MISSING_CONCERN_VALUE ) honoured = realized_value == declared_value + diagnostic: Diagnostic | None = None + entry: RealizationProvenanceEntry | None = None if requirement.explicitness is ExplicitnessClass.EXACT and not honoured: # The backend realized the exact concern with a different value or omitted # it entirely; both are forbidden silent approximation (I2). - return _silent_approximation_diagnostic(requirement), None - if realized_value is _MISSING_CONCERN_VALUE: - # A non-exact concern the backend left unrealized: nothing to disclose. - return None, None - return None, _realization_provenance_entry(requirement, honoured) + diagnostic = _silent_approximation_diagnostic(requirement) + elif realized_value is not _MISSING_CONCERN_VALUE: + # A located realized concern: disclose its provenance. (A non-exact concern + # the backend left unrealized falls through with nothing to disclose.) + entry = _realization_provenance_entry(requirement, honoured) + return diagnostic, entry def _realization_provenance_entry( diff --git a/implementations/python/packages/aces_runtime/backend_calls.py b/implementations/python/packages/aces_runtime/backend_calls.py index 94e339e8f..dab874216 100644 --- a/implementations/python/packages/aces_runtime/backend_calls.py +++ b/implementations/python/packages/aces_runtime/backend_calls.py @@ -100,9 +100,7 @@ def _finalize_backend_apply( ) if contract_diagnostics: return ApplyResult(success=False, snapshot=baseline_snapshot, diagnostics=contract_diagnostics) - if realization_provenance: - return _with_realization_provenance(result, realization_provenance) - return result + return _with_realization_provenance(result, realization_provenance) if realization_provenance else result def _with_realization_provenance( From 78486335adcdaec8f744e9862885f86eee249cea Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 04:31:45 +0200 Subject: [PATCH 61/70] Tighten citation hygiene across SDL lineage and precedent docs - Expand the Russo/Costa/Armando bare-DOI citation to a full inline reference (LIT-2) - Snapshot the Garg et al. Zotero-only preprint citation into a tracked docs/research/primary/ page and link it from lineage.md (LIT-3) - Relabel the SCN-010 'adversarial challenge panel' as architect-guided adversarial self-review and reconcile the peer-review overclaims (LIT-5) - Add a Syntax/Semantics/Both ('Borrowed') column to the design-precedent tables (LIT-6) - Sharpen the OpenC2 boundary: command/response principle borrowed, payload/target structures not adopted (LIT-7) - Add a research-corpus and citation-verification-scope note to limitations.md (LIT-8) --- changelog.d/509.fixed.md | 1 + .../scn010-expressivity-gap-analysis.md | 19 +- docs/explain/sdl/limitations.md | 27 +++ docs/explain/sdl/lineage.md | 19 +- docs/explain/sdl/precedents.md | 210 +++++++++--------- docs/index.md | 1 + docs/research/primary/index.md | 19 ++ .../literature/cyber-range-scenario-survey.md | 42 ++++ 8 files changed, 227 insertions(+), 111 deletions(-) create mode 100644 changelog.d/509.fixed.md create mode 100644 docs/research/primary/index.md create mode 100644 docs/research/primary/literature/cyber-range-scenario-survey.md diff --git a/changelog.d/509.fixed.md b/changelog.d/509.fixed.md new file mode 100644 index 000000000..2e1c14a53 --- /dev/null +++ b/changelog.d/509.fixed.md @@ -0,0 +1 @@ +Tightened citation hygiene across the SDL lineage and precedent documentation: expanded a bare-DOI citation to a full inline author-title-venue reference, snapshotted a Zotero-only preprint citation into a repo-tracked `docs/research/primary/` page so it is verifiable from the repository alone, relabelled the SCN-010 expressivity gap analysis's review process honestly (architect-guided adversarial self-review rather than an external peer-review panel), added a Syntax/Semantics/Both ("Borrowed") column to the design-precedent tables, sharpened the OpenC2 lineage boundary (command/response principle borrowed, payload/target structures not adopted), and added a research-corpus and citation-verification-scope note to the SDL limitations. diff --git a/docs/aces/inventory/scn010-expressivity-gap-analysis.md b/docs/aces/inventory/scn010-expressivity-gap-analysis.md index 1e6a3c1c7..e477c586b 100644 --- a/docs/aces/inventory/scn010-expressivity-gap-analysis.md +++ b/docs/aces/inventory/scn010-expressivity-gap-analysis.md @@ -1,4 +1,4 @@ -# SCN-010 Expressivity Gap Analysis — Final Peer-Review Report +# SCN-010 Expressivity Gap Analysis — Final Architect-Review Report ## ACES SDL runtime surface coverage for the 16 remaining APTL containers --- @@ -32,7 +32,18 @@ The reference exemplar is `aptl/docs/aces/inventory/wazuh.manager/` (README + ma ### 2.3 Derivation and adversarial filtering -Gaps were derived per-container by enumerating defining logical state (the `defining_state` corpus), testing each fact against the *closest* existing runtime surface field-by-field, and recording inadequacy only where the existing surface's **shape** cannot carry the fact (not merely "no instance authored yet"). Every candidate was then put through an adversarial challenge panel that asked six falsifiable questions: *is it a real gap; is it expressible today (cite the exact field that would carry it); is it a local optimization; is the grounding primary-literature-sufficient; does it meet parity depth; and what required revisions or rejections follow.* The verdicts are incorporated below: where a panel said `confirm`, the surface stands; where `revise`, the required revisions are applied (e.g. `data_model` and `agent_kind` reclassified OPEN; `scheduled_jobs` hollowed to cadence-only; the `event` schedule member dropped; `owner_kind` collapsed into a derived tier); where a candidate would have been expressible today, it is recorded as a **rejected candidate** in §4.6 to strengthen credibility. +Gaps were derived per-container by enumerating defining logical state (the `defining_state` corpus), testing each fact against the *closest* existing runtime surface field-by-field, and recording inadequacy only where the existing surface's **shape** cannot carry the fact (not merely "no instance authored yet"). Every candidate was then put through an adversarial challenge rubric of six falsifiable questions: *is it a real gap; is it expressible today (cite the exact field that would carry it); is it a local optimization; is the grounding primary-literature-sufficient; does it meet parity depth; and what required revisions or rejections follow.* The verdicts are incorporated below: where the rubric returned `confirm`, the surface stands; where `revise`, the required revisions are applied (e.g. `data_model` and `agent_kind` reclassified OPEN; `scheduled_jobs` hollowed to cadence-only; the `event` schedule member dropped; `owner_kind` collapsed into a derived tier); where a candidate would have been expressible today, it is recorded as a **rejected candidate** in §4.6 to strengthen credibility. + +**Review model and its limits.** The "adversarial challenge" above is an +architect-guided adversarial self-review, not an external review panel. A single +author (the architect) applied the fixed six-question rubric to each candidate +gap and recorded a `confirm`, `revise`, or `reject` verdict; `revise` verdicts +were reconciled by applying the stated revision inline, and `reject` verdicts are +listed as rejected candidates in §4.6. There were no independent external +reviewers — "rubric" and "verdict" name the self-applied questions and their +outcomes, not the judgement of a separate body. This report is therefore +architect self-review held to a peer-review-grade standard; it has not been +independently peer reviewed. All load-bearing code claims in this report were re-verified against the live tree: - `DatabaseObjectType` is closed to `database | schema | table` (`runtime_database_vocab.py:89–94` — even narrower than the gap text's "view" claim). @@ -124,7 +135,7 @@ Each confirmed/revised gap below states what cannot be typed, why the closest ex ### 4.6 Rejected candidate-gaps (expressible today — confirmation-folds) -Recording these strengthens peer-review credibility by proving the parity lens cuts **against over-building** too: +Recording these strengthens the analysis's credibility by proving the parity lens cuts **against over-building** too: - **Suricata standalone IDS** — *expressible today* via `network_detection_engines` (`RuntimeNetworkDetectionRuleSource.generated_by` + `THREAT_INTEL` kind at `runtime_network_detection.py:89,215`, output_streams `eve_json`, control_channels `rule_reload`, network_sets) + `network_sensors` (capture posture). Its defining state *is* detection-engine state, typed field-for-field. **Not a gap.** - **`misp-db`** — *expressible today* via `database_services`: `DatabaseEngine.MARIADB → DatabaseProtocol.MYSQL` (`runtime_database_vocab.py:28,123`), Database/Schema/Table/Role/Grant/Settings tree fits a genuine relational engine (the exact opposite of the Redis-as-relational mismatch). **Not a gap** — relational→datastore fold deferred as named coherence debt. @@ -233,7 +244,7 @@ After this work the entire runtime surface (old + new) is describable by **one s 8. **Registry.** Every family registers through `_runtime_service_families.py` with `collection_name`/`id_field`/`child_refs`; export-name uniqueness holds. 9. **Decomposition.** Same fact-shape → one guarded family; different shape → peer family; orthogonal recurring fact → referenced shared primitive; inter-node detail → relationship subtype; matching existing shape → named fold. -This survives expert review because the cohesion is **structural and executable, not aspirational**: ~10 candidate one-off families collapse to 2 guarded spines + 3 primitives + 1 forwarder family + 3 relationship subtypes; the highest-recurrence gap (app-internal RBAC, 8+ sites) is typed once; the `require_profile_for_` guards make over-abstraction *fail* (Redis-as-relational, MISP-as-counts, SOAR-as-object-list cannot pass); the named folds prove the gate is not over-applied; and the `#442` narrative is corrected to the verifiable codebase reality. The lint is the peer-review-survival proof made testable. +This is built to survive expert review because the cohesion is **structural and executable, not aspirational**: ~10 candidate one-off families collapse to 2 guarded spines + 3 primitives + 1 forwarder family + 3 relationship subtypes; the highest-recurrence gap (app-internal RBAC, 8+ sites) is typed once; the `require_profile_for_` guards make over-abstraction *fail* (Redis-as-relational, MISP-as-counts, SOAR-as-object-list cannot pass); the named folds prove the gate is not over-applied; and the `#442` narrative is corrected to the verifiable codebase reality. The lint makes that cohesion claim testable rather than asserted. --- diff --git a/docs/explain/sdl/limitations.md b/docs/explain/sdl/limitations.md index 9533456ee..a3c711516 100644 --- a/docs/explain/sdl/limitations.md +++ b/docs/explain/sdl/limitations.md @@ -201,3 +201,30 @@ ACES mirrors full BIND, CoreDNS, PowerDNS, NSD, Knot, provider API, passive DNS, or telemetry schemas. Property-based fuzz testing (Hypothesis) has run 1,050+ random inputs through the parser with zero unhandled crashes. + +## Research Corpus and Verification Scope + +The SDL lineage and precedent documents cite external work at two distinct +evidence levels, and the distinction is load-bearing: + +- **Primary-source-verified** — standards, specifications, peer-reviewed papers, + and technical reports cited by a resolvable DOI or maintainer URL that a + reader can check directly. These carry lineage and terminology weight. +- **Secondary or current-research** — preprints, surveys-in-progress, vendor + pages, and project manuals. These may explain terminology or motivate a + concern but are **not** treated as settled normative authority, and they are + labelled as such at the point of citation (for example, the TechRxiv + cyber-range scenario survey cited in [lineage.md](lineage.md) is identified as + a preprint). + +The project's working citation corpus lives in a local, gitignored `research/` +tree — the non-normative `research_notes` root in +[`authority-boundary.yaml`](../../../specs/authority/authority-boundary.yaml) — +so it is not part of the published repository. References that would otherwise +be verifiable only from that private corpus or a private Zotero library are +snapshotted as repo-tracked citation metadata under +[`docs/research/primary/`](../../research/primary/index.md), so every cited +claim is checkable from the repository alone. The experiment-core research log +applies the same discipline — published sources only, preprints excluded as +primary evidence (see its "Source Rule" in +[`2026-05-26-search-log.md`](../../research/experiment-core/2026-05-26-search-log.md)). diff --git a/docs/explain/sdl/lineage.md b/docs/explain/sdl/lineage.md index 041ca233e..3e28f7625 100644 --- a/docs/explain/sdl/lineage.md +++ b/docs/explain/sdl/lineage.md @@ -99,7 +99,10 @@ Russo/Costa/Armando, Swiler, Oberkampf/Roy, and Sargent are citable proceedings, technical-report, or book sources; Garg et al. is used as a current survey preprint rather than as settled normative authority. The working Zotero library tracks these identity-authority references under `aces-sdl-identity-authority` -and the adjacent V&V subset under `adjacent-vv-lineage`. +and the adjacent V&V subset under `adjacent-vv-lineage`; because that library is +private, the Garg et al. preprint citation is also snapshotted in-repo under +[`docs/research/primary/`](../../research/primary/literature/cyber-range-scenario-survey.md) +so the reference is verifiable from the repository alone. The design deliberately keeps provider-stable identifiers as data rather than as ACES reference identity. AD SIDs/objectGUIDs, LDAP DNs/entryUUIDs, SCIM @@ -109,10 +112,12 @@ the portable ACES references are stable `*_id` symbols scoped by the scenario and authority. Within one authority those ids share a single local namespace, so an id cannot be reused across service, subject, policy, relationship, and authority records. This matches the verification/validation posture in the -cyber-range literature (for example Russo/Costa/Armando on -[scenario validation](https://doi.org/10.1109/NCA.2018.8548324), Garg et al. -on the TechRxiv preprint -[scenario-design/execution survey](https://doi.org/10.36227/techrxiv.175942879.94813577/v1), +cyber-range literature (for example Russo, Costa, and Armando's +[Scenario Design and Validation for Next Generation Cyber Ranges](https://doi.org/10.1109/NCA.2018.8548324) +(IEEE NCA 2018), and Garg, Boualouache, Imeri, and Roth's +[A Survey of Cyber Range Training Exercise Scenario Description, Generation, and Execution](https://doi.org/10.36227/techrxiv.175942879.94813577/v1) +(TechRxiv preprint, 2025 — snapshotted in-repo at +[docs/research/primary](../../research/primary/literature/cyber-range-scenario-survey.md)), and Swiler plus Oberkampf/Roy/Sargent on [cyber-emulation V&V](https://doi.org/10.2172/1897016), [scientific-computing V&V](https://doi.org/10.1017/CBO9780511760396), and @@ -815,6 +820,10 @@ which dynamic queue/log/config details remain evidence or bounded settings. MITRE CALDERA, Atomic Red Team, and OpenC2 are adversary-emulation and command/response precedents. ACES treats them as behavior and execution sources that scenarios may bind to, not as replacements for the SDL. + From OpenC2 specifically, ACES borrows the command/response principle — an + action requested against a target with a status-bearing response — but does + not adopt OpenC2's action/target/argument payload structures as SDL or + runtime-contract schema. - OCSF is the preferred lineage for normalized security event and finding structure. ACES uses that style for observations and evidence without making raw telemetry equal to participant-visible state. diff --git a/docs/explain/sdl/precedents.md b/docs/explain/sdl/precedents.md index b539e490a..6388af5c4 100644 --- a/docs/explain/sdl/precedents.md +++ b/docs/explain/sdl/precedents.md @@ -18,25 +18,31 @@ The SDL does not borrow every concern from the same place. In practice: So the source tables below explain both where a section's author-facing shape comes from and, where needed, which systems inform its execution semantics. +Each source table adds a **Borrowed** column marking whether ACES took the +source's *syntax* (the author-facing shape, keys, or structure), its *semantics* +(the meaning or runtime behavior), or *both*. Where a row is ambiguous, the cell +names which part was adopted and which was not. The "Deliberate Omissions" table +is not a borrowing table and carries no such column. + ## Core Structure (from Open Cyber Range SDL) The 14 base sections start from the [OCR SDL](https://github.com/Open-Cyber-Range/SDL-parser) v0.21.2 surface and are adapted into Python/Pydantic. This repository aims for coverage parity across the adopted OCR concepts while remaining its own SDL; when behavior diverges or OCR's own sources disagree, this document states repository behavior explicitly instead of making clone-level compatibility claims. The OCR SDL was developed by the Norwegian Cyber Range (CR14/NTNU). -| SDL Element | OCR Source | Changes | -| -------------------------- | ------------------------- | --------------------------------------------------- | -| Scenario | `Scenario` struct | Added SDL extension fields | -| Node (VM/Switch) | `Node`, `VM`, `Switch` | Added `os`, `os_version`, `services`, `asset_value` | -| Resources | `Resources` | Human-readable RAM parsing via Python | -| Role | `Role` | Direct port | -| InfraNode | `InfraNode` | Added `acls`, `internal` flag | -| Feature | `Feature` | Direct port | -| Condition | `Condition` | Added `timeout`, `retries`, `start_period` | -| Vulnerability | `Vulnerability` | Direct port | -| Metric/Evaluation/TLO/Goal | OCR scoring pipeline | Direct port | -| Entity | `Entity` + OCR entity surface | Direct port, including OCR fact maps | -| Inject/Event/Script/Story | OCR orchestration | Direct port | -| Source | `Source` (name + version) | Made provider-neutral | +| SDL Element | OCR Source | Borrowed | Changes | +| -------------------------- | ------------------------- | --- | --------------------------------------------------- | +| Scenario | `Scenario` struct | Syntax | Added SDL extension fields | +| Node (VM/Switch) | `Node`, `VM`, `Switch` | Syntax | Added `os`, `os_version`, `services`, `asset_value` | +| Resources | `Resources` | Syntax | Human-readable RAM parsing via Python | +| Role | `Role` | Syntax | Direct port | +| InfraNode | `InfraNode` | Syntax | Added `acls`, `internal` flag | +| Feature | `Feature` | Syntax | Direct port | +| Condition | `Condition` | Both | Added `timeout`, `retries`, `start_period` | +| Vulnerability | `Vulnerability` | Syntax | Direct port | +| Metric/Evaluation/TLO/Goal | OCR scoring pipeline | Both | Direct port | +| Entity | `Entity` + OCR entity surface | Both | Direct port, including OCR fact maps | +| Inject/Event/Script/Story | OCR orchestration | Both | Direct port | +| Source | `Source` (name + version) | Syntax | Made provider-neutral | ## Extensions by Source @@ -44,14 +50,14 @@ The 14 base sections start from the [OCR SDL](https://github.com/Open-Cyber-Rang ### From CybORG CAGE Challenge -| SDL Element | CybORG Source | What We Adapted | -| ----------------------- | ------------------------------------------- | --------------------------------------------- | -| `Agent` | `Agents:` section (Scenario YAML) | Actions, starting sessions, reward calculator | -| `InitialKnowledge` | `INT:` (Initial Network Topology) | Known hosts and subnets at start | -| `Agent.allowed_subnets` | `AllowedSubnets:` | Network scope constraints | -| `AssetValue` | `ConfidentialityValue`, `AvailabilityValue` | Extended to CIA triad | -| `ACLRule` | `Subnets.NACLs` | Simplified from nested dict to flat rule list | -| `Objective.agent/actions` | Agent identity + action space | Objective actor binding and optional action subset validation | +| SDL Element | CybORG Source | Borrowed | What We Adapted | +| ----------------------- | ------------------------------------------- | --- | --------------------------------------------- | +| `Agent` | `Agents:` section (Scenario YAML) | Both | Actions, starting sessions, reward calculator | +| `InitialKnowledge` | `INT:` (Initial Network Topology) | Semantics | Known hosts and subnets at start | +| `Agent.allowed_subnets` | `AllowedSubnets:` | Both | Network scope constraints | +| `AssetValue` | `ConfidentialityValue`, `AvailabilityValue` | Semantics | Extended to CIA triad | +| `ACLRule` | `Subnets.NACLs` | Both | Simplified from nested dict to flat rule list | +| `Objective.agent/actions` | Agent identity + action space | Semantics | Objective actor binding and optional action subset validation | ### From Newer Participant And Benchmark Ecosystems @@ -61,14 +67,14 @@ ecosystem surfaces. In many cases they are precedents for concerns the requirements recognize even when the current SDL syntax does not expose the full shape directly. -| Concern | Primary Sources | What We Adapted | -| ------- | --------------- | --------------- | -| Participant decision surfaces and role-scoped observations | OpenRange episode/runtime model | Participant-visible decision context is treated as a first-class concern distinct from hidden truth assets and internal apparatus state | -| Control-context assets (instructions, directives, policies) | OpenRange prompt modes, agent-oriented benchmark/task systems | Execution-guiding context is modeled as a participant concern without binding the ecosystem to one prompting or policy framework | -| Trajectories, replay assets, and demonstration corpora | OpenRange training data, Open Thoughts Agent, Open Trajectory Gym | Stepwise participant interaction records are first-class experiment artifacts rather than incidental logs | -| Benchmark protocols, judges, verifiers, and rewards | OpenBench, Open Trajectory Gym, agent benchmark systems | Tasks, protocols, and evaluation components are treated as distinct experiment objects rather than hidden harness details | -| Hidden truth assets and adjudication surfaces | OpenRange private references, benchmark hidden tests/gold standards | Public task statements are kept distinct from hidden benchmark assets and adjudication material | -| Swappable participant implementations | Agent benchmark stacks, provider/model-selectable eval systems | Concrete agent/policy/script/human-control implementations are treated as apparatus surfaces distinct from SDL roles, processors, and backends | +| Concern | Primary Sources | Borrowed | What We Adapted | +| ------- | --------------- | --- | --------------- | +| Participant decision surfaces and role-scoped observations | OpenRange episode/runtime model | Semantics | Participant-visible decision context is treated as a first-class concern distinct from hidden truth assets and internal apparatus state | +| Control-context assets (instructions, directives, policies) | OpenRange prompt modes, agent-oriented benchmark/task systems | Semantics | Execution-guiding context is modeled as a participant concern without binding the ecosystem to one prompting or policy framework | +| Trajectories, replay assets, and demonstration corpora | OpenRange training data, Open Thoughts Agent, Open Trajectory Gym | Semantics | Stepwise participant interaction records are first-class experiment artifacts rather than incidental logs | +| Benchmark protocols, judges, verifiers, and rewards | OpenBench, Open Trajectory Gym, agent benchmark systems | Semantics | Tasks, protocols, and evaluation components are treated as distinct experiment objects rather than hidden harness details | +| Hidden truth assets and adjudication surfaces | OpenRange private references, benchmark hidden tests/gold standards | Semantics | Public task statements are kept distinct from hidden benchmark assets and adjudication material | +| Swappable participant implementations | Agent benchmark stacks, provider/model-selectable eval systems | Semantics | Concrete agent/policy/script/human-control implementations are treated as apparatus surfaces distinct from SDL roles, processors, and backends | ### Participant Semantics Theory @@ -77,19 +83,19 @@ Issue #71 adds a formal participant-semantics design in `specs/formal/participant-semantics/` and ADR-022. These precedents inform that design without becoming the ACES runtime API or authoring syntax. -| Concern | Primary Sources | What We Adapted | -| ------- | --------------- | --------------- | -| Single-agent episode interface | [OpenAI Gym](https://arxiv.org/abs/1606.01540), [Gymnasium](https://arxiv.org/abs/2407.17032) | Actions, observations, rewards, reset, termination, and truncation are explicit semantic concepts rather than incidental adapter methods | -| Multi-agent environment ordering | [PettingZoo](https://arxiv.org/abs/2009.14471), [OpenSpiel](https://arxiv.org/abs/1908.09453), Markov-game literature | Per-participant histories and information structure are first-class; joint behavior is not collapsed into one global action stream | -| Partial observability and local history | POMDP, Dec-POMDP, POSG, and imperfect-information game literature | Participant-visible state is a projection with history, uncertainty, latency, and disclosure basis, not world truth | -| Cyber-specific action/observation discipline | [CybORG](https://arxiv.org/abs/2108.09118), [CyberBattleSim](https://www.microsoft.com/en-us/research/project/cyberbattlesim/), [CyGIL](https://arxiv.org/abs/2304.01244) | Action/effect/observation semantics must disclose simulation, emulation, and realization assumptions instead of assuming transfer across fidelity modes | -| Adversary behavior under uncertainty | [CALDERA planning and acting](https://www.mitre.org/sites/default/files/2021-11/prs-18-0944-1-automated-adversary-emulation-planning-acting.pdf), [MITRE ATT&CK design](https://www.mitre.org/news-insights/publication/mitre-attck-design-and-philosophy) | Cyber actions may change foothold, knowledge, detection surface, and downstream outcomes; technique labels do not replace action contracts | -| Causality and temporal ordering | [Lamport ordering](https://systems.cs.columbia.edu/ds2-class/papers/lamport-time.pdf), [Halpern-Pearl structural causality](https://arxiv.org/abs/cs/0011012), HLA time management | Attribution edges require explicit ordering and evidence support; timestamp adjacency is not a causal claim | -| Checkable scenario semantics | [VSDL](https://arxiv.org/abs/2001.06681), [CRACK](https://iris.imtlucca.it/handle/20.500.11771/15672), [CyRIS](https://www.jaist.ac.jp/~razvan/publications/cyris_facilitating_training.pdf) | Participant semantics inherit the requirement for executable contracts and conformance tests, while staying separate from topology/deployment generation | -| Agent benchmark task structure | [Cybench](https://arxiv.org/abs/2408.08926), [AutoPenBench](https://arxiv.org/abs/2410.03225) | Task descriptions, starter files, evaluators, subtasks, gold steps, and milestones are treated as participant-view and outcome-interpretation inputs rather than hidden harness details | -| Integrated adversarial evaluation | [CAIBench](https://arxiv.org/abs/2510.24317) | Offensive, defensive, privacy, and cyber-physical capabilities require role-neutral multi-participant semantics and outcome layers richer than final score | -| Benchmark validity and overfitting controls | [AI Agents That Matter](https://arxiv.org/abs/2407.01502), [Benchmarking Practices in LLM-driven Offensive Security](https://arxiv.org/abs/2504.10112) | Run/study provenance, holdout discipline, scaffold disclosure, hidden assets, baselines, and cost/resource traces are experimental-instrumentation concerns | -| DSL language adequacy and evaluation | [Do Software Languages Engineers Evaluate their Languages?](https://arxiv.org/abs/1109.6794), [When and How to Develop Domain-Specific Languages](https://doi.org/10.1145/1118890.1118892), [Domain-Specific Languages: A Systematic Mapping Study](https://doi.org/10.1016/j.infsof.2015.11.001) | Issue #346 treats expressiveness, usability, effectiveness, maintainability, ambiguity, and domain-expert reviewability as evidence-gated language claims | +| Concern | Primary Sources | Borrowed | What We Adapted | +| ------- | --------------- | --- | --------------- | +| Single-agent episode interface | [OpenAI Gym](https://arxiv.org/abs/1606.01540), [Gymnasium](https://arxiv.org/abs/2407.17032) | Semantics | Actions, observations, rewards, reset, termination, and truncation are explicit semantic concepts rather than incidental adapter methods | +| Multi-agent environment ordering | [PettingZoo](https://arxiv.org/abs/2009.14471), [OpenSpiel](https://arxiv.org/abs/1908.09453), Markov-game literature | Semantics | Per-participant histories and information structure are first-class; joint behavior is not collapsed into one global action stream | +| Partial observability and local history | POMDP, Dec-POMDP, POSG, and imperfect-information game literature | Semantics | Participant-visible state is a projection with history, uncertainty, latency, and disclosure basis, not world truth | +| Cyber-specific action/observation discipline | [CybORG](https://arxiv.org/abs/2108.09118), [CyberBattleSim](https://www.microsoft.com/en-us/research/project/cyberbattlesim/), [CyGIL](https://arxiv.org/abs/2304.01244) | Semantics | Action/effect/observation semantics must disclose simulation, emulation, and realization assumptions instead of assuming transfer across fidelity modes | +| Adversary behavior under uncertainty | [CALDERA planning and acting](https://www.mitre.org/sites/default/files/2021-11/prs-18-0944-1-automated-adversary-emulation-planning-acting.pdf), [MITRE ATT&CK design](https://www.mitre.org/news-insights/publication/mitre-attck-design-and-philosophy) | Semantics | Cyber actions may change foothold, knowledge, detection surface, and downstream outcomes; technique labels do not replace action contracts | +| Causality and temporal ordering | [Lamport ordering](https://systems.cs.columbia.edu/ds2-class/papers/lamport-time.pdf), [Halpern-Pearl structural causality](https://arxiv.org/abs/cs/0011012), HLA time management | Semantics | Attribution edges require explicit ordering and evidence support; timestamp adjacency is not a causal claim | +| Checkable scenario semantics | [VSDL](https://arxiv.org/abs/2001.06681), [CRACK](https://iris.imtlucca.it/handle/20.500.11771/15672), [CyRIS](https://www.jaist.ac.jp/~razvan/publications/cyris_facilitating_training.pdf) | Semantics | Participant semantics inherit the requirement for executable contracts and conformance tests, while staying separate from topology/deployment generation | +| Agent benchmark task structure | [Cybench](https://arxiv.org/abs/2408.08926), [AutoPenBench](https://arxiv.org/abs/2410.03225) | Semantics | Task descriptions, starter files, evaluators, subtasks, gold steps, and milestones are treated as participant-view and outcome-interpretation inputs rather than hidden harness details | +| Integrated adversarial evaluation | [CAIBench](https://arxiv.org/abs/2510.24317) | Semantics | Offensive, defensive, privacy, and cyber-physical capabilities require role-neutral multi-participant semantics and outcome layers richer than final score | +| Benchmark validity and overfitting controls | [AI Agents That Matter](https://arxiv.org/abs/2407.01502), [Benchmarking Practices in LLM-driven Offensive Security](https://arxiv.org/abs/2504.10112) | Semantics | Run/study provenance, holdout discipline, scaffold disclosure, hidden assets, baselines, and cost/resource traces are experimental-instrumentation concerns | +| DSL language adequacy and evaluation | [Do Software Languages Engineers Evaluate their Languages?](https://arxiv.org/abs/1109.6794), [When and How to Develop Domain-Specific Languages](https://doi.org/10.1145/1118890.1118892), [Domain-Specific Languages: A Systematic Mapping Study](https://doi.org/10.1016/j.infsof.2015.11.001) | Semantics | Issue #346 treats expressiveness, usability, effectiveness, maintainability, ambiguity, and domain-expert reviewability as evidence-gated language claims | For the `SEM-209` implementation slice, ACES represents framework-neutral joint-action declarations and realized-order provenance. PettingZoo/OpenSpiel @@ -101,10 +107,10 @@ shared-state references without making framework/tool APIs the SDL authority. ### From CyRIS -| SDL Element | CyRIS Source | What We Adapted | -| ----------- | ----------------------------------------- | ------------------------------------------------- | -| `Content` | `copy_content`, `emulate_traffic_capture` | Generalized to file/dataset/directory types | -| `Account` | `add_account`, `modify_account` | Preserved host account-placement lineage; ACES-specific account metadata such as groups, password strength, SPN, and auth method are extensions, not CyRIS-derived directory semantics | +| SDL Element | CyRIS Source | Borrowed | What We Adapted | +| ----------- | ----------------------------------------- | --- | ------------------------------------------------- | +| `Content` | `copy_content`, `emulate_traffic_capture` | Semantics | Generalized to file/dataset/directory types | +| `Account` | `add_account`, `modify_account` | Semantics | Preserved host account-placement lineage; ACES-specific account metadata such as groups, password strength, SPN, and auth method are extensions, not CyRIS-derived directory semantics | ### From Identity, Directory, And Access-Control Sources @@ -113,14 +119,14 @@ The `runtime.identity_authorities` surface is a neutral runtime inventory surface. It borrows concepts from standards and literature, but does not adopt one provider schema as the SDL schema. -| SDL Element | Source Class | What We Adapted | -| ----------- | ------------ | --------------- | -| `RuntimeIdentityAuthority` | LDAP/X.500 naming contexts, Kerberos realms, SAML/OIDC issuers, SCIM/IAM tenants, NIST SP 800-63C-4 federation guidance | An authority boundary with stable ACES id plus observed namespace facts such as domain, realm, issuer, tenant, and base DN; all authority-local stable ids share one namespace | -| `RuntimeIdentityService` | LDAP/Kerberos/SAML/OIDC/SCIM/IAM protocol endpoints and same-node `Node.services` transport bindings | Protocol/API endpoint inventory without treating the endpoint as the directory contents | -| `RuntimeIdentitySubject` | LDAP entries, SCIM Users/Groups, AD users/groups/computers/service principals, SAML/OIDC subjects/clients, IAM roles/applications | Identity-bearing subjects with stable ACES ids, observed names/principals, provider identifiers as data, and bounded attributes | -| `RuntimeIdentityPolicy` | NIST SP 800-162 ABAC, RBAC, group policy, Kerberos/domain policy, conditional-access/MFA policy concepts | Portable policy records with `applies_to_refs` rather than provider-specific policy-object cloning | -| `RuntimeIdentityRelationship` | Access matrix/RBAC relationship concepts, directory membership, trust/federation/delegation/sync/ownership relations, BloodHound/OpenGraph node-edge analysis | Typed local authority edges with stable ids, usable by top-level relationship/objective refs and later attack-graph translation | -| Attribute and setting value classification | OCSF/UCO sensitivity/evidence posture, repository runtime sensitivity vocabulary | Secret-bearing identity values are redacted/classified rather than copied into SDL fixtures or diagnostics | +| SDL Element | Source Class | Borrowed | What We Adapted | +| ----------- | ------------ | --- | --------------- | +| `RuntimeIdentityAuthority` | LDAP/X.500 naming contexts, Kerberos realms, SAML/OIDC issuers, SCIM/IAM tenants, NIST SP 800-63C-4 federation guidance | Semantics | An authority boundary with stable ACES id plus observed namespace facts such as domain, realm, issuer, tenant, and base DN; all authority-local stable ids share one namespace | +| `RuntimeIdentityService` | LDAP/Kerberos/SAML/OIDC/SCIM/IAM protocol endpoints and same-node `Node.services` transport bindings | Semantics | Protocol/API endpoint inventory without treating the endpoint as the directory contents | +| `RuntimeIdentitySubject` | LDAP entries, SCIM Users/Groups, AD users/groups/computers/service principals, SAML/OIDC subjects/clients, IAM roles/applications | Semantics | Identity-bearing subjects with stable ACES ids, observed names/principals, provider identifiers as data, and bounded attributes | +| `RuntimeIdentityPolicy` | NIST SP 800-162 ABAC, RBAC, group policy, Kerberos/domain policy, conditional-access/MFA policy concepts | Semantics | Portable policy records with `applies_to_refs` rather than provider-specific policy-object cloning | +| `RuntimeIdentityRelationship` | Access matrix/RBAC relationship concepts, directory membership, trust/federation/delegation/sync/ownership relations, BloodHound/OpenGraph node-edge analysis | Semantics | Typed local authority edges with stable ids, usable by top-level relationship/objective refs and later attack-graph translation | +| Attribute and setting value classification | OCSF/UCO sensitivity/evidence posture, repository runtime sensitivity vocabulary | Semantics | Secret-bearing identity values are redacted/classified rather than copied into SDL fixtures or diagnostics | This surface depends on industry standards for protocol/object terminology and on academic/security literature for the subject-policy-authority separation. @@ -133,13 +139,13 @@ counterpart: it models the in-app authorization store of search clusters, key-value stores, dashboards, and platforms, distinct from the wire-protocol directory above and from database engine GRANTs. -| SDL Element | Source Class | What We Adapted | -| ----------- | ------------ | --------------- | -| `RuntimeAppAuthorization` | Ferraiolo/Kuhn RBAC, Sandhu et al. RBAC96, ANSI INCITS 359 | An application-internal authorization store with a stable ACES id and an open `resource_vocabulary` spine discriminator; tier placement is derived from the referencing spine, not declared | -| `RuntimeAppAuthorizationPrincipal` | OpenSearch/Elasticsearch security users, Cassandra `system_auth`, Redis ACL users, dashboard/platform accounts | Users, service accounts, API keys, and backend roles with reserved/hidden flags and a `credential_classification` only — no raw bcrypt hash, API key, or password | -| `RuntimeAppAuthorizationGrant` | RBAC96 / ANSI INCITS 359 permission-assignment, NIST SP 800-162 ABAC resource-scoping | The defining resource-scoped grant: role reference → actions → resource patterns with an allow/deny effect and a `resource_kind` that is the single author-settable resource vocabulary | -| `RuntimeAppAuthorizationRoleMapping` | OpenSearch backend-role mappings, directory-to-local role bindings | Bindings of backend roles, users, or hosts onto a local role | -| `RuntimeAppAuthorizationTenant` | OpenSearch/Kibana tenants, platform namespace scopes | Namespace/tenancy scopes within the authorization store | +| SDL Element | Source Class | Borrowed | What We Adapted | +| ----------- | ------------ | --- | --------------- | +| `RuntimeAppAuthorization` | Ferraiolo/Kuhn RBAC, Sandhu et al. RBAC96, ANSI INCITS 359 | Semantics | An application-internal authorization store with a stable ACES id and an open `resource_vocabulary` spine discriminator; tier placement is derived from the referencing spine, not declared | +| `RuntimeAppAuthorizationPrincipal` | OpenSearch/Elasticsearch security users, Cassandra `system_auth`, Redis ACL users, dashboard/platform accounts | Semantics | Users, service accounts, API keys, and backend roles with reserved/hidden flags and a `credential_classification` only — no raw bcrypt hash, API key, or password | +| `RuntimeAppAuthorizationGrant` | RBAC96 / ANSI INCITS 359 permission-assignment, NIST SP 800-162 ABAC resource-scoping | Semantics | The defining resource-scoped grant: role reference → actions → resource patterns with an allow/deny effect and a `resource_kind` that is the single author-settable resource vocabulary | +| `RuntimeAppAuthorizationRoleMapping` | OpenSearch backend-role mappings, directory-to-local role bindings | Semantics | Bindings of backend roles, users, or hosts onto a local role | +| `RuntimeAppAuthorizationTenant` | OpenSearch/Kibana tenants, platform namespace scopes | Semantics | Namespace/tenancy scopes within the authorization store | This surface depends on the RBAC/ABAC standards for the role-permission-subject spine and on product RBAC implementations for recurring facts; it does not adopt @@ -149,22 +155,22 @@ any one product's security configuration as the canonical authored SDL shape. ### From STIX 2.1 -| SDL Element | STIX Source | What We Adapted | -| -------------------------- | --------------------------------------- | ------------------------------------------ | -| `Relationship` | Relationship SRO (typed directed edges) | Simplified to 7 relationship types | -| Cross-reference validation | STIX object referencing model | Source/target resolve to any named element | -| `RelationshipForwardingEdge` / `RelationshipServiceIntegration` / `RelationshipProxyUpstream` | Relationship SRO typed-detail pattern | Domain access detail on an edge (syslog enrollment per RFC 5424/5425, API auth per RFC 6749, reverse-proxy upstream per RFC 9110/7239) without re-typing referenced families | +| SDL Element | STIX Source | Borrowed | What We Adapted | +| -------------------------- | --------------------------------------- | --- | ------------------------------------------ | +| `Relationship` | Relationship SRO (typed directed edges) | Both | Simplified to 7 relationship types | +| Cross-reference validation | STIX object referencing model | Semantics | Source/target resolve to any named element | +| `RelationshipForwardingEdge` / `RelationshipServiceIntegration` / `RelationshipProxyUpstream` | Relationship SRO typed-detail pattern | Both | Domain access detail on an edge (syslog enrollment per RFC 5424/5425, API auth per RFC 6749, reverse-proxy upstream per RFC 9110/7239) without re-typing referenced families | ### From CACAO v2.0 -| SDL Element | CACAO Source | What We Adapted | -| --------------------- | ---------------------------------- | ---------------------------------------------------------------------- | -| `Variable` | `playbook_variables` | Types, defaults, allowed_values | -| `${var}` substitution | CACAO variable substitution syntax | Deferred to instantiation time | -| `Objective` | agent/target/workflow context | Declarative actor-target-window-success binding without runtime probes | -| `Workflow` | workflow-step graph patterns | Branching/parallel objective composition with SDL-only step types | +| SDL Element | CACAO Source | Borrowed | What We Adapted | +| --------------------- | ---------------------------------- | --- | ---------------------------------------------------------------------- | +| `Variable` | `playbook_variables` | Syntax | Types, defaults, allowed_values | +| `${var}` substitution | CACAO variable substitution syntax | Both (syntax borrowed; resolution semantics deferred to instantiation) | Deferred to instantiation time | +| `Objective` | agent/target/workflow context | Semantics | Declarative actor-target-window-success binding without runtime probes | +| `Workflow` | workflow-step graph patterns | Syntax (graph shape; execution semantics from the workflow-systems table) | Branching/parallel objective composition with SDL-only step types | ### Control-Flow Semantics from Mature Workflow Systems @@ -172,13 +178,13 @@ any one product's security configuration as the canonical authored SDL shape. These sources do not define the YAML keys directly, but they strongly inform how the runtime interprets workflow behavior after parsing. -| Concern | Primary Sources | What We Adapted | -| ------- | --------------- | --------------- | -| Conditional branching over declared predicates | AWS Step Functions `Choice`, CACAO conditional steps | Explicit decision nodes with typed predicate dependencies instead of backend-local branching rules | -| Parallel branch execution and convergence | AWS Step Functions `Parallel`, W3C SCXML `parallel`, Argo DAG fan-out/fan-in patterns | Parallel branches are explicit, joins are explicit barriers, and foreign entry into a join is rejected | -| Retry and terminal outcome meaning | AWS Step Functions `Retry`/`Catch`, Argo retry strategy | Retry behavior is part of workflow semantics rather than a hidden adapter loop | -| Observable step state | Step Functions execution-visible state, SCXML completion semantics | Only selected step kinds expose portable lifecycle/outcome state for predicates and backend results | -| Workflow semantics as a first-class assurance surface | SCXML state-machine model, Kepler FM guidance for workflows/state machines | Workflow changes are treated as `FM3` state-machine work, not just parser changes | +| Concern | Primary Sources | Borrowed | What We Adapted | +| ------- | --------------- | --- | --------------- | +| Conditional branching over declared predicates | AWS Step Functions `Choice`, CACAO conditional steps | Semantics | Explicit decision nodes with typed predicate dependencies instead of backend-local branching rules | +| Parallel branch execution and convergence | AWS Step Functions `Parallel`, W3C SCXML `parallel`, Argo DAG fan-out/fan-in patterns | Semantics | Parallel branches are explicit, joins are explicit barriers, and foreign entry into a join is rejected | +| Retry and terminal outcome meaning | AWS Step Functions `Retry`/`Catch`, Argo retry strategy | Semantics | Retry behavior is part of workflow semantics rather than a hidden adapter loop | +| Observable step state | Step Functions execution-visible state, SCXML completion semantics | Semantics | Only selected step kinds expose portable lifecycle/outcome state for predicates and backend results | +| Workflow semantics as a first-class assurance surface | SCXML state-machine model, Kepler FM guidance for workflows/state machines | Semantics | Workflow changes are treated as `FM3` state-machine work, not just parser changes | ### Runtime Boundary and Contract Precedents @@ -186,12 +192,12 @@ how the runtime interprets workflow behavior after parsing. These sources inform the runtime/result contract rather than the SDL YAML surface. -| Concern | Primary Sources | What We Adapted | -| ------- | --------------- | --------------- | -| Language-neutral backend boundary | Kubernetes API objects, Temporal payload/history model, OpenC2 abstract model + JSON serialization | Backends exchange plain-data, versioned workflow result envelopes rather than Python object identity | -| Explicit compiled contract between definition and execution | Kubernetes versioned object schemas, Temporal workflow definition vs event-history separation | Compiler emits a dedicated `result_contract` instead of forcing the manager to infer semantics from incidental planner payloads | -| Internal typed adapters behind a plain-data boundary | Temporal SDK data conversion, Kubernetes typed models over portable representations | Python typed workflow result models are internal normalization helpers, not the backend protocol | -| Distinct apparatus declaration surfaces | OpenRange episode/runtime split, OpenBench model/provider configuration, benchmark registries | Processor, backend, and participant-implementation declaration surfaces remain distinct so the same scenario can be run under different apparatus honestly | +| Concern | Primary Sources | Borrowed | What We Adapted | +| ------- | --------------- | --- | --------------- | +| Language-neutral backend boundary | Kubernetes API objects, Temporal payload/history model, OpenC2 abstract model + JSON serialization | Semantics | Backends exchange plain-data, versioned workflow result envelopes rather than Python object identity | +| Explicit compiled contract between definition and execution | Kubernetes versioned object schemas, Temporal workflow definition vs event-history separation | Semantics | Compiler emits a dedicated `result_contract` instead of forcing the manager to infer semantics from incidental planner payloads | +| Internal typed adapters behind a plain-data boundary | Temporal SDK data conversion, Kubernetes typed models over portable representations | Semantics | Python typed workflow result models are internal normalization helpers, not the backend protocol | +| Distinct apparatus declaration surfaces | OpenRange episode/runtime split, OpenBench model/provider configuration, benchmark registries | Semantics | Processor, backend, and participant-implementation declaration surfaces remain distinct so the same scenario can be run under different apparatus honestly | ### From Time, Simulation, and Co-Simulation Systems @@ -204,32 +210,32 @@ honestly across simulation, emulation, and live infrastructure. The primary research set for this area is curated in `research/primary/literature/time-and-simulation/`. -| Concern | Primary Sources | What We Adapted | -| ------- | --------------- | --------------- | -| Distinct time domains and clock authority | [ROS 2 Clock and Time](https://design.ros2.org/articles/clock_and_time.html), [FMI 3.0.2](https://fmi-standard.org/docs/3.0.2/) | Authored temporal intent and realized clocks cannot be treated as the same thing; multiple clocks and explicit clock authority are first-class concerns | -| Event-driven, logical, and virtual time progression | [SimPy Time and Scheduling](https://simpy.readthedocs.io/en/4.0.2/topical_guides/time_and_scheduling.html), Misra virtual-time work, DEVS literature | Time advancement policy is part of system meaning, not just a backend optimization | -| Real-time pacing and synchronization | [ns-3 realtime execution](https://www.nsnam.org/docs/manual/html/realtime.html), adaptive time-dilation work for integrated simulation/emulation | Synchronization policy, pacing, and dilation are apparatus properties that affect experiment validity and comparability | -| Ordering and causality beyond raw timestamps | Time Warp, DEVS, distributed-simulation time-management literature | Event order, causality guarantees, and temporal windows/deadlines must be modeled separately from the existence of timestamps | -| Reset, replay, and episode-local temporal semantics | OpenRange episode model, benchmark/task systems, simulation literature | Episode boundaries, reset semantics, and replayability are temporal concerns, not just lifecycle bookkeeping | -| Realized-time disclosure and provenance | OpenRange run/training-data records, co-simulation timing literature | Runs need explicit disclosure of the realized time model when results are compared across backends or replayed | +| Concern | Primary Sources | Borrowed | What We Adapted | +| ------- | --------------- | --- | --------------- | +| Distinct time domains and clock authority | [ROS 2 Clock and Time](https://design.ros2.org/articles/clock_and_time.html), [FMI 3.0.2](https://fmi-standard.org/docs/3.0.2/) | Semantics | Authored temporal intent and realized clocks cannot be treated as the same thing; multiple clocks and explicit clock authority are first-class concerns | +| Event-driven, logical, and virtual time progression | [SimPy Time and Scheduling](https://simpy.readthedocs.io/en/4.0.2/topical_guides/time_and_scheduling.html), Misra virtual-time work, DEVS literature | Semantics | Time advancement policy is part of system meaning, not just a backend optimization | +| Real-time pacing and synchronization | [ns-3 realtime execution](https://www.nsnam.org/docs/manual/html/realtime.html), adaptive time-dilation work for integrated simulation/emulation | Semantics | Synchronization policy, pacing, and dilation are apparatus properties that affect experiment validity and comparability | +| Ordering and causality beyond raw timestamps | Time Warp, DEVS, distributed-simulation time-management literature | Semantics | Event order, causality guarantees, and temporal windows/deadlines must be modeled separately from the existence of timestamps | +| Reset, replay, and episode-local temporal semantics | OpenRange episode model, benchmark/task systems, simulation literature | Semantics | Episode boundaries, reset semantics, and replayability are temporal concerns, not just lifecycle bookkeeping | +| Realized-time disclosure and provenance | OpenRange run/training-data records, co-simulation timing literature | Semantics | Runs need explicit disclosure of the realized time model when results are compared across backends or replayed | ### From OCSF -| SDL Element | OCSF Source | What We Adapted | -| --------------- | ------------------- | -------------------------------- | -| `OSFamily` enum | `Device.os.type_id` | Vocabulary for OS classification | -| `ServicePort` | `NetworkEndpoint` | Simplified port/protocol/name; named bindings become first-class refs | +| SDL Element | OCSF Source | Borrowed | What We Adapted | +| --------------- | ------------------- | --- | -------------------------------- | +| `OSFamily` enum | `Device.os.type_id` | Both | Vocabulary for OS classification | +| `ServicePort` | `NetworkEndpoint` | Both | Simplified port/protocol/name; named bindings become first-class refs | ### From Docker / Deployment Patterns -| SDL Element | Source | What We Adapted | -| ---------------------------------------- | ------------------------------- | ---------------------------- | -| `SimpleProperties.internal` | Docker Compose `internal: true` | Network egress blocking flag | -| `Condition.timeout/retries/start_period` | Docker health check fields | Direct mapping | +| SDL Element | Source | Borrowed | What We Adapted | +| ---------------------------------------- | ------------------------------- | --- | ---------------------------- | +| `SimpleProperties.internal` | Docker Compose `internal: true` | Both | Network egress blocking flag | +| `Condition.timeout/retries/start_period` | Docker health check fields | Both | Direct mapping | ## Deliberate Omissions diff --git a/docs/index.md b/docs/index.md index facb52972..2a09e9c49 100644 --- a/docs/index.md +++ b/docs/index.md @@ -198,6 +198,7 @@ specs/formal lessons/README migration/README research/experiment-core/index +research/primary/index ``` ```{toctree} diff --git a/docs/research/primary/index.md b/docs/research/primary/index.md new file mode 100644 index 000000000..d64f474e5 --- /dev/null +++ b/docs/research/primary/index.md @@ -0,0 +1,19 @@ +# Primary Research Snapshots + +This directory holds repo-tracked bibliographic snapshots of references that +the documentation cites but that are otherwise tracked only in the project's +private Zotero library or in the gitignored local `research/` working corpus +(classified as the non-normative `research_notes` root in +[`authority-boundary.yaml`](../../../specs/authority/authority-boundary.yaml)). +The snapshots capture citation metadata only — title, authors, venue, year, +DOI, and verification status — so that the corresponding claims are verifiable +from the repository alone. + +These notes are research and lineage evidence, not normative contract +authority, and they do not strengthen any ACES claim. + +```{toctree} +:maxdepth: 1 + +literature/cyber-range-scenario-survey +``` diff --git a/docs/research/primary/literature/cyber-range-scenario-survey.md b/docs/research/primary/literature/cyber-range-scenario-survey.md new file mode 100644 index 000000000..7db932048 --- /dev/null +++ b/docs/research/primary/literature/cyber-range-scenario-survey.md @@ -0,0 +1,42 @@ +# Citation snapshot: Garg et al., cyber-range scenario survey (preprint) + +This page is a repo-tracked bibliographic snapshot of a reference that is +otherwise tracked only in the project's private Zotero library and the +gitignored local `research/` working corpus. It exists so that the citation in +[`lineage.md`](../../../explain/sdl/lineage.md) is verifiable from the repository +alone. It is lineage/evidence material, not normative authority, and it does not +strengthen any ACES claim. + +## Reference + +- **Title:** A Survey of Cyber Range Training Exercise Scenario Description, + Generation, and Execution +- **Authors:** A. Garg, A. Boualouache, A. Imeri, U. Roth +- **Affiliation:** Luxembourg Institute of Science and Technology (LIST) +- **Venue / type:** TechRxiv preprint (posted 2025) +- **DOI:** [10.36227/techrxiv.175942879.94813577/v1](https://doi.org/10.36227/techrxiv.175942879.94813577/v1) +- **Peer-review status:** preprint, **not peer reviewed** + +## Why it is cited + +`lineage.md` cites this survey as adjacent methodological context for the +verification/validation and scenario-design posture of the cyber-range +literature — as a **current survey preprint, not settled normative authority**. +Per its abstract, the survey applies a PRISMA methodology over 107 publications +(2010–2025) and organizes the field into a three-layer taxonomy — scenario +description languages, CTI-driven scenario generation, and scenario execution +platforms — and enumerates open challenges including formal semantics and +verification, behavioural fidelity, and CTI trustworthiness. ACES uses this as +lineage for *which concerns recur* in the field, not as authority for any ACES +semantics. + +## Verification scope + +This is a preprint. The project's experiment-core research log applies a +published-sources-only rule and explicitly excluded this preprint as primary +research evidence on that basis (see +[`2026-05-26-search-log.md`](../../experiment-core/2026-05-26-search-log.md), +"Source Rule"). The same honesty applies here: the snapshot records the +citation for repo-local verifiability; it does not promote a preprint to +peer-reviewed authority. The DOI resolves to TechRxiv for the full text; the +bibliographic fields above were confirmed against the publisher record. From ab2db50ae1b346a240eed9e370cfae8be793c1dc Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 04:38:50 +0200 Subject: [PATCH 62/70] Add related-work comparison positioning ACES against precedent systems Add docs/explain/sdl/related-work-comparison.md: a feature-by-feature comparison matrix across eight expressivity dimensions against OCR SDL, CybORG, CACAO, SISO Cyber DEM/FOM, and academic range DSLs (CRACK/KYPO/ CyRIS). Every non-ACES cell is grounded in the precedent's primary documentation, captured in docs/research/related-work-comparison/. The page states explicitly where the precedents lead ACES (HLA time management and standardization, CACAO workflow taxonomy and signed provenance, CybORG RL episode discipline, CRACK formal verification). Link the page from the README Lineage section and lineage.md, and add a Cyber DEM/FOM adopted-and-out-of-scope subsection to lineage.md's Runtime, Time, And Causality section (resolves review LIT-4). --- README.md | 4 + changelog.d/508.added.md | 6 + ...e-508-related-work-comparison-preflight.md | 143 +++++++ docs/explain/sdl/lineage.md | 32 +- docs/explain/sdl/related-work-comparison.md | 393 ++++++++++++++++++ docs/index.md | 3 + .../research/related-work-comparison/index.md | 71 ++++ .../related-work-comparison/search-log.md | 380 +++++++++++++++++ 8 files changed, 1031 insertions(+), 1 deletion(-) create mode 100644 changelog.d/508.added.md create mode 100644 docs/decisions/issue-508-related-work-comparison-preflight.md create mode 100644 docs/explain/sdl/related-work-comparison.md create mode 100644 docs/research/related-work-comparison/index.md create mode 100644 docs/research/related-work-comparison/search-log.md diff --git a/README.md b/README.md index 32ad5a8ac..2e6ed8da9 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,10 @@ uv run aces-mcp - [MITRE CALDERA](https://github.com/mitre/caldera) - [Atomic Red Team](https://github.com/redcanaryco/atomic-red-team) +For a dimension-by-dimension comparison against these systems — what ACES +expresses that they do not, and where they still lead ACES — see +[Related-Work Comparison](docs/explain/sdl/related-work-comparison.md). + ## Documentation The documentation source is under [`docs/`](docs/). Important entry points: diff --git a/changelog.d/508.added.md b/changelog.d/508.added.md new file mode 100644 index 000000000..6f4715cb0 --- /dev/null +++ b/changelog.d/508.added.md @@ -0,0 +1,6 @@ +Added a related-work comparison page (`docs/explain/sdl/related-work-comparison.md`) +positioning ACES against precedent systems — OCR SDL, CybORG, CACAO, SISO Cyber +DEM/FOM, and academic range DSLs (CRACK/KYPO/CyRIS) — across eight expressivity +dimensions. Every non-ACES cell carries a primary-source citation, and the page +states explicitly where the precedents lead ACES. Linked from the README Lineage +section and `lineage.md`, which gains a Cyber DEM/FOM differentiation subsection. diff --git a/docs/decisions/issue-508-related-work-comparison-preflight.md b/docs/decisions/issue-508-related-work-comparison-preflight.md new file mode 100644 index 000000000..e8a806abd --- /dev/null +++ b/docs/decisions/issue-508-related-work-comparison-preflight.md @@ -0,0 +1,143 @@ +# Issue 508 Related-Work Comparison Preflight + +This note is the architecture preflight for GitHub issue #508. It is guidance, +not an implementation plan. It does not author the comparison matrix, perform the +research, or implement the requested documentation change. + +## Architecture Decisions + +- The related-work comparison is explanatory synthesis. It must not become a new + semantic authority for ACES. ACES-side claims cite existing specs, ADRs, + contracts, and reference notes; non-ACES cells cite primary external sources. +- The matrix is an evidence surface, not a ranking. Cells should be `yes`, + `partial`, `no`, or `out of scope`, each with a one-line justification and + citation. Do not infer competitor capability from memory. +- Research notes should follow the existing `docs/research/` pattern. Use a + dedicated directory such as `docs/research/related-work-comparison/` with a + source log, source-scope rules, and per-precedent notes. Do not introduce a + new root-level `research/` tree unless the repository intentionally changes + its research-root convention. +- `README.md` Lineage and `docs/explain/sdl/lineage.md` remain navigation and + narrative source-map surfaces. Link to the new comparison page from both, and + add the issue-required Cyber DEM/FOM differentiation in `lineage.md` without + duplicating the whole matrix there. +- Every README lineage precedent must either appear in the matrix or be scoped + out explicitly: OCR SDL, OCSF, CACAO, STIX, CybORG, TENA, IEEE HLA, SISO + Cyber DEM, SISO Cyber FOM, CALDERA, and Atomic Red Team. The issue also + requires at least one academic range/testbed DSL such as KYPO, CRACK, VSDL, or + CyRIS. +- At least one row must honestly favor a precedent. HLA/TENA federation maturity + and Cyber DEM/FOM standardization/interoperability status are expected + candidates if supported by primary sources. + +## Canonical Incumbents + +Reuse these existing surfaces before adding new structure: + +- documentation stance and citation rules: + `docs/explain/reference/documentation-style-guide.md` +- current reference map: + `docs/explain/reference/canonical-reference-map.md` +- SDL lineage and element provenance: + `docs/explain/sdl/lineage.md` and `docs/explain/sdl/precedents.md` +- SDL guide and materialization limits: + `docs/explain/sdl/index.md` and `docs/explain/sdl/limitations.md` +- shared semantic and lifecycle boundaries: + `docs/explain/reference/shared-semantic-integrity.md` +- authoring vs instantiation: + `specs/sdl/variables-and-instantiation.md` and + `docs/explain/reference/explicitness-realization-semantics.md` +- objectives and workflows: + `specs/formal/objectives/`, `docs/explain/reference/objective-semantics.md`, + and `specs/formal/workflows/` +- participant behavior and episode semantics: + `specs/formal/participant-semantics/README.md`, ADR-020, ADR-022, and ADR-054 +- typed relationship subtypes: + ADR-052 plus the existing `RelationshipDatabaseAccess` and + `RelationshipMailAccess` precedents +- backend agnosticism and conformance: + `docs/explain/reference/backend-conformance.md`, ADR-008, ADR-009, ADR-036, + ADR-060, and `contracts/profiles/backend/` +- provenance, disclosure, and redaction: + ADR-041, ADR-055, ADR-056, ADR-057, runtime contracts, and the experiment-core + research notes +- time and causality: + `docs/explain/sdl/lineage.md`, `docs/decisions/sem-213-temporal-participant-preflight.md`, + and `specs/formal/participant-semantics/README.md` +- research-note pattern: + `docs/research/experiment-core/` and + `docs/research/participant-backend-contracts/` +- workflow gates: + `.ground-control.yaml`, `.gc/plan-rules.md`, `noxfile.py`, + `tools/check_repo_policy.py`, `tools/check_requirement_governance.py`, and + `tools/verify_all.py` + +## Cross-Cutting Layers + +The intended implementation is documentation-only, so most runtime gates must +remain untouched. That is still a design constraint: + +- Auth surface: no new auth path, token handling, control-plane call, or live + backend access belongs in this issue. +- Secret-handling surface: source notes, links, command examples, and citations + must not include bearer tokens, credentials, private repository URLs with + embedded tokens, private keys, or copied secret-bearing payloads. +- Env/config surface: no new environment variables, config files, or profile + selectors are needed. If research tooling requires credentials, do not record + them in docs or command lines. +- OS-level exposure: do not put API keys or access tokens in process argv in + captured commands. Prefer connector-managed or local citation tooling where + available. +- Parser/schema/validation surface: do not add SDL syntax, schemas, contract + fixtures, validators, DTOs, exception types, logging, or persistence. Markdown + must pass the existing MyST/Sphinx docs build and policy gates. +- Error-envelope surface: no new code means no new error envelope. If a helper is + later proposed, it must reuse existing diagnostics instead of creating a + comparison-specific exception or logging stack. +- Copyright/source surface: capture source metadata, scope notes, and short + paraphrased findings. Do not commit large third-party source copies or long + verbatim excerpts unless the repository already has a governed license basis. + +## Extensibility Seam + +The stable seam is the comparison dimension, not a new schema. Keep the matrix +rows as named, reviewable dimensions with short definitions near the table. A +future precedent column or academic DSL should require adding source notes and a +column, not reworking README lineage, existing ADRs, or SDL semantics. + +If future work wants machine-readable comparison data, that should be a separate +governed artifact decision. Issue #508 should stay prose/table documentation. + +## Gotchas And Anti-Patterns + +Avoid: + +- marketing posture, vague superiority claims, or ACES-winning-every-row framing +- treating docs under `docs/` or research notes as normative ACES semantics +- restating large ADR content instead of linking the owning authority +- claiming a precedent lacks a feature unless a primary source supports that + characterization +- merging distinct systems just because they share an acronym or ecosystem + lineage, especially Cyber DEM vs Cyber FOM and HLA vs TENA +- conflating runtime inventory with deployment format +- conflating OCSF/STIX telemetry or CTI objects with participant-visible state +- treating CACAO workflow/playbook concepts as complete ACES participant + behavior semantics +- treating CALDERA, ATT&CK, Atomic Red Team, or tool labels as action contracts +- treating Cyber DEM as an ACES scenario model +- treating HLA/TENA federation maturity as equivalent to ACES backend + conformance +- marking ACES time semantics as complete when the full time/clock authoring + model is still partially materialized + +## Non-Goals + +This preflight does not: + +- create `docs/explain/sdl/related-work-comparison.md` +- gather or characterize external primary sources +- update README lineage or `lineage.md` +- add SDL syntax, schema contracts, validators, fixtures, or code +- define a new ADR or normative spec +- complete issue #346's broader DSL language-evaluation evidence gate +- claim compatibility with any precedent system diff --git a/docs/explain/sdl/lineage.md b/docs/explain/sdl/lineage.md index 041ca233e..55b3185e6 100644 --- a/docs/explain/sdl/lineage.md +++ b/docs/explain/sdl/lineage.md @@ -7,7 +7,9 @@ the same problem. This page is a short map of the main influences. It is not a compatibility claim, and it is not an exhaustive bibliography. For element-level provenance, -see [Design Precedents](precedents.md). +see [Design Precedents](precedents.md). For a dimension-by-dimension comparison +against precedent systems, including where those systems lead ACES, see +[Related-Work Comparison](related-work-comparison.md). ## Specification Surface @@ -809,6 +811,34 @@ which dynamic queue/log/config details remain evidence or bounded settings. [Chockler-Halpern responsibility and blame](https://doi.org/10.1613/jair.1391) extends this to graded multi-cause attribution. +### Cyber DEM And Cyber FOM: Adopted And Out Of Scope + +[SISO Cyber DEM](https://cdn.ymaws.com/www.sisostandards.org/resource/resmgr/standards_products/siso-std-025-2023_cyberdem.pdf) +(SISO-STD-025-2023) and the +[Cyber FOM](https://www.sisostandards.org/news/690125/Publication-of-Cyber-FOM-and-SIRL-Users-Guide.htm) +(SISO-STD-025.3-2024) are distinct artifacts and are treated as distinct here. +Cyber DEM is a runtime data-exchange model: a shared ontology of cyber objects +(Device, System, Service, Network, Data) and typed effect/event types for +exchanging cyber conditions between simulators. The Cyber FOM is the HLA +federation object model derived from it. + +- **Adopted as precedent.** Cyber DEM's typed cyber-object and directed + relationship vocabulary, and its attack/defend/recon effect taxonomy, are + precedent for ACES treating typed relationships + ([ADR-052](../../decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md)) + and observed runtime objects as first-class. ACES adopts the *concept* of a + typed cyber-object vocabulary, not the Cyber DEM object set or its identifiers. +- **Out of scope.** ACES does not adopt Cyber DEM as its scenario model or the + Cyber FOM as its backend contract. Cyber DEM is consumed at runtime by + federates; ACES keeps an authored scenario surface separate from any runtime + exchange model, and does not treat HLA federation conformance as equivalent to + ACES backend conformance. +- **Where it leads ACES.** Because the Cyber FOM inherits IEEE 1516 HLA time + management and multi-vendor federation, it is more mature than ACES on + federated time and standardized interoperability. ACES's time-authoring + surface is partial and explicitly incomplete. This is detailed in the + [Related-Work Comparison](related-work-comparison.md). + ## Adversary Emulation And Security Knowledge - [MITRE ATT&CK](https://www.mitre.org/news-insights/publication/mitre-attck-design-and-philosophy), diff --git a/docs/explain/sdl/related-work-comparison.md b/docs/explain/sdl/related-work-comparison.md new file mode 100644 index 000000000..96f6a8a3c --- /dev/null +++ b/docs/explain/sdl/related-work-comparison.md @@ -0,0 +1,393 @@ +# Related-Work Comparison + +This page positions ACES against precedent systems dimension by dimension. It is +the comparison a peer review asks for first: what can ACES express that the +precedents cannot, and where do the precedents still lead ACES. + +It is an evidence surface, not a ranking and not a marketing claim. ACES leads on +some dimensions and trails on others, and several ACES surfaces are formally +specified but still materializing in the runtime. For element-by-element source +provenance see [Design Precedents](precedents.md); for the narrative source map +see [Lineage and Prior Work](lineage.md). Every non-ACES cell below is grounded +in the precedent's own documentation, standard text, source, or +originating-author literature, with the full audit trail in the +[related-work comparison research notes](../../research/related-work-comparison/search-log.md). +ACES cells cite repository specs, ADRs, and contracts; this page does not define +new ACES semantics. + +## How To Read The Matrix + +Each cell is one of: + +- **yes** — the system treats the dimension as a first-class capability. +- **partial** — the system addresses part of the dimension, or addresses it as a + side effect of another mechanism rather than as a first-class construct. +- **no** — the system does not address the dimension. +- **oos** — out of scope: the dimension is outside the system's design purpose. + +Columns: + +- **ACES** — this repository. +- **OCR SDL** — Open Cyber Range Scenario Definition Language. +- **CybORG** — the CAGE Challenge reinforcement-learning gym. +- **CACAO** — OASIS CACAO Security Playbooks v2.0. +- **Cyber DEM/FOM** — SISO Cyber Data Exchange Model and Cyber Federation Object + Model. +- **CRACK\*** — academic range DSLs; the column leads with CRACK and notes KYPO + and CyRIS where they differ. + +The eight dimensions are defined in [Dimensions](#dimensions) below. + +## Matrix + +| Dimension | ACES | OCR SDL | CybORG | CACAO | Cyber DEM/FOM | CRACK\* | +| --------- | ---- | ------- | ------ | ----- | ------------- | ------- | +| 1. Runtime inventory depth | yes | no | partial | no | partial | partial | +| 2. Typed relationship subtypes | yes | partial | partial | partial | yes | yes | +| 3. Participant behavior / episode contracts | partial | partial | yes | no | partial | partial | +| 4. Authoring vs. instantiation separation | yes | partial | partial | yes | oos | yes | +| 5. Backend agnosticism + conformance | yes | partial | partial | partial | yes | no | +| 6. Declarative objectives / workflows | yes | yes | partial | yes | no | yes | +| 7. Provenance / disclosure surfaces | yes | partial | no | yes | partial | no | +| 8. Time semantics status | partial | partial | partial | partial | yes | no | + +\* CRACK ([Russo et al. 2020](https://doi.org/10.1016/j.cose.2020.101837)), +with KYPO ([Vykopal et al. 2017](https://doi.org/10.5220/0006428203100321)) and +CyRIS ([Pham et al. 2016](https://doi.org/10.1145/3011077.3011087)) noted where +they differ. + +## Dimensions + +Each dimension below states its definition, then a one-line justification and +citation per system. Competitor citations are summarized here and recorded in +full in the [research notes](../../research/related-work-comparison/search-log.md). + +### 1. Runtime inventory depth + +Observed runtime node and service state — service listeners, identity and +directory authorities, datastores, mail and DNS services, application RBAC +stores, software components — modeled as first-class declarative inventory, +distinct from authored topology. + +- **ACES — yes.** A typed `Node.runtime.*` family models observed state + (identity authorities, application authorizations, datastore/mail/DNS + services, security-monitoring managers, detection engines, service listeners, + and more) as inventory distinct from authored nodes; see the runtime sections + of [precedents.md](precedents.md) ("Deliberate Omissions") and + [lineage.md](lineage.md), governed by ADRs such as + [ADR-043](../../decisions/adrs/adr-043-runtime-service-listener-surface.md) + and + [ADR-054](../../decisions/adrs/adr-054-participant-runtime-observable-lifecycle.md). +- **OCR SDL — no.** Nodes are VMs or switches and Features are + service/configuration/artifact deployment actions, with no observed-state + inventory ([SDL Reference](https://documentation.opencyberrange.ee/docs/sdl/reference/)). +- **CybORG — partial.** Host YAML and the simulator's finite-state machine track + services, processes, and sessions, but as internal simulation state rather than + a separate declarative inventory surface + ([Standen et al. 2021, §2](https://arxiv.org/abs/2108.09118)). +- **CACAO — no.** Agents and targets are command-dispatch references; runtime + service state is out of scope + ([CACAO v2.0 §7](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/security-playbooks-v2.0.html)). +- **Cyber DEM/FOM — partial.** Defines exchangeable cyber objects (Device, + System, Service, Network, Data) as transmitted model-state, not an authored + per-node inventory + ([SISO-STD-025-2023](https://cdn.ymaws.com/www.sisostandards.org/resource/resmgr/standards_products/siso-std-025-2023_cyberdem.pdf)). +- **CRACK\* — partial.** CRACK node types carry Datalog `runtime` predicates that + verify authored facts on deployed nodes, not a structured inventory record; + KYPO/CyRIS topology is static + ([Russo et al. 2020](https://doi.org/10.1016/j.cose.2020.101837)). + +### 2. Typed relationship subtypes + +Typed, directed relationship edges between scenario elements, beyond plain +topology or workflow-routing links. + +- **ACES — yes.** Seven STIX-derived relationship types plus typed-detail edges + (forwarding, service-integration, proxy-upstream), with typed runtime + relationship subtypes specified in + [ADR-052](../../decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md) + (see also the STIX mapping in [precedents.md](precedents.md)). +- **OCR SDL — partial.** Network links and deploy-order dependencies are + structurally distinct string references, with no trust/integration edge type + ([SDL-parser source](https://github.com/Open-Cyber-Range/SDL-parser)). +- **CybORG — partial.** Host-subnet, process parent/child, and NACL relations are + implicit in the YAML and FSM, with no named typed-edge vocabulary + ([Standen et al. 2021, §2](https://arxiv.org/abs/2108.09118)). +- **CACAO — partial.** Workflow steps carry typed routing edges + (`on_success`/`on_failure`, `on_true`/`on_false`, `cases`), but there is no + general typed inter-object relationship graph + ([CACAO v2.0 §4](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/security-playbooks-v2.0.html)). +- **Cyber DEM/FOM — yes.** A first-class `Relationship` object with a + `RelationshipType` enumeration of ten directed subtypes (Administers, + ComponentOf, ContainedIn, ProvidedBy, ResidesOn and inverses) + ([cyberdem-python](https://github.com/cmu-sei/cyberdem-python)). +- **CRACK\* — yes.** CRACK defines typed, directed TOSCA relationships + (`SetsWeakPassword`, `SetsEnumerableUsername`) and typed capability kinds + ([Russo et al. 2020](https://doi.org/10.1016/j.cose.2020.101837)). + +### 3. Participant behavior / episode contracts + +Participant actions, observations, rewards, episodes, and partial observability +as first-class semantics. + +- **ACES — partial.** A formal participant-semantics specification defines + actions, observations, visibility, causality, and outcomes portable across + human, AI-agent, scripted, and simulated participants + ([ADR-022](../../decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md), + `specs/formal/participant-semantics/`), + with backend-facing contracts in + [ADR-060](../../decisions/adrs/adr-060-participant-backend-facing-contract-surface.md); + the executed episode runtime is still materializing. +- **OCR SDL — partial.** Entities hold exercise roles and receive event-triggered + injects, and Metrics score trainees, but there are no agent + action/observation/reward/episode semantics + ([SDL Reference](https://documentation.opencyberrange.ee/docs/sdl/reference/)). +- **CybORG — yes.** Per-agent action spaces, role-filtered observations, + per-agent rewards, partial observability, and bounded episodes are the system's + core ([Standen et al. 2021, §2](https://arxiv.org/abs/2108.09118)). +- **CACAO — no.** No agent observation, reward, or episode concept; CACAO + orchestrates command execution + ([CACAO v2.0 §1, §7](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/security-playbooks-v2.0.html)). +- **Cyber DEM/FOM — partial.** Typed action/effect events carry actor, target, + and phase, but there is no observation model, reward, or episode boundary + ([cyberdem-python](https://github.com/cmu-sei/cyberdem-python)). +- **CRACK\* — partial.** CRACK `Principal` nodes carry role and knowledge linked + to Goals, as static assignments rather than behavioral contracts + ([Russo et al. 2020](https://doi.org/10.1016/j.cose.2020.101837)). + +### 4. Authoring vs. instantiation separation + +A logical authored scenario surface distinct from concrete deployment or +instantiation. + +- **ACES — yes.** A logical scenario surface is kept separate from backend + realization, with instantiation-time variable resolution + (`specs/sdl/variables-and-instantiation.md`, + [explicitness-realization-semantics.md](../reference/explicitness-realization-semantics.md)). +- **OCR SDL — partial.** Node templates and `count`/infrastructure addressing + separate capability from instances, but the language is the VM-deployment spec + ([SDL Reference](https://documentation.opencyberrange.ee/docs/sdl/reference/); + [VMware Handlers](https://documentation.opencyberrange.ee/docs/handlers/vmware-handlers/)). +- **CybORG — partial.** A scenario deploys to sim or emulation and re-randomizes + on reset via a `ScenarioGenerator`, but the boundary is a Python-code + interface, not a declared schema + ([Standen et al. 2021, §3](https://arxiv.org/abs/2108.09118)). +- **CACAO — yes.** `playbook_variables` are authored and `__variable__` + substitution resolves values at execution time + ([CACAO v2.0 §3.1, §10.18](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/security-playbooks-v2.0.html)). +- **Cyber DEM/FOM — oos.** A runtime data-exchange model between running + federates; there is no authored scenario compiled to an instance + ([SISO-STD-025-2023](https://cdn.ymaws.com/www.sisostandards.org/resource/resmgr/standards_products/siso-std-025-2023_cyberdem.pdf)). +- **CRACK\* — yes.** CRACK separates the SDL specification from instantiation via + a TOSCA orchestrator that generates deploy scripts; KYPO separates a sandbox + definition from pool allocation + ([Russo et al. 2020](https://doi.org/10.1016/j.cose.2020.101837); + [Vykopal et al. 2017](https://doi.org/10.5220/0006428203100321)). + +### 5. Backend agnosticism + conformance + +The same scenario realized across multiple backends, with a conformance +contract between definition and backend. + +- **ACES — yes.** A backend-agnostic boundary with a defined backend-conformance + model and conformance profiles + ([backend-conformance.md](../reference/backend-conformance.md), + `contracts/profiles/backend/`, + [ADR-009](../../decisions/adrs/adr-009-normative-artifact-authority-and-repository-structure.md)); + the conformance contract is defined while concrete backend implementations are + still being built. +- **OCR SDL — partial.** Ranger exposes a platform-agnostic gRPC handler + contract, but only a VMware handler ships and no conformance suite was found + ([Ranger docs](https://documentation.opencyberrange.ee/docs/ranger/)). +- **CybORG — partial.** A dual sim+emulation design sits behind one gym + interface, but backend equivalence is asserted, not a published conformance + contract ([Standen et al. 2021, §2–3](https://arxiv.org/abs/2108.09118)). +- **CACAO — partial.** Producer/consumer conformance classes and multi-engine + command types support exchange, though the spec notes playbooks require + per-environment modification and defines no equivalence test suite + ([CACAO v2.0 §11](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/security-playbooks-v2.0.html)). +- **Cyber DEM/FOM — yes.** Independent of, but translatable to, HLA/TENA/DIS/JSON; + the Cyber FOM is an IEEE 1516-compliant HLA FOM inheriting formal federate + conformance + ([SISO Cyber FOM / SIRL](https://www.sisostandards.org/news/690125/Publication-of-Cyber-FOM-and-SIRL-Users-Guide.htm)). +- **CRACK\* — no.** CRACK and KYPO target OpenStack only; CyRIS supports KVM and + AWS but with no cross-backend conformance suite + ([Russo et al. 2020](https://doi.org/10.1016/j.cose.2020.101837)). + +### 6. Declarative objectives / workflows + +Objectives and workflow graphs (branching, parallel, joins) as authored +constructs. + +- **ACES — yes.** Declarative objectives (actor-target-window-success) and a + workflow graph (decisions, switch/case, parallel, joins, retries, cancel and + timeout, compensation) + ([objective-semantics.md](../reference/objective-semantics.md), + `specs/formal/objectives/`, `specs/formal/workflows/`). +- **OCR SDL — yes.** A Goals → TLOs → Evaluations → Metrics → Conditions scoring + chain and Stories → Scripts → Events → Injects timelines with parallel + storylines and AND-gated triggers + ([SDL Reference](https://documentation.opencyberrange.ee/docs/sdl/reference/)). +- **CybORG — partial.** Objectives are encoded in reward-calculator classes and + termination conditions computed in code, not declared + ([Standen et al. 2021](https://arxiv.org/abs/2108.09118)). +- **CACAO — yes.** A declarative workflow graph with eight step types + (start, end, action, playbook-action, parallel, if-, while-, switch-condition) + and nested playbook invocation + ([CACAO v2.0 §4](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/security-playbooks-v2.0.html)). +- **Cyber DEM/FOM — no.** No objective or workflow construct; `CyberOrder` is a + command/control directive + ([cyberdem-python](https://github.com/cmu-sei/cyberdem-python)). +- **CRACK\* — yes.** CRACK `Goal` subtypes (CanReach, GainPrivilege, Knows) and + `Invariant` types are first-class declarative constructs verified against the + model ([Russo et al. 2020](https://doi.org/10.1016/j.cose.2020.101837)). + +### 7. Provenance / disclosure surfaces + +Run provenance and evidence, and participant information-boundary / disclosure, +as explicit artifacts. + +- **ACES — yes.** Participant information-boundary projection plus runtime value + redaction and credential-posture classification + ([ADR-056](../../decisions/adrs/adr-056-runtime-observed-values-and-credential-posture.md), + [ADR-057](../../decisions/adrs/adr-057-runtime-secret-name-classifier-boundaries.md)), + participant-implementation provenance + ([ADR-041](../../decisions/adrs/adr-041-participant-implementation-manifest-and-provenance.md)), + and experiment-core run records + ([ADR-055](../../decisions/adrs/adr-055-experiment-core-contract-boundary.md)). +- **OCR SDL — partial.** Inject stdout/stderr capture and timestamped per-entity + events provide runtime evidence, but the SDL has no first-class + provenance/disclosure construct + ([Ranger / Executor docs](https://documentation.opencyberrange.ee/docs/ranger/)). +- **CybORG — no.** Observation filtering is an RL mechanism, not a declared + disclosure or provenance artifact + ([Standen et al. 2021, §2](https://arxiv.org/abs/2108.09118)). +- **CACAO — yes.** First-class digital signatures (JSON Signature Scheme, + quantum-safe option) and TLP/IEP/statement data markings on all objects + ([CACAO v2.0 §2.4, §2.5](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/security-playbooks-v2.0.html)). +- **Cyber DEM/FOM — partial.** A `SensitivityType` classification and + data-state attributes track information state, but there is no participant + disclosure-boundary or evidence artifact + ([cyberdem-python](https://github.com/cmu-sei/cyberdem-python)). +- **CRACK\* — no.** CRACK `Knows` is a verification predicate, not a provenance + artifact; KYPO collects observational event logs only (partial), not an + authored construct + ([Russo et al. 2020](https://doi.org/10.1016/j.cose.2020.101837); + [KYPO dataset, PMC10770710](https://pmc.ncbi.nlm.nih.gov/articles/PMC10770710/)). + +### 8. Time semantics status + +Clock authority, logical or virtual time, ordering, causality, and +pacing/synchronization. + +- **ACES — partial.** ACES separates timestamp, ordering, clock authority, + pacing, and causality at the lineage level and is materializing the authoring + surface (the + [Runtime, Time, And Causality](lineage.md#runtime-time-and-causality) + section; [SEM-213 temporal-participant preflight](../../decisions/sem-213-temporal-participant-preflight.md)). + The full time/clock authoring model is **not complete**. +- **OCR SDL — partial.** Relative script offsets and `speed` multipliers provide + narrative pacing, with no clock authority or causality + ([SDL Reference](https://documentation.opencyberrange.ee/docs/sdl/reference/)). +- **CybORG — partial.** Discrete steps with fixed agent order, and variable + action durations in CAGE 4, but no clock-authority or causality specification + ([Standen et al. 2021, §2](https://arxiv.org/abs/2108.09118)). +- **CACAO — partial.** Step `delay`/`timeout` and playbook + `valid_from`/`valid_until`, with no clock-authority or causal-ordering + semantics + ([CACAO v2.0 §3.1, §4.1](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/security-playbooks-v2.0.html)). +- **Cyber DEM/FOM — yes.** Event timestamps and durations plus the inherited HLA + time-management stack (Time Stamp Order delivery, lookahead, GALT, + time-constrained/regulating roles) + ([SISO Cyber FOM](https://www.sisostandards.org/news/690125/Publication-of-Cyber-FOM-and-SIRL-Users-Guide.htm); + [IEEE 1516](https://standards.ieee.org/ieee/1516/3744/)). +- **CRACK\* — no.** CRACK's Datalog encoding is atemporal; KYPO records + timestamps for analysis (partial) but not as a scenario-language semantic + ([Russo et al. 2018, IEEE NCA](https://doi.org/10.1016/j.cose.2020.101837); + [KYPO dataset, PMC10770710](https://pmc.ncbi.nlm.nih.gov/articles/PMC10770710/)). + +## Where Precedents Lead ACES + +The matrix is not one-directional. Several precedents are more mature than ACES +on dimensions they were built for, and the documentation states this directly. + +- **Time semantics and federated time management — Cyber DEM/FOM, TENA, HLA.** + The Cyber FOM inherits IEEE 1516 HLA time management: Time Stamp Order + delivery, lookahead, GALT, and conservative/optimistic execution with + distributed causality. ACES cites this literature but its time-authoring + surface is partial and explicitly incomplete + ([IEEE 1516](https://standards.ieee.org/ieee/1516/3744/)). +- **Standardization and federation interoperability — Cyber DEM/FOM, TENA, HLA.** + SISO-STD-025-2023 is an approved multi-vendor standard with HLA-conformant + federation and canonical mappings to HLA/TENA/DIS/JSON. ACES has a defined + conformance model but no equivalent standards body, conformance authority, or + multi-vendor implementor community + ([SISO-STD-025-2023](https://cdn.ymaws.com/www.sisostandards.org/resource/resmgr/standards_products/siso-std-025-2023_cyberdem.pdf)). +- **Declarative workflow taxonomy and signed provenance — CACAO.** CACAO ships an + OASIS-standardized eight-step workflow taxonomy with conditional and looping + steps, plus first-class digital signatures and TLP/IEP data markings — a + signed-provenance mechanism ACES does not have + ([CACAO v2.0](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/security-playbooks-v2.0.html)). +- **Executed RL episode discipline — CybORG.** CybORG's per-agent observation, + reward, partial-observability, and multi-agent episode machinery is a working, + iterated implementation; ACES specifies participant semantics formally but the + executed episode runtime is still materializing + ([Standen et al. 2021](https://arxiv.org/abs/2108.09118)). +- **Formal scenario verification — CRACK.** CRACK encodes scenarios into Datalog, + checks them against validation goals, and turns verification traces into + runtime conformance tests. ACES's SMT/formal scenario verification is deferred + (see [precedents.md](precedents.md), "VSDL SMT verification") + ([Russo et al. 2020](https://doi.org/10.1016/j.cose.2020.101837)). +- **Operational training-runtime maturity — OCR SDL.** Open Cyber Range ships a + deployed scoring and narrative-orchestration runtime (Ranger, VMware handlers, + Deputy package library, participant UI); ACES's comparable runtime is younger + ([Open Cyber Range docs](https://documentation.opencyberrange.ee/docs/)). + +## Where ACES Leads + +ACES's distinguishing contributions, against this set, are the depth of +first-class **runtime inventory** (dimension 1) and the consistent **separation +of authored meaning, instantiation, backend realization, participant +implementations, runtime state, and evidence** (dimensions 4, 5, 7). No system +in this comparison models observed runtime node state — identity authorities, +datastores, detection engines, application RBAC stores, and similar — as a +typed, redaction-aware inventory distinct from authored topology. Those gains are +expressivity gains in the authoring and runtime-modeling layer; they do not +extend to the federated time management, standardization, or formal-verification +maturity the precedents above hold. + +## Precedents Scoped Out Of The Matrix + +These README lineage precedents are not comparison columns +because their purpose is not scenario authoring or runtime scenario modeling; +each is accounted for here so coverage is complete. + +- **OCSF** — a normalized security event/finding schema. ACES borrows its + observation/evidence style; it is not a scenario language + (). +- **STIX 2.1** — a cyber-threat-intelligence object/relationship model. ACES + adapts its typed-relationship pattern; STIX models threat intelligence, not + scenarios + (). +- **TENA** — runtime test-range integration middleware, not an authoring DSL; its + federation maturity is reflected in dimensions 5 and 8 + (). +- **IEEE HLA (1516)** — a distributed-simulation interoperability architecture + and the substrate the Cyber FOM builds on, not an authoring DSL; represented + through the Cyber DEM/FOM column + (). +- **MITRE CALDERA** — an adversary-emulation execution platform scenarios may + bind to, not a scenario DSL (). +- **Atomic Red Team** — a library of ATT&CK-mapped atomic test definitions; + test-execution content, not a scenario DSL + (). + +## References + +- [Related-work comparison research notes](../../research/related-work-comparison/index.md) + and [search log](../../research/related-work-comparison/search-log.md) — the + primary sources and grounded findings behind every non-ACES cell. +- [Design Precedents](precedents.md) — element-level source mapping. +- [Lineage and Prior Work](lineage.md) — narrative source map. +- [Documentation Style Guide](../reference/documentation-style-guide.md) — the + accuracy-before-persuasion and citation rules this page follows. diff --git a/docs/index.md b/docs/index.md index facb52972..c9715ec2e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -77,6 +77,7 @@ explain/sdl/agent-guidance explain/sdl/validation explain/sdl/precedents explain/sdl/lineage +explain/sdl/related-work-comparison explain/sdl/scenario-delivery-drift-audit explain/sdl/complex-scenarios explain/sdl/limitations @@ -164,6 +165,7 @@ decisions/adrs/adr-058-datastore-node-engine-provenance-and-endpoints decisions/adrs/adr-059-adr-amendment-policy-and-pin-gate decisions/adrs/adr-060-participant-backend-facing-contract-surface decisions/sem-213-temporal-participant-preflight +decisions/issue-508-related-work-comparison-preflight ``` ```{toctree} @@ -198,6 +200,7 @@ specs/formal lessons/README migration/README research/experiment-core/index +research/related-work-comparison/index ``` ```{toctree} diff --git a/docs/research/related-work-comparison/index.md b/docs/research/related-work-comparison/index.md new file mode 100644 index 000000000..5425231b7 --- /dev/null +++ b/docs/research/related-work-comparison/index.md @@ -0,0 +1,71 @@ +# Related-Work Comparison Research + +Issue: #508 (review LIT-1), also resolving review LIT-4. + +Purpose: gather the primary-source basis for a feature-by-feature comparison of +ACES against precedent systems before writing +[`docs/explain/sdl/related-work-comparison.md`](../../explain/sdl/related-work-comparison.md). +The comparison answers the first question of any peer review of a new language: +dimension by dimension, what can ACES express that the precedents cannot, and +where do the precedents still lead ACES. + +These notes do not characterize competitor capabilities from memory. Every +non-ACES claim in the comparison page is grounded in a precedent's own +documentation, standard text, source, or originating-author literature, recorded +in [`search-log.md`](search-log.md) with the exact source and a supporting +finding. + +## Relationship To Prior Research + +Element-level provenance and the source map already exist and are not repeated +here: + +- [`docs/explain/sdl/precedents.md`](../../explain/sdl/precedents.md) — element-by-element + source mapping. +- [`docs/explain/sdl/lineage.md`](../../explain/sdl/lineage.md) — narrative + source map by concern area. +- `specs/formal/participant-semantics/README.md` — participant-semantics + primary-source review. + +This note covers the question specific to issue #508: how ACES and the +precedents compare across named expressivity dimensions, with each cell +traceable to a primary source. + +## Contents + +- [Search log](search-log.md) — the source rule, tooling, per-system primary + sources with URLs/DOIs, and the grounded findings behind every non-ACES cell + in the comparison matrix. + +:::{toctree} +:hidden: + +search-log +::: + +## Source Rule + +- Non-ACES cells cite the precedent's primary documentation: the maintaining + body's standard text, the originating authors' peer-reviewed papers or + technical reports, official project documentation, or the project's own + source. Secondary summaries are used only to locate primary sources or where + no primary source is available, and are identified as secondary. +- ACES cells cite repository authority: specs under `specs/`, ADRs under + `docs/decisions/adrs/`, contracts under `contracts/`, or the reference notes + in `docs/explain/`. The comparison page is explanatory synthesis; it is not a + new authority for ACES semantics. +- Where a capability could not be grounded in a primary source, it is recorded + as a confidence gap rather than asserted. + +## Scope Boundaries + +- The comparison is an evidence surface, not a ranking. Cells are `yes`, + `partial`, `no`, or `out of scope`, each with a one-line justification and a + citation. +- At least one dimension honestly favors a precedent. In practice several do: + HLA-grounded time management and federation interoperability (SISO Cyber + DEM/FOM, TENA/HLA), OASIS-standardized workflow taxonomy and signed + provenance (CACAO), executed RL episode discipline (CybORG), and formal + scenario verification (CRACK). +- Maturity is stated honestly: several ACES surfaces are formally specified but + still materializing in the runtime, and ACES time semantics are not complete. diff --git a/docs/research/related-work-comparison/search-log.md b/docs/research/related-work-comparison/search-log.md new file mode 100644 index 000000000..2b11a1be3 --- /dev/null +++ b/docs/research/related-work-comparison/search-log.md @@ -0,0 +1,380 @@ +# Related-Work Comparison Search Log + +Issue: #508 (review LIT-1 / LIT-4). + +Purpose: record the primary sources and grounded findings behind every non-ACES +cell in [`docs/explain/sdl/related-work-comparison.md`](../../explain/sdl/related-work-comparison.md). +Each precedent's capabilities were checked against its own documentation, +standard text, source, or originating-author literature — not from memory. + +## Source Rule + +Primary sources only for competitor capability claims: the maintaining body's +standard, the originating authors' papers, official project documentation, or +the project's own source repository. Where only a secondary source was +available, it is marked secondary. Absence-of-feature claims are made only after +searching the primary documentation and source for the feature. + +## Tooling + +- Web search and direct page/document fetch for standards, official + documentation, and source repositories. +- Direct reads of project source (Rust parser, Python reference implementation, + scenario/topology YAML, TOSCA type definitions) where the source is the + authoritative encoding of the model. +- Crossref / arXiv / Semantic Scholar for DOIs and originating-author papers. + +The eight comparison dimensions are defined in the comparison page; the findings +below are organized by precedent and then by dimension. + +## Comparison Dimensions + +1. Runtime inventory depth — observed runtime node/service state as first-class + declarative inventory, distinct from authored topology. +2. Typed relationship subtypes — typed, directed relationship edges between + elements, beyond plain topology links. +3. Participant behavior / episode contracts — actions, observations, rewards, + episodes, partial observability as first-class semantics. +4. Authoring-vs-instantiation separation — a logical authored surface distinct + from concrete deployment/instantiation. +5. Backend agnosticism + conformance — the same scenario across multiple + backends, with a conformance contract. +6. Declarative objectives / workflows — objectives and workflow graphs as + authored constructs. +7. Provenance / disclosure surfaces — run provenance, evidence, and participant + information-boundary/disclosure as explicit artifacts. +8. Time semantics status — clock authority, logical/virtual time, ordering, + causality, pacing/synchronization. + +--- + +## Open Cyber Range (OCR) SDL + +YAML-based, VM-centric authoring language for cyber defense/training exercises +(infrastructure, deployable software features, scoring objectives, narrative +event timelines, participant teams), implemented as a Rust parser used across +the Open Cyber Range platform (Ranger / Handlers / Deputy). + +Primary sources: + +- SDL Reference — Open Cyber Range documentation. + and + +- SDL-parser source (Rust) — Open-Cyber-Range, GitHub. + (`node.rs`, `feature.rs`, + `infrastructure.rs`, `inject.rs`, `event.rs`, `script.rs`, `story.rs`, + `entity.rs`, `metric.rs`, `evaluation.rs`, `vulnerability.rs`) +- Ranger / VMware Handlers / Deputy — Open Cyber Range documentation. + , + +- Kaunis, K. *Hypervisor Agnostic Scenario Definition Language for Cyber + Ranges.* TalTech MSc thesis, 2022 (design intent / scope). + +Findings: + +1. Runtime inventory depth — **no.** Nodes are `Switch | VM`; Features are + `service | configuration | artifact` install/configure actions + (deployment intent), not observed runtime state. No directory, datastore, + mail, DNS, or RBAC node types (SDL Reference; SDL-parser `node.rs`, + `feature.rs`). +2. Typed relationship subtypes — **partial.** `InfraNode.links` (network) and + `InfraNode.dependencies` (deploy order) are structurally distinct, and + injects carry `from-entity`/`to-entities`; all are string references, with no + trust/integration edge type or edge-type discriminator (SDL-parser + `infrastructure.rs`, `inject.rs`). +3. Participant behavior / episode contracts — **partial.** Entities carry + exercise roles (White/Green/Red/Blue) and receive event-triggered injects; + condition-driven Metrics score trainees 0–1. No agent actions, observations, + rewards, or episode/partial-observability semantics (SDL Reference, Entities + and Metrics). +4. Authoring-vs-instantiation separation — **partial.** Node templates and + `count`/infrastructure addressing separate capability from instances + (Templater builds reusable VM templates; Machiner instantiates), but the + language is the VM-deployment spec, not a backend-neutral logical surface + (SDL Reference, Infrastructure; VMware Handlers). +5. Backend agnosticism + conformance — **partial.** Ranger exposes a + "virtualization-platform-agnostic" gRPC handler contract, but the only + shipped handler suite is VMware vSphere/NSX-T and no backend conformance + test suite was found (Ranger docs; VMware Handlers docs). +6. Declarative objectives / workflows — **yes.** Goals → TLOs → Evaluations → + Metrics → Conditions scoring chain, plus Stories → Scripts → Events → + Injects narrative timelines with parallel storylines and AND-gated condition + triggers (SDL Reference and Example). +7. Provenance / disclosure surfaces — **partial.** Inject stdout/stderr are + captured to the manager view and events are timestamped and scoped per + entity, but the SDL document has no first-class provenance/disclosure + construct (Executor / Ranger participant-guide docs). +8. Time semantics status — **partial.** Script `start-time`/`end-time`, `speed` + multipliers, and condition poll `interval` give relative/narrative pacing; + no clock authority, logical time, or causality model (SDL Reference, + Scripts/Stories; SDL-parser `script.rs`, `story.rs`). + +--- + +## CybORG (CAGE Challenge) + +Discrete-step reinforcement-learning gym for autonomous cyber operations, with a +shared OpenAI-gym interface over both a simulation backend and an AWS emulation +backend, used across the CAGE Challenge series. + +Primary sources: + +- Standen, M., Lucas, M., Bowman, D., Richer, T.J., Kim, J., Marriott, D. + "CybORG: A Gym for the Development of Autonomous Cyber Agents." arXiv:2108.09118, + 2021. +- CybORG source and CAGE Challenge scenario files — cage-challenge, GitHub. + , + CAGE Challenge 2 `Scenario1b.yaml`, CAGE Challenge 4 challenge details + (). + +Findings: + +1. Runtime inventory depth — **partial.** Host YAML declares OS, services, + processes, users, and subnets and the simulator tracks live session/process + state as a finite-state machine, but this is internal simulation state, not a + separate first-class declarative runtime-inventory surface (arXiv:2108.09118, + §2 "Scenario"). +2. Typed relationship subtypes — **partial.** Host-to-subnet membership, + process parent/child, and subnet NACL rules are implicit in the YAML and the + FSM; there is no named, directed typed-edge vocabulary (arXiv:2108.09118 §2; + `Scenario1b.yaml`). +3. Participant behavior / episode contracts — **yes.** Per-agent action spaces, + role-filtered observations, per-agent reward calculators, partial + observability, and step- or goal-bounded episodes are first-class; CAGE 4 + adds multi-agent coordination with restricted inter-agent messaging + (arXiv:2108.09118 §2; `Scenario1b.yaml`; CAGE 4 details). +4. Authoring-vs-instantiation separation — **partial.** A scenario file deploys + to sim or emulation and re-randomizes on reset; the `ScenarioGenerator` + abstraction separates generation from use, but the boundary is a Python-code + interface, not a declared logical/instance schema (arXiv:2108.09118 §3; + CybORG changelog v3.0). +5. Backend agnosticism + conformance — **partial.** A genuine dual sim+emulation + design sits behind one gym interface (each action defined for both), but + backend equivalence is asserted/empirical, not a published conformance + contract (arXiv:2108.09118 §2–3). +6. Declarative objectives / workflows — **partial.** Objectives are encoded in + reward-calculator classes and episode-termination conditions, computed in + code; there is no declarative objective expression or workflow graph + (`Scenario1b.yaml` `reward_calculator_type`; arXiv:2108.09118). +7. Provenance / disclosure surfaces — **no.** Role-based observation filtering is + an RL mechanism enforced at runtime, not a declared disclosure-boundary or + provenance artifact; evaluation emits text result files, not structured + provenance (arXiv:2108.09118 §2; CAGE 4 details). +8. Time semantics status — **partial.** Discrete steps with a fixed agent order, + and CAGE 4 variable action durations (ticks); no clock authority, + logical-time, or causality specification (arXiv:2108.09118 §2; CAGE 4 + details). + +--- + +## CACAO Security Playbooks v2.0 (OASIS) + +JSON schema and taxonomy for documenting, sharing, and orchestrating +cybersecurity response/detection/mitigation workflows across organizational and +tooling boundaries — a playbook workflow standard, not a cyber-range topology or +scenario DSL. + +Primary source: + +- CACAO Security Playbooks Version 2.0, OASIS Committee Specification 01, + 27 November 2023. + + +Findings (section numbers are CACAO v2.0): + +1. Runtime inventory depth — **no.** Agents and Targets (§7) are authoring-time + connection references for command dispatch; runtime node/service state is + explicitly out of scope. +2. Typed relationship subtypes — **partial.** Workflow steps carry typed, + directed routing edges (`on_completion`, `on_success`/`on_failure`, + `on_true`/`on_false`, `cases`, `next_steps`), but there is no general typed + inter-object relationship graph (§4). +3. Participant behavior / episode contracts — **no.** No agent observation, + reward, episode, or partial-observability concept; the standard orchestrates + command execution (§1, §7). +4. Authoring-vs-instantiation separation — **yes.** `playbook_variables` are + authored; `__variable__` substitution and step `in_args`/`out_args` resolve + values at execution time, with step scope overriding playbook scope + (§3.1, §4.1, §10.18). +5. Backend agnosticism + conformance — **partial.** Producer/consumer + conformance classes and abstract agent/target plus multi-engine command + types support exchange, but the spec notes playbooks "will require some amount + of modification" per environment, and defines no equivalence test suite (§11). +6. Declarative objectives / workflows — **yes.** A full declarative workflow + graph with eight step types (start, end, action, playbook-action, parallel, + if-condition, while-condition, switch-condition), conditional routing, and + nested playbook invocation (§4). +7. Provenance / disclosure surfaces — **yes.** First-class digital signatures + (JSON Signature Scheme, embedded/detached, quantum-safe option), TLP/IEP/ + statement data markings applied to all objects, and `created_by` provenance + (§2.4, §2.5, §9). +8. Time semantics status — **partial.** Step `delay`/`timeout` and playbook + `valid_from`/`valid_until`; no clock authority or causal-ordering semantics + (§3.1, §4.1). + +--- + +## SISO Cyber DEM and Cyber FOM + +A runtime data-exchange model (not a scenario-authoring DSL) defining a shared +ontology of cyber objects and events/effects so cyber conditions can be exchanged +bi-directionally between cyber ranges, cyber simulations, and the +Live-Virtual-Constructive environments of kinetic simulation. The Cyber FOM is +the HLA-specific federation object model derived from it. + +Primary sources: + +- SISO-STD-025-2023, Cyber Data Exchange Model (DEM) — SISO, 2023. + +- SISO-STD-025.3-2024 Cyber Federation Object Model and SIRL User's Guide — + SISO, 2024. + +- cyberdem-python reference implementation of the CyberDEM object/event model — + CMU SEI, GitHub. + (`base/__init__.py`, + `enumerations/__init__.py`) +- IEEE Std 1516 (High Level Architecture) for the inherited federation and time + management framework (secondary, for HLA mechanics). + +Findings: + +1. Runtime inventory depth — **partial.** Defines exchangeable cyber objects + (Device, System, Application, OperatingSystem, Service, Network, NetworkLink, + Data) as transmitted model-state; this is live federation exchange, not an + authored per-node declarative inventory (cyberdem-python `base`). +2. Typed relationship subtypes — **yes.** A first-class `Relationship` object + with a `RelationshipType` enumeration of ten directed subtypes + (Administers/AdministeredBy, ComponentOf/HasComponent, ContainedIn/Contains, + ProvidedBy/Provides, ResidesOn/HasResident) (cyberdem-python `enumerations`). +3. Participant behavior / episode contracts — **partial.** Typed action/effect + events (CyberAttack/CyberDefend/CyberRecon with MITRE ATT&CK references; a + Deny/Detect/Manipulate effect hierarchy) carry `actor_ids`, `target_ids`, and + a `phase`; no observation model, reward, or episode boundary + (cyberdem-python `base`). +4. Authoring-vs-instantiation separation — **out of scope.** Objects and events + are exchanged between running federates at runtime; there is no authored + scenario surface compiled to an instance (SISO-STD-025-2023 scope; + originating-author statement that CyberDEM represents cyber events/objects in + a format independent of simulation interoperability solutions). +5. Backend agnosticism + conformance — **yes.** Designed to be independent of, + but unambiguously translatable to, HLA/TENA/DIS/JSON; the Cyber FOM is an + IEEE 1516-compliant HLA FOM, inheriting formal HLA federate conformance + (SISO-STD-025-2023; SISO-STD-025.3-2024 Cyber FOM; IEEE 1516). +6. Declarative objectives / workflows — **no.** No objective, mission-goal, or + workflow construct; `CyberOrder` is a command/control directive, not a + declarative objective or workflow graph (cyberdem-python `base`; + SISO-STD-025-2023 scope). +7. Provenance / disclosure surfaces — **partial.** A `SensitivityType` + classification (17 values) plus `Data.confidentiality`, `encrypted`, and + `status` track information state, but there is no participant + information-boundary or evidence/provenance artifact (cyberdem-python + `enumerations`, `base`). +8. Time semantics status — **yes.** `_CyberEvent` carries `event_time` and + `duration`, and the Cyber FOM inherits the full HLA time-management stack + (time-advance request/grant, Time Stamp Order delivery, lookahead, GALT, + time-constrained/regulating roles) (cyberdem-python `base`; IEEE 1516 time + management; SISO-REF-072-2024). + +--- + +## Academic range DSLs: CRACK, KYPO, CyRIS + +Academic cyber-range systems for security-training exercise generation, +deployment, and (CRACK) formal verification. The comparison column leads with +**CRACK** and notes KYPO/CyRIS where they differ. + +Primary sources: + +- Russo, E., Costa, G., Armando, A. "Building next generation Cyber Ranges with + CRACK." *Computers & Security* 95:101837, 2020. DOI: + [10.1016/j.cose.2020.101837](https://doi.org/10.1016/j.cose.2020.101837). +- Russo, E., Costa, G., Armando, A. "Scenario Design and Validation for Next + Generation Cyber Ranges." IEEE NCA 2018. IEEE Xplore 8548324. +- CRACK source (TOSCA type definitions) — enricorusso/CRACK, GitHub. + +- Vykopal, J., et al. "KYPO Cyber Range: Design and Use Cases." ICSOFT 2017. + DOI: [10.5220/0006428203100321](https://doi.org/10.5220/0006428203100321). +- KYPO hands-on training behavior dataset, PMC10770710, 2024; + KYPO platform documentation, . +- Pham, C., Tang, D., Chinen, K.-I., Beuran, R. "CyRIS: A Cyber Range + Instantiation System for Facilitating Security Training." SoICT 2016. DOI: + [10.1145/3011077.3011087](https://doi.org/10.1145/3011077.3011087); + source — crond-jaist/cyris, GitHub. + +Findings (verdict leads with CRACK): + +1. Runtime inventory depth — **partial.** CRACK node types carry `runtime` + Datalog predicate maps (`isConnected`, `listeningOn`, `hostACL`, + `existsRoute`) executed as live checks to confirm authored predicates, not a + structured inventory record; KYPO/CyRIS topology is static authored YAML + (Russo et al. 2020; CRACK `types/`; Vykopal et al. 2017; Pham et al. 2016). +2. Typed relationship subtypes — **yes.** CRACK defines typed, directed TOSCA + relationships (e.g., `SetsWeakPassword`, `SetsEnumerableUsername`) and typed + capability kinds (VulnerabilityContainer, PrivilegeProvider, KnowledgeProvider, + GoalProvider, PrincipalProvider); KYPO/CyRIS encode connectivity edges only + (CRACK `types/sdl.yaml`; Russo et al. 2020). +3. Participant behavior / episode contracts — **partial.** CRACK `Principal` + nodes carry a role and a knowledge requirement, with Goals linked to + principals; these are static role/knowledge assignments, not action/ + observation/reward/episode contracts. KYPO prescribes tasks/scoring; CyRIS + models none (CRACK `types/sdl-principal.yaml`, `sdl-goal.yaml`; + PMC10770710). +4. Authoring-vs-instantiation separation — **yes.** CRACK separates the SDL + specification from instantiation via an ARIA TOSCA orchestrator that + generates OpenStack/Terraform/Packer deploy scripts; KYPO separates a sandbox + definition from pool allocation (Russo et al. 2020; Vykopal et al. 2017). +5. Backend agnosticism + conformance — **no.** CRACK and KYPO target OpenStack + only; CyRIS supports KVM and AWS but with no cross-backend conformance suite + (CRACK `types/openstack-*.yaml`; KYPO OpenStack requirements; Pham et al. + 2016). +6. Declarative objectives / workflows — **yes.** CRACK `Goal` node subtypes + (CanReach, GainPrivilege, Knows) and `Invariant` types are first-class + declarative constructs verified against the model; KYPO encodes + human-readable objectives plus flag answers (CRACK `types/sdl-goal.yaml`, + `sdl-invariant.yaml`; Russo et al. 2020; PMC10770710). +7. Provenance / disclosure surfaces — **no (KYPO partial).** CRACK `Knows` + models knowledge as a verification predicate, not a disclosure/provenance + artifact; KYPO collects observational event logs and command histories, not a + first-class authored construct (CRACK `types/`; PMC10770710 §3.3–3.4). +8. Time semantics status — **no (KYPO partial).** CRACK's Datalog encoding is + atemporal (set-theoretic reachability/privilege); KYPO records timestamps and + notes absolute time does not order events across trainees (relative + timestamps added); CyRIS has only an `attack_time` date (Russo et al. 2018; + PMC10770710 §3.5; CyRIS `examples/full.yml`). + +CRACK's distinguishing strength is machine-checkable formal verification: +SDL specifications are encoded into Datalog and checked against validation +goals, and verification traces are turned into runtime conformance test cases +(Russo et al. 2018; Russo et al. 2020). ACES's SMT/formal scenario verification +is deferred (see [`precedents.md`](../../explain/sdl/precedents.md), "VSDL SMT +verification"), so this is a genuine dimension where this class leads ACES. + +--- + +## Precedents Scoped Out Of The Matrix + +These README-lineage precedents are not comparison columns because their purpose +is not scenario authoring or runtime scenario modeling. Each is accounted for so +the matrix coverage is complete. + +- **OCSF** — a normalized security event/finding schema. ACES borrows its + observation/evidence style; it is not a scenario-authoring or runtime-modeling + language (). +- **STIX 2.1** — a cyber-threat-intelligence object and relationship model. ACES + adapts its typed-relationship pattern for scenario elements; STIX models + threat intelligence, not scenarios + (). +- **TENA** — a runtime test-range integration architecture and object-model + middleware for live test ranges, not an authoring DSL; relevant to runtime + federation maturity, captured under dimensions 5 and 8 + (). +- **IEEE HLA (1516)** — a distributed-simulation interoperability architecture + (RTI, FOM, time management), the substrate the Cyber FOM builds on, not an + authoring DSL; its time-management strength is represented through the SISO + Cyber DEM/FOM column (). +- **MITRE CALDERA** — an adversary-emulation execution platform; ACES treats it + as a behavior/execution source scenarios may bind to, not a scenario DSL + (). +- **Atomic Red Team** — a library of ATT&CK-mapped atomic test definitions; + test-execution content, not a scenario DSL + (). From 7024618167d6398b60d0b976f68c1805cf8dbd03 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 04:43:04 +0200 Subject: [PATCH 63/70] Add backend conformance proof tests with seeded violations --- changelog.d/502.added.md | 1 + docs/explain/reference/backend-conformance.md | 27 +++- .../tests/test_backend_conformance_cli.py | 137 ++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 changelog.d/502.added.md diff --git a/changelog.d/502.added.md b/changelog.d/502.added.md new file mode 100644 index 000000000..0f69ce957 --- /dev/null +++ b/changelog.d/502.added.md @@ -0,0 +1 @@ +The `aces conformance backend` suite now has end-to-end proof tests: a realistic stub backend manifest passes against the canonical `contracts/fixtures` corpus under a full runtime-contract profile, and two seeded-violation tests copy the corpus to a temp tree, corrupt a required field in a required contract fixture (the backend manifest and a deep participant-episode runtime contract), and assert the runner exits non-zero while naming the offending contract. This demonstrates a caught contract violation rather than only a missing-fixture failure, backing the conformance CI claim in `docs/explain/reference/backend-conformance.md` (ASR-502). diff --git a/docs/explain/reference/backend-conformance.md b/docs/explain/reference/backend-conformance.md index a86f4d303..f853dc659 100644 --- a/docs/explain/reference/backend-conformance.md +++ b/docs/explain/reference/backend-conformance.md @@ -28,6 +28,11 @@ fixture, or profile model. JSON by hand. - CLI entry points belong in the Typer-based `aces_cli` surface unless a compatibility wrapper is retained only as a thin delegate. +- End-to-end proof tests for backend conformance should exercise the public + runner or CLI against a full backend profile and the published fixture corpus; + seeded-corruption tests should mutate a temporary copy of one existing + required fixture and expect the shared conformance diagnostics to name the + contract and validation failure. ## Cross-Cutting Concerns @@ -38,6 +43,9 @@ Reuse these existing surfaces before adding anything new: runtime envelope models, plan models, and history event models - contract publication inventory: `schema_bundle()` and `contracts/schema-publication-manifest.json` +- contract-corpus resolution: `aces_contracts.corpus.corpus_family_root()` with + the `FIXTURES` and `PROFILES` family constants, so source checkouts and + packaged installs resolve the same published corpus through one seam - backend contract authority: `aces_contracts.manifest_authority.BACKEND_SUPPORTED_CONTRACT_IDS` and `validate_backend_supported_contract_versions()` @@ -64,6 +72,13 @@ The conformance path touches these gates: - JSON parsing: load only local fixture/profile files selected by explicit roots or canonical repo roots; do not fetch remote fixtures or execute fixture content. +- Corpus path resolution: default fixture/profile roots must flow through + `aces_contracts.corpus`; tests may pass explicit temporary override roots for + mutated corpora, but loaders must not reintroduce `Path(__file__).parents[N]` + repo-root heuristics. +- Profile id and path validation: profile ids must pass the backend-profile id + grammar before path construction, and any caller-supplied `profiles_root` + resolution must remain confined to that root. - Contract shape validation: all fixture, manifest, plan, status, result, history, and snapshot payloads must pass the existing Pydantic contract models and closed-world `extra="forbid"` behavior. @@ -86,6 +101,8 @@ The conformance path touches these gates: - Error-envelope leakage: report failures as `Diagnostic` values with stable codes and messages; do not surface raw tracebacks, environment variables, bearer tokens, credentials, or backend-private object representations. + Proof tests should assert stable `Diagnostic.code`, `contract_name`, and + structured addresses rather than exact Pydantic prose. - Host/OS exposure: CLI options may accept profile names and local roots, but must not require secrets or bearer tokens in process argv. Live-target authentication belongs in headers or process-local configuration that is not @@ -130,7 +147,10 @@ Avoid: profile artifacts exist - accepting `backend-manifest-v1` or legacy conformance paths as current defaults -- editing `contracts/schemas/` directly instead of generator inputs +- changing `contracts/schemas/` without the + `contracts/schema-publication-manifest.json` change ledger, or changing only + reference implementation models while leaving the published schema authority + untouched - validating fixtures with ad hoc JSON key checks when contract models already exist - adding a conformance-specific exception hierarchy, logging stack, schema @@ -138,6 +158,11 @@ Avoid: - leaking backend exception strings that may include secrets into diagnostics - making invalid fixtures multi-concern when a single-concern fixture can prove the same contract rule +- proving only an empty-root failure; issue `#502` needs a realistic pass over + the canonical corpus and a seeded violation in a temporary copy of a required + contract fixture +- mutating source fixtures in place, relying on exact validator prose, or + broadening the test into unrelated profile, schema, or live-backend behavior ## Authority and CLI diff --git a/implementations/python/tests/test_backend_conformance_cli.py b/implementations/python/tests/test_backend_conformance_cli.py index 6da452954..b60a2984d 100644 --- a/implementations/python/tests/test_backend_conformance_cli.py +++ b/implementations/python/tests/test_backend_conformance_cli.py @@ -3,12 +3,43 @@ from __future__ import annotations import json +import shutil from pathlib import Path +import pytest from aces_cli.main import app +from aces_conformance.conformance import fixtures_root from typer.testing import CliRunner +def _contract_valid_dir(root: Path, contract_name: str) -> Path: + """Resolve a contract's ``valid`` fixture directory under ``root``. + + Mirrors the runner's ``_fixture_contract_root`` glob so a seeded-violation + test stays robust to where in the corpus a contract family lives instead of + hard-coding its tree position. + """ + + matches = sorted(path for path in root.glob(f"**/{contract_name}") if path.is_dir()) + assert matches, f"no fixture directory for contract {contract_name!r} under {root}" + return matches[0] / "valid" + + +def _seed_canonical_corpus(tmp_path: Path) -> Path: + """Copy the published ``contracts/fixtures`` corpus into ``tmp_path``. + + Seeded-violation tests mutate the *copy* so the canonical corpus is never + corrupted in place. The source root is resolved through the same + ``corpus_family_root(FIXTURES)`` seam the runner uses, not a + ``Path(__file__).parents[N]`` heuristic. + """ + + destination = tmp_path / "fixtures" + shutil.copytree(fixtures_root(), destination) + return destination + + +@pytest.mark.integration def test_backend_conformance_cli_passes_for_provisioning_only_profile(): runner = CliRunner() result = runner.invoke(app, ["conformance", "backend", "--profile", "provisioning-only"]) @@ -26,6 +57,7 @@ def test_backend_conformance_cli_passes_for_provisioning_only_profile(): } +@pytest.mark.integration def test_backend_conformance_cli_passes_for_full_remote_control_plane_profile(): runner = CliRunner() result = runner.invoke(app, ["conformance", "backend", "--profile", "full-remote-control-plane"]) @@ -37,6 +69,13 @@ def test_backend_conformance_cli_passes_for_full_remote_control_plane_profile(): assert "participant-episode-state-envelope-v1" in contract_names assert "participant-episode-history-event-stream-v1" in contract_names assert "participant-behavior-history-event-stream-v1" in contract_names + # CT-5 scope item 1: the realistic stub backend manifest passes end-to-end + # against the canonical corpus under a profile that requires runtime contracts. + stub_cases = [ + case for case in payload["cases"] if case["contract_name"] == "backend-manifest-v2" and case["name"] == "stub" + ] + assert stub_cases, "expected the stub backend-manifest-v2 fixture to be exercised" + assert all(case["passed"] for case in stub_cases) def test_backend_conformance_cli_exits_non_zero_when_fixtures_missing(tmp_path: Path): @@ -98,6 +137,7 @@ def test_backend_conformance_cli_emits_structured_profile_load_diagnostics(tmp_p assert codes == {"conformance.profile-load-failed"} +@pytest.mark.integration def test_backend_conformance_cli_respects_profiles_root_override(tmp_path: Path): """The CLI must thread ``--profiles-root`` through to the runner so the published profile JSON is provably the authority end-to-end.""" @@ -130,6 +170,7 @@ def test_backend_conformance_cli_respects_profiles_root_override(tmp_path: Path) assert contract_names == {"backend-manifest-v2"} +@pytest.mark.integration def test_backend_conformance_cli_accepts_unknown_profile_id_from_corpus(tmp_path: Path): """ASR-502 + codex review (issue #66, finding 1 of cycle 3): the CLI must accept any profile id discoverable from the JSON corpus, not only the @@ -167,3 +208,99 @@ def test_backend_conformance_cli_accepts_unknown_profile_id_from_corpus(tmp_path payload = json.loads(result.output) assert payload["profile"] == "future-control-plane" assert payload["passed"] is True + + +@pytest.mark.integration +def test_backend_conformance_cli_catches_seeded_manifest_violation(tmp_path: Path): + """CT-5 scope item 2 (manifest surface): a real contract violation is caught. + + Copy the canonical fixture corpus to tmp, drop the required ``identity`` + field from the ``backend-manifest-v2`` ``stub`` fixture, then run the CLI + against the mutated corpus. The runner must exit non-zero and report a + ``conformance.schema-invalid`` diagnostic that names the offending contract + — proving "backends can be checked against contracts" has a demonstrated + catch, not just a missing-fixture failure. Assertions stay on stable + diagnostic ``code``/``contract_name``/``address`` rather than exact Pydantic + prose.""" + + corpus = _seed_canonical_corpus(tmp_path) + target = _contract_valid_dir(corpus, "backend-manifest-v2") / "stub.json" + payload = json.loads(target.read_text(encoding="utf-8")) + assert "identity" in payload, "fixture precondition: stub manifest declares identity" + del payload["identity"] + target.write_text(json.dumps(payload) + "\n", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke( + app, + [ + "conformance", + "backend", + "--profile", + "provisioning-only", + "--fixtures-root", + str(corpus), + ], + ) + + assert result.exit_code == 1, result.output + report = json.loads(result.output) + assert report["passed"] is False + stub_cases = [ + case for case in report["cases"] if case["contract_name"] == "backend-manifest-v2" and case["name"] == "stub" + ] + assert stub_cases, "expected a backend-manifest-v2 'stub' case in the report" + stub_case = stub_cases[0] + assert stub_case["passed"] is False + schema_diags = [diag for diag in stub_case["diagnostics"] if diag["code"] == "conformance.schema-invalid"] + assert schema_diags, f"expected a schema-invalid diagnostic, got {stub_case['diagnostics']}" + assert any(diag["address"] == "backend-manifest-v2" for diag in schema_diags) + assert all(diag["domain"] == "conformance" for diag in schema_diags) + + +@pytest.mark.integration +def test_backend_conformance_cli_catches_seeded_deep_runtime_violation(tmp_path: Path): + """CT-5 scope items 2 + 3 (deep runtime surface): the catch reaches the + runtime contracts a full profile requires, not just the manifest. + + Copy the canonical corpus to tmp, drop the required ``participant_address`` + field from a ``participant-episode-state-envelope-v1`` valid fixture (a + contract required only by the deep ``full-remote-control-plane`` profile), + and run the CLI against the mutated corpus. The runner must exit non-zero + and surface a ``conformance.schema-invalid`` diagnostic naming the deep + contract.""" + + corpus = _seed_canonical_corpus(tmp_path) + target = _contract_valid_dir(corpus, "participant-episode-state-envelope-v1") / "initialized.json" + payload = json.loads(target.read_text(encoding="utf-8")) + assert "participant_address" in payload, "fixture precondition: episode state declares participant_address" + del payload["participant_address"] + target.write_text(json.dumps(payload) + "\n", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke( + app, + [ + "conformance", + "backend", + "--profile", + "full-remote-control-plane", + "--fixtures-root", + str(corpus), + ], + ) + + assert result.exit_code == 1, result.output + report = json.loads(result.output) + assert report["passed"] is False + episode_cases = [ + case + for case in report["cases"] + if case["contract_name"] == "participant-episode-state-envelope-v1" and case["name"] == "initialized" + ] + assert episode_cases, "expected a participant-episode-state-envelope-v1 'initialized' case in the report" + episode_case = episode_cases[0] + assert episode_case["passed"] is False + schema_diags = [diag for diag in episode_case["diagnostics"] if diag["code"] == "conformance.schema-invalid"] + assert schema_diags, f"expected a schema-invalid diagnostic, got {episode_case['diagnostics']}" + assert any(diag["address"] == "participant-episode-state-envelope-v1" for diag in schema_diags) From c38020628894b36ad8dc0440caddeb1caf358ede Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 06:34:34 +0200 Subject: [PATCH 64/70] Make local import lockfile resolved_source checkout-independent Persist a local: import's resolved_source as an SDL-base-relative POSIX path instead of an absolute, machine-specific path, so a committed aces.lock.json verifies on any checkout (CI, other contributors). ResolvedModule.root_file stays the absolute runtime Path used for reads, digesting, parsing, and cycle detection; OCI lock identity and all trust, digest, signature, version, and export-hash checks are unchanged. Pre-existing absolute-path lockfiles are treated as stale and fixed by re-running aces sdl resolve. Closes #551 --- changelog.d/551.fixed.md | 1 + ...1-import-lockfile-portability-preflight.md | 159 ++++++++++++++++++ .../packages/aces_sdl/module_registry.py | 19 ++- .../python/tests/test_sdl_module_registry.py | 41 ++++- 4 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 changelog.d/551.fixed.md create mode 100644 docs/decisions/issue-551-import-lockfile-portability-preflight.md diff --git a/changelog.d/551.fixed.md b/changelog.d/551.fixed.md new file mode 100644 index 000000000..ed8742561 --- /dev/null +++ b/changelog.d/551.fixed.md @@ -0,0 +1 @@ +`aces sdl resolve` now records `local:` imports with a checkout-independent, SDL-base-relative `resolved_source` (POSIX separators) instead of an absolute machine path, so a committed `aces.lock.json` is portable. `aces sdl verify-imports` now passes on any checkout regardless of its absolute path — including CI and other contributors' machines — and fails only when imported content actually changes. Lockfiles generated before this fix contain absolute paths and are treated as stale; re-run `aces sdl resolve` to regenerate them. diff --git a/docs/decisions/issue-551-import-lockfile-portability-preflight.md b/docs/decisions/issue-551-import-lockfile-portability-preflight.md new file mode 100644 index 000000000..8d1583468 --- /dev/null +++ b/docs/decisions/issue-551-import-lockfile-portability-preflight.md @@ -0,0 +1,159 @@ +# Issue 551 Import Lockfile Portability Preflight + +Date: 2026-06-15 + +Issue: #551. + +Requirement: none. The issue title, body, and acceptance criteria are the +contract. + +This note records architecture preflight guardrails for making SDL import +lockfiles checkout-independent. It is guidance for implementation only: it does +not change module resolution, lockfile serialization, CLI behavior, tests, or +published documentation. + +## Binding Sources + +- ADR-053 owns SDL module composition: imports are resolved through the module + registry, locked, checked against trust/digest/version/export policy, expanded + before semantic validation, and compiled as one canonical scenario. +- `docs/explain/sdl/parser.md`, `docs/explain/sdl/sections.md`, and + `docs/explain/sdl/runtime-architecture.md` describe the current user-facing + import source classes and CLI workflow. +- `aces_sdl.module_registry` owns `LockRecord`, `Lockfile`, + `ResolvedModule`, `resolve_import()`, `resolve_lock_records()`, + `load_lockfile()`, and `write_lockfile()`. The CLI should consume that model + rather than define a second lockfile contract. +- `.ground-control.yaml`, `.gc/plan-rules.md`, `noxfile.py`, and + `implementations/python/pyproject.toml` define the repository workflow and + verification graph for the eventual implementation. + +## Architecture Decisions + +- Store the persisted `resolved_source` for `local:` imports as a stable + SDL-base-relative identity, not an absolute checkout path. Use POSIX-style + separators so the lockfile is stable across supported developer and CI + checkouts. +- Keep `ResolvedModule.root_file` as the runtime filesystem path used for file + reads, parsing, digest calculation, cycle detection, and recursive expansion. + Do not reuse persisted lock identity as the runtime path. +- Preserve full structural lockfile comparison in `aces sdl verify-imports` + once `resolve_lock_records()` emits checkout-stable records. Excluding + `resolved_source` from comparison would not satisfy the acceptance criterion + that `aces sdl resolve` writes no absolute local machine paths. +- Keep OCI `resolved_source` semantics unchanged. Registry coordinates plus + manifest digest are already checkout-independent and supply-chain relevant. +- Treat previously committed absolute-path lockfiles as stale. The portable + behavior should come from rerunning `aces sdl resolve`, not from silently + accepting host-specific lock records forever. +- Keep `source` as the authored import declaration and `resolved_source` as the + resolved lock identity. Do not conflate either field with `root_file`, package + source shorthand, or module descriptor identity. + +## Required Incumbents + +- Lockfile model and persistence: + `LockRecord`, `Lockfile`, `LOCKFILE_NAME`, `LOCKFILE_SCHEMA_VERSION`, + `load_lockfile()`, `write_lockfile()`, and the deterministic + `json.dumps(..., sort_keys=True)` serialization already in the registry and + CLI. +- Resolution and validation: + `ImportDecl.normalized_source`, `ModuleDescriptor`, `_satisfies_version()`, + `_validate_digest_pin()`, `_descriptor_digest()`, + `_verify_allowed_parameters()`, `TrustPolicy`, and + `RegistryTrustPolicy`. +- Composition and parse flow: + `parse_sdl_file()`, `_load_normalized_data()`, + `aces_sdl.composition.expand_sdl_modules()`, import cycle detection, + namespace collision checks, and whole-scenario `SemanticValidator` + validation. +- Error handling: + `SDLParseError` / `SDLValidationError` inside SDL code and Typer + `BadParameter` for the existing CLI stale-lock envelope. +- Tests and workflow: + `implementations/python/tests/test_sdl_module_registry.py`, + `test_pipeline_determinism.py`, `nox -s tests`, `nox -s lint`, + `nox -s hygiene`, and `nox -s verify`. +- Compatibility boundary: + `implementations/python/src/aces/` is compatibility-only re-export code and + must not receive new implementation logic. + +## Cross-Cutting Layers + +- YAML/config parsing: local imports must still enter through + `_load_normalized_data()` and `ImportDecl` validation, with `SDLModel` + `extra="forbid"` closure. No ad hoc YAML or JSON parser should be introduced + for lockfile comparison. +- Filesystem security: continue resolving local imports from the SDL base + directory before reading files. The persisted identity may be relative, but + filesystem reads, existence checks, digesting, and recursive traversal must + use validated `Path` objects, not string concatenation. +- Trust and supply-chain policy: preserve `TrustPolicy`, + `allow_unsigned_local_sources`, registry allowlists, signature verification, + digest pins, lockfile digest checks, version matching, and export-hash checks. + The portability fix must not create a path that bypasses these gates. +- Persistence and determinism: keep one Pydantic lockfile model and one JSON + serialization shape. Do not add a parallel DTO, lockfile schema, comparison + schema, or CLI-only normalization map. +- CLI error envelope: keep missing/stale lockfile failures in Typer's existing + user-facing error path. Do not dump structural diffs that include host paths, + file contents, raw YAML payloads, environment variables, or tracebacks. +- OS/process exposure: the CLI may accept absolute scenario paths, but those + paths must not be persisted into local lock records. No shelling out, + environment-variable configuration, tokens, private keys, or network access + are required for this issue. +- Auth, API, persistence services, and logging: no control-plane auth, + authorization, HTTP API, database, audit-log, or runtime-manager behavior is + in scope. Do not add logging just to observe lock comparison. +- Repository policy: implementation changes should remain under + `implementations/python/packages/` and tests under + `implementations/python/tests/`; user-visible behavior needs a towncrier + fragment under `changelog.d/551.fixed.md`. + +## Extension Boundary + +Use one private lock-identity normalization seam in the module registry, +parameterized by import source class and SDL base directory. Local imports use +base-relative POSIX paths; OCI imports keep registry/digest identity. A future +source class should add one branch to that seam rather than change CLI +comparison or duplicate lock serialization. + +The seam must return only persisted identity. Runtime path resolution belongs +to `ResolvedModule.root_file`, so future changes such as additional local path +policy, packaged-resource imports, or alternate registry schemes do not force +callers to reinterpret `resolved_source` as a filesystem path. + +## Gotchas And Anti-Patterns + +Avoid: + +- excluding `resolved_source` from `verify-imports` while continuing to write + absolute local paths; +- adding `resolved_path`, `relative_source`, or another duplicate field instead + of fixing the existing `resolved_source` contract; +- computing relative strings before the path has been resolved and checked; +- using `Path.relative_to()` without a clear fallback or error policy for + existing local imports that intentionally resolve outside the SDL directory; +- letting Windows backslashes or drive roots leak into committed lockfiles; +- changing OCI lock identity, signature/trust behavior, digest pinning, + descriptor validation, or export-hash checks; +- comparing pretty JSON text, dict insertion order, object reprs, or partial + hand-built dictionaries instead of the canonical Pydantic lockfile dump; +- editing compatibility-only `implementations/python/src/aces/` wrappers; +- bumping published contract schemas, changing schema publication manifests, or + creating a new authority surface for this narrow bug fix; +- widening the issue into import sandboxing, registry caching, lockfile + migration tooling, or module packaging redesign. + +## Non-Goals + +- Implementing the portability change, tests, changelog, or documentation + updates in this preflight. +- Changing SDL import source classes, module descriptor semantics, trust policy + defaults, or OCI publishing behavior. +- Introducing a new lockfile schema version, duplicate schema, duplicate + validator, duplicate exception hierarchy, or alternate CLI verification path. +- Changing parser normalization, semantic validation, instantiation, compiler, + runtime, control-plane, backend conformance, or MCP behavior. +- Guaranteeing compatibility for stale absolute-path lockfiles without rerun of + `aces sdl resolve`. diff --git a/implementations/python/packages/aces_sdl/module_registry.py b/implementations/python/packages/aces_sdl/module_registry.py index 8d7d1a705..a77530f42 100644 --- a/implementations/python/packages/aces_sdl/module_registry.py +++ b/implementations/python/packages/aces_sdl/module_registry.py @@ -6,6 +6,7 @@ import hashlib import io import json +import os import tarfile from dataclasses import dataclass from pathlib import Path @@ -343,6 +344,22 @@ def _validate_digest_pin(actual_digest: str, expected_digest: str, *, source: st raise SDLParseError(f"Digest mismatch for import '{source}': {expected_digest!r} != {actual_digest!r}") +def _local_resolved_source(import_path: Path, base_dir: Path) -> str: + """Persisted lock identity for a ``local:`` import (issue #551). + + The lockfile is committed and verified across machines and CI, so a local + import's ``resolved_source`` must be a checkout-independent identity rather + than an absolute, machine-specific path. Express it relative to the SDL base + directory using POSIX separators so the same lockfile verifies on any + checkout. ``ResolvedModule.root_file`` remains the absolute runtime ``Path`` + used for reads, digesting, parsing, and cycle detection; this is the single + normalization seam for persisted local lock identity (OCI imports keep their + registry/digest identity). + """ + relative = os.path.relpath(import_path, base_dir.resolve()) + return Path(relative).as_posix() + + def resolve_import( import_decl: ImportDecl, *, @@ -414,7 +431,7 @@ def resolve_import( import_decl=import_decl, module_descriptor=descriptor, root_file=import_path, - resolved_source=str(import_path), + resolved_source=_local_resolved_source(import_path, base_dir), content_digest=content_digest, export_hash=_descriptor_digest(descriptor.exports), ) diff --git a/implementations/python/tests/test_sdl_module_registry.py b/implementations/python/tests/test_sdl_module_registry.py index 2670745a1..05de2f5a1 100644 --- a/implementations/python/tests/test_sdl_module_registry.py +++ b/implementations/python/tests/test_sdl_module_registry.py @@ -4,6 +4,7 @@ import base64 import json +import shutil import textwrap import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -261,7 +262,7 @@ def test_module_exports_are_enforced_for_importers(tmp_path: Path): """, ) - with pytest.raises(SDLValidationError): + with pytest.raises(SDLValidationError, match=r"Metric 'uptime' references undefined condition 'shared\.health'"): parse_sdl_file(root) @@ -325,6 +326,44 @@ def test_sdl_resolve_and_verify_detect_lockfile_drift(tmp_path: Path): assert "stale" in stale.output.lower() +def test_local_import_lockfile_is_checkout_independent(tmp_path: Path): + # Author the scenario under one absolute checkout path, with the imported + # module in a subdirectory so the persisted identity is a multi-segment + # relative path rather than a bare filename. + checkout_a = tmp_path / "checkout_a" + _local_module(checkout_a / "nodes" / "shared.yaml") + root_a = _root_import( + checkout_a / "root.yaml", + "source: local:nodes/shared.yaml\n namespace: shared", + ) + runner = CliRunner() + + resolve_result = runner.invoke(app, ["sdl", "resolve", str(root_a)]) + assert resolve_result.exit_code == 0, resolve_result.output + + # Acceptance: `resolve` writes no absolute machine paths for local: imports. + lock_text = (checkout_a / LOCKFILE_NAME).read_text(encoding="utf-8") + assert str(checkout_a) not in lock_text + lockfile = load_lockfile(checkout_a) + assert lockfile is not None + record = lockfile.imports[0] + assert not Path(record.resolved_source).is_absolute() + assert record.resolved_source == "nodes/shared.yaml" + + # Acceptance: `verify-imports` passes on a checkout at a different absolute + # path than the one that generated the lock. + checkout_b = tmp_path / "checkout_b" + shutil.copytree(checkout_a, checkout_b) + verify = runner.invoke(app, ["sdl", "verify-imports", str(checkout_b / "root.yaml")]) + assert verify.exit_code == 0, verify.output + + # ...and still fails when the imported content actually changes. + _local_module(checkout_b / "nodes" / "shared.yaml", version="1.2.4") + stale = runner.invoke(app, ["sdl", "verify-imports", str(checkout_b / "root.yaml")]) + assert stale.exit_code != 0 + assert "stale" in stale.output.lower() + + def test_signed_oci_import_resolution_and_publish_cli(tmp_path: Path): module_path = _local_module(tmp_path / "shared.yaml") runner = CliRunner() From 24e384b2d6a1fd2669893b7eec0998f397878112 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 07:04:34 +0200 Subject: [PATCH 65/70] Backfill schema-publication ledger entries for participant contract family The last_change change-ledger requirement in check_schema_publication.py (ADR-009 section 7, _check_change_ledger) was introduced on dev after the participant backend-facing contract family (ADR-060, issue #76) and the DSL-132/DSL-141 datastore additions had already landed. Per-PR CI compares schemas against dev, so those schema changes never required a ledger entry; the dev->main PR (#545) is the first evaluation against main, where all 11 schemas read as changed and the gate fails. Backfill a contract-facing last_change entry (summary + current content_hash) for each of the 11 affected published schemas so the dev->main schema publication gate passes. No schema content changes. --- contracts/schema-publication-manifest.json | 66 ++++++++++++++++++---- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/contracts/schema-publication-manifest.json b/contracts/schema-publication-manifest.json index 12bd2b1b7..d28290f7e 100644 --- a/contracts/schema-publication-manifest.json +++ b/contracts/schema-publication-manifest.json @@ -12,13 +12,21 @@ "contract_id": "backend-manifest-v2", "schema_path": "contracts/schemas/backend-manifest/backend-manifest-v2.json", "stability": "draft", - "content_hash": "da156411d9877ad75045569b47e3b98597b6dec23a827d176c1fc64d9532cc90" + "content_hash": "da156411d9877ad75045569b47e3b98597b6dec23a827d176c1fc64d9532cc90", + "last_change": { + "summary": "Extended the backend manifest with the participant backend-facing runtime capability surface (API-407 manifest extension; ADR-060, issue #76).", + "content_hash": "da156411d9877ad75045569b47e3b98597b6dec23a827d176c1fc64d9532cc90" + } }, { "contract_id": "backend-profile-v1", "schema_path": "contracts/schemas/profiles/backend-profile-v1.json", "stability": "draft", - "content_hash": "1c9ffe10f1c2c420610d1353dc1406bb0e0d63001c4f6a32e1aca061b164af1d" + "content_hash": "1c9ffe10f1c2c420610d1353dc1406bb0e0d63001c4f6a32e1aca061b164af1d", + "last_change": { + "summary": "Added the participant backend-facing contract identifiers (lifecycle-event, observation-envelope, shared-state-record, outcome-report) to the backend profile contract vocabulary (ADR-060, issue #76).", + "content_hash": "1c9ffe10f1c2c420610d1353dc1406bb0e0d63001c4f6a32e1aca061b164af1d" + } }, { "contract_id": "concept-families-v1", @@ -106,13 +114,21 @@ "contract_id": "participant-behavior-history-event-stream-v1", "schema_path": "contracts/schemas/control-plane/participant-behavior-history-event-stream-v1.json", "stability": "draft", - "content_hash": "4aeeaba0d4834a38bed72aca834733806fe8625d763b15e7d2dba764637f4234" + "content_hash": "4aeeaba0d4834a38bed72aca834733806fe8625d763b15e7d2dba764637f4234", + "last_change": { + "summary": "Revised the participant behavior-history event-stream contract as part of the backend-facing contract family publication (ADR-060, issue #76).", + "content_hash": "4aeeaba0d4834a38bed72aca834733806fe8625d763b15e7d2dba764637f4234" + } }, { "contract_id": "participant-context-view-v1", "schema_path": "contracts/schemas/control-plane/participant-context-view-v1.json", "stability": "draft", - "content_hash": "ff2e5866bde73898ead051cd5cfb20d32168ad3f8a6f9775d8481c728c40c24b" + "content_hash": "ff2e5866bde73898ead051cd5cfb20d32168ad3f8a6f9775d8481c728c40c24b", + "last_change": { + "summary": "Initial publication of the participant context-view control-plane contract: the participant-visible context projection in the backend-facing contract family (ADR-060, issue #76).", + "content_hash": "ff2e5866bde73898ead051cd5cfb20d32168ad3f8a6f9775d8481c728c40c24b" + } }, { "contract_id": "participant-episode-history-event-stream-v1", @@ -130,7 +146,11 @@ "contract_id": "participant-history-view-v1", "schema_path": "contracts/schemas/control-plane/participant-history-view-v1.json", "stability": "draft", - "content_hash": "380257f288c098c0b94e343dbeb6d51c5216c6b14d91543cc2c147dd06633435" + "content_hash": "380257f288c098c0b94e343dbeb6d51c5216c6b14d91543cc2c147dd06633435", + "last_change": { + "summary": "Initial publication of the participant history-view control-plane contract: the participant-local interaction-history projection in the backend-facing contract family (ADR-060, issue #76).", + "content_hash": "380257f288c098c0b94e343dbeb6d51c5216c6b14d91543cc2c147dd06633435" + } }, { "contract_id": "participant-implementation-manifest-v1", @@ -148,31 +168,51 @@ "contract_id": "participant-lifecycle-event-v1", "schema_path": "contracts/schemas/participant-runtime/participant-lifecycle-event-v1.json", "stability": "draft", - "content_hash": "769ac4f54a36ec7e6031f584595555f7e8faea41ce0bf05b656aaf33e413d5c6" + "content_hash": "769ac4f54a36ec7e6031f584595555f7e8faea41ce0bf05b656aaf33e413d5c6", + "last_change": { + "summary": "Initial publication of the participant lifecycle-event runtime contract: episode/lifecycle event records in the backend-facing contract family (ADR-060, issue #76).", + "content_hash": "769ac4f54a36ec7e6031f584595555f7e8faea41ce0bf05b656aaf33e413d5c6" + } }, { "contract_id": "participant-observation-envelope-v1", "schema_path": "contracts/schemas/participant-runtime/participant-observation-envelope-v1.json", "stability": "draft", - "content_hash": "57c7ca12df146ce59b164551a5a87a2e72dd1f9b6644e3b0975b7da1a71e8d40" + "content_hash": "57c7ca12df146ce59b164551a5a87a2e72dd1f9b6644e3b0975b7da1a71e8d40", + "last_change": { + "summary": "Initial publication of the participant observation-envelope runtime contract: the participant observation payload envelope in the backend-facing contract family (ADR-060, issue #76).", + "content_hash": "57c7ca12df146ce59b164551a5a87a2e72dd1f9b6644e3b0975b7da1a71e8d40" + } }, { "contract_id": "participant-outcome-report-v1", "schema_path": "contracts/schemas/participant-runtime/participant-outcome-report-v1.json", "stability": "draft", - "content_hash": "1b65d36136812ae18c80ba7822df69b0ca65b569fd4cc2e2bd6c1fd07e5e4abf" + "content_hash": "1b65d36136812ae18c80ba7822df69b0ca65b569fd4cc2e2bd6c1fd07e5e4abf", + "last_change": { + "summary": "Initial publication of the participant outcome-report runtime contract: participant outcome reporting in the backend-facing contract family (ADR-060, issue #76).", + "content_hash": "1b65d36136812ae18c80ba7822df69b0ca65b569fd4cc2e2bd6c1fd07e5e4abf" + } }, { "contract_id": "participant-shared-state-record-v1", "schema_path": "contracts/schemas/participant-runtime/participant-shared-state-record-v1.json", "stability": "draft", - "content_hash": "a3ad56415946417bfbd19cd637886d8442a3e2be510212471e1fcb139cf241d1" + "content_hash": "a3ad56415946417bfbd19cd637886d8442a3e2be510212471e1fcb139cf241d1", + "last_change": { + "summary": "Initial publication of the participant shared-state-record runtime contract: shared-state records in the backend-facing contract family (ADR-060, issue #76).", + "content_hash": "a3ad56415946417bfbd19cd637886d8442a3e2be510212471e1fcb139cf241d1" + } }, { "contract_id": "participant-status-view-v1", "schema_path": "contracts/schemas/control-plane/participant-status-view-v1.json", "stability": "draft", - "content_hash": "aef73b7d52c99de2a51cb2a58de0e30c37fc7fb2af5627cd6241a6aad288d07c" + "content_hash": "aef73b7d52c99de2a51cb2a58de0e30c37fc7fb2af5627cd6241a6aad288d07c", + "last_change": { + "summary": "Initial publication of the participant status-view control-plane contract: the participant status/lifecycle projection in the backend-facing contract family (ADR-060, issue #76).", + "content_hash": "aef73b7d52c99de2a51cb2a58de0e30c37fc7fb2af5627cd6241a6aad288d07c" + } }, { "contract_id": "processor-manifest-v2", @@ -212,7 +252,11 @@ "contract_id": "sdl-authoring-input-v1", "schema_path": "contracts/schemas/sdl/sdl-authoring-input-v1.json", "stability": "draft", - "content_hash": "805355fa8df9c0ca360f89c1379fb55052dcc176c83d80a0b8963096ffa38134" + "content_hash": "805355fa8df9c0ca360f89c1379fb55052dcc176c83d80a0b8963096ffa38134", + "last_change": { + "summary": "Extended the SDL authoring-input scenario schema for the DSL-132/DSL-141 runtime datastore-node surface: per-node engine provenance, listener topology, datastore cardinality, and structured index/template mapping manifests.", + "content_hash": "805355fa8df9c0ca360f89c1379fb55052dcc176c83d80a0b8963096ffa38134" + } }, { "contract_id": "semantic-profile-v1", From a55b64994d315d8fd4a26d6f28715f3cfd4bc107 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 07:43:57 +0200 Subject: [PATCH 66/70] Bump 5 Python dependencies to clear moderate Dependabot advisories Update the lockfile to patched versions and raise the directly-declared floors so a future re-lock cannot regress: - asyncssh 2.22.0 -> 2.23.1 (GHSA-g794-3fmp-753h, AuthorizedKeysFile %u path traversal) - cryptography 46.0.6 -> 49.0.0 (GHSA-p423-j2cm-9vmq, non-contiguous-buffer overflow) - idna 3.11 -> 3.18 (GHSA-65pc-fj4g-8rjx, encode() CVE-2024-3651 bypass) - pytest 9.0.2 -> 9.1.0 (GHSA-6w46-j5rx-g56g, tmpdir handling) - starlette 1.0.0 -> 1.3.1 (GHSA-86qp-5c8j-p5mr, missing Host-header validation) Full nox verify (hygiene, policy, lint, contracts, full pytest + integration, docs) passes under the bumped versions. --- .../+dependabot-security-bumps.security.md | 1 + implementations/python/pyproject.toml | 6 +- implementations/python/uv.lock | 129 +++++++++--------- 3 files changed, 67 insertions(+), 69 deletions(-) create mode 100644 changelog.d/+dependabot-security-bumps.security.md diff --git a/changelog.d/+dependabot-security-bumps.security.md b/changelog.d/+dependabot-security-bumps.security.md new file mode 100644 index 000000000..bd0a067ed --- /dev/null +++ b/changelog.d/+dependabot-security-bumps.security.md @@ -0,0 +1 @@ +Bumped `asyncssh` (2.23.1), `cryptography` (49.0.0), `idna` (3.18), `pytest` (9.1.0), and `starlette` (1.3.1) in the Python lockfile to clear five moderate Dependabot advisories: AsyncSSH `AuthorizedKeysFile %u` path traversal (GHSA-g794-3fmp-753h), cryptography non-contiguous-buffer overflow (GHSA-p423-j2cm-9vmq), idna `encode()` CVE-2024-3651 bypass (GHSA-65pc-fj4g-8rjx), pytest tmpdir handling (GHSA-6w46-j5rx-g56g), and Starlette missing Host-header validation (GHSA-86qp-5c8j-p5mr). The directly-declared floors for `cryptography`, `asyncssh`, and `pytest` were raised to their patched versions to prevent regression. diff --git a/implementations/python/pyproject.toml b/implementations/python/pyproject.toml index f56130aa4..7ec563d11 100644 --- a/implementations/python/pyproject.toml +++ b/implementations/python/pyproject.toml @@ -12,18 +12,18 @@ dependencies = [ "pydantic>=2.0.0", "rich>=13.0.0", "PyYAML>=6.0", - "cryptography>=45.0.0", + "cryptography>=46.0.7", "fastapi>=0.115.0", "uvicorn[standard]>=0.34.0", "sse-starlette>=2.0.0", - "asyncssh>=2.17.0", + "asyncssh>=2.23.0", "mcp>=1.0.0", "packaging>=23.0", ] [project.optional-dependencies] dev = [ - "pytest>=8.0.0", + "pytest>=9.0.3", "pytest-mock>=3.12.0", "coverage>=7.0.0", "httpx>=0.27.0", diff --git a/implementations/python/uv.lock b/implementations/python/uv.lock index e03c3ab59..58771e0d9 100644 --- a/implementations/python/uv.lock +++ b/implementations/python/uv.lock @@ -55,9 +55,9 @@ docs = [ [package.metadata] requires-dist = [ - { name = "asyncssh", specifier = ">=2.17.0" }, + { name = "asyncssh", specifier = ">=2.23.0" }, { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "cryptography", specifier = ">=45.0.0" }, + { name = "cryptography", specifier = ">=46.0.7" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "furo", marker = "extra == 'docs'", specifier = ">=2024.5.6" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, @@ -66,7 +66,7 @@ requires-dist = [ { name = "myst-parser", marker = "extra == 'docs'", specifier = ">=3.0.0" }, { name = "packaging", specifier = ">=23.0" }, { name = "pydantic", specifier = ">=2.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "rich", specifier = ">=13.0.0" }, @@ -121,15 +121,15 @@ wheels = [ [[package]] name = "asyncssh" -version = "2.22.0" +version = "2.23.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/d5/957886c316466349d55c4de6a688a10a98295c0b4429deb8db1a17f3eb19/asyncssh-2.22.0.tar.gz", hash = "sha256:c3ce72b01be4f97b40e62844dd384227e5ff5a401a3793007c42f86a5c8eb537", size = 540523, upload-time = "2025-12-21T23:38:30.5Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/95/212d3d394f2a6ccb3f95056d3b9a7ce13c2f58503cbd6a38d037ef48cb13/asyncssh-2.23.1.tar.gz", hash = "sha256:d9dc3bc0206f3e4b5d80d1c0e6a24af2b4ad4beb556884c41fb2ad1c7ca3f44f", size = 542883, upload-time = "2026-06-07T14:15:18.832Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/ae/0da2f2214fc183338af1afe5a103a2052fd03464e8eafbd827abff58a4d0/asyncssh-2.22.0-py3-none-any.whl", hash = "sha256:d16465ccdf1ed20eba1131b14415b155e047f6f5be0d19f39c2e0b61331ee0e7", size = 374938, upload-time = "2025-12-21T23:38:28.976Z" }, + { url = "https://files.pythonhosted.org/packages/ef/94/9aa81bde40627af70388d634152e7c53e7533788e662b8093047501a1473/asyncssh-2.23.1-py3-none-any.whl", hash = "sha256:f68e55476d41253d785bcac9a90834ae5fdea0f417bd6d7182608bda248de88e", size = 376054, upload-time = "2026-06-07T14:15:17.375Z" }, ] [[package]] @@ -453,61 +453,58 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.6" +version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, - { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, - { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, - { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, - { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, - { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, - { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, - { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, - { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, - { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, - { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, - { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, - { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, - { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, - { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, - { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, - { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, - { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, - { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, - { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, - { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, - { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, - { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, - { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, - { url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" }, - { url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" }, - { url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" }, - { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] [[package]] @@ -648,11 +645,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -1040,7 +1037,7 @@ crypto = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1049,9 +1046,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, ] [[package]] @@ -1528,15 +1525,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] From 6858d74cb28e02261b46ff281dcad226f830772f Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 16:42:56 +0200 Subject: [PATCH 67/70] Split aces_sdl.validator into a per-seam mixin package - Convert validator.py (4,139 lines) into a package of <=600-line per-seam mixin modules (core, runtime families, relationships, content/objectives, workflows, sections) behind an API-stable SemanticValidator re-export (#42). - Pure refactor: methods moved verbatim; no validation behavior, diagnostics, pass ordering, exception type, or public-API change. The #38 cycle break (PR #53) is the satisfied ordering prerequisite. - Adapt the error-vs-advisory boundary lint (test_sdl_diagnostic_boundary) to scan the package and aggregate methods across the composed mixins; its rules and synthetic negative-path fixture are unchanged, so the IMP-3/#505 control keeps full teeth. Rationale recorded on the issue. - Remove validator.py from tools/policy/oversized_allowlist.yaml; repoint the F821 forward-ref per-file-ignore to validator/_sections.py. - Repoint stale validator.py references in the semantic-coverage matrix (-> validator/__init__.py) and specs/formal prose (-> package) to the new layout. --- changelog.d/42.changed.md | 1 + ...ue-42-validator-package-split-preflight.md | 157 + .../reference/shared-semantic-integrity.md | 14 +- .../sdl/scenario-delivery-drift-audit.md | 8 +- docs/index.md | 1 + .../packages/aces_sdl/semantics/__init__.py | 4 +- .../python/packages/aces_sdl/validator.py | 4139 ----------------- .../packages/aces_sdl/validator/__init__.py | 37 + .../aces_sdl/validator/_content_objectives.py | 362 ++ .../packages/aces_sdl/validator/_core.py | 424 ++ .../validator/_nodes_infra_network.py | 343 ++ .../aces_sdl/validator/_relationships.py | 491 ++ .../validator/_runtime_identity_data.py | 359 ++ .../aces_sdl/validator/_runtime_mail.py | 307 ++ .../aces_sdl/validator/_runtime_platform.py | 527 +++ .../aces_sdl/validator/_runtime_services.py | 487 ++ .../packages/aces_sdl/validator/_sections.py | 215 + .../packages/aces_sdl/validator/_support.py | 30 + .../aces_sdl/validator/_workflows_analysis.py | 337 ++ .../aces_sdl/validator/_workflows_verify.py | 338 ++ implementations/python/pyproject.toml | 2 +- .../tests/test_sdl_diagnostic_boundary.py | 37 +- specs/formal/assessment/README.md | 2 +- .../formal/assessment/pipeline-consistency.md | 2 +- specs/formal/objectives/README.md | 2 +- .../declarative-objective-semantics.md | 2 +- specs/formal/objectives/window-consistency.md | 2 +- specs/formal/participant-semantics/README.md | 6 +- specs/formal/realization/README.md | 2 +- .../explicitness-and-realization.md | 4 +- specs/formal/workflows/README.md | 2 +- specs/formal/workflows/compensation.md | 2 +- specs/formal/workflows/state-machine.md | 2 +- tools/policy/oversized_allowlist.yaml | 1 - 34 files changed, 4471 insertions(+), 4178 deletions(-) create mode 100644 changelog.d/42.changed.md create mode 100644 docs/decisions/issue-42-validator-package-split-preflight.md delete mode 100644 implementations/python/packages/aces_sdl/validator.py create mode 100644 implementations/python/packages/aces_sdl/validator/__init__.py create mode 100644 implementations/python/packages/aces_sdl/validator/_content_objectives.py create mode 100644 implementations/python/packages/aces_sdl/validator/_core.py create mode 100644 implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py create mode 100644 implementations/python/packages/aces_sdl/validator/_relationships.py create mode 100644 implementations/python/packages/aces_sdl/validator/_runtime_identity_data.py create mode 100644 implementations/python/packages/aces_sdl/validator/_runtime_mail.py create mode 100644 implementations/python/packages/aces_sdl/validator/_runtime_platform.py create mode 100644 implementations/python/packages/aces_sdl/validator/_runtime_services.py create mode 100644 implementations/python/packages/aces_sdl/validator/_sections.py create mode 100644 implementations/python/packages/aces_sdl/validator/_support.py create mode 100644 implementations/python/packages/aces_sdl/validator/_workflows_analysis.py create mode 100644 implementations/python/packages/aces_sdl/validator/_workflows_verify.py diff --git a/changelog.d/42.changed.md b/changelog.d/42.changed.md new file mode 100644 index 000000000..16d7a52c5 --- /dev/null +++ b/changelog.d/42.changed.md @@ -0,0 +1 @@ +Split the oversized `aces_sdl.validator` module (4,139 lines) into a package of per-validation-seam mixin modules (`_core`, runtime families, relationships, content/objectives, workflows, sections), each under the ADR-015 600-line cap, behind an API-stable `SemanticValidator` re-export. Pure refactor: no validation behavior, diagnostics, pass ordering, or public-API change. The `validator.py` entry is removed from the oversized-source allowlist. diff --git a/docs/decisions/issue-42-validator-package-split-preflight.md b/docs/decisions/issue-42-validator-package-split-preflight.md new file mode 100644 index 000000000..8faa3db0e --- /dev/null +++ b/docs/decisions/issue-42-validator-package-split-preflight.md @@ -0,0 +1,157 @@ +# Issue 42 Validator Package Split Preflight + +Date: 2026-06-15 + +Issue: #42. + +Requirement: none. The GitHub issue title, body, and acceptance criteria are +the implementation contract. + +This note records architecture preflight guardrails for splitting +`aces_sdl.validator` into a package. It is guidance for implementation only: it +does not split the validator, alter validation behavior, change tests, drain +the oversized allowlist, or add release-note content. + +## Binding Sources + +- ADR-015 owns the SDL-to-processor layering rule and the 600-line source-file + cap. `aces_sdl` must not import `aces_processor`, and the oversized allowlist + may only shrink. +- In this checkout, `implementations/python/packages/aces_sdl/validator.py` is + 4,139 lines. Treat the issue body's older 1,440-line count as stale; the + acceptance criteria still require every file in the replacement package to be + at or below 600 lines. +- `tools/policy/oversized_allowlist.yaml` currently carries + `implementations/python/packages/aces_sdl/validator.py`; the split PR removes + that entry only after the file has genuinely become a package whose Python + files are all below the cap. +- `specs/sdl/diagnostics.md` owns the error/advisory boundary. Validation + errors remain fatal and collect-all; advisories remain non-fatal and + structurally separate. +- `implementations/python/packages/aces_sdl/parser.py` and + `instantiate.py` are the in-package callers of `SemanticValidator`. +- Direct `aces_sdl.validator` import sites are currently limited to + `implementations/python/tests/test_runtime_service_units.py` importing + `SemanticValidator` and + `implementations/python/tests/test_sdl_diagnostic_boundary.py` importing the + module. Legacy `aces.core.sdl.validator` imports also resolve through the + same public package surface and currently import only `SemanticValidator`. +- `implementations/python/src/aces/core/sdl/validator.py` is compatibility-only + and re-exports `aces_sdl.validator` through `aces._compat.reexport`. +- `implementations/python/tests/test_sdl_diagnostic_boundary.py` imports + `aces_sdl.validator` as a module and calls `inspect.getsource()` on it, so the + package shim must not make the diagnostic-boundary lint blind by accident. +- `.ground-control.yaml`, `.gc/plan-rules.md`, `noxfile.py`, and + `tools/check_repo_policy.py` define the eventual verification graph. + +## Architecture Decisions + +- Treat the change as a pure module-boundary refactor. Public behavior, + validation order, error strings, warning strings, and exception types are not + part of the refactor surface. +- Convert `aces_sdl.validator` into a package with a thin public surface. The + package must export `SemanticValidator` and any deliberately preserved public + names. The current module has no `__all__`, so adding one changes star-import + behavior even though `aces._compat.reexport` still copies module attributes; + do that only after an explicit public-surface comparison. +- Preserve direct import compatibility for both owning-package callers + (`aces_sdl.validator`) and compatibility callers (`aces.core.sdl.validator`). + Do not move implementation logic into `implementations/python/src/aces/`. +- Split by validation domain, not by arbitrary line ranges. Cohesive domains in + the current file are: core state/error collection and reference indexes, + diagnostic renderers, runtime network/application/listener/process checks, + runtime identity/file/database/DNS/datastore/platform/forwarding/orchestration + and mail checks, relationship cross-checks, participant/objective/workflow + checks, and legacy SDL section checks. +- If private mixins are used to keep one `SemanticValidator` type while moving + methods into domain files, keep them stateless and package-private. They are + a mechanical source split, not a new validator service, registry, DTO layer, + exception hierarchy, or plugin mechanism. +- Keep the explicit `validate()` ordering as the coordination seam. A future + semantic pass should add one domain method plus one ordered call there, not a + second pass registry or competing workflow engine. + +## Required Incumbents + +- Validation model and errors: `Scenario`, Pydantic model validators, + `SDLValidationError`, `SDLError`, `SDLParseError`, and + `SDLInstantiationError`. +- Shared SDL helpers: `is_variable_ref()`, `extract_variable_name()`, + `flatten_entities()`, `classify_scenario_explicitness()`, + `collect_qualified_runtime_family_refs()`, `SimpleProperties`, `NodeType`, + and the runtime enum/model modules already imported by the validator. +- Existing semantic analyzers: + `aces_sdl.semantics.assessment`, `objective_semantics`, + `participant_behavior`, `participant_outcome`, and `workflow`. Do not inline + or duplicate their schemas, issue codes, or dependency logic inside the + package split. +- Compatibility helper: `aces._compat.reexport` remains the only compatibility + shim pattern for `aces.core.sdl.validator`. +- Repository policy: ADR-015 line cap, layering rule, allowlist-drain checks, + changelog-fragment policy, and the canonical nox `verify` session. + +## Cross-Cutting Layers + +- Structural/schema layer: YAML normalization and Pydantic closed-world parsing + still run before `SemanticValidator`. The split must not add schema files, + change published contracts, or move cross-reference checks into structural + model validators. +- Semantic-validation layer: all cross-section reference checks continue to + append through `_err()` and raise one `SDLValidationError` containing the full + collected list. Advisory checks continue through `_warn()` and `warnings`. +- Diagnostic-boundary layer: package conversion must keep the + error/advisory-channel guard meaningful. If inherited/private split methods + make `inspect.getsource(aces_sdl.validator)` insufficient, add package-aware + coverage rather than weakening or deleting the guard. +- Import/layering layer: package submodules remain under `aces_sdl`, use + relative imports where appropriate, do not import `aces_processor`, and do not + introduce implementation code under the compatibility tree. +- Error-envelope and leakage layer: preserve existing human-readable error + strings and do not add logging that dumps raw SDL payloads, environment + variables, credentials, file contents, or tracebacks. +- OS/process exposure layer: this refactor should not introduce subprocesses, + command-line token handling, environment-variable configuration, network + calls, database state, or filesystem traversal behavior. +- Repository-policy layer: every Python file under the new + `aces_sdl/validator/` package, including `__init__.py`, must be at or below + 600 lines after the allowlist entry is removed. + +## Extension Boundary + +The extension seam is the package-private validation-domain module plus the +single public `SemanticValidator` coordinator. Future validation domains should +land in a new or existing subdomain file, export only private helpers or private +mixin methods, and be wired once into the explicit `validate()` order. The +package surface should remain stable so callers never need to know which +submodule owns a pass. + +## Gotchas And Anti-Patterns + +Avoid: + +- changing import lines that reference `aces_sdl.validator`, even if a direct + submodule import looks cleaner; +- losing the module-level source-inspection contract used by + `test_sdl_diagnostic_boundary.py`; +- changing error text, error ordering, warning ordering, or advisory severity + while moving code; +- "fixing" incidental validation behavior during the split, including + reference-parsing edge cases; +- duplicating issue-renderer dictionaries, semantic analyzers, named-reference + indexes, service-ref parsing, exception classes, or schema definitions; +- making `__init__.py` a second large implementation module that violates the + same size-cap pressure the issue is meant to remove; +- editing `CHANGELOG.md` directly. Repo policy expects a fragment such as + `changelog.d/42.changed.md` for the implementation PR. + +## Non-Goals + +- No behavior change, schema change, validation-rule change, parser change, or + instantiation change. +- No new authorization, persistence, runtime API, logging, CLI, environment, or + network surface. +- No new public validator abstraction beyond preserving `SemanticValidator` at + `aces_sdl.validator`. +- No changes to existing tests just to make the refactor pass; add focused + package-aware coverage only if the split would otherwise weaken an existing + guard. diff --git a/docs/explain/reference/shared-semantic-integrity.md b/docs/explain/reference/shared-semantic-integrity.md index 9f12e6c8b..300c9a55a 100644 --- a/docs/explain/reference/shared-semantic-integrity.md +++ b/docs/explain/reference/shared-semantic-integrity.md @@ -224,12 +224,12 @@ so they are tracked by their own requirements, not here. | Construct family | Owning requirement(s) | Phases covered | Realizing artifacts | Status | | --- | --- | --- | --- | --- | -| Fail-closed semantic validation (cross-cutting gate) | SEM-201 | validation, instantiation | `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/tests/test_sdl_validator.py` | active | -| Stable identifiers, parameterized values, and qualified references | DSL-101, DSL-102, SEM-205 | authoring, validation, instantiation, compilation, planning, observation | `implementations/python/packages/aces_sdl/parser.py`, `implementations/python/packages/aces_sdl/variables.py`, `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/tests/test_sdl_parser.py`, `implementations/python/tests/test_sdl_validator.py` | active | +| Fail-closed semantic validation (cross-cutting gate) | SEM-201 | validation, instantiation | `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/tests/test_sdl_validator.py` | active | +| Stable identifiers, parameterized values, and qualified references | DSL-101, DSL-102, SEM-205 | authoring, validation, instantiation, compilation, planning, observation | `implementations/python/packages/aces_sdl/parser.py`, `implementations/python/packages/aces_sdl/variables.py`, `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/tests/test_sdl_parser.py`, `implementations/python/tests/test_sdl_validator.py` | active | | Deterministic module composition and canonical-identity stability across expansion | DSL-103, SEM-205 | authoring, validation, compilation | `implementations/python/packages/aces_sdl/composition.py`, `implementations/python/packages/aces_sdl/module_registry.py`, `specs/formal/composition-readiness.md`, `implementations/python/tests/test_sdl_module_registry.py` | active | | Instantiation and revalidation of concrete scenarios | RUN-301 | instantiation, validation | `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/tests/test_sdl_validator.py`, `implementations/python/tests/test_run_300_lifecycle.py` | active | | Objective windows, referenced scopes, reachability, and refresh | SEM-202 | validation, compilation, planning | `implementations/python/packages/aces_sdl/semantics/objectives.py`, `specs/formal/objectives/README.md`, `specs/formal/objectives/window-consistency.md`, `implementations/python/tests/test_semantics_objectives.py`, `implementations/python/tests/test_fm2_semantics.py` | active | -| Declarative objective actor binding, target resolution, success interpretation, and dependency ordering | DSL-112, SEM-207 | authoring, validation, instantiation, compilation, planning | `implementations/python/packages/aces_sdl/objectives.py`, `implementations/python/packages/aces_sdl/semantics/objective_semantics.py`, `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `specs/formal/objectives/README.md`, `specs/formal/objectives/declarative-objective-semantics.md`, `implementations/python/tests/test_semantics_objectives.py`, `implementations/python/tests/test_fm2_semantics.py`, `implementations/python/tests/test_sdl_validator.py` | active | +| Declarative objective actor binding, target resolution, success interpretation, and dependency ordering | DSL-112, SEM-207 | authoring, validation, instantiation, compilation, planning | `implementations/python/packages/aces_sdl/objectives.py`, `implementations/python/packages/aces_sdl/semantics/objective_semantics.py`, `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `specs/formal/objectives/README.md`, `specs/formal/objectives/declarative-objective-semantics.md`, `implementations/python/tests/test_semantics_objectives.py`, `implementations/python/tests/test_fm2_semantics.py`, `implementations/python/tests/test_sdl_validator.py` | active | | Workflow control semantics (branching, joins, calling, retry, completion, history) | DSL-113, SEM-203 | authoring, validation, compilation, planning, execution, observation | `implementations/python/packages/aces_sdl/orchestration.py`, `implementations/python/packages/aces_sdl/semantics/workflow.py`, `specs/formal/workflows/README.md`, `specs/formal/workflows/state-machine.md`, `implementations/python/tests/test_sdl_validator.py`, `implementations/python/tests/test_runtime_models.py`, `implementations/python/tests/test_sdl_models.py` | active | | Workflow compensation semantics (registration, triggering, ordering, observation) | SEM-204 | validation, compilation, execution, observation | `implementations/python/packages/aces_sdl/semantics/workflow.py`, `specs/formal/workflows/compensation.md`, `implementations/python/tests/test_sdl_validator.py`, `implementations/python/tests/test_runtime_manager.py` | partial | | Assessment model and pipeline semantics (conditions, metrics, evaluations, TLOs, goals) | DSL-110, SEM-206 | authoring, validation, compilation, planning, execution, observation | `implementations/python/packages/aces_sdl/scoring.py`, `implementations/python/packages/aces_sdl/conditions.py`, `implementations/python/packages/aces_sdl/semantics/assessment.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `specs/formal/assessment/README.md`, `specs/formal/assessment/pipeline-consistency.md`, `implementations/python/tests/test_semantics_assessment.py`, `implementations/python/tests/test_sdl_models.py`, `implementations/python/tests/test_fm2_semantics.py` | active | @@ -241,10 +241,10 @@ so they are tracked by their own requirements, not here. | Backend and processor identity, capability, and compatibility manifests | API-401, API-412 | planning, execution | `implementations/python/packages/aces_processor/manifest.py`, `implementations/python/packages/aces_processor/capabilities.py`, `implementations/python/packages/aces_contracts/apparatus.py`, `implementations/python/packages/aces_contracts/manifest_authority.py`, `implementations/python/tests/test_backend_manifest.py`, `implementations/python/tests/test_processor_manifest.py` | active | | Concept authority, controlled vocabularies, reference models, and semantic profiles (meta-layer) | GOV-920 | authoring, validation, compilation, planning, execution | `specs/concept-authority/concept-authority.md`, `specs/concept-authority/semantic-profiles.md`, `implementations/python/packages/aces_contracts/semantic_profiles.py`, `implementations/python/packages/aces_contracts/controlled_vocabularies.py`, `implementations/python/packages/aces_contracts/reference_models.py`, `docs/explain/reference/shared-concept-model.md`, `implementations/python/tests/test_concept_authority.py`, `implementations/python/tests/test_semantic_profiles.py` | active | | Participant episode lifecycle boundaries (initialization, reset, completion, timeout, truncation, interruption) | RUN-311, SEM-222 | execution, observation | `docs/decisions/adrs/adr-013-participant-episode-lifecycle-boundaries.md`, `implementations/python/tests/test_run_311_participant_episode_lifecycle.py` | partial | -| Declarative participant framing (identity, role, starting conditions, authority anchors, operating scope) | ACT-601 | authoring, validation | `implementations/python/packages/aces_sdl/agents.py`, `implementations/python/packages/aces_sdl/validator.py`, `docs/decisions/adrs/adr-020-declarative-participant-framing-boundaries.md`, `implementations/python/tests/test_sdl_models.py`, `implementations/python/tests/test_sdl_validator.py` | active | +| Declarative participant framing (identity, role, starting conditions, authority anchors, operating scope) | ACT-601 | authoring, validation | `implementations/python/packages/aces_sdl/agents.py`, `implementations/python/packages/aces_sdl/validator/__init__.py`, `docs/decisions/adrs/adr-020-declarative-participant-framing-boundaries.md`, `implementations/python/tests/test_sdl_models.py`, `implementations/python/tests/test_sdl_validator.py` | active | | Participant behavior semantics (actions, observations, state transitions) | ACT-602, SEM-208 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/tests/test_sem_208_participant_behavior.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | -| Multi-participant interaction and participant-local histories | SEM-209, SEM-226 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_conformance/conformance.py`, `implementations/python/tests/test_sem_208_participant_behavior.py`, `implementations/python/tests/test_runtime_conformance.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | -| Visibility and information-boundary semantics | SEM-210 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/tests/test_sem_208_participant_behavior.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | active | +| Multi-participant interaction and participant-local histories | SEM-209, SEM-226 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_conformance/conformance.py`, `implementations/python/tests/test_sem_208_participant_behavior.py`, `implementations/python/tests/test_runtime_conformance.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | +| Visibility and information-boundary semantics | SEM-210 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_sdl/semantics/participant_behavior.py`, `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/tests/test_sem_208_participant_behavior.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | active | | Participant preconditions, effects, failure, causality, and attribution semantics | SEM-211, SEM-212 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/packages/aces_sdl/participant_action_semantics.py`, `implementations/python/packages/aces_sdl/participant_attribution_semantics.py`, `implementations/python/packages/aces_sdl/participant_behavior.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_conformance/conformance.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/tests/test_sem_211_participant_action_semantics.py`, `implementations/python/tests/test_sem_212_participant_attribution_semantics.py`, `implementations/python/tests/test_runtime_conformance.py`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | | Participant temporal, tool/affordance, and decision-surface semantics | SEM-213, SEM-219, SEM-220 | authoring, validation, compilation, planning, execution, observation | `specs/formal/participant-semantics/README.md`, `docs/decisions/adrs/adr-022-participant-behavior-and-interaction-semantics.md`, `implementations/python/tests/test_participant_semantics_invariant_oracle.py` | partial | | Participant reference trajectories, demonstrations, budgets, and quota/exhaustion semantics | SEM-221, SEM-223 | — | — | planned | @@ -252,5 +252,5 @@ so they are tracked by their own requirements, not here. | Derived operational context views (portable meaning and comparability) | SEM-214 | — | — | planned | | Evidence, evaluation, view-boundary, and observability-plane semantics | SEM-216, SEM-224, SEM-225 | — | — | planned | | External knowledge bindings semantics | SEM-217 | — | — | planned | -| Explicitness and realization semantics (binding declarations vs processor/backend realization) | SEM-218 | validation, instantiation, compilation, planning, execution, observation | `specs/formal/realization/explicitness-and-realization.md`, `specs/formal/realization/README.md`, `docs/explain/reference/explicitness-realization-semantics.md`, `implementations/python/packages/aces_sdl/explicitness.py`, `implementations/python/packages/aces_sdl/validator.py`, `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/packages/aces_contracts/apparatus.py`, `implementations/python/packages/aces_contracts/vocabulary.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/packages/aces_contracts/runtime_state.py`, `implementations/python/packages/aces_backend_protocols/manifest.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_processor/planner.py`, `implementations/python/packages/aces_processor/semantics/realization.py`, `implementations/python/packages/aces_runtime/backend_calls.py`, `implementations/python/packages/aces_runtime/manager.py`, `implementations/python/packages/aces_runtime/control_plane_store.py`, `implementations/python/tests/test_sem_218_explicitness.py`, `implementations/python/tests/test_sem_218_realization.py`, `implementations/python/tests/test_sem_218_runtime_realization.py`, `implementations/python/tests/test_runtime_planner.py`, `implementations/python/tests/test_backend_manifest.py`, `implementations/python/tests/test_processor_manifest.py`, `implementations/python/tests/test_runtime_contracts.py` | active | +| Explicitness and realization semantics (binding declarations vs processor/backend realization) | SEM-218 | validation, instantiation, compilation, planning, execution, observation | `specs/formal/realization/explicitness-and-realization.md`, `specs/formal/realization/README.md`, `docs/explain/reference/explicitness-realization-semantics.md`, `implementations/python/packages/aces_sdl/explicitness.py`, `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/packages/aces_contracts/apparatus.py`, `implementations/python/packages/aces_contracts/vocabulary.py`, `implementations/python/packages/aces_contracts/contracts.py`, `implementations/python/packages/aces_contracts/runtime_state.py`, `implementations/python/packages/aces_backend_protocols/manifest.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_processor/models.py`, `implementations/python/packages/aces_processor/planner.py`, `implementations/python/packages/aces_processor/semantics/realization.py`, `implementations/python/packages/aces_runtime/backend_calls.py`, `implementations/python/packages/aces_runtime/manager.py`, `implementations/python/packages/aces_runtime/control_plane_store.py`, `implementations/python/tests/test_sem_218_explicitness.py`, `implementations/python/tests/test_sem_218_realization.py`, `implementations/python/tests/test_sem_218_runtime_realization.py`, `implementations/python/tests/test_runtime_planner.py`, `implementations/python/tests/test_backend_manifest.py`, `implementations/python/tests/test_processor_manifest.py`, `implementations/python/tests/test_runtime_contracts.py` | active | | Clock, time-domain, advancement/pacing/synchronization, and temporal ordering/causality semantics | SEM-227, SEM-228, SEM-229 | — | — | planned | diff --git a/docs/explain/sdl/scenario-delivery-drift-audit.md b/docs/explain/sdl/scenario-delivery-drift-audit.md index abf533862..56a713b0b 100644 --- a/docs/explain/sdl/scenario-delivery-drift-audit.md +++ b/docs/explain/sdl/scenario-delivery-drift-audit.md @@ -158,7 +158,7 @@ places container network realization facts under `Node.runtime`, and separates host publication from services and image exposed ports. Implementation evidence appears in `implementations/python/packages/aces_sdl/runtime_network.py` and validator -references around `implementations/python/packages/aces_sdl/validator.py:683`. +references around `implementations/python/packages/aces_sdl/validator/`. **Suspect exclusion or scope language:** Host port bindings, aliases, endpoint IDs, DNS names, and backend driver/IPAM details can look like Docker or @@ -208,7 +208,7 @@ puts SSH server configuration under node runtime; lines around `AcceptEnv`, `ForceCommand`, and `Match` rules as participant-observable sshd policy. Implementation evidence appears in `implementations/python/packages/aces_sdl/runtime_ssh_server.py` and -`implementations/python/packages/aces_sdl/validator.py:831`. +`implementations/python/packages/aces_sdl/validator/`. **Suspect exclusion or scope language:** `sshd_config`, wrapper commands, and accepted environment names can look like backend or process implementation @@ -261,7 +261,7 @@ redactable `ExecStart`, optional same-node service ref). Implementation evidence appears in `implementations/python/packages/aces_sdl/runtime_service_units.py` and the new validator hook in -`implementations/python/packages/aces_sdl/validator.py:832`. +`implementations/python/packages/aces_sdl/validator/`. **Suspect exclusion or scope language:** `systemctl` output, unit-file text, journal excerpts, and Docker/container-host configuration adjacency can look @@ -447,7 +447,7 @@ owns `mounts`, `filesystem_inventory`, `container`, `network`, `linux_capabilities`, `local_identity`, `identity_authorities`, `applications`, `database_services`, `ssh_servers`, and `software_components`; -`implementations/python/packages/aces_sdl/validator.py` adds runtime +`implementations/python/packages/aces_sdl/validator/` adds runtime application, database, identity authority, SSH, network, and capability cross-checks; tests in `implementations/python/tests/test_sdl_models.py`, `test_runtime_models.py`, `test_sdl_parser.py`, `test_sdl_validator.py`, and diff --git a/docs/index.md b/docs/index.md index 659411407..2319a2b54 100644 --- a/docs/index.md +++ b/docs/index.md @@ -166,6 +166,7 @@ decisions/adrs/adr-059-adr-amendment-policy-and-pin-gate decisions/adrs/adr-060-participant-backend-facing-contract-surface decisions/sem-213-temporal-participant-preflight decisions/issue-508-related-work-comparison-preflight +decisions/issue-42-validator-package-split-preflight ``` ```{toctree} diff --git a/implementations/python/packages/aces_sdl/semantics/__init__.py b/implementations/python/packages/aces_sdl/semantics/__init__.py index d8fe7c6f3..52777d4c4 100644 --- a/implementations/python/packages/aces_sdl/semantics/__init__.py +++ b/implementations/python/packages/aces_sdl/semantics/__init__.py @@ -1,6 +1,6 @@ """SDL-language semantic rules (objective windows, workflow step contracts). -Per ADR-015, these helpers live with the SDL package: ``aces_sdl/validator.py`` -uses them, and they have no processor-runtime dependencies. The processor's +Per ADR-015, these helpers live with the SDL package: the ``aces_sdl.validator`` +package uses them, and they have no processor-runtime dependencies. The processor's own reconciliation helpers stay at ``aces_processor.semantics.planner``. """ diff --git a/implementations/python/packages/aces_sdl/validator.py b/implementations/python/packages/aces_sdl/validator.py deleted file mode 100644 index 08ba13d36..000000000 --- a/implementations/python/packages/aces_sdl/validator.py +++ /dev/null @@ -1,4139 +0,0 @@ -"""Semantic validation for SDL scenarios. - -Goes beyond Pydantic structural checks to enforce cross-reference -integrity, dependency cycle detection, IP/CIDR consistency, and -domain-specific rules. Collects all errors rather than failing on -the first one. -""" - -from collections import defaultdict, deque -from collections.abc import Callable, Iterable, Sequence -from dataclasses import dataclass -from ipaddress import ip_address, ip_network - -from pydantic import BaseModel - -from ._base import extract_variable_name, is_variable_ref -from ._errors import SDLValidationError -from ._runtime_service_families import collect_qualified_runtime_family_refs -from .entities import flatten_entities -from .explicitness import classify_scenario_explicitness -from .infrastructure import SimpleProperties -from .nodes import MAX_NODE_NAME_LENGTH, NodeType -from .orchestration import Workflow, WorkflowPredicate, WorkflowStep, WorkflowStepType -from .runtime_database import DatabaseObjectType -from .runtime_forwarding_agent_vocab import RuntimeForwardingProtocol -from .runtime_mounts import RuntimeControlInterfaceAccess, RuntimeControlInterfaceKind -from .runtime_orchestration import RuntimeOrchestrationPrivilegeClass -from .runtime_security_monitoring import RuntimeSecurityMonitoringListenerRole -from .runtime_ssh_server import SshMatchCriterionKind -from .scenario import Scenario -from .semantics.assessment import AssessmentIssue, analyze_assessment_pipeline -from .semantics.objective_semantics import ( - AssessmentResourceCatalog, - ObjectiveIssue, - WindowResourceCatalog, - analyze_objective_semantics, -) -from .semantics.participant_behavior import ( - ParticipantBehaviorIssue, - analyze_participant_behavior, -) -from .semantics.participant_outcome import ( - ParticipantOutcomeIssue, - analyze_participant_outcome_interpretations, -) -from .semantics.workflow import branch_closure, workflow_step_semantic_contract - -# Common ref-path prefix used by qualified runtime/service refs (e.g. -# ``nodes.vm.services.http``, ``nodes.vm.runtime.applications.webapp``). -_NODES_PREFIX = "nodes." - - -@dataclass(frozen=True) -class _MailServiceLocalIds: - components: set[str] - domains: set[str] - stores: set[str] - mailboxes: set[str] - aliases: set[str] - routing_refs: set[str] - - -@dataclass(frozen=True) -class _MailRefTail: - service_id: str - collection_name: str - child_id: str - - -_MailChildIdReader = Callable[[object], Iterable[str]] -_MAIL_CHILD_ID_READERS: dict[str, _MailChildIdReader] = { - "components": lambda service: (component.component_id for component in service.components), - "listeners": lambda service: (listener.listener_id for listener in service.listeners), - "domains": lambda service: (domain.domain_id for domain in service.domains), - "mailbox_stores": lambda service: (store.store_id for store in service.mailbox_stores), - "mailboxes": lambda service: (mailbox.mailbox_id for mailbox in service.mailboxes), - "aliases": lambda service: (alias.alias_id for alias in service.aliases), - "routing_rules": lambda service: (rule.rule_id for rule in service.routing_rules), - "queues": lambda service: (queue.queue_id for queue in service.queues), - "settings": lambda service: (setting.setting_id for setting in service.settings), -} - - -def _mail_services_for_node(node: object) -> Sequence[object]: - runtime = getattr(node, "runtime", None) - return () if runtime is None else runtime.mail_services - - -def _mail_services_for_node_name(scenario: object, node_name: str) -> Sequence[object]: - node = scenario.nodes.get(node_name) - return () if node is None else _mail_services_for_node(node) - - -def _collect_mail_service_local_ids(service: object) -> _MailServiceLocalIds: - mailbox_ids = {mailbox.mailbox_id for mailbox in service.mailboxes} - alias_ids = {alias.alias_id for alias in service.aliases} - domain_ids = {domain.domain_id for domain in service.domains} - return _MailServiceLocalIds( - components={component.component_id for component in service.components}, - domains=domain_ids, - stores={store.store_id for store in service.mailbox_stores}, - mailboxes=mailbox_ids, - aliases=alias_ids, - routing_refs=mailbox_ids | alias_ids | domain_ids, - ) - - -def _parse_mail_ref_tail(tail: str) -> _MailRefTail | None: - tail_parts = tail.split(".") - if len(tail_parts) == 1: - return _MailRefTail(tail_parts[0], "", "") - if len(tail_parts) == 3: - return _MailRefTail(*tail_parts) - return None - - -def _resolve_mail_service_tail( - mail_services: Sequence[object], - parsed_tail: _MailRefTail, -) -> object | None: - for service in mail_services: - if service.mail_service_id != parsed_tail.service_id: - continue - return _matched_service_for_tail(service, parsed_tail) - return None - - -def _matched_service_for_tail(service: object, parsed_tail: _MailRefTail) -> object | None: - matches_service = not parsed_tail.collection_name - matches_child = bool( - parsed_tail.collection_name - and _mail_child_ref_exists(service, parsed_tail.collection_name, parsed_tail.child_id) - ) - return service if matches_service or matches_child else None - - -def _mail_child_ref_exists(service: object, collection_name: str, child_id: str) -> bool: - read_child_ids = _MAIL_CHILD_ID_READERS.get(collection_name) - return read_child_ids is not None and child_id in read_child_ids(service) - - -# Renders an objective-semantics issue (machine-readable code from -# ``aces_sdl.semantics.objective_semantics``) into the authoring-error string -# the SDL surface has always used. Keyed by issue code so a new code is a new -# line here rather than a new branch in a growing conditional. -_OBJECTIVE_ISSUE_RENDERERS = { - "objective.actor-agent-undeclared": ( - lambda i: f"Objective '{i.objective_name}' references undefined agent '{i.ref}'" - ), - "objective.actor-entity-undeclared": ( - lambda i: f"Objective '{i.objective_name}' references undefined entity '{i.ref}'" - ), - "objective.action-not-declared": ( - lambda i: f"Objective '{i.objective_name}' action '{i.ref}' is not declared by agent '{i.actor_name}'" - ), - "objective.target-unresolvable": ( - lambda i: f"Objective '{i.objective_name}' target '{i.ref}' does not reference any defined targetable element" - ), - "objective.target-ambiguous": ( - lambda i: f"Objective '{i.objective_name}' target '{i.ref}' is ambiguous; use one of: {', '.join(i.candidates)}" - ), - "objective.success-condition-undeclared": ( - lambda i: f"Objective '{i.objective_name}' references undefined condition '{i.ref}' in success criteria" - ), - "objective.success-metric-undeclared": ( - lambda i: f"Objective '{i.objective_name}' references undefined metric '{i.ref}' in success criteria" - ), - "objective.success-evaluation-undeclared": ( - lambda i: f"Objective '{i.objective_name}' references undefined evaluation '{i.ref}' in success criteria" - ), - "objective.success-tlo-undeclared": ( - lambda i: f"Objective '{i.objective_name}' references undefined TLO '{i.ref}' in success criteria" - ), - "objective.success-goal-undeclared": ( - lambda i: f"Objective '{i.objective_name}' references undefined goal '{i.ref}' in success criteria" - ), - "objective.window.story-unbound": ( - lambda i: f"Objective '{i.objective_name}' references undefined story '{i.ref}' in window" - ), - "objective.window.script-unbound": ( - lambda i: f"Objective '{i.objective_name}' references undefined script '{i.ref}' in window" - ), - "objective.window.script-outside-window-stories": ( - lambda i: f"Objective '{i.objective_name}' window script '{i.ref}' is not included by the referenced stories" - ), - "objective.window.event-unbound": ( - lambda i: f"Objective '{i.objective_name}' references undefined event '{i.ref}' in window" - ), - "objective.window.event-outside-window-scripts": ( - lambda i: f"Objective '{i.objective_name}' window event '{i.ref}' is not included by the referenced scripts" - ), - "objective.window.workflow-unbound": ( - lambda i: f"Objective '{i.objective_name}' references undefined workflow '{i.ref}' in window" - ), - "objective.window.step-requires-workflow-window": ( - lambda i: f"Objective '{i.objective_name}' window steps require at least one referenced workflow" - ), - "objective.window.step-invalid-format": ( - lambda i: f"Objective '{i.objective_name}' window step '{i.ref}' must use '.' syntax" - ), - "objective.window.step-workflow-unbound": ( - lambda i: ( - f"Objective '{i.objective_name}' window step '{i.ref}' references undefined workflow '{i.workflow_name}'" - ) - ), - "objective.window.step-workflow-outside-window": ( - lambda i: f"Objective '{i.objective_name}' window step '{i.ref}' is not part of the referenced workflows" - ), - "objective.window.step-unbound": ( - lambda i: f"Objective '{i.objective_name}' window step '{i.ref}' references undefined step '{i.step_name}'" - ), - "objective.dependency-undeclared": ( - lambda i: f"Objective '{i.objective_name}' depends on undefined objective '{i.ref}'" - ), - "objective.dependency-cycle": lambda _i: "Objective dependency graph contains a cycle", -} - -_PARTICIPANT_BEHAVIOR_ISSUE_RENDERERS = { - "participant.action-contract-unbound": ( - lambda i: f"Agent '{i.participant_name}' action '{i.ref}' does not reference a declared action_contract" - ), - "participant.observation-boundary-unbound": ( - lambda i: ( - f"Agent '{i.participant_name}' observation_boundary '{i.ref}' " - "does not reference a declared observation_boundary" - ) - ), - "participant.interaction-action-unbound": ( - lambda i: ( - f"Action contract '{i.action_name}' interaction related_action '{i.ref}' " - "does not reference a declared action_contract" - ) - ), - "participant.view-rule-ref-unbound": ( - lambda i: ( - f"Observation boundary '{i.boundary_name}' view_rule information_ref '{i.ref}' " - "is not declared by observable_refs, hidden_refs, or evidence_refs" - ) - ), - "participant.view-rule-evidence-unbound": ( - lambda i: ( - f"Observation boundary '{i.boundary_name}' view_rule evidence_ref '{i.ref}' " - "is not declared by evidence_refs" - ) - ), - "participant.view-transition-ref-unbound": ( - lambda i: ( - f"Observation boundary '{i.boundary_name}' view_transition '{i.transition_id}' " - f"information_ref '{i.ref}' is not declared by observable_refs, hidden_refs, or evidence_refs" - ) - ), - "participant.view-transition-evidence-unbound": ( - lambda i: ( - f"Observation boundary '{i.boundary_name}' view_transition '{i.transition_id}' " - f"evidence_ref '{i.ref}' is not declared by evidence_refs" - ) - ), -} - -_PARTICIPANT_OUTCOME_ISSUE_RENDERERS = { - "participant.outcome.source-action-unbound": ( - lambda i: f"Outcome interpretation rule '{i.rule_name}' source '{i.ref}' references undefined action contract" - ), - "participant.outcome.source-objective-unbound": ( - lambda i: f"Outcome interpretation rule '{i.rule_name}' source '{i.ref}' references undefined objective" - ), - "participant.outcome.source-workflow-unbound": ( - lambda i: f"Outcome interpretation rule '{i.rule_name}' source '{i.ref}' references undefined workflow" - ), - "participant.outcome.source-evaluation-unbound": ( - lambda i: f"Outcome interpretation rule '{i.rule_name}' source '{i.ref}' references undefined evaluation" - ), - "participant.outcome.target-objective-unbound": ( - lambda i: f"Outcome interpretation rule '{i.rule_name}' target '{i.ref}' references undefined objective" - ), - "participant.outcome.target-workflow-unbound": ( - lambda i: f"Outcome interpretation rule '{i.rule_name}' target '{i.ref}' references undefined workflow" - ), - "participant.outcome.target-evaluation-unbound": ( - lambda i: f"Outcome interpretation rule '{i.rule_name}' target '{i.ref}' references undefined evaluation" - ), -} - - -def _topological_sort(graph: dict[str, list[str]]) -> list[str] | None: - """Return topological order or None if a cycle exists.""" - in_degree: dict[str, int] = defaultdict(int) - for node in graph: - in_degree.setdefault(node, 0) - for deps in graph.values(): - for dep in deps: - in_degree[dep] += 1 - - queue = deque(n for n, d in in_degree.items() if d == 0) - order: list[str] = [] - - while queue: - node = queue.popleft() - order.append(node) - for dep in graph.get(node, []): - in_degree[dep] -= 1 - if in_degree[dep] == 0: - queue.append(dep) - - return order if len(order) == len(in_degree) else None - - -class SemanticValidator: - """Validates a Scenario beyond structural Pydantic checks. - - Call ``validate()`` to run all passes. Raises ``SDLValidationError`` - with all collected errors if any pass fails. - """ - - def __init__(self, scenario: Scenario) -> None: - self._s = scenario - self._errors: list[str] = [] - self._warnings: list[str] = [] - - def _err(self, msg: str) -> None: - self._errors.append(msg) - - def _warn(self, msg: str) -> None: - self._warnings.append(msg) - - def _is_unresolved_var(self, value: object) -> bool: - return is_variable_ref(value) - - def _node_type(self, node_name: str) -> NodeType | None: - node = self._s.nodes.get(node_name) - return node.type if node is not None else None - - def _is_switch_node(self, node_name: str) -> bool: - return self._node_type(node_name) == NodeType.SWITCH - - def _is_vm_node(self, node_name: str) -> bool: - return self._node_type(node_name) == NodeType.VM - - def _all_entity_names(self) -> set[str]: - return set(flatten_entities(self._s.entities).keys()) - - def _qualified_service_refs(self) -> set[str]: - refs: set[str] = set() - for node_name, node in self._s.nodes.items(): - for service in node.services: - if service.name: - refs.add(f"nodes.{node_name}.services.{service.name}") - return refs - - def _split_node_service_ref(self, ref: object) -> tuple[str, str] | None: - """Split ``nodes..services.`` into node/service parts. - - Node names may contain dots (for example ``wazuh.manager``), so service - refs must be partitioned on the ``.services.`` marker instead of split - by position. - """ - if not isinstance(ref, str) or not ref.startswith(_NODES_PREFIX): - return None - node_name, sep, service_name = ref[len(_NODES_PREFIX) :].partition(".services.") - if not sep or not node_name or not service_name: - return None - return node_name, service_name - - def _qualified_runtime_refs(self) -> set[str]: - """Qualified refs for node-scoped runtime inventories. - - These let a top-level relationship endpoint resolve to a runtime - service family or stable child record. This keeps runtime-observed - logical state targetable without promoting those records to top-level - SDL sections. - """ - return collect_qualified_runtime_family_refs(self._s) - - def _qualified_acl_refs(self) -> set[str]: - refs: set[str] = set() - for infra_name, infra in self._s.infrastructure.items(): - for acl in infra.acls: - if acl.name: - refs.add(f"infrastructure.{infra_name}.acls.{acl.name}") - return refs - - def _workflow_step_refs(self) -> set[str]: - refs: set[str] = set() - for workflow_name, workflow in self._s.workflows.items(): - for step_name in workflow.steps: - refs.add(f"{workflow_name}.{step_name}") - return refs - - def _named_ref_index(self, *, targetable: bool = False) -> dict[str, set[str]]: - """Build the alias map for generic relationship/objective refs. - - Bare refs stay available for most top-level sections when they are - unambiguous. Qualified refs are always accepted for top-level sections, - and are required for infrastructure entries because those keys - intentionally mirror node names. - """ - index: dict[str, set[str]] = defaultdict(set) - self._populate_named_ref_index(index) - if not targetable: - return {alias: set(candidates) for alias, candidates in index.items()} - return self._filter_targetable_aliases(index) - - _NAMED_REF_TOP_LEVEL_SECTIONS = ( - ("nodes", True), - ("features", True), - ("conditions", True), - ("vulnerabilities", True), - ("infrastructure", False), - ("metrics", True), - ("evaluations", True), - ("tlos", True), - ("goals", True), - ("content", True), - ("accounts", True), - ("agents", True), - ("action_contracts", True), - ("observation_boundaries", True), - ("objectives", True), - ("workflows", True), - ("relationships", True), - ("variables", True), - ("injects", True), - ("events", True), - ("scripts", True), - ("stories", True), - ) - - _TARGETABLE_DISALLOWED_PREFIXES = ( - "variables.", - "objectives.", - "workflows.", - ) - - def _populate_named_ref_index(self, index: dict[str, set[str]]) -> None: - self._add_top_level_section_aliases(index) - self._add_entity_aliases(index) - self._add_content_item_aliases(index) - self._add_qualified_aliases(index) - - def _add_top_level_section_aliases(self, index: dict[str, set[str]]) -> None: - for section_name, allow_bare in self._NAMED_REF_TOP_LEVEL_SECTIONS: - for name in getattr(self._s, section_name): - canonical = f"{section_name}.{name}" - index[canonical].add(canonical) - if allow_bare: - index[name].add(canonical) - - def _add_entity_aliases(self, index: dict[str, set[str]]) -> None: - for entity_name in self._all_entity_names(): - canonical = f"entities.{entity_name}" - index[canonical].add(canonical) - index[entity_name].add(canonical) - - def _add_content_item_aliases(self, index: dict[str, set[str]]) -> None: - for content_name, content in self._s.content.items(): - for item in content.items: - if not item.name: - continue - canonical = f"content.{content_name}.items.{item.name}" - index[canonical].add(canonical) - index[item.name].add(canonical) - - def _add_qualified_aliases(self, index: dict[str, set[str]]) -> None: - for qualified_refs in ( - self._qualified_service_refs(), - self._qualified_acl_refs(), - self._qualified_runtime_refs(), - ): - for ref in qualified_refs: - index[ref].add(ref) - - def _filter_targetable_aliases(self, index: dict[str, set[str]]) -> dict[str, set[str]]: - filtered: dict[str, set[str]] = {} - for alias, candidates in index.items(): - keep = { - candidate for candidate in candidates if not candidate.startswith(self._TARGETABLE_DISALLOWED_PREFIXES) - } - if keep: - filtered[alias] = keep - return filtered - - def _operating_scope_ref_index(self) -> dict[str, set[str]]: - """Build the alias map for ACT-601 ``Agent.operating_scope``. - - ADR-020 §2 defines operating scope as the declarative boundary for - where the participant may act or observe — concretely subnets, - hosts, services, and content (and content items). The split here - mirrors the pre-existing scope-validation patterns: - - - hosts come from ``nodes.*`` but only VM nodes (matches - ``initial_knowledge.hosts``). - - subnets come from ``infrastructure.*`` but only switch-backed - entries (matches ``allowed_subnets``). - - services come from declared services on VM nodes. - - content references stay open across content sections and items. - - Non-spatial, non-resource elements (conditions, metrics, accounts, - relationships, objectives, …) are not scope boundaries even though - they appear in the generic targetable index. - """ - index: dict[str, set[str]] = defaultdict(set) - - # Hosts: VM nodes only. Both bare (`vm`) and qualified (`nodes.vm`) - # aliases are accepted. Switch nodes go through the subnets path, - # never the host path. - for node_name, node in self._s.nodes.items(): - if node.type != NodeType.VM: - continue - canonical = f"nodes.{node_name}" - index[node_name].add(canonical) - index[canonical].add(canonical) - - # Subnets: switch-backed infrastructure only. Both bare and - # qualified aliases. VM-backed infrastructure entries (which - # mirror VM nodes' names) go through the host path's `nodes.*` - # alias, not here. - for infra_name, _infra in self._s.infrastructure.items(): - if not self._is_switch_node(infra_name): - continue - canonical = f"infrastructure.{infra_name}" - index[infra_name].add(canonical) - index[canonical].add(canonical) - - # Services: qualified `nodes..services.` refs plus bare - # service names. The service-ref helper only emits names declared - # on VM nodes (a service on a switch is meaningless), so no extra - # filtering is needed here. - for ref in self._qualified_service_refs(): - index[ref].add(ref) - tail = ref.rsplit(".", 1)[-1] - if tail: - index[tail].add(ref) - - # Content: sections and items keep the unrestricted aliasing from - # the targetable index; ADR-020 does not split content by sub-type. - for content_name in self._s.content: - canonical = f"content.{content_name}" - index[content_name].add(canonical) - index[canonical].add(canonical) - for content_name, content in self._s.content.items(): - for item in content.items: - if not item.name: - continue - canonical = f"content.{content_name}.items.{item.name}" - index[item.name].add(canonical) - index[canonical].add(canonical) - - return {alias: set(candidates) for alias, candidates in index.items()} - - def _validate_operating_scope_ref(self, ref: str, *, owner_label: str) -> None: - """Validate ``operating_scope`` against the spatial/resource index.""" - index = self._operating_scope_ref_index() - candidates = index.get(ref) - if not candidates: - self._err(f"{owner_label} operating_scope '{ref}' does not reference any defined targetable element") - return - if len(candidates) > 1: - choices = ", ".join(sorted(candidates)) - self._err(f"{owner_label} operating_scope '{ref}' is ambiguous; use one of: {choices}") - - def _validate_named_ref( - self, - ref: str, - *, - owner_label: str, - ref_label: str, - targetable: bool = False, - ) -> None: - """Validate a generic reference against the named-element index.""" - index = self._named_ref_index(targetable=targetable) - candidates = index.get(ref) - if not candidates: - qualifier = "targetable " if targetable else "" - self._err(f"{owner_label} {ref_label} '{ref}' does not reference any defined {qualifier}element") - return - - if len(candidates) > 1: - choices = ", ".join(sorted(candidates)) - self._err(f"{owner_label} {ref_label} '{ref}' is ambiguous; use one of: {choices}") - - def validate(self) -> None: - """Run all validation passes and raise on errors.""" - self._errors = [] - self._warnings = [] - - # OCR passes - self._verify_nodes() - self._verify_infrastructure() - self._verify_runtime_network() - self._verify_runtime_network_sensors() - self._verify_runtime_network_detection_engines() - self._verify_runtime_service_listeners() - self._verify_runtime_application() - self._verify_runtime_capability_overrides() - self._verify_runtime_database_services() - self._verify_runtime_dns_services() - self._verify_runtime_ssh_servers() - self._verify_runtime_app_authorizations() - self._verify_runtime_service_manager_units() - self._verify_runtime_identity_authorities() - self._verify_runtime_file_services() - self._verify_runtime_security_monitoring_managers() - self._verify_runtime_datastore_services() - self._verify_runtime_platform_applications() - self._verify_runtime_forwarding_agents() - self._verify_runtime_orchestration_authorities() - self._verify_runtime_mail_services() - self._verify_features() - self._verify_conditions() - self._verify_vulnerabilities() - self._verify_assessment_pipeline() - self._verify_entities() - self._verify_injects() - self._verify_events() - self._verify_scripts() - self._verify_stories() - self._verify_roles() - - # New section passes - self._verify_content() - self._verify_accounts() - self._verify_relationships() - self._verify_relationship_database_access() - self._verify_relationship_mail_access() - self._verify_relationship_forwarding_edges() - self._verify_relationship_service_integrations() - self._verify_relationship_proxy_upstreams() - self._verify_agents() - self._verify_participant_behavior() - self._verify_objectives() - self._verify_workflows() - self._verify_participant_outcomes() - self._verify_variables() - self._verify_explicitness() - self._collect_advisories() - - if self._errors: - raise SDLValidationError(self._errors) - - @property - def warnings(self) -> list[str]: - """Return non-fatal advisories collected during validation.""" - return list(self._warnings) - - def _collect_advisories(self) -> None: - self._warn_missing_vm_resources() - - def _warn_missing_vm_resources(self) -> None: - for name, node in self._s.nodes.items(): - if node.type != NodeType.VM: - continue - if node.resources is None: - self._warn( - f"Node '{name}' is a VM without 'resources'. This is " - "valid SDL, but may be undeployable unless the backend " - "supplies defaults." - ) - - # ------------------------------------------------------------------ - # OCR validation passes - # ------------------------------------------------------------------ - - def _verify_nodes(self) -> None: - for name, node in self._s.nodes.items(): - if len(name) > MAX_NODE_NAME_LENGTH: - self._err(f"Node '{name}' name exceeds 35 characters") - - for feat_name, role_name in node.features.items(): - if feat_name not in self._s.features: - self._err(f"Node '{name}' references undefined feature '{feat_name}'") - if role_name and not self._is_unresolved_var(role_name) and role_name not in node.roles: - self._err(f"Node '{name}' feature '{feat_name}' references undefined role '{role_name}'") - - for cond_name, role_name in node.conditions.items(): - if cond_name not in self._s.conditions: - self._err(f"Node '{name}' references undefined condition '{cond_name}'") - if role_name and not self._is_unresolved_var(role_name) and role_name not in node.roles: - self._err(f"Node '{name}' condition '{cond_name}' references undefined role '{role_name}'") - - for inj_name, role_name in node.injects.items(): - if inj_name not in self._s.injects: - self._err(f"Node '{name}' references undefined inject '{inj_name}'") - if role_name and not self._is_unresolved_var(role_name) and role_name not in node.roles: - self._err(f"Node '{name}' inject '{inj_name}' references undefined role '{role_name}'") - - for vuln_name in node.vulnerabilities: - if self._is_unresolved_var(vuln_name): - continue - if vuln_name not in self._s.vulnerabilities: - self._err(f"Node '{name}' references undefined vulnerability '{vuln_name}'") - - def _verify_infrastructure(self) -> None: - for name, infra in self._s.infrastructure.items(): - if name not in self._s.nodes: - self._err(f"Infrastructure '{name}' does not match any defined node") - - for link in infra.links: - if self._is_unresolved_var(link): - continue - if link not in self._s.infrastructure: - self._err(f"Infrastructure '{name}' links to undefined '{link}'") - elif not self._is_switch_node(link): - self._err(f"Infrastructure '{name}' link '{link}' must reference a switch/network entry") - - for dep in infra.dependencies: - if self._is_unresolved_var(dep): - continue - if dep not in self._s.infrastructure: - self._err(f"Infrastructure '{name}' depends on undefined '{dep}'") - - # Switch nodes cannot have count > 1 - if name in self._s.nodes: - if self._s.nodes[name].type == NodeType.SWITCH and isinstance(infra.count, int) and infra.count > 1: - self._err(f"Switch node '{name}' cannot have count > 1") - if ( - self._s.nodes[name].type == NodeType.VM - and self._s.nodes[name].conditions - and isinstance(infra.count, int) - and infra.count > 1 - ): - self._err(f"Node '{name}' has conditions and cannot have count > 1") - - # Validate complex properties IP within linked CIDR - if isinstance(infra.properties, list): - for prop_entry in infra.properties: - for link_name, ip_str in prop_entry.items(): - if self._is_unresolved_var(link_name): - continue - if link_name not in infra.links: - self._err(f"Infrastructure '{name}' property references unlinked node '{link_name}'") - if not self._is_switch_node(link_name): - self._err( - f"Infrastructure '{name}' property link " - f"'{link_name}' must reference a switch/network entry" - ) - continue - # Check IP is within the linked node's CIDR - linked_infra = self._s.infrastructure.get(link_name) - if linked_infra is None: - continue - if not isinstance(linked_infra.properties, SimpleProperties): - self._err( - f"Infrastructure '{name}' property link " - f"'{link_name}' must reference a network with CIDR " - "properties" - ) - continue - if self._is_unresolved_var(ip_str): - continue - if self._is_unresolved_var(linked_infra.properties.cidr): - continue - try: - net = ip_network(linked_infra.properties.cidr, strict=False) - except ValueError: - self._err(f"Infrastructure '{link_name}' has invalid CIDR {linked_infra.properties.cidr}") - continue - try: - addr = ip_address(ip_str) - except ValueError: - self._err( - f"Infrastructure '{name}' has invalid IP assignment '{ip_str}' for link '{link_name}'" - ) - continue - if addr not in net: - self._err( - f"Infrastructure '{name}' IP {ip_str} " - f"not within '{link_name}' CIDR " - f"{linked_infra.properties.cidr}" - ) - - # Validate ACL network references - for acl in infra.acls: - for ref in (acl.from_net, acl.to_net): - if self._is_unresolved_var(ref): - continue - if ref and ref not in self._s.infrastructure: - self._err(f"Infrastructure '{name}' ACL references undefined network '{ref}'") - elif ref and not self._is_switch_node(ref): - self._err(f"Infrastructure '{name}' ACL reference '{ref}' must point to a switch/network entry") - - def _verify_runtime_network(self) -> None: - """Validate observed runtime network endpoints against declared topology. - - Each endpoint's ``network`` must resolve to a switch-backed - infrastructure entry; concrete endpoint IPs and gateways are checked - against the referenced network CIDR when one is declared (ADR-025). - """ - for name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or runtime.network is None: - continue - for endpoint in runtime.network.endpoints: - net = endpoint.network - if self._is_unresolved_var(net): - continue - if net not in self._s.infrastructure: - self._err(f"Node '{name}' runtime network endpoint references undefined network '{net}'") - continue - if not self._is_switch_node(net): - self._err( - f"Node '{name}' runtime network endpoint network '{net}' must reference a switch/network entry" - ) - continue - self._verify_endpoint_addressing(name, net, endpoint) - - def _verify_endpoint_addressing(self, node_name: str, net: str, endpoint: object) -> None: - infra = self._s.infrastructure.get(net) - props = infra.properties if infra is not None else None - if not isinstance(props, SimpleProperties): - return - cidr = props.cidr - if not cidr or self._is_unresolved_var(cidr): - return - try: - network = ip_network(cidr, strict=False) - except ValueError: - return - for label in ("ip_address", "gateway"): - value = getattr(endpoint, label, "") - if not value or self._is_unresolved_var(value): - continue - try: - addr = ip_address(value) - except ValueError: - continue # malformed addresses are reported by the model-level validator - if addr.version == network.version and addr not in network: - self._err( - f"Node '{node_name}' runtime network endpoint {label} {value} " - f"is not within network '{net}' CIDR {cidr}" - ) - - def _verify_runtime_network_sensors(self) -> None: - """Validate observed network-sensor monitoring scope. - - A network sensor explicitly states which declared network resources it - observes. Runtime endpoint attachment is a separate fact, so when the - node records endpoint inventory, the monitored networks must be among - those endpoint attachments. - """ - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.network_sensors: - continue - observed_paths = self._node_observed_paths(node) - attached_networks = self._runtime_endpoint_networks(runtime) - for sensor in runtime.network_sensors: - self._verify_network_sensor( - node_name=node_name, - sensor=sensor, - observed_paths=observed_paths, - attached_networks=attached_networks, - ) - - @staticmethod - def _runtime_endpoint_networks(runtime: object) -> set[str]: - network = getattr(runtime, "network", None) - if network is None: - return set() - return {endpoint.network for endpoint in network.endpoints if endpoint.network} - - def _verify_network_sensor( - self, - *, - node_name: str, - sensor: object, - observed_paths: set[str], - attached_networks: set[str], - ) -> None: - owner_label = f"Node '{node_name}' runtime network sensor '{sensor.network_sensor_id}'" - for field_name in ("configuration_file_refs", "log_file_refs", "evidence_refs"): - self._verify_dns_file_refs( - owner_label, - getattr(sensor, field_name, []), - field_name=field_name, - observed_paths=observed_paths, - ) - for network_ref in sensor.monitored_network_refs: - self._verify_network_sensor_monitored_ref( - node_name=node_name, - sensor_id=sensor.network_sensor_id, - network_ref=network_ref, - attached_networks=attached_networks, - ) - - def _verify_network_sensor_monitored_ref( - self, - *, - node_name: str, - sensor_id: str, - network_ref: str, - attached_networks: set[str], - ) -> None: - if self._is_unresolved_var(network_ref): - return - label = f"Node '{node_name}' runtime network sensor '{sensor_id}'" - if network_ref not in self._s.infrastructure: - self._err(f"{label} monitored_network_ref '{network_ref}' references undefined network") - return - if not self._is_switch_node(network_ref): - self._err(f"{label} monitored_network_ref '{network_ref}' must reference a switch/network entry") - return - if attached_networks and network_ref not in attached_networks: - self._err(f"{label} monitored_network_ref '{network_ref}' is not attached to node '{node_name}'") - - def _verify_runtime_network_detection_engines(self) -> None: - """Validate observed IDS/NDR detection-engine inventories. - - Detection engines may point at a same-node network sensor, filesystem - evidence, switch-backed network/address sets, and bounded control - channels. Raw rules, packet payloads, and alert telemetry stay outside - the SDL model. - """ - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.network_detection_engines: - continue - service_names = self._node_service_names(node) - observed_paths = self._node_observed_paths(node) - sensor_ids = {sensor.network_sensor_id for sensor in runtime.network_sensors} - for engine in runtime.network_detection_engines: - self._verify_network_detection_engine( - node_name=node_name, - engine=engine, - service_names=service_names, - observed_paths=observed_paths, - sensor_ids=sensor_ids, - ) - - def _verify_network_detection_engine( - self, - *, - node_name: str, - engine: object, - service_names: set[str], - observed_paths: set[str], - sensor_ids: set[str], - ) -> None: - owner_label = f"Node '{node_name}' runtime network detection engine '{engine.network_detection_engine_id}'" - sensor_ref = getattr(engine, "sensor_ref", "") - if sensor_ref and not self._is_unresolved_var(sensor_ref) and sensor_ref not in sensor_ids: - self._err(f"{owner_label} sensor_ref '{sensor_ref}' does not resolve to a same-node network sensor") - for field_name in ("configuration_file_refs", "log_file_refs", "evidence_refs"): - self._verify_dns_file_refs( - owner_label, - getattr(engine, field_name, []), - field_name=field_name, - observed_paths=observed_paths, - ) - for source in engine.rule_sources: - self._verify_dns_file_refs( - f"{owner_label} rule_source '{source.source_id}'", - getattr(source, "file_refs", []), - field_name="file_refs", - observed_paths=observed_paths, - ) - for network_set in engine.network_sets: - set_label = f"{owner_label} network_set '{network_set.set_id}'" - for network_ref in network_set.network_refs: - self._verify_network_detection_network_ref(set_label, network_ref) - for stream in engine.output_streams: - self._verify_dns_file_refs( - f"{owner_label} output_stream '{stream.stream_id}'", - [stream.path] if stream.path else [], - field_name="path", - observed_paths=observed_paths, - ) - for channel in engine.control_channels: - channel_label = f"{owner_label} control_channel '{channel.channel_id}'" - self._verify_owned_service_ref( - node_name, - getattr(channel, "service", ""), - service_names, - owner_label=channel_label, - ) - self._verify_dns_file_refs( - channel_label, - [channel.path] if channel.path else [], - field_name="path", - observed_paths=observed_paths, - ) - - def _verify_network_detection_network_ref(self, owner_label: str, network_ref: str) -> None: - if self._is_unresolved_var(network_ref): - return - if network_ref not in self._s.infrastructure: - self._err(f"{owner_label} network_ref '{network_ref}' references undefined network") - return - if not self._is_switch_node(network_ref): - self._err(f"{owner_label} network_ref '{network_ref}' must reference a switch/network entry") - - def _verify_runtime_application(self) -> None: - """Validate observed runtime application surfaces against the scenario. - - Each surface's owning service must resolve to a service on the same - node; route vulnerability refs must resolve to top-level - ``vulnerabilities``; and template/static refs should resolve to the - node's observed file inventory when one is recorded (ADR-026). - """ - for name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.applications: - continue - service_names = self._node_service_names(node) - observed_paths = self._node_observed_paths(node) - for application in runtime.applications: - self._verify_application_service(name, application, service_names) - for route in application.routes: - self._verify_route_refs(name, application, route, observed_paths) - self._verify_route_upstream_target(name, application, route) - - @staticmethod - def _node_service_names(node: object) -> set[str]: - return {service.name for service in getattr(node, "services", []) if service.name} - - @staticmethod - def _node_services_by_name(node: object) -> dict[str, object]: - return {service.name: service for service in getattr(node, "services", []) if service.name} - - @staticmethod - def _node_observed_paths(node: object) -> set[str]: - """Collect file paths the node observably exposes for template/static refs.""" - paths: set[str] = set() - runtime = getattr(node, "runtime", None) - if runtime is not None: - paths.update(entry.path for entry in runtime.filesystem_inventory if entry.path) - source = getattr(node, "source", None) - build = getattr(source, "build", None) if source is not None else None - if build is not None: - paths.update(item.destination_path for item in build.copied_sources if item.destination_path) - paths.update(item.destination_path for item in build.source_inputs if item.destination_path) - return paths - - def _verify_application_service(self, node_name: str, application: object, service_names: set[str]) -> None: - self._verify_owned_service_ref( - node_name, - getattr(application, "service", ""), - service_names, - owner_label=f"Node '{node_name}' runtime application '{application.application_id}'", - ) - - def _verify_owned_service_ref( - self, - node_name: str, - ref: str, - service_names: set[str], - *, - owner_label: str, - ) -> None: - """Validate a runtime surface's owning transport-service reference. - - The ref is a bare ``Node.services[].name`` or the qualified - ``nodes..services.`` form, and must resolve to a service on - the same node. Shared by runtime applications, database services, and - identity authority services. - """ - if not ref or self._is_unresolved_var(ref): - return - service_name = ref - if ref.startswith(_NODES_PREFIX): - split = self._split_node_service_ref(ref) - if split is None: - self._err( - f"{owner_label} service ref '{ref}' must be a bare service name or 'nodes..services.'" - ) - return - ref_node_name, service_name = split - if ref_node_name != node_name: - self._err(f"{owner_label} service ref '{ref}' must reference a service on the same node") - return - if service_name not in service_names: - self._err(f"{owner_label} references undefined service '{service_name}'") - - def _resolve_owned_service_ref( - self, - node_name: str, - ref: str, - services_by_name: dict[str, object], - *, - owner_label: str, - ) -> object | None: - if not ref or self._is_unresolved_var(ref): - return None - service_name = ref - if ref.startswith(_NODES_PREFIX): - split = self._split_node_service_ref(ref) - if split is None: - self._err( - f"{owner_label} service ref '{ref}' must be a bare service name or 'nodes..services.'" - ) - return None - ref_node_name, service_name = split - if ref_node_name != node_name: - self._err(f"{owner_label} service ref '{ref}' must reference a service on the same node") - return None - service = services_by_name.get(service_name) - if service is None: - self._err(f"{owner_label} references undefined service '{service_name}'") - return service - - def _verify_route_upstream_target(self, node_name: str, application: object, route: object) -> None: - target = getattr(route, "upstream_target", None) - if target is None: - return - label = ( - f"Node '{node_name}' runtime application '{application.application_id}' " - f"route '{route.route_id}' upstream_target" - ) - target_node_name = self._check_proxy_upstream_node_ref( - getattr(target, "target_node_ref", ""), - label, - context="upstream_target", - field_name="target_node_ref", - ) - self._check_proxy_upstream_service_ref( - getattr(target, "target_service", ""), - upstream_node_ref=target_node_name or "", - relationship_target="", - label=label, - context="upstream_target", - field_name="target_service", - ) - - def _verify_runtime_service_listeners(self) -> None: - """Validate observed service listeners against same-node runtime facts.""" - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.service_listeners: - continue - services_by_name = self._node_services_by_name(node) - process_refs = self._node_runtime_process_refs(node) - published_ports = self._node_published_port_keys(node) - for listener in runtime.service_listeners: - label = f"Node '{node_name}' runtime service listener '{listener.service_listener_id}'" - service = self._resolve_owned_service_ref( - node_name, - getattr(listener, "service", ""), - services_by_name, - owner_label=label, - ) - if service is not None: - self._verify_listener_service_binding(label, listener, service) - self._verify_listener_process_ref(label, listener, process_refs) - self._verify_listener_published_port_refs(label, listener, published_ports) - - def _verify_listener_service_binding(self, label: str, listener: object, service: object) -> None: - listener_port = getattr(listener, "port", None) - listener_protocol = self._enum_or_raw(getattr(listener, "protocol", "")) - service_port = getattr(service, "port", None) - service_protocol = getattr(service, "protocol", "") - if any(self._is_unresolved_var(v) for v in (listener_port, listener_protocol, service_port, service_protocol)): - return - if listener_port is None: - return - if listener_port != service_port or str(listener_protocol).lower() != str(service_protocol).lower(): - self._err(f"{label} port/protocol must match service '{service.name}'") - - @staticmethod - def _enum_or_raw(value: object) -> object: - return value.value if hasattr(value, "value") else value - - def _verify_listener_process_ref(self, label: str, listener: object, process_refs: set[str]) -> None: - ref = getattr(listener, "process_ref", "") - if not ref or self._is_unresolved_var(ref): - return - if ref not in process_refs: - self._err(f"{label} process_ref '{ref}' does not resolve to a runtime process name or pid") - - def _verify_listener_published_port_refs( - self, - label: str, - listener: object, - published_ports: set[tuple[str, int | str | None, int | str, str]], - ) -> None: - listener_port = getattr(listener, "port", None) - listener_protocol = self._enum_or_raw(getattr(listener, "protocol", "")) - for ref in getattr(listener, "published_port_refs", []): - values = (ref.host_ip, ref.host_port, ref.container_port, ref.protocol) - if any(self._is_unresolved_var(v) for v in values): - continue - if any(self._is_unresolved_var(v) for v in (listener_port, listener_protocol)): - continue - if listener_port is not None and ( - ref.container_port != listener_port or ref.protocol != str(listener_protocol).lower() - ): - self._err(f"{label} published_port_refs entry must match listener port/protocol") - continue - if values not in published_ports: - self._err(f"{label} published_port_refs entry does not resolve to runtime.network.published_ports") - - def _node_runtime_process_refs(self, node: object) -> set[str]: - runtime = getattr(node, "runtime", None) - if runtime is None: - return set() - refs: set[str] = set() - processes = [getattr(runtime, "process", None), *getattr(runtime, "processes", [])] - for process in processes: - if process is None: - continue - name = getattr(process, "name", "") - pid = getattr(process, "pid", None) - if name and not self._is_unresolved_var(name): - refs.add(str(name)) - if pid is not None and not self._is_unresolved_var(pid): - refs.add(str(pid)) - return refs - - @staticmethod - def _node_published_port_keys(node: object) -> set[tuple[str, int | str | None, int | str, str]]: - runtime = getattr(node, "runtime", None) - network = getattr(runtime, "network", None) if runtime is not None else None - if network is None: - return set() - return { - (binding.host_ip, binding.host_port, binding.container_port, binding.protocol) - for binding in network.published_ports - } - - def _verify_route_refs( - self, - node_name: str, - application: object, - route: object, - observed_paths: set[str], - ) -> None: - app_id = application.application_id - route_id = route.route_id - for ref in route.vulnerability_refs: - if self._is_unresolved_var(ref): - continue - if ref not in self._s.vulnerabilities: - self._err( - f"Node '{node_name}' runtime application '{app_id}' route '{route_id}' " - f"references undefined vulnerability '{ref}'" - ) - if not observed_paths: - return - for field_name in ("templates", "static_assets"): - for ref in getattr(route, field_name): - if self._is_unresolved_var(ref): - continue - if ref not in observed_paths: - self._err( - f"Node '{node_name}' runtime application '{app_id}' route '{route_id}' " - f"{field_name} ref '{ref}' does not resolve to an observed file on the node" - ) - - def _verify_runtime_service_manager_units(self) -> None: - """Validate observed service-manager unit inventories (ADR-035). - - Each ``ServiceManagerUnit.service`` ref, when set and not a variable, - must resolve to a service on the same node (bare name OR - ``nodes..services.``). When a ``unit_file_path`` is set - and ``runtime.filesystem_inventory`` is non-empty, the path SHOULD - appear in that inventory; otherwise we emit a soft semantic error so - downstream consumers can tell unit-file evidence and filesystem - inventory apart. - """ - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.service_manager_units: - continue - service_names = self._node_service_names(node) - observed_paths = self._node_observed_paths(node) - for unit in runtime.service_manager_units: - owner_label = f"Node '{node_name}' runtime service_manager_unit '{unit.unit_id}'" - self._verify_owned_service_ref( - node_name, - getattr(unit, "service", ""), - service_names, - owner_label=owner_label, - ) - unit_file_path = getattr(unit, "unit_file_path", "") - if ( - unit_file_path - and observed_paths - and not self._is_unresolved_var(unit_file_path) - and unit_file_path not in observed_paths - ): - self._err( - f"{owner_label} unit_file_path '{unit_file_path}' does not resolve to an " - f"observed file on the node" - ) - - def _verify_runtime_ssh_servers(self) -> None: - """Validate observed SSH server configurations against the scenario. - - Each ``RuntimeSshServer.service`` must resolve to a service on the - same node (bare name OR ``nodes..services.``). - Each ``Match`` rule's ``LOCAL_USER`` criterion whose pattern is a - concrete (non-wildcard, non-variable) literal MAY be cross-checked - against ``runtime.local_identity.users`` when that inventory is - present and non-empty (ADR-031 § "Semantic validation gate"). - """ - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.ssh_servers: - continue - service_names = self._node_service_names(node) - local_usernames = self._node_local_usernames(node) - for server in runtime.ssh_servers: - self._verify_ssh_server_service(node_name, server, service_names) - for rule in server.match_rules: - self._verify_ssh_match_rule(node_name, server, rule, local_usernames) - - @staticmethod - def _node_local_usernames(node: object) -> set[str]: - runtime = getattr(node, "runtime", None) - if runtime is None: - return set() - identity = getattr(runtime, "local_identity", None) - if identity is None: - return set() - return {user.username for user in identity.users if user.username} - - def _verify_ssh_server_service( - self, - node_name: str, - server: object, - service_names: set[str], - ) -> None: - ref = getattr(server, "service", "") - if not ref or self._is_unresolved_var(ref): - return - server_id = server.ssh_server_id - service_name = ref - if ref.startswith("nodes."): - parts = ref.split(".") - if len(parts) != 4 or parts[2] != "services": - self._err( - f"Node '{node_name}' runtime ssh_server '{server_id}' service ref '{ref}' " - f"must be a bare service name or 'nodes..services.'" - ) - return - if parts[1] != node_name: - self._err( - f"Node '{node_name}' runtime ssh_server '{server_id}' service ref '{ref}' " - f"must reference a service on the same node" - ) - return - service_name = parts[3] - if service_name not in service_names: - self._err( - f"Node '{node_name}' runtime ssh_server '{server_id}' references undefined service '{service_name}'" - ) - - def _verify_ssh_match_rule( - self, - node_name: str, - server: object, - rule: object, - local_usernames: set[str], - ) -> None: - if not local_usernames: - return - for criterion in rule.criteria: - if criterion.kind != SshMatchCriterionKind.LOCAL_USER: - continue - pattern = criterion.pattern - if self._is_unresolved_var(pattern): - continue - if any(ch in pattern for ch in "*?!,"): - # Wildcard or comma-separated list — not a single concrete identity. - continue - if pattern not in local_usernames: - self._err( - f"Node '{node_name}' runtime ssh_server '{server.ssh_server_id}' " - f"match rule '{rule.match_id}' references local user '{pattern}' " - f"not present in runtime.local_identity.users" - ) - - def _verify_runtime_app_authorizations(self) -> None: - """Validate observed application-internal RBAC stores. - - Permission-grant and role-mapping ``role_ref`` values are - authorization-local role references: each must resolve to a role - declared within the same ``app_authorization`` store (RBAC96 / - ANSI INCITS 359 role-permission and user-role assignment integrity). - """ - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.app_authorizations: - continue - for authorization in runtime.app_authorizations: - self._verify_app_authorization_role_refs(node_name, authorization) - - def _verify_app_authorization_role_refs(self, node_name: str, authorization: object) -> None: - role_ids = {role.role_id for role in authorization.roles} - label = f"Node '{node_name}' runtime app_authorization '{authorization.app_authorization_id}'" - for grant in authorization.permission_grants: - self._verify_app_authorization_role_ref( - f"{label} permission_grant '{grant.grant_id}'", - getattr(grant, "role_ref", ""), - role_ids, - ) - for mapping in authorization.role_mappings: - self._verify_app_authorization_role_ref( - f"{label} role_mapping '{mapping.mapping_id}'", - getattr(mapping, "role_ref", ""), - role_ids, - ) - - def _verify_app_authorization_role_ref(self, owner_label: str, role_ref: str, role_ids: set[str]) -> None: - if not role_ref or self._is_unresolved_var(role_ref): - return - if role_ref not in role_ids: - self._err(f"{owner_label} role_ref '{role_ref}' is not a role in the authorization") - - def _verify_runtime_capability_overrides(self) -> None: - """Cross-check ``linux_capabilities.process_overrides`` selectors. - - Per ADR-030, scoped capability records identify a subject via - ``RuntimeProcessIdentity`` selectors and the capability list ships - through the same closed-world Pydantic gates as the container-wide - lists. The only check that cannot live on the model itself is the - scenario-level cross-reference: when an override's - ``subject.name`` is a literal value and the enclosing node declares - ``runtime.processes``, the name SHOULD match one of those observed - processes. A miss is reported as an error so inventories that - forget to add the new process surface fail fast rather than ship a - scoped-policy claim that points at nothing. - """ - for node_name, node in self._s.nodes.items(): - overrides = self._capability_overrides_for(node) - if not overrides: - continue - observed = self._observed_process_names(node) - if not observed: - # No declared process inventory to cross-check against — the - # override stands on its own selectors. - continue - self._check_override_subject_names(node_name, overrides, observed) - - def _capability_overrides_for(self, node: object) -> list[object]: - runtime = getattr(node, "runtime", None) - if runtime is None: - return [] - capability_policy = getattr(runtime, "linux_capabilities", None) - if capability_policy is None: - return [] - return list(getattr(capability_policy, "process_overrides", None) or []) - - def _observed_process_names(self, node: object) -> set[str]: - runtime = getattr(node, "runtime", None) - if runtime is None: - return set() - return { - process.name - for process in (runtime.processes or []) - if process.name and not self._is_unresolved_var(process.name) - } - - def _check_override_subject_names( - self, - node_name: str, - overrides: list[object], - observed: set[str], - ) -> None: - for override in overrides: - subject_name = override.subject.name - if not subject_name or self._is_unresolved_var(subject_name): - continue - if subject_name not in observed: - self._err( - f"Node '{node_name}' runtime capability override subject " - f"'{subject_name}' does not match any process declared in " - "'runtime.processes'" - ) - - def _verify_runtime_identity_authorities(self) -> None: - """Validate observed identity authorities against the scenario. - - Authority endpoint ``service`` refs resolve like other node-scoped - runtime service ownership claims. Relationship and policy refs are - local to the authority inventory so membership and trust facts cannot - silently point at missing users, groups, policies, or the authority - record itself. - """ - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.identity_authorities: - continue - service_names = self._node_service_names(node) - for authority in runtime.identity_authorities: - self._verify_identity_authority_services(node_name, authority, service_names) - local_refs = self._identity_authority_local_refs(authority) - self._verify_identity_authority_relationships(node_name, authority, local_refs) - self._verify_identity_authority_policies(node_name, authority, local_refs) - - def _verify_identity_authority_services( - self, - node_name: str, - authority: object, - service_names: set[str], - ) -> None: - for service in authority.services: - self._verify_owned_service_ref( - node_name, - getattr(service, "service", ""), - service_names, - owner_label=( - f"Node '{node_name}' runtime identity authority '{authority.identity_authority_id}' " - f"service '{service.service_id}'" - ), - ) - - @staticmethod - def _identity_authority_local_refs(authority: object) -> set[str]: - refs = {authority.identity_authority_id} - refs.update(service.service_id for service in authority.services) - refs.update(subject.subject_id for subject in authority.subjects) - refs.update(policy.policy_id for policy in authority.policies) - refs.update(relationship.relationship_id for relationship in authority.relationships) - return refs - - def _verify_identity_authority_relationships( - self, - node_name: str, - authority: object, - local_refs: set[str], - ) -> None: - label = f"Node '{node_name}' runtime identity authority '{authority.identity_authority_id}'" - for relationship in authority.relationships: - rel_label = f"{label} relationship '{relationship.relationship_id}'" - self._verify_identity_ref( - getattr(relationship, "source_ref", ""), - local_refs, - label=rel_label, - field_name="source_ref", - ) - if relationship.target_ref: - self._verify_identity_ref( - relationship.target_ref, - local_refs, - label=rel_label, - field_name="target_ref", - ) - - def _verify_identity_authority_policies( - self, - node_name: str, - authority: object, - local_refs: set[str], - ) -> None: - label = f"Node '{node_name}' runtime identity authority '{authority.identity_authority_id}'" - for policy in authority.policies: - policy_label = f"{label} policy '{policy.policy_id}'" - for ref in policy.applies_to_refs: - self._verify_identity_ref( - ref, - local_refs, - label=policy_label, - field_name="applies_to_ref", - ) - - def _verify_identity_ref( - self, - ref: str, - local_refs: set[str], - *, - label: str, - field_name: str, - ) -> None: - if self._is_unresolved_var(ref): - return - if ref not in local_refs: - self._err(f"{label} {field_name} '{ref}' does not resolve inside identity authority") - - # File-service surface (ADR-037). - - _FILE_SERVICE_SUBJECT_LITERALS: frozenset[str] = frozenset({"anonymous", "guest"}) - - def _verify_runtime_file_services(self) -> None: - """Validate observed runtime file services against the scenario. - - Each service's owning transport service must resolve to a service on - the same node (mirroring ``runtime.applications``). Rule/observation - ``subject_ref`` resolves against service-local principal ids plus the - reserved literals ``anonymous`` and ``guest``. ``resource_ref`` - resolves against service-local share ids; a ``share_id:path`` form is - allowed for narrowed resources. Optional ``local_user_ref`` and - ``directory_subject_ref`` on a principal are checked against - ``runtime.local_identity.users`` and the qualified identity-authority - ref shape, respectively, when present. - """ - identity_subject_refs = self._identity_authority_subject_refs() - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.file_services: - continue - service_names = self._node_service_names(node) - local_user_names = self._node_local_user_names(node) - for service in runtime.file_services: - owner_label = f"Node '{node_name}' runtime file service '{service.file_service_id}'" - self._verify_owned_service_ref( - node_name, - getattr(service, "service", ""), - service_names, - owner_label=owner_label, - ) - self._verify_file_service_principals( - owner_label, - service, - local_user_names, - identity_subject_refs, - ) - share_ids = {share.share_id for share in service.shares} - subject_refs = { - principal.principal_id for principal in service.principals - } | self._FILE_SERVICE_SUBJECT_LITERALS - for rule in service.access_rules: - self._verify_file_service_ref( - rule.subject_ref, - subject_refs, - label=f"{owner_label} rule '{rule.rule_id}'", - field_name="subject_ref", - ) - self._verify_file_service_resource_ref( - rule.resource_ref, - share_ids, - label=f"{owner_label} rule '{rule.rule_id}'", - ) - for observation in service.access_observations: - self._verify_file_service_ref( - observation.subject_ref, - subject_refs, - label=f"{owner_label} observation '{observation.observation_id}'", - field_name="subject_ref", - ) - self._verify_file_service_resource_ref( - observation.resource_ref, - share_ids, - label=f"{owner_label} observation '{observation.observation_id}'", - ) - - @staticmethod - def _node_local_user_names(node: object) -> set[str]: - runtime = getattr(node, "runtime", None) - local_identity = getattr(runtime, "local_identity", None) if runtime is not None else None - if local_identity is None: - return set() - return {user.username for user in getattr(local_identity, "users", []) if user.username} - - def _identity_authority_subject_refs(self) -> set[str]: - """Qualified subject refs across all node-scoped identity authorities. - - Shape: ``nodes..runtime.identity_authorities..subjects.``. - Used by file-service ``directory_subject_ref`` resolution so a - principal cannot smuggle a dangling pointer at a missing authority or - subject past semantic validation. - """ - refs: set[str] = set() - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None: - continue - for authority in runtime.identity_authorities: - base = f"{_NODES_PREFIX}{node_name}.runtime.identity_authorities.{authority.identity_authority_id}" - for subject in authority.subjects: - refs.add(f"{base}.subjects.{subject.subject_id}") - return refs - - def _verify_file_service_principals( - self, - owner_label: str, - service: object, - local_user_names: set[str], - identity_subject_refs: set[str], - ) -> None: - for principal in service.principals: - local_user_ref = getattr(principal, "local_user_ref", "") - if local_user_ref and not self._is_unresolved_var(local_user_ref): - if local_user_names and local_user_ref not in local_user_names: - self._err( - f"{owner_label} principal '{principal.principal_id}' local_user_ref " - f"'{local_user_ref}' does not resolve to a runtime.local_identity user" - ) - directory_ref = getattr(principal, "directory_subject_ref", "") - if not directory_ref or self._is_unresolved_var(directory_ref): - continue - if not directory_ref.startswith(_NODES_PREFIX): - self._err( - f"{owner_label} principal '{principal.principal_id}' " - f"directory_subject_ref '{directory_ref}' must be a qualified " - f"'nodes..runtime.identity_authorities..subjects.' reference" - ) - continue - if directory_ref not in identity_subject_refs: - self._err( - f"{owner_label} principal '{principal.principal_id}' " - f"directory_subject_ref '{directory_ref}' does not resolve to a known " - "identity-authority subject" - ) - - def _verify_file_service_ref( - self, - ref: str, - local_refs: set[str], - *, - label: str, - field_name: str, - ) -> None: - if not ref or self._is_unresolved_var(ref): - return - if ref not in local_refs: - self._err(f"{label} {field_name} '{ref}' does not resolve inside file service") - - def _verify_file_service_resource_ref( - self, - ref: str, - share_ids: set[str], - *, - label: str, - ) -> None: - if not ref or self._is_unresolved_var(ref): - return - share_segment = ref.split(":", 1)[0] - if share_segment not in share_ids: - self._err(f"{label} resource_ref '{ref}' does not resolve to a share in the file service") - - def _verify_runtime_database_services(self) -> None: - """Validate observed database services against the scenario. - - Each service's owning transport service must resolve to a service on - the same node (mirroring ``runtime.applications``); grant grantee/object - refs must resolve to roles and logical objects within the same service - (ADR-029 §6). - """ - for name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.database_services: - continue - service_names = self._node_service_names(node) - for dbsvc in runtime.database_services: - self._verify_owned_service_ref( - name, - getattr(dbsvc, "service", ""), - service_names, - owner_label=f"Node '{name}' runtime database service '{dbsvc.database_service_id}'", - ) - self._verify_database_grants(name, dbsvc) - - def _verify_database_grants(self, node_name: str, dbsvc: object) -> None: - role_ids = {role.role_id for role in dbsvc.roles} - objects_by_type: dict[str, set[str]] = { - "database": {db.database_id for db in dbsvc.databases}, - "schema": {schema.schema_id for db in dbsvc.databases for schema in db.schemas}, - "table": {table.table_id for db in dbsvc.databases for schema in db.schemas for table in schema.tables}, - } - label = f"Node '{node_name}' runtime database service '{dbsvc.database_service_id}'" - for grant in dbsvc.grants: - if not self._is_unresolved_var(grant.grantee_role_ref) and grant.grantee_role_ref not in role_ids: - self._err(f"{label} grant grantee_role_ref '{grant.grantee_role_ref}' is not a role in the service") - object_type = grant.object_type - type_value = object_type.value if isinstance(object_type, DatabaseObjectType) else object_type - if self._is_unresolved_var(grant.object_ref) or self._is_unresolved_var(type_value): - continue - if grant.object_ref not in objects_by_type.get(type_value, set()): - self._err(f"{label} grant object_ref '{grant.object_ref}' is not a {type_value} in the service") - - def _verify_runtime_dns_services(self) -> None: - """Validate observed DNS services against the scenario. - - Each DNS service's owning transport service must resolve to a service - on the same node. Optional configuration, log, and zone-file refs are - checked against ``runtime.filesystem_inventory`` when the node has an - observed file inventory, keeping evidence paths tied to node-scoped - runtime facts without embedding raw zone-file content. - """ - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.dns_services: - continue - service_names = self._node_service_names(node) - observed_paths = self._node_observed_paths(node) - for dns_service in runtime.dns_services: - owner_label = f"Node '{node_name}' runtime DNS service '{dns_service.dns_service_id}'" - self._verify_owned_service_ref( - node_name, - getattr(dns_service, "service", ""), - service_names, - owner_label=owner_label, - ) - self._verify_dns_file_refs( - owner_label, - getattr(dns_service, "configuration_file_refs", []), - field_name="configuration_file_refs", - observed_paths=observed_paths, - ) - self._verify_dns_file_refs( - owner_label, - getattr(dns_service, "log_file_refs", []), - field_name="log_file_refs", - observed_paths=observed_paths, - ) - for zone in dns_service.zones: - self._verify_dns_file_refs( - f"{owner_label} zone '{zone.zone_id}'", - getattr(zone, "zone_file_refs", []), - field_name="zone_file_refs", - observed_paths=observed_paths, - ) - - def _verify_dns_file_refs( - self, - owner_label: str, - refs: list[str], - *, - field_name: str, - observed_paths: set[str], - ) -> None: - if not observed_paths: - return - for ref in refs: - if self._is_unresolved_var(ref): - continue - if ref not in observed_paths: - self._err(f"{owner_label} {field_name} ref '{ref}' does not resolve to an observed file on the node") - - def _verify_runtime_security_monitoring_managers(self) -> None: - """Validate observed SIEM/security-monitoring manager inventories. - - Manager and listener service refs are node-local transport ownership - claims. File refs are checked only when a filesystem inventory exists, - matching the DNS and mail runtime surfaces' evidence-bound posture. - Agent/group and setting/component refs are manager-local stable ids. - """ - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.security_monitoring_managers: - continue - service_names = self._node_service_names(node) - observed_paths = self._node_observed_paths(node) - for manager in runtime.security_monitoring_managers: - self._verify_security_monitoring_manager( - node_name=node_name, - manager=manager, - service_names=service_names, - observed_paths=observed_paths, - ) - - def _verify_security_monitoring_manager( - self, - *, - node_name: str, - manager: object, - service_names: set[str], - observed_paths: set[str], - ) -> None: - owner_label = ( - f"Node '{node_name}' runtime security-monitoring manager '{manager.security_monitoring_manager_id}'" - ) - self._verify_owned_service_ref( - node_name, - getattr(manager, "service", ""), - service_names, - owner_label=owner_label, - ) - self._verify_dns_file_refs( - owner_label, - getattr(manager, "configuration_file_refs", []), - field_name="configuration_file_refs", - observed_paths=observed_paths, - ) - self._verify_dns_file_refs( - owner_label, - getattr(manager, "log_file_refs", []), - field_name="log_file_refs", - observed_paths=observed_paths, - ) - self._verify_dns_file_refs( - owner_label, - getattr(manager, "evidence_refs", []), - field_name="evidence_refs", - observed_paths=observed_paths, - ) - self._verify_security_monitoring_children( - node_name=node_name, - manager=manager, - owner_label=owner_label, - service_names=service_names, - observed_paths=observed_paths, - ) - - def _verify_security_monitoring_children( - self, - *, - node_name: str, - manager: object, - owner_label: str, - service_names: set[str], - observed_paths: set[str], - ) -> None: - component_ids = {component.component_id for component in manager.components} - agent_ids = {agent.agent_id for agent in manager.agents} - group_ids = {group.group_id for group in manager.agent_groups} - content_set_ids = {content_set.content_id for content_set in manager.content_sets} - definition_ids = {definition.definition_id for definition in manager.detection_definitions} - for listener in manager.listeners: - self._verify_owned_service_ref( - node_name, - getattr(listener, "service", ""), - service_names, - owner_label=f"{owner_label} listener '{listener.listener_id}'", - ) - for group in manager.agent_groups: - group_label = f"{owner_label} agent_group '{group.group_id}'" - self._verify_dns_file_refs( - group_label, - getattr(group, "configuration_file_refs", []), - field_name="configuration_file_refs", - observed_paths=observed_paths, - ) - for member_ref in group.member_refs: - self._verify_security_monitoring_local_ref( - member_ref, - agent_ids, - owner_label=group_label, - field_name="member_ref", - target_label="agent", - ) - for agent in manager.agents: - agent_label = f"{owner_label} agent '{agent.agent_id}'" - for group_ref in agent.group_refs: - self._verify_security_monitoring_local_ref( - group_ref, - group_ids, - owner_label=agent_label, - field_name="group_ref", - target_label="agent group", - ) - for content_set in manager.content_sets: - self._verify_dns_file_refs( - f"{owner_label} content_set '{content_set.content_id}'", - getattr(content_set, "file_refs", []), - field_name="file_refs", - observed_paths=observed_paths, - ) - for definition in manager.detection_definitions: - definition_label = f"{owner_label} detection_definition '{definition.definition_id}'" - self._verify_security_monitoring_local_ref( - getattr(definition, "content_set_ref", ""), - content_set_ids, - owner_label=definition_label, - field_name="content_set_ref", - target_label="content set", - ) - self._verify_dns_file_refs( - definition_label, - [definition.source_file_ref] if definition.source_file_ref else [], - field_name="source_file_ref", - observed_paths=observed_paths, - ) - self._verify_dns_file_refs( - definition_label, - getattr(definition, "evidence_refs", []), - field_name="evidence_refs", - observed_paths=observed_paths, - ) - for field_name, refs in ( - ("if_sid_ref", getattr(definition, "if_sid_refs", [])), - ("if_matched_sid_ref", getattr(definition, "if_matched_sid_refs", [])), - ("parent_definition_ref", getattr(definition, "parent_definition_refs", [])), - ): - for ref in refs: - self._verify_security_monitoring_local_ref( - ref, - definition_ids, - owner_label=definition_label, - field_name=field_name, - target_label="detection definition", - ) - source_artifact_ref = getattr(definition, "source_artifact_ref", "") - if source_artifact_ref and not self._is_unresolved_var(source_artifact_ref): - self._validate_named_ref( - source_artifact_ref, - owner_label=definition_label, - ref_label="source_artifact_ref", - ) - for target_ref in getattr(definition, "target_refs", []): - if self._is_unresolved_var(target_ref): - continue - self._validate_named_ref( - target_ref, - owner_label=definition_label, - ref_label="target_ref", - targetable=True, - ) - for setting in manager.settings: - setting_label = f"{owner_label} setting '{setting.setting_id}'" - self._verify_security_monitoring_local_ref( - getattr(setting, "component_ref", ""), - component_ids, - owner_label=setting_label, - field_name="component_ref", - target_label="component", - ) - self._verify_dns_file_refs( - setting_label, - [setting.source_path] if setting.source_path else [], - field_name="source_path", - observed_paths=observed_paths, - ) - - def _verify_security_monitoring_local_ref( - self, - ref: str, - local_refs: set[str], - *, - owner_label: str, - field_name: str, - target_label: str, - ) -> None: - if not ref or self._is_unresolved_var(ref): - return - if ref not in local_refs: - self._err( - f"{owner_label} {field_name} '{ref}' does not resolve to a " - f"{target_label} in the security-monitoring manager" - ) - - def _verify_runtime_datastore_services(self) -> None: - """Validate observed datastore services against the scenario. - - Each service's owning transport service must resolve to a service on - the same node (mirroring ``runtime.applications`` and - ``runtime.database_services``); a non-empty, non-variable - ``authorization_ref`` must resolve to an ``app_authorization`` declared - on the same node's runtime (the delegated internal RBAC store). - """ - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.datastore_services: - continue - service_names = self._node_service_names(node) - authorization_ids = self._node_app_authorization_ids(runtime) - for datastore in runtime.datastore_services: - owner_label = f"Node '{node_name}' runtime datastore service '{datastore.datastore_service_id}'" - self._verify_owned_service_ref( - node_name, - getattr(datastore, "service", ""), - service_names, - owner_label=owner_label, - ) - self._verify_runtime_authorization_ref( - getattr(datastore, "authorization_ref", ""), - authorization_ids, - owner_label=owner_label, - ) - - def _verify_runtime_platform_applications(self) -> None: - """Validate observed platform-application inventories against the scenario. - - Each application's owning transport service must resolve to a service on - the same node; a non-empty, non-variable ``authorization_ref`` must - resolve to a same-node ``app_authorization``; content-object - ``references`` must resolve to sibling ``content_object_id`` values and - ``marking_refs`` to sibling ``marking_id`` values within the same - application (intra-application referential integrity). - """ - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.platform_applications: - continue - service_names = self._node_service_names(node) - authorization_ids = self._node_app_authorization_ids(runtime) - for application in runtime.platform_applications: - self._verify_platform_application( - node_name=node_name, - application=application, - service_names=service_names, - authorization_ids=authorization_ids, - ) - - def _verify_platform_application( - self, - *, - node_name: str, - application: object, - service_names: set[str], - authorization_ids: set[str], - ) -> None: - owner_label = f"Node '{node_name}' runtime platform application '{application.platform_application_id}'" - self._verify_owned_service_ref( - node_name, - getattr(application, "service", ""), - service_names, - owner_label=owner_label, - ) - self._verify_runtime_authorization_ref( - getattr(application, "authorization_ref", ""), - authorization_ids, - owner_label=owner_label, - ) - content_object_ids = {obj.content_object_id for obj in application.content_objects} - marking_ids = {marking.marking_id for marking in application.markings} - for content_object in application.content_objects: - object_label = f"{owner_label} content_object '{content_object.content_object_id}'" - for reference in content_object.references: - if self._is_unresolved_var(reference): - continue - if reference not in content_object_ids: - self._err( - f"{object_label} reference '{reference}' does not resolve to a " - f"content_object in the platform application" - ) - for marking_ref in content_object.marking_refs: - if self._is_unresolved_var(marking_ref): - continue - if marking_ref not in marking_ids: - self._err( - f"{object_label} marking_ref '{marking_ref}' does not resolve to a " - f"marking in the platform application" - ) - - @staticmethod - def _node_app_authorization_ids(runtime: object) -> set[str]: - """Collect ``app_authorization_id`` values declared on a node's runtime.""" - return { - authorization.app_authorization_id - for authorization in getattr(runtime, "app_authorizations", []) - if authorization.app_authorization_id - } - - def _verify_runtime_authorization_ref( - self, - authorization_ref: str, - authorization_ids: set[str], - *, - owner_label: str, - ) -> None: - """Resolve a delegated ``authorization_ref`` to a same-node app_authorization.""" - if not authorization_ref or self._is_unresolved_var(authorization_ref): - return - if authorization_ref not in authorization_ids: - self._err( - f"{owner_label} authorization_ref '{authorization_ref}' does not resolve to an " - f"app_authorization on the same node" - ) - - def _verify_runtime_forwarding_agents(self) -> None: - """Validate observed forwarding / intel-sync agent inventories. - - A ship target's ``target_node_ref``, when present and concrete, must - resolve to a defined node; a present, concrete ``target_service_ref`` - must resolve to a service on that referenced node (or, when no node ref - is given, to a service on the owning node). The agent-internal - ``require_profile_for_agent_kind`` guard (model-local) has already - enforced the per-``agent_kind`` profile shape. - """ - self._verify_forwarding_agent_id_uniqueness() - for agent in self._s.forwarding_agents: - owner_label = f"Scenario forwarding agent '{agent.forwarding_agent_id}'" - for target in agent.ship_targets: - self._verify_scenario_forwarding_ship_target( - target=target, - owner_label=f"{owner_label} ship_target '{target.target_id}'", - ) - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.forwarding_agents: - continue - local_service_names = self._node_service_names(node) - for agent in runtime.forwarding_agents: - owner_label = f"Node '{node_name}' runtime forwarding agent '{agent.forwarding_agent_id}'" - for target in agent.ship_targets: - self._verify_forwarding_ship_target( - node_name=node_name, - local_service_names=local_service_names, - target=target, - owner_label=f"{owner_label} ship_target '{target.target_id}'", - ) - - def _verify_forwarding_agent_id_uniqueness(self) -> None: - locations: dict[str, list[str]] = defaultdict(list) - for agent in self._s.forwarding_agents: - if agent.forwarding_agent_id: - locations[agent.forwarding_agent_id].append("scenario forwarding_agents") - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None: - continue - for agent in getattr(runtime, "forwarding_agents", []): - if agent.forwarding_agent_id: - locations[agent.forwarding_agent_id].append(f"node '{node_name}' runtime.forwarding_agents") - - for agent_id, agent_locations in locations.items(): - if len(agent_locations) > 1: - self._err(f"Duplicate forwarding_agent_id '{agent_id}' across {', '.join(agent_locations)}") - - def _verify_scenario_forwarding_ship_target(self, *, target: object, owner_label: str) -> None: - node_ref = getattr(target, "target_node_ref", "") - service_ref = getattr(target, "target_service_ref", "") - resolved_node = None - if node_ref and not self._is_unresolved_var(node_ref): - resolved_node = self._s.nodes.get(node_ref) - if resolved_node is None: - self._err(f"{owner_label} target_node_ref '{node_ref}' does not resolve to a defined node") - return - - if service_ref and not self._is_unresolved_var(service_ref): - if not node_ref: - self._err( - f"{owner_label} target_service_ref '{service_ref}' requires target_node_ref because " - "scenario-level forwarding agents have no owning node" - ) - return - if self._is_unresolved_var(node_ref): - return - if resolved_node is None: - return - if service_ref not in self._node_service_names(resolved_node): - self._err( - f"{owner_label} target_service_ref '{service_ref}' does not resolve to a service " - f"on node '{node_ref}'" - ) - - def _verify_forwarding_ship_target( - self, - *, - node_name: str, - local_service_names: set[str], - target: object, - owner_label: str, - ) -> None: - node_ref = getattr(target, "target_node_ref", "") - service_ref = getattr(target, "target_service_ref", "") - resolved_node_name = node_name - resolved_node = self._s.nodes.get(node_name) - if node_ref and not self._is_unresolved_var(node_ref): - resolved_node = self._s.nodes.get(node_ref) - if resolved_node is None: - self._err(f"{owner_label} target_node_ref '{node_ref}' does not resolve to a defined node") - return - resolved_node_name = node_ref - if service_ref and not self._is_unresolved_var(service_ref): - if resolved_node is None: - return - target_service_names = ( - local_service_names if resolved_node_name == node_name else self._node_service_names(resolved_node) - ) - if service_ref not in target_service_names: - self._err( - f"{owner_label} target_service_ref '{service_ref}' does not resolve to a service " - f"on node '{resolved_node_name}'" - ) - - def _verify_runtime_orchestration_authorities(self) -> None: - """Validate observed container-spawn orchestration-authority inventories. - - Each authority's ``control_interface_ref``, when present and concrete, - must resolve to a :class:`RuntimeControlInterface` declared in the same - node's ``runtime.local_control_interfaces`` (by ``control_interface_id``). - For a ``host_root_equivalent`` privilege class, the referenced control - interface must additionally be a read-write docker socket (a read-write - unix socket whose path is a ``docker.sock``), making the host-root - privilege-escalation fact resolvable at scenario scope. The - model-local ``require_profile_for_privilege_class`` guard has already - rejected a host-root-equivalent authority that carries no concrete - ``control_interface_ref``. - """ - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.orchestration_authorities: - continue - interfaces_by_id = { - interface.control_interface_id: interface - for interface in getattr(runtime, "local_control_interfaces", []) - if interface.control_interface_id - } - for authority in runtime.orchestration_authorities: - self._verify_orchestration_authority( - node_name=node_name, - authority=authority, - interfaces_by_id=interfaces_by_id, - ) - - def _verify_orchestration_authority( - self, - *, - node_name: str, - authority: object, - interfaces_by_id: dict[str, object], - ) -> None: - owner_label = f"Node '{node_name}' runtime orchestration authority '{authority.orchestration_authority_id}'" - ref = getattr(authority, "control_interface_ref", "") - if not ref or self._is_unresolved_var(ref): - return - interface = interfaces_by_id.get(ref) - if interface is None: - self._err( - f"{owner_label} control_interface_ref '{ref}' does not resolve to a " - f"control interface in the same node's runtime.local_control_interfaces" - ) - return - privilege = getattr(authority, "privilege_class", None) - if ( - isinstance(privilege, RuntimeOrchestrationPrivilegeClass) - and privilege is RuntimeOrchestrationPrivilegeClass.HOST_ROOT_EQUIVALENT - ): - self._verify_host_root_control_interface(owner_label=owner_label, ref=ref, interface=interface) - - @staticmethod - def _control_interface_is_docker_socket(interface: object) -> bool: - """Return whether a control interface is a read-write docker unix socket.""" - access = getattr(interface, "access", None) - kind = getattr(interface, "kind", None) - path = getattr(interface, "path", "") or "" - is_read_write = access is RuntimeControlInterfaceAccess.READ_WRITE - is_unix_socket = kind is RuntimeControlInterfaceKind.UNIX_SOCKET - is_docker_sock = isinstance(path, str) and path.endswith("docker.sock") - return is_read_write and is_unix_socket and is_docker_sock - - def _verify_host_root_control_interface( - self, - *, - owner_label: str, - ref: str, - interface: object, - ) -> None: - # ``${var}`` placeholders on the interface's access/kind/path are - # permissive: a deferred discriminator cannot be proven non-conformant. - access = getattr(interface, "access", None) - kind = getattr(interface, "kind", None) - path = getattr(interface, "path", "") or "" - if is_variable_ref(access) or is_variable_ref(kind) or is_variable_ref(path): - return - if not self._control_interface_is_docker_socket(interface): - self._err( - f"{owner_label} privilege_class 'host_root_equivalent' control_interface_ref '{ref}' " - f"must resolve to a read-write docker socket " - f"(access 'read_write', kind 'unix_socket', path ending in 'docker.sock')" - ) - - def _split_runtime_ref(self, ref: object, *, surface: str) -> tuple[str, str] | None: - """Split ``nodes..runtime..`` into (node, rest). - - Module composition rewrites the node segment to a dotted namespaced - form (``shared.web``), so we cannot split on ``.`` and index by - position. Partition on the surface marker instead so the node name - survives an arbitrary number of namespace prefixes. - """ - if not isinstance(ref, str) or not ref.startswith(_NODES_PREFIX): - return None - marker = f".runtime.{surface}." - head, sep, tail = ref[len(_NODES_PREFIX) :].partition(marker) - if not sep or not head or not tail: - return None - return head, tail - - def _resolve_database_service_ref(self, ref: object) -> object | None: - """Resolve a qualified ``nodes..runtime.database_services.`` ref. - - Accepts the database-service form and the ``.databases.`` form; both - resolve to the owning :class:`RuntimeDatabaseService` so a relationship's - ``database_access`` can be checked against it. - """ - split = self._split_runtime_ref(ref, surface="database_services") - if split is None: - return None - node_name, tail = split - tail_parts = tail.split(".") - # tail is ```` (1 part) or ``.databases.`` (3). - if len(tail_parts) == 1 or (len(tail_parts) == 3 and tail_parts[1] == "databases"): - svc_id = tail_parts[0] - else: - return None - node = self._s.nodes.get(node_name) - runtime = getattr(node, "runtime", None) if node is not None else None - if runtime is None: - return None - for dbsvc in runtime.database_services: - if dbsvc.database_service_id == svc_id: - return dbsvc - return None - - def _resolve_application_ref(self, ref: object) -> object | None: - """Resolve a qualified ``nodes..runtime.applications.`` ref. - - Returns the owning :class:`RuntimeApplicationSurface` so a relationship's - ``database_access`` source endpoint can be confirmed to be a runtime - application (ADR-029 §4). - """ - split = self._split_runtime_ref(ref, surface="applications") - if split is None: - return None - node_name, tail = split - if "." in tail: - return None - node = self._s.nodes.get(node_name) - runtime = getattr(node, "runtime", None) if node is not None else None - if runtime is None: - return None - for application in runtime.applications: - if application.application_id == tail: - return application - return None - - def _verify_runtime_mail_services(self) -> None: - """Validate runtime mail-service inventories against the scenario graph.""" - for node_name, node in self._s.nodes.items(): - mail_services = _mail_services_for_node(node) - if not mail_services: - continue - service_names = self._node_service_names(node) - observed_paths = self._node_observed_paths(node) - local_user_names = self._node_local_user_names(node) - for service in mail_services: - label = f"Node '{node_name}' runtime mail service '{service.mail_service_id}'" - self._verify_owned_service_ref( - node_name, - service.service, - service_names, - owner_label=label, - ) - self._verify_mail_service_children( - node_name=node_name, - label=label, - service=service, - service_names=service_names, - observed_paths=observed_paths, - local_user_names=local_user_names, - ) - - def _verify_mail_service_children( - self, - *, - node_name: str, - label: str, - service: object, - service_names: set[str], - observed_paths: set[str], - local_user_names: set[str], - ) -> None: - local_ids = _collect_mail_service_local_ids(service) - self._verify_mail_listeners(node_name, label, service, service_names, local_ids) - self._verify_mailboxes(label, service, local_user_names, local_ids) - self._verify_mail_aliases(label, service, local_ids) - self._verify_mail_routing_rules(label, service, local_ids) - self._verify_mail_settings(label, service, observed_paths, local_ids) - - def _verify_mail_listeners( - self, - node_name: str, - label: str, - service: object, - service_names: set[str], - local_ids: "_MailServiceLocalIds", - ) -> None: - for listener in service.listeners: - listener_label = f"{label} listener '{listener.listener_id}'" - self._verify_owned_service_ref( - node_name, - listener.service, - service_names, - owner_label=listener_label, - ) - self._verify_mail_ref( - listener.component_ref, - local_ids.components, - label=listener_label, - field_name="component_ref", - ) - - def _verify_mailboxes( - self, - label: str, - service: object, - local_user_names: set[str], - local_ids: "_MailServiceLocalIds", - ) -> None: - for mailbox in service.mailboxes: - mailbox_label = f"{label} mailbox '{mailbox.mailbox_id}'" - self._verify_mail_ref(mailbox.domain_ref, local_ids.domains, label=mailbox_label, field_name="domain_ref") - self._verify_mail_ref(mailbox.store_ref, local_ids.stores, label=mailbox_label, field_name="store_ref") - self._verify_mail_account_ref(mailbox.account_ref, mailbox_label) - self._verify_mail_local_user_ref(mailbox.local_user_ref, local_user_names, mailbox_label) - - def _verify_mail_aliases( - self, - label: str, - service: object, - local_ids: "_MailServiceLocalIds", - ) -> None: - for alias in service.aliases: - alias_label = f"{label} alias '{alias.alias_id}'" - self._verify_mail_ref(alias.domain_ref, local_ids.domains, label=alias_label, field_name="domain_ref") - for target_ref in alias.target_refs: - self._verify_mail_ref( - target_ref, - local_ids.mailboxes | local_ids.aliases, - label=alias_label, - field_name="target_ref", - ) - - def _verify_mail_routing_rules( - self, - label: str, - service: object, - local_ids: "_MailServiceLocalIds", - ) -> None: - for rule in service.routing_rules: - rule_label = f"{label} routing rule '{rule.rule_id}'" - self._verify_mail_ref(rule.source_ref, local_ids.routing_refs, label=rule_label, field_name="source_ref") - self._verify_mail_ref(rule.target_ref, local_ids.routing_refs, label=rule_label, field_name="target_ref") - - def _verify_mail_settings( - self, - label: str, - service: object, - observed_paths: set[str], - local_ids: "_MailServiceLocalIds", - ) -> None: - for setting in service.settings: - setting_label = f"{label} setting '{setting.setting_id}'" - self._verify_mail_ref( - setting.component_ref, - local_ids.components, - label=setting_label, - field_name="component_ref", - ) - if self._source_path_misses_observed_inventory(setting.source_path, observed_paths): - self._err(f"{setting_label} source_path '{setting.source_path}' does not resolve to an observed file") - - def _source_path_misses_observed_inventory(self, source_path: str, observed_paths: set[str]) -> bool: - return bool( - source_path - and observed_paths - and not self._is_unresolved_var(source_path) - and source_path not in observed_paths - ) - - def _verify_mail_ref( - self, - ref: str, - local_refs: set[str], - *, - label: str, - field_name: str, - ) -> None: - if not ref or self._is_unresolved_var(ref): - return - if ref not in local_refs: - self._err(f"{label} {field_name} '{ref}' does not resolve inside mail service") - - def _verify_mail_account_ref(self, ref: str, label: str) -> None: - if not ref or self._is_unresolved_var(ref): - return - if ref not in self._s.accounts: - self._err(f"{label} account_ref '{ref}' does not resolve to a top-level account") - - def _verify_mail_local_user_ref(self, ref: str, local_user_names: set[str], label: str) -> None: - if not ref or self._is_unresolved_var(ref): - return - if local_user_names and ref not in local_user_names: - self._err(f"{label} local_user_ref '{ref}' does not resolve to a runtime.local_identity user") - - def _verify_relationship_mail_access(self) -> None: - """Validate typed ``mail_access`` blocks on top-level relationship edges.""" - for name, relationship in self._s.relationships.items(): - access = relationship.mail_access - if access is None: - continue - label = f"Relationship '{name}'" - mail_service = self._check_mail_access_target(relationship.target, label) - if mail_service is None: - continue - self._verify_mail_ref( - access.listener_ref, - {listener.listener_id for listener in mail_service.listeners}, - label=f"{label} mail_access", - field_name="listener_ref", - ) - self._verify_mail_ref( - access.mailbox_ref, - {mailbox.mailbox_id for mailbox in mail_service.mailboxes}, - label=f"{label} mail_access", - field_name="mailbox_ref", - ) - self._verify_mail_ref( - access.domain_ref, - {domain.domain_id for domain in mail_service.domains}, - label=f"{label} mail_access", - field_name="domain_ref", - ) - - def _check_mail_access_target(self, target: str, label: str) -> object | None: - mail_service = self._resolve_mail_service_ref(target) - if mail_service is not None or self._is_unresolved_var(target): - return mail_service - self._err(f"{label} has mail_access but target '{target}' does not resolve to a mail service") - return None - - def _resolve_mail_service_ref(self, ref: object) -> object | None: - """Resolve a qualified runtime mail-service or child ref to the service.""" - split = self._split_runtime_ref(ref, surface="mail_services") - if split is None: - return None - node_name, tail = split - parsed_tail = _parse_mail_ref_tail(tail) - if parsed_tail is None: - return None - return _resolve_mail_service_tail( - _mail_services_for_node_name(self._s, node_name), - parsed_tail, - ) - - def _verify_relationship_database_access(self) -> None: - """Validate typed ``database_access`` blocks on relationship edges. - - When a relationship carries ``database_access``, its ``source`` must - resolve to a runtime application and its ``target`` must resolve to a - database service (or logical database); the access ``role_ref`` must - name a role in that service (ADR-029 §4). - """ - for name, rel in self._s.relationships.items(): - access = rel.database_access - if access is None: - continue - label = f"Relationship '{name}'" - self._check_database_access_source(rel.source, label) - dbsvc = self._check_database_access_target(rel.target, label) - if dbsvc is not None: - self._check_database_access_role(access.role_ref, dbsvc, label) - - def _check_database_access_source(self, source: str, label: str) -> None: - if self._is_unresolved_var(source) or self._resolve_application_ref(source) is not None: - return - self._err(f"{label} has database_access but source '{source}' does not resolve to a runtime application") - - def _check_database_access_target(self, target: str, label: str) -> object | None: - dbsvc = self._resolve_database_service_ref(target) - if dbsvc is not None or self._is_unresolved_var(target): - return dbsvc - self._err( - f"{label} has database_access but target '{target}' does not resolve to a database service or database" - ) - return None - - def _check_database_access_role(self, role_ref: str, dbsvc: object, label: str) -> None: - if not role_ref or self._is_unresolved_var(role_ref): - return - if role_ref not in {role.role_id for role in dbsvc.roles}: - self._err( - f"{label} database_access role_ref '{role_ref}' " - f"is not a role in database service '{dbsvc.database_service_id}'" - ) - - # ------------------------------------------------------------------ # - # Typed relationship subtypes (SCN-010 §5.7) - # ------------------------------------------------------------------ # - - def _verify_relationship_forwarding_edges(self) -> None: - """Validate typed ``forwarding_edge`` blocks on relationship edges. - - ``forwarder_ref`` must resolve to a ``RuntimeForwardingAgent`` (by - ``forwarding_agent_id``) on some node. AGREEMENT GUARD: when that agent - carries ``ship_targets``, the edge's ``target_listener_role`` and - ``protocol`` — where both sides are concrete — must be consistent with - at least one ship_target, so the inter-node trust edge and the - agent-side shipping state cannot disagree (SCN-010 §5.7). - """ - for name, rel in self._s.relationships.items(): - edge = rel.forwarding_edge - if edge is None: - continue - label = f"Relationship '{name}'" - ref = edge.forwarder_ref - if not ref or self._is_unresolved_var(ref): - continue - agents = self._resolve_forwarding_agent_refs(ref) - if not agents: - self._err( - f"{label} forwarding_edge forwarder_ref '{ref}' does not resolve to a forwarding agent " - "on any node or in scenario forwarding_agents" - ) - continue - if len(agents) > 1: - self._err( - f"{label} forwarding_edge forwarder_ref '{ref}' resolves to multiple forwarding agents; " - "forwarding_agent_id values must be unique across scenario forwarding_agents and node runtimes" - ) - continue - self._check_forwarding_edge_agreement(edge, agents[0], label) - - def _resolve_forwarding_agent_refs(self, ref: str) -> list[object]: - """Resolve a ``forwarding_agent_id`` across node and scenario registries.""" - matches: list[object] = [agent for agent in self._s.forwarding_agents if agent.forwarding_agent_id == ref] - for node in self._s.nodes.values(): - runtime = getattr(node, "runtime", None) - if runtime is None: - continue - for agent in getattr(runtime, "forwarding_agents", []): - if agent.forwarding_agent_id == ref: - matches.append(agent) - return matches - - def _check_forwarding_edge_agreement(self, edge: object, agent: object, label: str) -> None: - ship_targets = list(getattr(agent, "ship_targets", [])) - if not ship_targets: - return - agent_id = agent.forwarding_agent_id - self._check_forwarding_edge_protocol_agreement(edge, ship_targets, agent_id, label) - self._check_forwarding_edge_role_agreement(edge, ship_targets, agent_id, label) - - def _check_forwarding_edge_protocol_agreement( - self, edge: object, ship_targets: list, agent_id: str, label: str - ) -> None: - # The edge ``protocol`` is a free string; agreement is asserted only - # against ship_target protocols that are concrete enum members. If no - # ship_target carries a concrete protocol, nothing concrete to compare. - edge_protocol = getattr(edge, "protocol", "") - if not edge_protocol or self._is_unresolved_var(edge_protocol): - return - target_protocols = [t.protocol.value for t in ship_targets if isinstance(t.protocol, RuntimeForwardingProtocol)] - if not target_protocols: - return - if edge_protocol not in target_protocols: - self._err( - f"{label} forwarding_edge protocol '{edge_protocol}' does not match any ship_target " - f"protocol on forwarding agent '{agent_id}' (one of: {', '.join(sorted(set(target_protocols)))})" - ) - - def _check_forwarding_edge_role_agreement( - self, edge: object, ship_targets: list, agent_id: str, label: str - ) -> None: - # An ``agent_event_ingestion`` listener role requires a ship_target with - # an ingestion endpoint; an ``agent_enrollment`` role requires one with - # an enrollment endpoint. Other roles impose no ship_target shape. - role = getattr(edge, "target_listener_role", None) - if not isinstance(role, RuntimeSecurityMonitoringListenerRole): - return - if role is RuntimeSecurityMonitoringListenerRole.AGENT_EVENT_INGESTION: - if not any(t.has_ingestion_endpoint() for t in ship_targets): - self._err( - f"{label} forwarding_edge target_listener_role 'agent_event_ingestion' has no agreeing " - f"ship_target carrying an ingestion endpoint on forwarding agent '{agent_id}'" - ) - elif role is RuntimeSecurityMonitoringListenerRole.AGENT_ENROLLMENT: - if not any(t.has_enrollment_endpoint() for t in ship_targets): - self._err( - f"{label} forwarding_edge target_listener_role 'agent_enrollment' has no agreeing " - f"ship_target carrying an enrollment endpoint on forwarding agent '{agent_id}'" - ) - - def _verify_relationship_service_integrations(self) -> None: - """Validate typed ``service_integration`` blocks on relationship edges. - - ``consumer_ref``/``engine_ref`` (when concrete) must resolve to platform - applications by ``platform_application_id``; a concrete - ``auth_principal_ref`` must resolve to the engine application's - referenced ``app_authorization`` store when ``authorization_ref`` is set - (the integration authenticates into the engine's internal RBAC store). - """ - for name, rel in self._s.relationships.items(): - integration = rel.service_integration - if integration is None: - continue - label = f"Relationship '{name}'" - self._check_service_integration_endpoint(integration.consumer_ref, "consumer_ref", label) - engine = self._check_service_integration_endpoint(integration.engine_ref, "engine_ref", label) - self._check_service_integration_auth_principal(integration.auth_principal_ref, engine, label) - - def _check_service_integration_endpoint(self, ref: str, field_name: str, label: str) -> object | None: - if not ref or self._is_unresolved_var(ref): - return None - resolved = self._resolve_platform_application_ref(ref) - if resolved is None: - self._err( - f"{label} service_integration {field_name} '{ref}' does not resolve to a " - f"platform application on any node" - ) - return None - return resolved[1] - - def _resolve_platform_application_ref(self, ref: str) -> tuple[str, object] | None: - """Resolve a ``platform_application_id`` to (node_name, application).""" - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None: - continue - for application in getattr(runtime, "platform_applications", []): - if application.platform_application_id == ref: - return node_name, application - return None - - def _check_service_integration_auth_principal(self, ref: str, engine: object | None, label: str) -> None: - if not ref or self._is_unresolved_var(ref) or engine is None: - return - node_name = self._node_name_of_platform_application(engine) - if node_name is None: - return - node = self._s.nodes.get(node_name) - runtime = getattr(node, "runtime", None) if node is not None else None - authorizations = list(getattr(runtime, "app_authorizations", [])) - authorization_ref = getattr(engine, "authorization_ref", "") - if authorization_ref and not self._is_unresolved_var(authorization_ref): - authorizations = [ - authorization - for authorization in authorizations - if getattr(authorization, "app_authorization_id", "") == authorization_ref - ] - if not authorizations: - # The runtime platform application validator reports the bad - # authorization_ref; avoid emitting a misleading principal-scope - # error from this relationship pass as well. - return - principal_ids = { - principal.principal_id for authorization in authorizations for principal in authorization.principals - } - if ref not in principal_ids: - scope = ( - f"authorization '{authorization_ref}'" - if authorization_ref and not self._is_unresolved_var(authorization_ref) - else "an app_authorization principal" - ) - self._err( - f"{label} service_integration auth_principal_ref '{ref}' does not resolve to " - f"{scope} on the engine application's node '{node_name}'" - ) - - def _node_name_of_platform_application(self, application: object) -> str | None: - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None: - continue - if application in getattr(runtime, "platform_applications", []): - return node_name - return None - - def _verify_relationship_proxy_upstreams(self) -> None: - """Validate typed ``proxy_upstream`` blocks on relationship edges. - - ``route_ref`` must resolve to an application route (by ``route_id``) on - the relationship's ``source`` proxy; ``upstream_node_ref`` / - ``upstream_service_ref`` (when concrete) must resolve. AGREEMENT GUARD: - when the referenced route ALSO carries an ``upstream_target``, the shared - facts (target node, target service, and the TLS-termination boolean) MUST - agree between ``route.upstream_target`` and the ``RelationshipProxyUpstream`` - so the same fact recorded at two scopes is never silently duplicated and - contradictory (SCN-010 §5.7). - """ - for name, rel in self._s.relationships.items(): - upstream = rel.proxy_upstream - if upstream is None: - continue - label = f"Relationship '{name}'" - target_node_name = self._check_proxy_upstream_node_ref( - upstream.upstream_node_ref, - label, - context="proxy_upstream", - field_name="upstream_node_ref", - ) - self._check_proxy_upstream_service_ref( - upstream.upstream_service_ref, - upstream_node_ref=target_node_name or "", - relationship_target=rel.target, - label=label, - context="proxy_upstream", - field_name="upstream_service_ref", - ) - route = self._check_proxy_upstream_route_ref(upstream.route_ref, rel.source, label) - if route is not None: - self._check_proxy_upstream_agreement(upstream, route, label, relationship_target=rel.target) - - def _check_proxy_upstream_node_ref( - self, - node_ref: str, - label: str, - *, - context: str, - field_name: str, - ) -> str | None: - if not node_ref or self._is_unresolved_var(node_ref): - return None - if node_ref not in self._s.nodes: - self._err(f"{label} {context} {field_name} '{node_ref}' does not resolve to a defined node") - return None - return node_ref - - def _check_proxy_upstream_service_ref( - self, - service_ref: str, - *, - upstream_node_ref: str, - relationship_target: str, - label: str, - context: str, - field_name: str, - ) -> None: - if not service_ref or self._is_unresolved_var(service_ref): - return - resolved = self._resolve_upstream_service_ref( - service_ref, - upstream_node_ref=upstream_node_ref, - relationship_target=relationship_target, - ) - if resolved is None: - self._err( - f"{label} {context} {field_name} '{service_ref}' cannot be resolved without a concrete upstream node" - ) - return - node_name, service_name = resolved - expected_node_name = upstream_node_ref or self._node_name_from_relationship_target(relationship_target) - if expected_node_name and node_name != expected_node_name: - self._err( - f"{label} {context} {field_name} '{service_ref}' must reference a service " - f"on upstream node '{expected_node_name}'" - ) - return - node = self._s.nodes.get(node_name) - if node is None: - self._err(f"{label} {context} upstream service node '{node_name}' does not resolve to a defined node") - return - if service_name not in self._node_service_names(node): - self._err( - f"{label} {context} {field_name} '{service_ref}' does not resolve to a service on node '{node_name}'" - ) - - def _resolve_upstream_service_ref( - self, - service_ref: str, - *, - upstream_node_ref: str, - relationship_target: str, - ) -> tuple[str, str] | None: - split = self._split_node_service_ref(service_ref) - if split is not None: - return split - node_name = "" - if upstream_node_ref and not self._is_unresolved_var(upstream_node_ref): - node_name = upstream_node_ref - else: - target_node_name = self._node_name_from_relationship_target(relationship_target) - if target_node_name is not None: - node_name = target_node_name - if not node_name: - return None - return node_name, service_ref - - def _node_name_from_relationship_target(self, target: object) -> str | None: - if not isinstance(target, str) or self._is_unresolved_var(target): - return None - if target in self._s.nodes: - return target - service_split = self._split_node_service_ref(target) - if service_split is not None: - node_name, _service_name = service_split - return node_name if node_name in self._s.nodes else None - if target.startswith(_NODES_PREFIX): - node_name, sep, _tail = target[len(_NODES_PREFIX) :].partition(".runtime.") - if sep and node_name in self._s.nodes: - return node_name - return None - - def _check_proxy_upstream_route_ref(self, route_ref: str, source: str, label: str) -> object | None: - if not route_ref or self._is_unresolved_var(route_ref): - return None - routes = self._source_application_routes(source) - if routes is None: - # The source does not resolve to a runtime application surface; the - # generic relationship endpoint check already reports an unresolved - # source, so the route_ref check is deferred rather than duplicated. - return None - route = routes.get(route_ref) - if route is None: - self._err( - f"{label} proxy_upstream route_ref '{route_ref}' does not resolve to an " - f"application route on source '{source}'" - ) - return route - - def _source_application_routes(self, source: str) -> dict[str, object] | None: - """Collect ``route_id``->route for every application surface on ``source``. - - ``source`` may be a qualified ``nodes..runtime.applications.`` - ref or a bare node name; either way the proxy route lives on that node's - application surface(s). - """ - application = self._resolve_application_ref(source) - if application is not None: - return {route.route_id: route for route in application.routes} - node = self._s.nodes.get(source) - runtime = getattr(node, "runtime", None) if node is not None else None - if runtime is None: - return None - routes: dict[str, object] = {} - for application in getattr(runtime, "applications", []): - for route in application.routes: - routes[route.route_id] = route - return routes - - def _check_proxy_upstream_agreement( - self, - upstream: object, - route: object, - label: str, - *, - relationship_target: str, - ) -> None: - target = getattr(route, "upstream_target", None) - if target is None: - return - self._assert_shared_field_agreement( - label, - field_label="upstream node", - relationship_value=getattr(upstream, "upstream_node_ref", ""), - route_value=getattr(target, "target_node_ref", ""), - ) - self._assert_shared_field_agreement( - label, - field_label="upstream service", - relationship_value=getattr(upstream, "upstream_service_ref", ""), - route_value=getattr(target, "target_service", ""), - upstream_node_ref=getattr(upstream, "upstream_node_ref", "") or getattr(target, "target_node_ref", ""), - relationship_target=relationship_target, - ) - self._assert_shared_bool_agreement( - label, - field_label="TLS-termination", - relationship_value=getattr(upstream, "client_tls_terminated", None), - route_value=getattr(target, "tls_terminated_here", None), - ) - - def _assert_shared_field_agreement( - self, - label: str, - *, - field_label: str, - relationship_value: str, - route_value: str, - upstream_node_ref: str = "", - relationship_target: str = "", - ) -> None: - if not relationship_value or self._is_unresolved_var(relationship_value): - return - if not route_value or self._is_unresolved_var(route_value): - return - if self._shared_field_values_agree( - field_label=field_label, - relationship_value=relationship_value, - route_value=route_value, - upstream_node_ref=upstream_node_ref, - relationship_target=relationship_target, - ): - return - else: - self._err( - f"{label} proxy_upstream {field_label} '{relationship_value}' disagrees with the " - f"route's upstream_target value '{route_value}'" - ) - - def _shared_field_values_agree( - self, - *, - field_label: str, - relationship_value: str, - route_value: str, - upstream_node_ref: str, - relationship_target: str, - ) -> bool: - if field_label != "upstream service": - return relationship_value == route_value - relationship_ref = self._resolve_upstream_service_ref( - relationship_value, - upstream_node_ref=upstream_node_ref, - relationship_target=relationship_target, - ) - route_ref = self._resolve_upstream_service_ref( - route_value, - upstream_node_ref=upstream_node_ref, - relationship_target=relationship_target, - ) - if relationship_ref is None or route_ref is None: - return relationship_value == route_value - return relationship_ref == route_ref - - def _assert_shared_bool_agreement( - self, label: str, *, field_label: str, relationship_value: object, route_value: object - ) -> None: - if not isinstance(relationship_value, bool) or not isinstance(route_value, bool): - return - if relationship_value != route_value: - self._err( - f"{label} proxy_upstream {field_label} '{relationship_value}' disagrees with the " - f"route's upstream_target value '{route_value}'" - ) - - def _verify_content(self) -> None: - for name, item in self._s.content.items(): - if item.target and not self._is_unresolved_var(item.target) and item.target not in self._s.nodes: - self._err(f"Content '{name}' targets undefined node '{item.target}'") - elif item.target and not self._is_unresolved_var(item.target) and not self._is_vm_node(item.target): - self._err(f"Content '{name}' target '{item.target}' must be a VM node") - - def _verify_accounts(self) -> None: - for name, acct in self._s.accounts.items(): - if acct.node and not self._is_unresolved_var(acct.node) and acct.node not in self._s.nodes: - self._err(f"Account '{name}' references undefined node '{acct.node}'") - elif acct.node and not self._is_unresolved_var(acct.node) and not self._is_vm_node(acct.node): - self._err(f"Account '{name}' node '{acct.node}' must be a VM node") - - def _verify_relationships(self) -> None: - for name, rel in self._s.relationships.items(): - if not self._is_unresolved_var(rel.source): - self._validate_named_ref( - rel.source, - owner_label=f"Relationship '{name}'", - ref_label="source", - ) - if not self._is_unresolved_var(rel.target): - self._validate_named_ref( - rel.target, - owner_label=f"Relationship '{name}'", - ref_label="target", - ) - - def _verify_agents(self) -> None: - flat_entity_names = self._all_entity_names() - service_names = {service.name for node in self._s.nodes.values() for service in node.services if service.name} - - for name, agent in self._s.agents.items(): - if agent.entity and not self._is_unresolved_var(agent.entity) and agent.entity not in flat_entity_names: - self._err(f"Agent '{name}' references undefined entity '{agent.entity}'") - for acct_name in agent.starting_accounts: - if self._is_unresolved_var(acct_name): - continue - if acct_name not in self._s.accounts: - self._err(f"Agent '{name}' starting_account '{acct_name}' not in accounts section") - for subnet in agent.allowed_subnets: - if self._is_unresolved_var(subnet): - continue - if subnet not in self._s.infrastructure: - self._err(f"Agent '{name}' allowed_subnet '{subnet}' not in infrastructure section") - elif not self._is_switch_node(subnet): - self._err(f"Agent '{name}' allowed_subnet '{subnet}' must reference a switch/network entry") - if agent.initial_knowledge: - for host in agent.initial_knowledge.hosts: - if self._is_unresolved_var(host): - continue - if host not in self._s.nodes: - self._err(f"Agent '{name}' initial_knowledge host '{host}' not in nodes section") - elif not self._is_vm_node(host): - self._err(f"Agent '{name}' initial_knowledge host '{host}' must reference a VM node") - for subnet in agent.initial_knowledge.subnets: - if self._is_unresolved_var(subnet): - continue - if subnet not in self._s.infrastructure: - self._err(f"Agent '{name}' initial_knowledge subnet '{subnet}' not in infrastructure section") - elif not self._is_switch_node(subnet): - self._err( - f"Agent '{name}' initial_knowledge subnet '{subnet}' must reference a switch/network entry" - ) - for service_name in agent.initial_knowledge.services: - if self._is_unresolved_var(service_name): - continue - if service_name not in service_names: - self._err( - f"Agent '{name}' initial_knowledge service '{service_name}' not in node service names" - ) - for acct_name in agent.initial_knowledge.accounts: - if self._is_unresolved_var(acct_name): - continue - if acct_name not in self._s.accounts: - self._err(f"Agent '{name}' initial_knowledge account '{acct_name}' not in accounts section") - for cond_name in agent.starting_conditions: - if self._is_unresolved_var(cond_name): - continue - # ADR-020 §6 publishes starting_conditions as accepting bare - # (`health`) or section-qualified (`conditions.health`) - # references. Strip the `conditions.` prefix when present so - # both forms resolve against the same dict. - bare_name = cond_name.removeprefix("conditions.") - if bare_name not in self._s.conditions: - self._err(f"Agent '{name}' starting_condition '{cond_name}' not in conditions section") - for anchor in agent.authority_anchors: - if self._is_unresolved_var(anchor): - continue - self._validate_named_ref( - anchor, - owner_label=f"Agent '{name}'", - ref_label="authority_anchor", - targetable=False, - ) - for scope in agent.operating_scope: - if self._is_unresolved_var(scope): - continue - self._validate_operating_scope_ref(scope, owner_label=f"Agent '{name}'") - - def _verify_participant_behavior(self) -> None: - analysis = analyze_participant_behavior( - agents_by_name=self._s.agents, - action_contracts=self._s.action_contracts, - observation_boundaries=self._s.observation_boundaries, - is_unresolved=self._is_unresolved_var, - ) - for issue in analysis.issues: - self._err(self._format_participant_behavior_issue(issue)) - self._verify_participant_interaction_refs() - - def _verify_participant_interaction_refs(self) -> None: - for action_name, action_contract in self._s.action_contracts.items(): - for index, interaction in enumerate(action_contract.interactions): - owner_label = f"Action contract '{action_name}' interaction[{index}]" - if not self._is_unresolved_var(interaction.target): - self._validate_named_ref( - interaction.target, - owner_label=owner_label, - ref_label="target", - targetable=True, - ) - for ref in interaction.shared_state_refs: - if self._is_unresolved_var(ref): - continue - self._validate_named_ref( - ref, - owner_label=owner_label, - ref_label="shared_state_ref", - targetable=True, - ) - - def _verify_participant_outcomes(self) -> None: - analysis = analyze_participant_outcome_interpretations( - outcome_interpretation_rules=self._s.outcome_interpretation_rules, - action_contracts=self._s.action_contracts, - objectives=self._s.objectives, - workflows=self._s.workflows, - evaluations=self._s.evaluations, - is_unresolved=self._is_unresolved_var, - ) - for issue in analysis.issues: - self._err(self._format_participant_outcome_issue(issue)) - - def _verify_objectives(self) -> None: - # Declarative-objective semantics — actor binding, target resolution, - # success interpretation, windows, and dependency ordering (SEM-207). - # The name-level reference graph, ordering/refresh-role model, and - # fail-closed issue set live in ``aces_sdl.semantics.objective_semantics``; - # this pass renders the machine-readable issues it reports as authoring - # errors. - analysis = analyze_objective_semantics( - objectives_by_name=self._s.objectives, - agents_by_name=self._s.agents, - entity_names=self._all_entity_names(), - assessment_resources=AssessmentResourceCatalog( - conditions=self._s.conditions, - metrics=self._s.metrics, - evaluations=self._s.evaluations, - tlos=self._s.tlos, - goals=self._s.goals, - ), - window_resources=WindowResourceCatalog( - stories=self._s.stories, - scripts=self._s.scripts, - events=self._s.events, - workflows=self._s.workflows, - ), - targetable_name_index=self._named_ref_index(targetable=True), - is_unresolved=self._is_unresolved_var, - ) - for issue in analysis.issues: - self._err(self._format_objective_issue(issue)) - - @staticmethod - def _format_objective_issue(issue: ObjectiveIssue) -> str: - try: - renderer = _OBJECTIVE_ISSUE_RENDERERS[issue.code] - except KeyError: # pragma: no cover - defensive: a new code without a renderer - raise AssertionError(f"unhandled objective-semantics issue code: {issue.code}") from None - return renderer(issue) - - @staticmethod - def _format_participant_behavior_issue(issue: ParticipantBehaviorIssue) -> str: - try: - renderer = _PARTICIPANT_BEHAVIOR_ISSUE_RENDERERS[issue.code] - except KeyError: # pragma: no cover - defensive: a new code without a renderer - raise AssertionError(f"unhandled participant-behavior issue code: {issue.code}") from None - return renderer(issue) - - @staticmethod - def _format_participant_outcome_issue(issue: ParticipantOutcomeIssue) -> str: - try: - renderer = _PARTICIPANT_OUTCOME_ISSUE_RENDERERS[issue.code] - except KeyError: # pragma: no cover - defensive: a new code without a renderer - raise AssertionError(f"unhandled participant-outcome issue code: {issue.code}") from None - return renderer(issue) - - def _validate_workflow_predicate( - self, - workflow_name: str, - step_name: str, - predicate: WorkflowPredicate, - workflow_steps: dict[str, WorkflowStep], - ) -> list[str]: - """Validate all references within a workflow predicate.""" - step_refs: list[str] = [] - predicate_sections = ( - ("condition", predicate.conditions, self._s.conditions), - ("metric", predicate.metrics, self._s.metrics), - ("evaluation", predicate.evaluations, self._s.evaluations), - ("TLO", predicate.tlos, self._s.tlos), - ("goal", predicate.goals, self._s.goals), - ("objective", predicate.objectives, self._s.objectives), - ) - for label, refs, section in predicate_sections: - for ref in refs: - if self._is_unresolved_var(ref): - continue - if ref not in section: - self._err( - f"Workflow '{workflow_name}' step " - f"'{step_name}' references undefined " - f"{label} '{ref}' in predicate" - ) - for step_state in predicate.steps: - if self._is_unresolved_var(step_state.step): - continue - if step_state.step not in workflow_steps: - self._err( - f"Workflow '{workflow_name}' step '{step_name}' " - f"references undefined step state " - f"'{step_state.step}' in predicate" - ) - continue - if step_state.step == step_name: - self._err( - f"Workflow '{workflow_name}' step '{step_name}' cannot reference its own state in a predicate" - ) - continue - ref_step = workflow_steps[step_state.step] - contract = workflow_step_semantic_contract(ref_step.type.value) - if not contract.state_observable: - self._err( - f"Workflow '{workflow_name}' step '{step_name}' " - f"cannot reference non-executable step '{step_state.step}' " - "in a predicate" - ) - continue - invalid_outcomes = [ - outcome.value for outcome in step_state.outcomes if outcome.value not in contract.observable_outcomes - ] - if invalid_outcomes: - allowed = ", ".join(contract.observable_outcomes) - self._err( - f"Workflow '{workflow_name}' step '{step_name}' " - f"references step '{step_state.step}' with impossible " - f"outcomes {invalid_outcomes}; allowed outcomes are: {allowed}" - ) - continue - step_refs.append(step_state.step) - return step_refs - - def _is_executable_workflow_step(self, step: WorkflowStep) -> bool: - return workflow_step_semantic_contract(step.type.value).state_observable - - def _validate_workflow_target_ref( - self, - workflow_name: str, - step_name: str, - field_name: str, - target: str, - workflow_steps: dict[str, WorkflowStep], - ) -> str | None: - if not target: - return None - if self._is_unresolved_var(target): - return None - if target not in workflow_steps: - self._err(f"Workflow '{workflow_name}' step '{step_name}' {field_name} step '{target}' is not defined") - return None - return target - - def _all_paths_reach_join( - self, - node: str, - join: str, - graph: dict[str, list[str]], - *, - memo: dict[str, bool], - visiting: set[str], - ) -> bool: - if node == join: - return True - if node in memo: - return memo[node] - if node in visiting: - return False - - visiting.add(node) - successors = graph.get(node, []) - if not successors: - visiting.remove(node) - memo[node] = False - return False - - result = all( - self._all_paths_reach_join( - successor, - join, - graph, - memo=memo, - visiting=visiting, - ) - for successor in successors - ) - visiting.remove(node) - memo[node] = result - return result - - def _branch_guaranteed_states( - self, - node: str, - join: str, - graph: dict[str, list[str]], - workflow_steps: dict[str, WorkflowStep], - *, - memo: dict[tuple[str, str], set[str]], - visiting: set[tuple[str, str]], - ) -> set[str]: - if node == join: - return set() - - key = (node, join) - if key in memo: - return set(memo[key]) - if key in visiting: - return set() - - visiting.add(key) - successors = graph.get(node, []) - guaranteed_after: set[str] = set() - if successors: - successor_sets: list[set[str]] = [] - for successor in successors: - if successor == join: - successor_sets.append(set()) - continue - if successor not in workflow_steps: - continue - successor_sets.append( - self._branch_guaranteed_states( - successor, - join, - graph, - workflow_steps, - memo=memo, - visiting=visiting, - ) - ) - if successor_sets: - guaranteed_after = set.intersection(*successor_sets) - - result = set(guaranteed_after) - step = workflow_steps[node] - if self._is_executable_workflow_step(step): - result.add(node) - - visiting.remove(key) - memo[key] = set(result) - return result - - def _edge_available_state( - self, - step_name: str, - successor: str, - workflow_steps: dict[str, WorkflowStep], - graph: dict[str, list[str]], - predecessors: dict[str, set[str]], - start: str, - join_targets: dict[str, list[str]], - *, - available_memo: dict[str, set[str]], - branch_memo: dict[tuple[str, str], set[str]], - visiting: set[str], - ) -> set[str]: - available = self._available_step_state_before( - step_name, - workflow_steps, - graph, - predecessors, - start, - join_targets, - available_memo=available_memo, - branch_memo=branch_memo, - visiting=visiting, - ) - step = workflow_steps[step_name] - if step.type in { - WorkflowStepType.OBJECTIVE, - WorkflowStepType.RETRY, - WorkflowStepType.CALL, - } or (step.type == WorkflowStepType.PARALLEL and step.on_failure and successor == step.on_failure): - available.add(step_name) - return available - - def _available_step_state_before( - self, - step_name: str, - workflow_steps: dict[str, WorkflowStep], - graph: dict[str, list[str]], - predecessors: dict[str, set[str]], - start: str, - join_targets: dict[str, list[str]], - *, - available_memo: dict[str, set[str]], - branch_memo: dict[tuple[str, str], set[str]], - visiting: set[str], - ) -> set[str]: - if step_name in available_memo: - return set(available_memo[step_name]) - if step_name in visiting: - return set() - - visiting.add(step_name) - step = workflow_steps[step_name] - - if step_name == start: - result = set() - elif step.type == WorkflowStepType.JOIN and join_targets.get(step_name): - owner = join_targets[step_name][0] - result = self._available_step_state_before( - owner, - workflow_steps, - graph, - predecessors, - start, - join_targets, - available_memo=available_memo, - branch_memo=branch_memo, - visiting=visiting, - ) - result.add(owner) - owner_step = workflow_steps[owner] - for branch in owner_step.branches: - if branch not in workflow_steps: - continue - result.update( - self._branch_guaranteed_states( - branch, - step_name, - graph, - workflow_steps, - memo=branch_memo, - visiting=set(), - ) - ) - else: - incoming_states: list[set[str]] = [] - for predecessor in predecessors.get(step_name, set()): - if predecessor not in workflow_steps: - continue - incoming_states.append( - self._edge_available_state( - predecessor, - step_name, - workflow_steps, - graph, - predecessors, - start, - join_targets, - available_memo=available_memo, - branch_memo=branch_memo, - visiting=visiting, - ) - ) - result = set.intersection(*incoming_states) if incoming_states else set() - - visiting.remove(step_name) - available_memo[step_name] = set(result) - return result - - def _verify_step_terminator_and_compensation( - self, - *, - workflow_name: str, - step_name: str, - step: WorkflowStep, - workflow: Workflow, - graph: dict[str, list[str]], - workflow_compensation_graph: dict[str, set[str]], - compensation_target_workflows: set[str], - workflows_with_compensation_steps: set[str], - ) -> None: - """Shared validation for `on-success`/`on-failure` and `compensate_with`. - - OBJECTIVE and CALL workflow steps both carry the same terminator and - compensation-handling shape, so this method centralizes the - appended-edge bookkeeping and undefined-workflow error reporting - for both call sites. - """ - for field_name, target in ( - ("on-success", step.on_success), - ("on-failure", step.on_failure), - ): - resolved = self._validate_workflow_target_ref( - workflow_name, - step_name, - field_name, - target, - workflow.steps, - ) - if resolved is not None: - graph[step_name].append(resolved) - if step.compensate_with: - workflows_with_compensation_steps.add(workflow_name) - if not self._is_unresolved_var(step.compensate_with) and step.compensate_with not in self._s.workflows: - self._err( - f"Workflow '{workflow_name}' step '{step_name}' " - "references undefined compensation workflow " - f"'{step.compensate_with}'" - ) - elif not self._is_unresolved_var(step.compensate_with): - workflow_compensation_graph.setdefault(workflow_name, set()).add(step.compensate_with) - compensation_target_workflows.add(step.compensate_with) - - def _verify_workflows(self) -> None: - workflow_call_graph: dict[str, set[str]] = {workflow_name: set() for workflow_name in self._s.workflows} - workflow_compensation_graph: dict[str, set[str]] = {workflow_name: set() for workflow_name in self._s.workflows} - compensation_target_workflows: set[str] = set() - workflows_with_compensation_steps: set[str] = set() - for workflow_name, workflow in self._s.workflows.items(): - if not self._is_unresolved_var(workflow.start) and workflow.start not in workflow.steps: - self._err(f"Workflow '{workflow_name}' start step '{workflow.start}' is not defined") - - graph: dict[str, list[str]] = {step_name: [] for step_name in workflow.steps} - predicate_step_refs: dict[str, list[str]] = {} - join_targets: dict[str, list[str]] = defaultdict(list) - - for step_name, step in workflow.steps.items(): - if "." in step_name: - self._err( - f"Workflow '{workflow_name}' step '{step_name}' cannot " - "contain '.' because objective windows use " - "'.' syntax" - ) - - if step.type == WorkflowStepType.OBJECTIVE: - if not self._is_unresolved_var(step.objective) and step.objective not in self._s.objectives: - self._err( - f"Workflow '{workflow_name}' step '{step_name}' " - f"references undefined objective '{step.objective}'" - ) - self._verify_step_terminator_and_compensation( - workflow_name=workflow_name, - step_name=step_name, - step=step, - workflow=workflow, - graph=graph, - workflow_compensation_graph=workflow_compensation_graph, - compensation_target_workflows=compensation_target_workflows, - workflows_with_compensation_steps=workflows_with_compensation_steps, - ) - - elif step.type == WorkflowStepType.DECISION: - predicate_step_refs[step_name] = self._validate_workflow_predicate( - workflow_name, - step_name, - step.when, - workflow.steps, - ) - - for branch_label, branch_ref in ( - ("then", step.then_step), - ("else", step.else_step), - ): - resolved = self._validate_workflow_target_ref( - workflow_name, - step_name, - branch_label, - branch_ref, - workflow.steps, - ) - if resolved is not None: - graph[step_name].append(resolved) - - elif step.type == WorkflowStepType.SWITCH: - aggregated_refs: list[str] = [] - for case_index, case in enumerate(step.cases): - aggregated_refs.extend( - self._validate_workflow_predicate( - workflow_name, - f"{step_name}.case[{case_index}]", - case.when, - workflow.steps, - ) - ) - resolved = self._validate_workflow_target_ref( - workflow_name, - step_name, - f"case[{case_index}] next", - case.next_step, - workflow.steps, - ) - if resolved is not None: - graph[step_name].append(resolved) - predicate_step_refs[step_name] = aggregated_refs - resolved_default = self._validate_workflow_target_ref( - workflow_name, - step_name, - "default", - step.default_step, - workflow.steps, - ) - if resolved_default is not None: - graph[step_name].append(resolved_default) - - elif step.type == WorkflowStepType.PARALLEL: - for branch_ref in step.branches: - resolved = self._validate_workflow_target_ref( - workflow_name, - step_name, - "branch", - branch_ref, - workflow.steps, - ) - if resolved is not None: - graph[step_name].append(resolved) - resolved_join = self._validate_workflow_target_ref( - workflow_name, - step_name, - "join", - step.join, - workflow.steps, - ) - if resolved_join is not None: - join_targets[resolved_join].append(step_name) - resolved_failure = self._validate_workflow_target_ref( - workflow_name, - step_name, - "on-failure", - step.on_failure, - workflow.steps, - ) - if resolved_failure is not None: - graph[step_name].append(resolved_failure) - - elif step.type == WorkflowStepType.JOIN: - resolved = self._validate_workflow_target_ref( - workflow_name, - step_name, - "next", - step.next, - workflow.steps, - ) - if resolved is not None: - graph[step_name].append(resolved) - - elif step.type == WorkflowStepType.RETRY: - if not self._is_unresolved_var(step.objective) and step.objective not in self._s.objectives: - self._err( - f"Workflow '{workflow_name}' step '{step_name}' " - f"references undefined objective '{step.objective}'" - ) - for field_name, target in ( - ("on-success", step.on_success), - ("on-exhausted", step.on_exhausted), - ): - resolved = self._validate_workflow_target_ref( - workflow_name, - step_name, - field_name, - target, - workflow.steps, - ) - if resolved is not None: - graph[step_name].append(resolved) - - elif step.type == WorkflowStepType.CALL: - if not self._is_unresolved_var(step.workflow) and step.workflow not in self._s.workflows: - self._err( - f"Workflow '{workflow_name}' step '{step_name}' " - f"references undefined workflow '{step.workflow}'" - ) - elif not self._is_unresolved_var(step.workflow): - workflow_call_graph.setdefault(workflow_name, set()).add(step.workflow) - self._verify_step_terminator_and_compensation( - workflow_name=workflow_name, - step_name=step_name, - step=step, - workflow=workflow, - graph=graph, - workflow_compensation_graph=workflow_compensation_graph, - compensation_target_workflows=compensation_target_workflows, - workflows_with_compensation_steps=workflows_with_compensation_steps, - ) - - elif step.type == WorkflowStepType.END: - graph[step_name] = [] - - if step_name not in graph: - graph[step_name] = [] - - for join_step, sources in join_targets.items(): - if self._is_unresolved_var(join_step): - continue - join_def = workflow.steps.get(join_step) - if join_def is not None and join_def.type != WorkflowStepType.JOIN: - self._err( - f"Workflow '{workflow_name}' step '{join_step}' is used " - "as a parallel join but is not a join step" - ) - if len(sources) > 1: - self._err( - f"Workflow '{workflow_name}' join step '{join_step}' may only be targeted by one parallel step" - ) - - for step_name, step in workflow.steps.items(): - if step.type != WorkflowStepType.JOIN: - continue - sources = join_targets.get(step_name, []) - if not sources: - self._err( - f"Workflow '{workflow_name}' join step '{step_name}' is not referenced by any parallel step" - ) - - if graph and _topological_sort(graph) is None: - self._err(f"Workflow '{workflow_name}' graph contains a cycle") - - if self._is_unresolved_var(workflow.start) or workflow.start not in workflow.steps: - continue - - reachable: set[str] = set() - stack = [workflow.start] - while stack: - current = stack.pop() - if current in reachable: - continue - reachable.add(current) - stack.extend(graph.get(current, [])) - - unreachable = sorted(set(workflow.steps) - reachable) - if unreachable: - self._err(f"Workflow '{workflow_name}' contains unreachable steps: " + ", ".join(unreachable)) - - predecessors: dict[str, set[str]] = {step_name: set() for step_name in reachable} - for source, edges in graph.items(): - if source not in reachable: - continue - for target in edges: - if target in reachable: - predecessors[target].add(source) - - for _step_name, step in workflow.steps.items(): - if step.type != WorkflowStepType.PARALLEL: - continue - if self._is_unresolved_var(step.join) or step.join not in workflow.steps or step.join not in reachable: - continue - allowed_predecessors = branch_closure( - graph, - branches=(branch for branch in step.branches if branch in reachable and branch in workflow.steps), - join_step=step.join, - ) - foreign_predecessors = sorted( - predecessor - for predecessor in predecessors.get(step.join, set()) - if predecessor not in allowed_predecessors - ) - if foreign_predecessors: - self._err( - f"Workflow '{workflow_name}' join step '{step.join}' " - "may only be entered from the owning parallel's branch " - "closure; unexpected predecessors: " + ", ".join(foreign_predecessors) - ) - - available_memo: dict[str, set[str]] = {} - branch_memo: dict[tuple[str, str], set[str]] = {} - - for step_name, refs in predicate_step_refs.items(): - if step_name not in reachable: - continue - available_before = self._available_step_state_before( - step_name, - workflow.steps, - graph, - predecessors, - workflow.start, - join_targets, - available_memo=available_memo, - branch_memo=branch_memo, - visiting=set(), - ) - for ref_name in refs: - if self._is_unresolved_var(ref_name): - continue - if ref_name not in available_before: - self._err( - f"Workflow '{workflow_name}' step '{step_name}' " - f"references step state '{ref_name}' that is not " - "guaranteed to be known before this predicate" - ) - - for step_name, step in workflow.steps.items(): - if step.type != WorkflowStepType.PARALLEL: - continue - if self._is_unresolved_var(step.join) or step.join not in workflow.steps: - continue - for branch_ref in step.branches: - if self._is_unresolved_var(branch_ref) or branch_ref not in workflow.steps: - continue - if not self._all_paths_reach_join( - branch_ref, - step.join, - graph, - memo={}, - visiting=set(), - ): - self._err( - f"Workflow '{workflow_name}' parallel step " - f"'{step_name}' requires every explicit branch path " - f"from '{branch_ref}' to converge on join " - f"'{step.join}'" - ) - - if ( - workflow_call_graph - and _topological_sort( - { - workflow_name: sorted(callee for callee in callees if callee in workflow_call_graph) - for workflow_name, callees in workflow_call_graph.items() - } - ) - is None - ): - self._err("Workflow call graph contains a cycle") - - combined_workflow_graph = { - workflow_name: sorted( - workflow_call_graph.get(workflow_name, set()) | workflow_compensation_graph.get(workflow_name, set()) - ) - for workflow_name in self._s.workflows - } - if combined_workflow_graph and _topological_sort(combined_workflow_graph) is None: - self._err("Combined workflow call/compensation graph contains a cycle") - - for workflow_name in sorted(compensation_target_workflows): - if workflow_name in workflows_with_compensation_steps: - self._err( - f"Workflow '{workflow_name}' cannot be used as a compensation " - "workflow because it also declares compensate-with steps" - ) - - def _verify_variables(self) -> None: - defined = set(self._s.variables.keys()) - - def visit(value: object, path: str) -> None: - if isinstance(value, BaseModel): - for field_name in value.__class__.model_fields: - if isinstance(value, Scenario) and field_name == "variables": - continue - child = getattr(value, field_name) - child_path = f"{path}.{field_name}" if path else field_name - visit(child, child_path) - return - - if isinstance(value, dict): - for key, child in value.items(): - child_path = f"{path}.{key}" if path else str(key) - visit(child, child_path) - return - - if isinstance(value, list): - for index, child in enumerate(value): - child_path = f"{path}[{index}]" - visit(child, child_path) - return - - if self._is_unresolved_var(value): - variable_name = extract_variable_name(value) - if variable_name and variable_name not in defined: - self._err(f"Undefined variable '{variable_name}' referenced at '{path}'") - - visit(self._s, "") - - def _verify_explicitness(self) -> None: - result = classify_scenario_explicitness(self._s) - self._s._set_explicitness(result.records) - for error in result.errors: - self._err(error) - - def _all_named_elements(self) -> set[str]: - """Collect all named element keys across all scenario sections.""" - return set(self._named_ref_index().keys()) - - def _all_targetable_elements(self) -> set[str]: - """Collect named elements that can serve as objective targets.""" - return set(self._named_ref_index(targetable=True).keys()) - - def _verify_features(self) -> None: - # Check vulnerability references - for name, feat in self._s.features.items(): - for vuln_name in feat.vulnerabilities: - if self._is_unresolved_var(vuln_name): - continue - if vuln_name not in self._s.vulnerabilities: - self._err(f"Feature '{name}' references undefined vulnerability '{vuln_name}'") - - # Check dependency references and detect cycles - dep_graph: dict[str, list[str]] = {} - for name, feat in self._s.features.items(): - dep_graph[name] = [] - for dep in feat.dependencies: - if self._is_unresolved_var(dep): - continue - if dep not in self._s.features: - self._err(f"Feature '{name}' depends on undefined feature '{dep}'") - else: - dep_graph[name].append(dep) - - if dep_graph and _topological_sort(dep_graph) is None: - self._err("Feature dependency graph contains a cycle") - - def _verify_conditions(self) -> None: - # Individual condition validation is handled by Pydantic model_validator. - # This pass checks for consistency with the broader scenario. - pass - - def _verify_vulnerabilities(self) -> None: - # CWE format validation is handled by the Pydantic field_validator. - pass - - def _verify_assessment_pipeline(self) -> None: - # The condition -> metric -> evaluation -> TLO -> goal scoring chain. - # Reference, aggregation, and dependency-role semantics live in - # ``aces_sdl.semantics.assessment`` (SEM-206); this pass renders the - # machine-readable issues it reports as authoring errors. - analysis = analyze_assessment_pipeline( - conditions_by_name=self._s.conditions, - metrics_by_name=self._s.metrics, - evaluations_by_name=self._s.evaluations, - tlos_by_name=self._s.tlos, - goals_by_name=self._s.goals, - is_unresolved=self._is_unresolved_var, - ) - for issue in analysis.issues: - self._err(self._format_assessment_issue(issue)) - - @staticmethod - def _format_assessment_issue(issue: AssessmentIssue) -> str: - name, ref = issue.resource_name, issue.ref - if issue.code == "metric.condition-undeclared": - return f"Metric '{name}' references undefined condition '{ref}'" - if issue.code == "metric.condition-multiply-scored": - return f"Condition '{name}' is referenced by multiple metrics" - if issue.code == "evaluation.metric-undeclared": - return f"Evaluation '{name}' references undefined metric '{ref}'" - if issue.code == "evaluation.min-score-exceeds-metric-total": - return ( - f"Evaluation '{name}' absolute min-score " - f"({issue.observed}) exceeds sum of " - f"metric max-scores ({issue.limit})" - ) - if issue.code == "tlo.evaluation-undeclared": - return f"TLO '{name}' references undefined evaluation '{ref}'" - if issue.code == "goal.tlo-undeclared": - return f"Goal '{name}' references undefined TLO '{ref}'" - raise AssertionError(f"unhandled assessment-pipeline issue code: {issue.code}") - - def _verify_entities(self) -> None: - flat = flatten_entities(self._s.entities) - - def check_entity(name: str, entity: "Entity") -> None: - for tlo_name in entity.tlos: - if self._is_unresolved_var(tlo_name): - continue - if tlo_name not in self._s.tlos: - self._err(f"Entity '{name}' references undefined TLO '{tlo_name}'") - for vuln_name in entity.vulnerabilities: - if self._is_unresolved_var(vuln_name): - continue - if vuln_name not in self._s.vulnerabilities: - self._err(f"Entity '{name}' references undefined vulnerability '{vuln_name}'") - for event_name in entity.events: - if self._is_unresolved_var(event_name): - continue - if event_name not in self._s.events: - self._err(f"Entity '{name}' references undefined event '{event_name}'") - - for name, entity in flat.items(): - check_entity(name, entity) - - def _verify_injects(self) -> None: - flat_names = self._all_entity_names() - - for name, inject in self._s.injects.items(): - if ( - inject.from_entity - and not self._is_unresolved_var(inject.from_entity) - and inject.from_entity not in flat_names - ): - self._err(f"Inject '{name}' from_entity '{inject.from_entity}' is not a defined entity") - for to_name in inject.to_entities: - if self._is_unresolved_var(to_name): - continue - if to_name not in flat_names: - self._err(f"Inject '{name}' to_entity '{to_name}' is not a defined entity") - for tlo_name in inject.tlos: - if self._is_unresolved_var(tlo_name): - continue - if tlo_name not in self._s.tlos: - self._err(f"Inject '{name}' references undefined TLO '{tlo_name}'") - - def _verify_events(self) -> None: - for name, event in self._s.events.items(): - for cond_name in event.conditions: - if self._is_unresolved_var(cond_name): - continue - if cond_name not in self._s.conditions: - self._err(f"Event '{name}' references undefined condition '{cond_name}'") - for inj_name in event.injects: - if self._is_unresolved_var(inj_name): - continue - if inj_name not in self._s.injects: - self._err(f"Event '{name}' references undefined inject '{inj_name}'") - - def _verify_scripts(self) -> None: - for name, script in self._s.scripts.items(): - for event_name in script.events: - if self._is_unresolved_var(event_name): - continue - if event_name not in self._s.events: - self._err(f"Script '{name}' references undefined event '{event_name}'") - - def _verify_stories(self) -> None: - for name, story in self._s.stories.items(): - for script_name in story.scripts: - if self._is_unresolved_var(script_name): - continue - if script_name not in self._s.scripts: - self._err(f"Story '{name}' references undefined script '{script_name}'") - - def _verify_roles(self) -> None: - flat_names = self._all_entity_names() - - for node_name, node in self._s.nodes.items(): - for role_name, role in node.roles.items(): - for entity_ref in role.entities: - if self._is_unresolved_var(entity_ref): - continue - if entity_ref not in flat_names: - self._err(f"Node '{node_name}' role '{role_name}' references undefined entity '{entity_ref}'") diff --git a/implementations/python/packages/aces_sdl/validator/__init__.py b/implementations/python/packages/aces_sdl/validator/__init__.py new file mode 100644 index 000000000..c73bf9c88 --- /dev/null +++ b/implementations/python/packages/aces_sdl/validator/__init__.py @@ -0,0 +1,37 @@ +"""Semantic validation for SDL scenarios (package split of the former +validator.py). Public API is unchanged: import ``SemanticValidator``. +""" + +from ._content_objectives import _ContentObjectivesMixin +from ._core import _ValidatorCore +from ._nodes_infra_network import _NodesInfraNetworkMixin +from ._relationships import _RelationshipsMixin +from ._runtime_identity_data import _RuntimeIdentityDataMixin +from ._runtime_mail import _RuntimeMailMixin +from ._runtime_platform import _RuntimePlatformMixin +from ._runtime_services import _RuntimeServicesMixin +from ._sections import _SectionsMixin +from ._workflows_analysis import _WorkflowAnalysisMixin +from ._workflows_verify import _WorkflowVerifyMixin + +__all__ = ["SemanticValidator"] + + +class SemanticValidator( + _NodesInfraNetworkMixin, + _RuntimeServicesMixin, + _RuntimeIdentityDataMixin, + _RuntimePlatformMixin, + _RuntimeMailMixin, + _RelationshipsMixin, + _ContentObjectivesMixin, + _WorkflowAnalysisMixin, + _WorkflowVerifyMixin, + _SectionsMixin, + _ValidatorCore, +): + """Validates a Scenario beyond structural Pydantic checks. + + Call ``validate()`` to run all passes. Raises ``SDLValidationError`` + with all collected errors if any pass fails. + """ diff --git a/implementations/python/packages/aces_sdl/validator/_content_objectives.py b/implementations/python/packages/aces_sdl/validator/_content_objectives.py new file mode 100644 index 000000000..7a852e365 --- /dev/null +++ b/implementations/python/packages/aces_sdl/validator/_content_objectives.py @@ -0,0 +1,362 @@ +"""SemanticValidator _ContentObjectivesMixin (split from validator.py). + +Part of the SemanticValidator mixin composition; see __init__.py. +""" + +from ..semantics.objective_semantics import ( + AssessmentResourceCatalog, + ObjectiveIssue, + WindowResourceCatalog, + analyze_objective_semantics, +) +from ..semantics.participant_behavior import ( + ParticipantBehaviorIssue, + analyze_participant_behavior, +) +from ..semantics.participant_outcome import ( + ParticipantOutcomeIssue, + analyze_participant_outcome_interpretations, +) + +# Renders an objective-semantics issue (machine-readable code from +# ``aces_sdl.semantics.objective_semantics``) into the authoring-error string +# the SDL surface has always used. Keyed by issue code so a new code is a new +# line here rather than a new branch in a growing conditional. +_OBJECTIVE_ISSUE_RENDERERS = { + "objective.actor-agent-undeclared": ( + lambda i: f"Objective '{i.objective_name}' references undefined agent '{i.ref}'" + ), + "objective.actor-entity-undeclared": ( + lambda i: f"Objective '{i.objective_name}' references undefined entity '{i.ref}'" + ), + "objective.action-not-declared": ( + lambda i: f"Objective '{i.objective_name}' action '{i.ref}' is not declared by agent '{i.actor_name}'" + ), + "objective.target-unresolvable": ( + lambda i: f"Objective '{i.objective_name}' target '{i.ref}' does not reference any defined targetable element" + ), + "objective.target-ambiguous": ( + lambda i: f"Objective '{i.objective_name}' target '{i.ref}' is ambiguous; use one of: {', '.join(i.candidates)}" + ), + "objective.success-condition-undeclared": ( + lambda i: f"Objective '{i.objective_name}' references undefined condition '{i.ref}' in success criteria" + ), + "objective.success-metric-undeclared": ( + lambda i: f"Objective '{i.objective_name}' references undefined metric '{i.ref}' in success criteria" + ), + "objective.success-evaluation-undeclared": ( + lambda i: f"Objective '{i.objective_name}' references undefined evaluation '{i.ref}' in success criteria" + ), + "objective.success-tlo-undeclared": ( + lambda i: f"Objective '{i.objective_name}' references undefined TLO '{i.ref}' in success criteria" + ), + "objective.success-goal-undeclared": ( + lambda i: f"Objective '{i.objective_name}' references undefined goal '{i.ref}' in success criteria" + ), + "objective.window.story-unbound": ( + lambda i: f"Objective '{i.objective_name}' references undefined story '{i.ref}' in window" + ), + "objective.window.script-unbound": ( + lambda i: f"Objective '{i.objective_name}' references undefined script '{i.ref}' in window" + ), + "objective.window.script-outside-window-stories": ( + lambda i: f"Objective '{i.objective_name}' window script '{i.ref}' is not included by the referenced stories" + ), + "objective.window.event-unbound": ( + lambda i: f"Objective '{i.objective_name}' references undefined event '{i.ref}' in window" + ), + "objective.window.event-outside-window-scripts": ( + lambda i: f"Objective '{i.objective_name}' window event '{i.ref}' is not included by the referenced scripts" + ), + "objective.window.workflow-unbound": ( + lambda i: f"Objective '{i.objective_name}' references undefined workflow '{i.ref}' in window" + ), + "objective.window.step-requires-workflow-window": ( + lambda i: f"Objective '{i.objective_name}' window steps require at least one referenced workflow" + ), + "objective.window.step-invalid-format": ( + lambda i: f"Objective '{i.objective_name}' window step '{i.ref}' must use '.' syntax" + ), + "objective.window.step-workflow-unbound": ( + lambda i: ( + f"Objective '{i.objective_name}' window step '{i.ref}' references undefined workflow '{i.workflow_name}'" + ) + ), + "objective.window.step-workflow-outside-window": ( + lambda i: f"Objective '{i.objective_name}' window step '{i.ref}' is not part of the referenced workflows" + ), + "objective.window.step-unbound": ( + lambda i: f"Objective '{i.objective_name}' window step '{i.ref}' references undefined step '{i.step_name}'" + ), + "objective.dependency-undeclared": ( + lambda i: f"Objective '{i.objective_name}' depends on undefined objective '{i.ref}'" + ), + "objective.dependency-cycle": lambda _i: "Objective dependency graph contains a cycle", +} + +_PARTICIPANT_BEHAVIOR_ISSUE_RENDERERS = { + "participant.action-contract-unbound": ( + lambda i: f"Agent '{i.participant_name}' action '{i.ref}' does not reference a declared action_contract" + ), + "participant.observation-boundary-unbound": ( + lambda i: ( + f"Agent '{i.participant_name}' observation_boundary '{i.ref}' " + "does not reference a declared observation_boundary" + ) + ), + "participant.interaction-action-unbound": ( + lambda i: ( + f"Action contract '{i.action_name}' interaction related_action '{i.ref}' " + "does not reference a declared action_contract" + ) + ), + "participant.view-rule-ref-unbound": ( + lambda i: ( + f"Observation boundary '{i.boundary_name}' view_rule information_ref '{i.ref}' " + "is not declared by observable_refs, hidden_refs, or evidence_refs" + ) + ), + "participant.view-rule-evidence-unbound": ( + lambda i: ( + f"Observation boundary '{i.boundary_name}' view_rule evidence_ref '{i.ref}' " + "is not declared by evidence_refs" + ) + ), + "participant.view-transition-ref-unbound": ( + lambda i: ( + f"Observation boundary '{i.boundary_name}' view_transition '{i.transition_id}' " + f"information_ref '{i.ref}' is not declared by observable_refs, hidden_refs, or evidence_refs" + ) + ), + "participant.view-transition-evidence-unbound": ( + lambda i: ( + f"Observation boundary '{i.boundary_name}' view_transition '{i.transition_id}' " + f"evidence_ref '{i.ref}' is not declared by evidence_refs" + ) + ), +} + +_PARTICIPANT_OUTCOME_ISSUE_RENDERERS = { + "participant.outcome.source-action-unbound": ( + lambda i: f"Outcome interpretation rule '{i.rule_name}' source '{i.ref}' references undefined action contract" + ), + "participant.outcome.source-objective-unbound": ( + lambda i: f"Outcome interpretation rule '{i.rule_name}' source '{i.ref}' references undefined objective" + ), + "participant.outcome.source-workflow-unbound": ( + lambda i: f"Outcome interpretation rule '{i.rule_name}' source '{i.ref}' references undefined workflow" + ), + "participant.outcome.source-evaluation-unbound": ( + lambda i: f"Outcome interpretation rule '{i.rule_name}' source '{i.ref}' references undefined evaluation" + ), + "participant.outcome.target-objective-unbound": ( + lambda i: f"Outcome interpretation rule '{i.rule_name}' target '{i.ref}' references undefined objective" + ), + "participant.outcome.target-workflow-unbound": ( + lambda i: f"Outcome interpretation rule '{i.rule_name}' target '{i.ref}' references undefined workflow" + ), + "participant.outcome.target-evaluation-unbound": ( + lambda i: f"Outcome interpretation rule '{i.rule_name}' target '{i.ref}' references undefined evaluation" + ), +} + + +class _ContentObjectivesMixin: + def _verify_content(self) -> None: + for name, item in self._s.content.items(): + if item.target and not self._is_unresolved_var(item.target) and item.target not in self._s.nodes: + self._err(f"Content '{name}' targets undefined node '{item.target}'") + elif item.target and not self._is_unresolved_var(item.target) and not self._is_vm_node(item.target): + self._err(f"Content '{name}' target '{item.target}' must be a VM node") + + def _verify_accounts(self) -> None: + for name, acct in self._s.accounts.items(): + if acct.node and not self._is_unresolved_var(acct.node) and acct.node not in self._s.nodes: + self._err(f"Account '{name}' references undefined node '{acct.node}'") + elif acct.node and not self._is_unresolved_var(acct.node) and not self._is_vm_node(acct.node): + self._err(f"Account '{name}' node '{acct.node}' must be a VM node") + + def _verify_relationships(self) -> None: + for name, rel in self._s.relationships.items(): + if not self._is_unresolved_var(rel.source): + self._validate_named_ref( + rel.source, + owner_label=f"Relationship '{name}'", + ref_label="source", + ) + if not self._is_unresolved_var(rel.target): + self._validate_named_ref( + rel.target, + owner_label=f"Relationship '{name}'", + ref_label="target", + ) + + def _verify_agents(self) -> None: + flat_entity_names = self._all_entity_names() + service_names = {service.name for node in self._s.nodes.values() for service in node.services if service.name} + + for name, agent in self._s.agents.items(): + if agent.entity and not self._is_unresolved_var(agent.entity) and agent.entity not in flat_entity_names: + self._err(f"Agent '{name}' references undefined entity '{agent.entity}'") + for acct_name in agent.starting_accounts: + if self._is_unresolved_var(acct_name): + continue + if acct_name not in self._s.accounts: + self._err(f"Agent '{name}' starting_account '{acct_name}' not in accounts section") + for subnet in agent.allowed_subnets: + if self._is_unresolved_var(subnet): + continue + if subnet not in self._s.infrastructure: + self._err(f"Agent '{name}' allowed_subnet '{subnet}' not in infrastructure section") + elif not self._is_switch_node(subnet): + self._err(f"Agent '{name}' allowed_subnet '{subnet}' must reference a switch/network entry") + if agent.initial_knowledge: + for host in agent.initial_knowledge.hosts: + if self._is_unresolved_var(host): + continue + if host not in self._s.nodes: + self._err(f"Agent '{name}' initial_knowledge host '{host}' not in nodes section") + elif not self._is_vm_node(host): + self._err(f"Agent '{name}' initial_knowledge host '{host}' must reference a VM node") + for subnet in agent.initial_knowledge.subnets: + if self._is_unresolved_var(subnet): + continue + if subnet not in self._s.infrastructure: + self._err(f"Agent '{name}' initial_knowledge subnet '{subnet}' not in infrastructure section") + elif not self._is_switch_node(subnet): + self._err( + f"Agent '{name}' initial_knowledge subnet '{subnet}' must reference a switch/network entry" + ) + for service_name in agent.initial_knowledge.services: + if self._is_unresolved_var(service_name): + continue + if service_name not in service_names: + self._err( + f"Agent '{name}' initial_knowledge service '{service_name}' not in node service names" + ) + for acct_name in agent.initial_knowledge.accounts: + if self._is_unresolved_var(acct_name): + continue + if acct_name not in self._s.accounts: + self._err(f"Agent '{name}' initial_knowledge account '{acct_name}' not in accounts section") + for cond_name in agent.starting_conditions: + if self._is_unresolved_var(cond_name): + continue + # ADR-020 §6 publishes starting_conditions as accepting bare + # (`health`) or section-qualified (`conditions.health`) + # references. Strip the `conditions.` prefix when present so + # both forms resolve against the same dict. + bare_name = cond_name.removeprefix("conditions.") + if bare_name not in self._s.conditions: + self._err(f"Agent '{name}' starting_condition '{cond_name}' not in conditions section") + for anchor in agent.authority_anchors: + if self._is_unresolved_var(anchor): + continue + self._validate_named_ref( + anchor, + owner_label=f"Agent '{name}'", + ref_label="authority_anchor", + targetable=False, + ) + for scope in agent.operating_scope: + if self._is_unresolved_var(scope): + continue + self._validate_operating_scope_ref(scope, owner_label=f"Agent '{name}'") + + def _verify_participant_behavior(self) -> None: + analysis = analyze_participant_behavior( + agents_by_name=self._s.agents, + action_contracts=self._s.action_contracts, + observation_boundaries=self._s.observation_boundaries, + is_unresolved=self._is_unresolved_var, + ) + for issue in analysis.issues: + self._err(self._format_participant_behavior_issue(issue)) + self._verify_participant_interaction_refs() + + def _verify_participant_interaction_refs(self) -> None: + for action_name, action_contract in self._s.action_contracts.items(): + for index, interaction in enumerate(action_contract.interactions): + owner_label = f"Action contract '{action_name}' interaction[{index}]" + if not self._is_unresolved_var(interaction.target): + self._validate_named_ref( + interaction.target, + owner_label=owner_label, + ref_label="target", + targetable=True, + ) + for ref in interaction.shared_state_refs: + if self._is_unresolved_var(ref): + continue + self._validate_named_ref( + ref, + owner_label=owner_label, + ref_label="shared_state_ref", + targetable=True, + ) + + def _verify_participant_outcomes(self) -> None: + analysis = analyze_participant_outcome_interpretations( + outcome_interpretation_rules=self._s.outcome_interpretation_rules, + action_contracts=self._s.action_contracts, + objectives=self._s.objectives, + workflows=self._s.workflows, + evaluations=self._s.evaluations, + is_unresolved=self._is_unresolved_var, + ) + for issue in analysis.issues: + self._err(self._format_participant_outcome_issue(issue)) + + def _verify_objectives(self) -> None: + # Declarative-objective semantics — actor binding, target resolution, + # success interpretation, windows, and dependency ordering (SEM-207). + # The name-level reference graph, ordering/refresh-role model, and + # fail-closed issue set live in ``aces_sdl.semantics.objective_semantics``; + # this pass renders the machine-readable issues it reports as authoring + # errors. + analysis = analyze_objective_semantics( + objectives_by_name=self._s.objectives, + agents_by_name=self._s.agents, + entity_names=self._all_entity_names(), + assessment_resources=AssessmentResourceCatalog( + conditions=self._s.conditions, + metrics=self._s.metrics, + evaluations=self._s.evaluations, + tlos=self._s.tlos, + goals=self._s.goals, + ), + window_resources=WindowResourceCatalog( + stories=self._s.stories, + scripts=self._s.scripts, + events=self._s.events, + workflows=self._s.workflows, + ), + targetable_name_index=self._named_ref_index(targetable=True), + is_unresolved=self._is_unresolved_var, + ) + for issue in analysis.issues: + self._err(self._format_objective_issue(issue)) + + @staticmethod + def _format_objective_issue(issue: ObjectiveIssue) -> str: + try: + renderer = _OBJECTIVE_ISSUE_RENDERERS[issue.code] + except KeyError: # pragma: no cover - defensive: a new code without a renderer + raise AssertionError(f"unhandled objective-semantics issue code: {issue.code}") from None + return renderer(issue) + + @staticmethod + def _format_participant_behavior_issue(issue: ParticipantBehaviorIssue) -> str: + try: + renderer = _PARTICIPANT_BEHAVIOR_ISSUE_RENDERERS[issue.code] + except KeyError: # pragma: no cover - defensive: a new code without a renderer + raise AssertionError(f"unhandled participant-behavior issue code: {issue.code}") from None + return renderer(issue) + + @staticmethod + def _format_participant_outcome_issue(issue: ParticipantOutcomeIssue) -> str: + try: + renderer = _PARTICIPANT_OUTCOME_ISSUE_RENDERERS[issue.code] + except KeyError: # pragma: no cover - defensive: a new code without a renderer + raise AssertionError(f"unhandled participant-outcome issue code: {issue.code}") from None + return renderer(issue) diff --git a/implementations/python/packages/aces_sdl/validator/_core.py b/implementations/python/packages/aces_sdl/validator/_core.py new file mode 100644 index 000000000..4a7837467 --- /dev/null +++ b/implementations/python/packages/aces_sdl/validator/_core.py @@ -0,0 +1,424 @@ +"""SemanticValidator _ValidatorCore (split from validator.py). + +Part of the SemanticValidator mixin composition; see __init__.py. +""" + +from collections import defaultdict + +from .._base import is_variable_ref +from .._errors import SDLValidationError +from .._runtime_service_families import collect_qualified_runtime_family_refs +from ..entities import flatten_entities +from ..nodes import NodeType +from ..scenario import Scenario +from ._support import _NODES_PREFIX + + +class _ValidatorCore: + def __init__(self, scenario: Scenario) -> None: + self._s = scenario + self._errors: list[str] = [] + self._warnings: list[str] = [] + + def _err(self, msg: str) -> None: + self._errors.append(msg) + + def _warn(self, msg: str) -> None: + self._warnings.append(msg) + + def _is_unresolved_var(self, value: object) -> bool: + return is_variable_ref(value) + + def _node_type(self, node_name: str) -> NodeType | None: + node = self._s.nodes.get(node_name) + return node.type if node is not None else None + + def _is_switch_node(self, node_name: str) -> bool: + return self._node_type(node_name) == NodeType.SWITCH + + def _is_vm_node(self, node_name: str) -> bool: + return self._node_type(node_name) == NodeType.VM + + def _all_entity_names(self) -> set[str]: + return set(flatten_entities(self._s.entities).keys()) + + def _qualified_service_refs(self) -> set[str]: + refs: set[str] = set() + for node_name, node in self._s.nodes.items(): + for service in node.services: + if service.name: + refs.add(f"nodes.{node_name}.services.{service.name}") + return refs + + def _split_node_service_ref(self, ref: object) -> tuple[str, str] | None: + """Split ``nodes..services.`` into node/service parts. + + Node names may contain dots (for example ``wazuh.manager``), so service + refs must be partitioned on the ``.services.`` marker instead of split + by position. + """ + if not isinstance(ref, str) or not ref.startswith(_NODES_PREFIX): + return None + node_name, sep, service_name = ref[len(_NODES_PREFIX) :].partition(".services.") + if not sep or not node_name or not service_name: + return None + return node_name, service_name + + def _qualified_runtime_refs(self) -> set[str]: + """Qualified refs for node-scoped runtime inventories. + + These let a top-level relationship endpoint resolve to a runtime + service family or stable child record. This keeps runtime-observed + logical state targetable without promoting those records to top-level + SDL sections. + """ + return collect_qualified_runtime_family_refs(self._s) + + def _qualified_acl_refs(self) -> set[str]: + refs: set[str] = set() + for infra_name, infra in self._s.infrastructure.items(): + for acl in infra.acls: + if acl.name: + refs.add(f"infrastructure.{infra_name}.acls.{acl.name}") + return refs + + def _workflow_step_refs(self) -> set[str]: + refs: set[str] = set() + for workflow_name, workflow in self._s.workflows.items(): + for step_name in workflow.steps: + refs.add(f"{workflow_name}.{step_name}") + return refs + + def _named_ref_index(self, *, targetable: bool = False) -> dict[str, set[str]]: + """Build the alias map for generic relationship/objective refs. + + Bare refs stay available for most top-level sections when they are + unambiguous. Qualified refs are always accepted for top-level sections, + and are required for infrastructure entries because those keys + intentionally mirror node names. + """ + index: dict[str, set[str]] = defaultdict(set) + self._populate_named_ref_index(index) + if not targetable: + return {alias: set(candidates) for alias, candidates in index.items()} + return self._filter_targetable_aliases(index) + + _NAMED_REF_TOP_LEVEL_SECTIONS = ( + ("nodes", True), + ("features", True), + ("conditions", True), + ("vulnerabilities", True), + ("infrastructure", False), + ("metrics", True), + ("evaluations", True), + ("tlos", True), + ("goals", True), + ("content", True), + ("accounts", True), + ("agents", True), + ("action_contracts", True), + ("observation_boundaries", True), + ("objectives", True), + ("workflows", True), + ("relationships", True), + ("variables", True), + ("injects", True), + ("events", True), + ("scripts", True), + ("stories", True), + ) + + _TARGETABLE_DISALLOWED_PREFIXES = ( + "variables.", + "objectives.", + "workflows.", + ) + + def _populate_named_ref_index(self, index: dict[str, set[str]]) -> None: + self._add_top_level_section_aliases(index) + self._add_entity_aliases(index) + self._add_content_item_aliases(index) + self._add_qualified_aliases(index) + + def _add_top_level_section_aliases(self, index: dict[str, set[str]]) -> None: + for section_name, allow_bare in self._NAMED_REF_TOP_LEVEL_SECTIONS: + for name in getattr(self._s, section_name): + canonical = f"{section_name}.{name}" + index[canonical].add(canonical) + if allow_bare: + index[name].add(canonical) + + def _add_entity_aliases(self, index: dict[str, set[str]]) -> None: + for entity_name in self._all_entity_names(): + canonical = f"entities.{entity_name}" + index[canonical].add(canonical) + index[entity_name].add(canonical) + + def _add_content_item_aliases(self, index: dict[str, set[str]]) -> None: + for content_name, content in self._s.content.items(): + for item in content.items: + if not item.name: + continue + canonical = f"content.{content_name}.items.{item.name}" + index[canonical].add(canonical) + index[item.name].add(canonical) + + def _add_qualified_aliases(self, index: dict[str, set[str]]) -> None: + for qualified_refs in ( + self._qualified_service_refs(), + self._qualified_acl_refs(), + self._qualified_runtime_refs(), + ): + for ref in qualified_refs: + index[ref].add(ref) + + def _filter_targetable_aliases(self, index: dict[str, set[str]]) -> dict[str, set[str]]: + filtered: dict[str, set[str]] = {} + for alias, candidates in index.items(): + keep = { + candidate for candidate in candidates if not candidate.startswith(self._TARGETABLE_DISALLOWED_PREFIXES) + } + if keep: + filtered[alias] = keep + return filtered + + def _operating_scope_ref_index(self) -> dict[str, set[str]]: + """Build the alias map for ACT-601 ``Agent.operating_scope``. + + ADR-020 §2 defines operating scope as the declarative boundary for + where the participant may act or observe — concretely subnets, + hosts, services, and content (and content items). The split here + mirrors the pre-existing scope-validation patterns: + + - hosts come from ``nodes.*`` but only VM nodes (matches + ``initial_knowledge.hosts``). + - subnets come from ``infrastructure.*`` but only switch-backed + entries (matches ``allowed_subnets``). + - services come from declared services on VM nodes. + - content references stay open across content sections and items. + + Non-spatial, non-resource elements (conditions, metrics, accounts, + relationships, objectives, …) are not scope boundaries even though + they appear in the generic targetable index. + """ + index: dict[str, set[str]] = defaultdict(set) + + # Hosts: VM nodes only. Both bare (`vm`) and qualified (`nodes.vm`) + # aliases are accepted. Switch nodes go through the subnets path, + # never the host path. + for node_name, node in self._s.nodes.items(): + if node.type != NodeType.VM: + continue + canonical = f"nodes.{node_name}" + index[node_name].add(canonical) + index[canonical].add(canonical) + + # Subnets: switch-backed infrastructure only. Both bare and + # qualified aliases. VM-backed infrastructure entries (which + # mirror VM nodes' names) go through the host path's `nodes.*` + # alias, not here. + for infra_name, _infra in self._s.infrastructure.items(): + if not self._is_switch_node(infra_name): + continue + canonical = f"infrastructure.{infra_name}" + index[infra_name].add(canonical) + index[canonical].add(canonical) + + # Services: qualified `nodes..services.` refs plus bare + # service names. The service-ref helper only emits names declared + # on VM nodes (a service on a switch is meaningless), so no extra + # filtering is needed here. + for ref in self._qualified_service_refs(): + index[ref].add(ref) + tail = ref.rsplit(".", 1)[-1] + if tail: + index[tail].add(ref) + + # Content: sections and items keep the unrestricted aliasing from + # the targetable index; ADR-020 does not split content by sub-type. + for content_name in self._s.content: + canonical = f"content.{content_name}" + index[content_name].add(canonical) + index[canonical].add(canonical) + for content_name, content in self._s.content.items(): + for item in content.items: + if not item.name: + continue + canonical = f"content.{content_name}.items.{item.name}" + index[item.name].add(canonical) + index[canonical].add(canonical) + + return {alias: set(candidates) for alias, candidates in index.items()} + + def _validate_operating_scope_ref(self, ref: str, *, owner_label: str) -> None: + """Validate ``operating_scope`` against the spatial/resource index.""" + index = self._operating_scope_ref_index() + candidates = index.get(ref) + if not candidates: + self._err(f"{owner_label} operating_scope '{ref}' does not reference any defined targetable element") + return + if len(candidates) > 1: + choices = ", ".join(sorted(candidates)) + self._err(f"{owner_label} operating_scope '{ref}' is ambiguous; use one of: {choices}") + + def _validate_named_ref( + self, + ref: str, + *, + owner_label: str, + ref_label: str, + targetable: bool = False, + ) -> None: + """Validate a generic reference against the named-element index.""" + index = self._named_ref_index(targetable=targetable) + candidates = index.get(ref) + if not candidates: + qualifier = "targetable " if targetable else "" + self._err(f"{owner_label} {ref_label} '{ref}' does not reference any defined {qualifier}element") + return + + if len(candidates) > 1: + choices = ", ".join(sorted(candidates)) + self._err(f"{owner_label} {ref_label} '{ref}' is ambiguous; use one of: {choices}") + + def validate(self) -> None: + """Run all validation passes and raise on errors.""" + self._errors = [] + self._warnings = [] + + # OCR passes + self._verify_nodes() + self._verify_infrastructure() + self._verify_runtime_network() + self._verify_runtime_network_sensors() + self._verify_runtime_network_detection_engines() + self._verify_runtime_service_listeners() + self._verify_runtime_application() + self._verify_runtime_capability_overrides() + self._verify_runtime_database_services() + self._verify_runtime_dns_services() + self._verify_runtime_ssh_servers() + self._verify_runtime_app_authorizations() + self._verify_runtime_service_manager_units() + self._verify_runtime_identity_authorities() + self._verify_runtime_file_services() + self._verify_runtime_security_monitoring_managers() + self._verify_runtime_datastore_services() + self._verify_runtime_platform_applications() + self._verify_runtime_forwarding_agents() + self._verify_runtime_orchestration_authorities() + self._verify_runtime_mail_services() + self._verify_features() + self._verify_conditions() + self._verify_vulnerabilities() + self._verify_assessment_pipeline() + self._verify_entities() + self._verify_injects() + self._verify_events() + self._verify_scripts() + self._verify_stories() + self._verify_roles() + + # New section passes + self._verify_content() + self._verify_accounts() + self._verify_relationships() + self._verify_relationship_database_access() + self._verify_relationship_mail_access() + self._verify_relationship_forwarding_edges() + self._verify_relationship_service_integrations() + self._verify_relationship_proxy_upstreams() + self._verify_agents() + self._verify_participant_behavior() + self._verify_objectives() + self._verify_workflows() + self._verify_participant_outcomes() + self._verify_variables() + self._verify_explicitness() + self._collect_advisories() + + if self._errors: + raise SDLValidationError(self._errors) + + @property + def warnings(self) -> list[str]: + """Return non-fatal advisories collected during validation.""" + return list(self._warnings) + + def _collect_advisories(self) -> None: + self._warn_missing_vm_resources() + + def _warn_missing_vm_resources(self) -> None: + for name, node in self._s.nodes.items(): + if node.type != NodeType.VM: + continue + if node.resources is None: + self._warn( + f"Node '{name}' is a VM without 'resources'. This is " + "valid SDL, but may be undeployable unless the backend " + "supplies defaults." + ) + + def _split_runtime_ref(self, ref: object, *, surface: str) -> tuple[str, str] | None: + """Split ``nodes..runtime..`` into (node, rest). + + Module composition rewrites the node segment to a dotted namespaced + form (``shared.web``), so we cannot split on ``.`` and index by + position. Partition on the surface marker instead so the node name + survives an arbitrary number of namespace prefixes. + """ + if not isinstance(ref, str) or not ref.startswith(_NODES_PREFIX): + return None + marker = f".runtime.{surface}." + head, sep, tail = ref[len(_NODES_PREFIX) :].partition(marker) + if not sep or not head or not tail: + return None + return head, tail + + def _resolve_database_service_ref(self, ref: object) -> object | None: + """Resolve a qualified ``nodes..runtime.database_services.`` ref. + + Accepts the database-service form and the ``.databases.`` form; both + resolve to the owning :class:`RuntimeDatabaseService` so a relationship's + ``database_access`` can be checked against it. + """ + split = self._split_runtime_ref(ref, surface="database_services") + if split is None: + return None + node_name, tail = split + tail_parts = tail.split(".") + # tail is ```` (1 part) or ``.databases.`` (3). + if len(tail_parts) == 1 or (len(tail_parts) == 3 and tail_parts[1] == "databases"): + svc_id = tail_parts[0] + else: + return None + node = self._s.nodes.get(node_name) + runtime = getattr(node, "runtime", None) if node is not None else None + if runtime is None: + return None + for dbsvc in runtime.database_services: + if dbsvc.database_service_id == svc_id: + return dbsvc + return None + + def _resolve_application_ref(self, ref: object) -> object | None: + """Resolve a qualified ``nodes..runtime.applications.`` ref. + + Returns the owning :class:`RuntimeApplicationSurface` so a relationship's + ``database_access`` source endpoint can be confirmed to be a runtime + application (ADR-029 §4). + """ + split = self._split_runtime_ref(ref, surface="applications") + if split is None: + return None + node_name, tail = split + if "." in tail: + return None + node = self._s.nodes.get(node_name) + runtime = getattr(node, "runtime", None) if node is not None else None + if runtime is None: + return None + for application in runtime.applications: + if application.application_id == tail: + return application + return None diff --git a/implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py b/implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py new file mode 100644 index 000000000..bcb33e20e --- /dev/null +++ b/implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py @@ -0,0 +1,343 @@ +"""SemanticValidator _NodesInfraNetworkMixin (split from validator.py). + +Part of the SemanticValidator mixin composition; see __init__.py. +""" + +from ipaddress import ip_address, ip_network + +from ..infrastructure import SimpleProperties +from ..nodes import MAX_NODE_NAME_LENGTH, NodeType + + +class _NodesInfraNetworkMixin: + # ------------------------------------------------------------------ + # OCR validation passes + # ------------------------------------------------------------------ + + def _verify_nodes(self) -> None: + for name, node in self._s.nodes.items(): + if len(name) > MAX_NODE_NAME_LENGTH: + self._err(f"Node '{name}' name exceeds 35 characters") + + for feat_name, role_name in node.features.items(): + if feat_name not in self._s.features: + self._err(f"Node '{name}' references undefined feature '{feat_name}'") + if role_name and not self._is_unresolved_var(role_name) and role_name not in node.roles: + self._err(f"Node '{name}' feature '{feat_name}' references undefined role '{role_name}'") + + for cond_name, role_name in node.conditions.items(): + if cond_name not in self._s.conditions: + self._err(f"Node '{name}' references undefined condition '{cond_name}'") + if role_name and not self._is_unresolved_var(role_name) and role_name not in node.roles: + self._err(f"Node '{name}' condition '{cond_name}' references undefined role '{role_name}'") + + for inj_name, role_name in node.injects.items(): + if inj_name not in self._s.injects: + self._err(f"Node '{name}' references undefined inject '{inj_name}'") + if role_name and not self._is_unresolved_var(role_name) and role_name not in node.roles: + self._err(f"Node '{name}' inject '{inj_name}' references undefined role '{role_name}'") + + for vuln_name in node.vulnerabilities: + if self._is_unresolved_var(vuln_name): + continue + if vuln_name not in self._s.vulnerabilities: + self._err(f"Node '{name}' references undefined vulnerability '{vuln_name}'") + + def _verify_infrastructure(self) -> None: + for name, infra in self._s.infrastructure.items(): + if name not in self._s.nodes: + self._err(f"Infrastructure '{name}' does not match any defined node") + + for link in infra.links: + if self._is_unresolved_var(link): + continue + if link not in self._s.infrastructure: + self._err(f"Infrastructure '{name}' links to undefined '{link}'") + elif not self._is_switch_node(link): + self._err(f"Infrastructure '{name}' link '{link}' must reference a switch/network entry") + + for dep in infra.dependencies: + if self._is_unresolved_var(dep): + continue + if dep not in self._s.infrastructure: + self._err(f"Infrastructure '{name}' depends on undefined '{dep}'") + + # Switch nodes cannot have count > 1 + if name in self._s.nodes: + if self._s.nodes[name].type == NodeType.SWITCH and isinstance(infra.count, int) and infra.count > 1: + self._err(f"Switch node '{name}' cannot have count > 1") + if ( + self._s.nodes[name].type == NodeType.VM + and self._s.nodes[name].conditions + and isinstance(infra.count, int) + and infra.count > 1 + ): + self._err(f"Node '{name}' has conditions and cannot have count > 1") + + # Validate complex properties IP within linked CIDR + if isinstance(infra.properties, list): + for prop_entry in infra.properties: + for link_name, ip_str in prop_entry.items(): + if self._is_unresolved_var(link_name): + continue + if link_name not in infra.links: + self._err(f"Infrastructure '{name}' property references unlinked node '{link_name}'") + if not self._is_switch_node(link_name): + self._err( + f"Infrastructure '{name}' property link " + f"'{link_name}' must reference a switch/network entry" + ) + continue + # Check IP is within the linked node's CIDR + linked_infra = self._s.infrastructure.get(link_name) + if linked_infra is None: + continue + if not isinstance(linked_infra.properties, SimpleProperties): + self._err( + f"Infrastructure '{name}' property link " + f"'{link_name}' must reference a network with CIDR " + "properties" + ) + continue + if self._is_unresolved_var(ip_str): + continue + if self._is_unresolved_var(linked_infra.properties.cidr): + continue + try: + net = ip_network(linked_infra.properties.cidr, strict=False) + except ValueError: + self._err(f"Infrastructure '{link_name}' has invalid CIDR {linked_infra.properties.cidr}") + continue + try: + addr = ip_address(ip_str) + except ValueError: + self._err( + f"Infrastructure '{name}' has invalid IP assignment '{ip_str}' for link '{link_name}'" + ) + continue + if addr not in net: + self._err( + f"Infrastructure '{name}' IP {ip_str} " + f"not within '{link_name}' CIDR " + f"{linked_infra.properties.cidr}" + ) + + # Validate ACL network references + for acl in infra.acls: + for ref in (acl.from_net, acl.to_net): + if self._is_unresolved_var(ref): + continue + if ref and ref not in self._s.infrastructure: + self._err(f"Infrastructure '{name}' ACL references undefined network '{ref}'") + elif ref and not self._is_switch_node(ref): + self._err(f"Infrastructure '{name}' ACL reference '{ref}' must point to a switch/network entry") + + def _verify_runtime_network(self) -> None: + """Validate observed runtime network endpoints against declared topology. + + Each endpoint's ``network`` must resolve to a switch-backed + infrastructure entry; concrete endpoint IPs and gateways are checked + against the referenced network CIDR when one is declared (ADR-025). + """ + for name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or runtime.network is None: + continue + for endpoint in runtime.network.endpoints: + net = endpoint.network + if self._is_unresolved_var(net): + continue + if net not in self._s.infrastructure: + self._err(f"Node '{name}' runtime network endpoint references undefined network '{net}'") + continue + if not self._is_switch_node(net): + self._err( + f"Node '{name}' runtime network endpoint network '{net}' must reference a switch/network entry" + ) + continue + self._verify_endpoint_addressing(name, net, endpoint) + + def _verify_endpoint_addressing(self, node_name: str, net: str, endpoint: object) -> None: + infra = self._s.infrastructure.get(net) + props = infra.properties if infra is not None else None + if not isinstance(props, SimpleProperties): + return + cidr = props.cidr + if not cidr or self._is_unresolved_var(cidr): + return + try: + network = ip_network(cidr, strict=False) + except ValueError: + return + for label in ("ip_address", "gateway"): + value = getattr(endpoint, label, "") + if not value or self._is_unresolved_var(value): + continue + try: + addr = ip_address(value) + except ValueError: + continue # malformed addresses are reported by the model-level validator + if addr.version == network.version and addr not in network: + self._err( + f"Node '{node_name}' runtime network endpoint {label} {value} " + f"is not within network '{net}' CIDR {cidr}" + ) + + def _verify_runtime_network_sensors(self) -> None: + """Validate observed network-sensor monitoring scope. + + A network sensor explicitly states which declared network resources it + observes. Runtime endpoint attachment is a separate fact, so when the + node records endpoint inventory, the monitored networks must be among + those endpoint attachments. + """ + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.network_sensors: + continue + observed_paths = self._node_observed_paths(node) + attached_networks = self._runtime_endpoint_networks(runtime) + for sensor in runtime.network_sensors: + self._verify_network_sensor( + node_name=node_name, + sensor=sensor, + observed_paths=observed_paths, + attached_networks=attached_networks, + ) + + @staticmethod + def _runtime_endpoint_networks(runtime: object) -> set[str]: + network = getattr(runtime, "network", None) + if network is None: + return set() + return {endpoint.network for endpoint in network.endpoints if endpoint.network} + + def _verify_network_sensor( + self, + *, + node_name: str, + sensor: object, + observed_paths: set[str], + attached_networks: set[str], + ) -> None: + owner_label = f"Node '{node_name}' runtime network sensor '{sensor.network_sensor_id}'" + for field_name in ("configuration_file_refs", "log_file_refs", "evidence_refs"): + self._verify_dns_file_refs( + owner_label, + getattr(sensor, field_name, []), + field_name=field_name, + observed_paths=observed_paths, + ) + for network_ref in sensor.monitored_network_refs: + self._verify_network_sensor_monitored_ref( + node_name=node_name, + sensor_id=sensor.network_sensor_id, + network_ref=network_ref, + attached_networks=attached_networks, + ) + + def _verify_network_sensor_monitored_ref( + self, + *, + node_name: str, + sensor_id: str, + network_ref: str, + attached_networks: set[str], + ) -> None: + if self._is_unresolved_var(network_ref): + return + label = f"Node '{node_name}' runtime network sensor '{sensor_id}'" + if network_ref not in self._s.infrastructure: + self._err(f"{label} monitored_network_ref '{network_ref}' references undefined network") + return + if not self._is_switch_node(network_ref): + self._err(f"{label} monitored_network_ref '{network_ref}' must reference a switch/network entry") + return + if attached_networks and network_ref not in attached_networks: + self._err(f"{label} monitored_network_ref '{network_ref}' is not attached to node '{node_name}'") + + def _verify_runtime_network_detection_engines(self) -> None: + """Validate observed IDS/NDR detection-engine inventories. + + Detection engines may point at a same-node network sensor, filesystem + evidence, switch-backed network/address sets, and bounded control + channels. Raw rules, packet payloads, and alert telemetry stay outside + the SDL model. + """ + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.network_detection_engines: + continue + service_names = self._node_service_names(node) + observed_paths = self._node_observed_paths(node) + sensor_ids = {sensor.network_sensor_id for sensor in runtime.network_sensors} + for engine in runtime.network_detection_engines: + self._verify_network_detection_engine( + node_name=node_name, + engine=engine, + service_names=service_names, + observed_paths=observed_paths, + sensor_ids=sensor_ids, + ) + + def _verify_network_detection_engine( + self, + *, + node_name: str, + engine: object, + service_names: set[str], + observed_paths: set[str], + sensor_ids: set[str], + ) -> None: + owner_label = f"Node '{node_name}' runtime network detection engine '{engine.network_detection_engine_id}'" + sensor_ref = getattr(engine, "sensor_ref", "") + if sensor_ref and not self._is_unresolved_var(sensor_ref) and sensor_ref not in sensor_ids: + self._err(f"{owner_label} sensor_ref '{sensor_ref}' does not resolve to a same-node network sensor") + for field_name in ("configuration_file_refs", "log_file_refs", "evidence_refs"): + self._verify_dns_file_refs( + owner_label, + getattr(engine, field_name, []), + field_name=field_name, + observed_paths=observed_paths, + ) + for source in engine.rule_sources: + self._verify_dns_file_refs( + f"{owner_label} rule_source '{source.source_id}'", + getattr(source, "file_refs", []), + field_name="file_refs", + observed_paths=observed_paths, + ) + for network_set in engine.network_sets: + set_label = f"{owner_label} network_set '{network_set.set_id}'" + for network_ref in network_set.network_refs: + self._verify_network_detection_network_ref(set_label, network_ref) + for stream in engine.output_streams: + self._verify_dns_file_refs( + f"{owner_label} output_stream '{stream.stream_id}'", + [stream.path] if stream.path else [], + field_name="path", + observed_paths=observed_paths, + ) + for channel in engine.control_channels: + channel_label = f"{owner_label} control_channel '{channel.channel_id}'" + self._verify_owned_service_ref( + node_name, + getattr(channel, "service", ""), + service_names, + owner_label=channel_label, + ) + self._verify_dns_file_refs( + channel_label, + [channel.path] if channel.path else [], + field_name="path", + observed_paths=observed_paths, + ) + + def _verify_network_detection_network_ref(self, owner_label: str, network_ref: str) -> None: + if self._is_unresolved_var(network_ref): + return + if network_ref not in self._s.infrastructure: + self._err(f"{owner_label} network_ref '{network_ref}' references undefined network") + return + if not self._is_switch_node(network_ref): + self._err(f"{owner_label} network_ref '{network_ref}' must reference a switch/network entry") diff --git a/implementations/python/packages/aces_sdl/validator/_relationships.py b/implementations/python/packages/aces_sdl/validator/_relationships.py new file mode 100644 index 000000000..4c2968a03 --- /dev/null +++ b/implementations/python/packages/aces_sdl/validator/_relationships.py @@ -0,0 +1,491 @@ +"""SemanticValidator _RelationshipsMixin (split from validator.py). + +Part of the SemanticValidator mixin composition; see __init__.py. +""" + +from ..runtime_forwarding_agent_vocab import RuntimeForwardingProtocol +from ..runtime_security_monitoring import RuntimeSecurityMonitoringListenerRole +from ._support import _NODES_PREFIX + + +class _RelationshipsMixin: + def _verify_relationship_database_access(self) -> None: + """Validate typed ``database_access`` blocks on relationship edges. + + When a relationship carries ``database_access``, its ``source`` must + resolve to a runtime application and its ``target`` must resolve to a + database service (or logical database); the access ``role_ref`` must + name a role in that service (ADR-029 §4). + """ + for name, rel in self._s.relationships.items(): + access = rel.database_access + if access is None: + continue + label = f"Relationship '{name}'" + self._check_database_access_source(rel.source, label) + dbsvc = self._check_database_access_target(rel.target, label) + if dbsvc is not None: + self._check_database_access_role(access.role_ref, dbsvc, label) + + def _check_database_access_source(self, source: str, label: str) -> None: + if self._is_unresolved_var(source) or self._resolve_application_ref(source) is not None: + return + self._err(f"{label} has database_access but source '{source}' does not resolve to a runtime application") + + def _check_database_access_target(self, target: str, label: str) -> object | None: + dbsvc = self._resolve_database_service_ref(target) + if dbsvc is not None or self._is_unresolved_var(target): + return dbsvc + self._err( + f"{label} has database_access but target '{target}' does not resolve to a database service or database" + ) + return None + + def _check_database_access_role(self, role_ref: str, dbsvc: object, label: str) -> None: + if not role_ref or self._is_unresolved_var(role_ref): + return + if role_ref not in {role.role_id for role in dbsvc.roles}: + self._err( + f"{label} database_access role_ref '{role_ref}' " + f"is not a role in database service '{dbsvc.database_service_id}'" + ) + + # ------------------------------------------------------------------ # + # Typed relationship subtypes (SCN-010 §5.7) + # ------------------------------------------------------------------ # + + def _verify_relationship_forwarding_edges(self) -> None: + """Validate typed ``forwarding_edge`` blocks on relationship edges. + + ``forwarder_ref`` must resolve to a ``RuntimeForwardingAgent`` (by + ``forwarding_agent_id``) on some node. AGREEMENT GUARD: when that agent + carries ``ship_targets``, the edge's ``target_listener_role`` and + ``protocol`` — where both sides are concrete — must be consistent with + at least one ship_target, so the inter-node trust edge and the + agent-side shipping state cannot disagree (SCN-010 §5.7). + """ + for name, rel in self._s.relationships.items(): + edge = rel.forwarding_edge + if edge is None: + continue + label = f"Relationship '{name}'" + ref = edge.forwarder_ref + if not ref or self._is_unresolved_var(ref): + continue + agents = self._resolve_forwarding_agent_refs(ref) + if not agents: + self._err( + f"{label} forwarding_edge forwarder_ref '{ref}' does not resolve to a forwarding agent " + "on any node or in scenario forwarding_agents" + ) + continue + if len(agents) > 1: + self._err( + f"{label} forwarding_edge forwarder_ref '{ref}' resolves to multiple forwarding agents; " + "forwarding_agent_id values must be unique across scenario forwarding_agents and node runtimes" + ) + continue + self._check_forwarding_edge_agreement(edge, agents[0], label) + + def _resolve_forwarding_agent_refs(self, ref: str) -> list[object]: + """Resolve a ``forwarding_agent_id`` across node and scenario registries.""" + matches: list[object] = [agent for agent in self._s.forwarding_agents if agent.forwarding_agent_id == ref] + for node in self._s.nodes.values(): + runtime = getattr(node, "runtime", None) + if runtime is None: + continue + for agent in getattr(runtime, "forwarding_agents", []): + if agent.forwarding_agent_id == ref: + matches.append(agent) + return matches + + def _check_forwarding_edge_agreement(self, edge: object, agent: object, label: str) -> None: + ship_targets = list(getattr(agent, "ship_targets", [])) + if not ship_targets: + return + agent_id = agent.forwarding_agent_id + self._check_forwarding_edge_protocol_agreement(edge, ship_targets, agent_id, label) + self._check_forwarding_edge_role_agreement(edge, ship_targets, agent_id, label) + + def _check_forwarding_edge_protocol_agreement( + self, edge: object, ship_targets: list, agent_id: str, label: str + ) -> None: + # The edge ``protocol`` is a free string; agreement is asserted only + # against ship_target protocols that are concrete enum members. If no + # ship_target carries a concrete protocol, nothing concrete to compare. + edge_protocol = getattr(edge, "protocol", "") + if not edge_protocol or self._is_unresolved_var(edge_protocol): + return + target_protocols = [t.protocol.value for t in ship_targets if isinstance(t.protocol, RuntimeForwardingProtocol)] + if not target_protocols: + return + if edge_protocol not in target_protocols: + self._err( + f"{label} forwarding_edge protocol '{edge_protocol}' does not match any ship_target " + f"protocol on forwarding agent '{agent_id}' (one of: {', '.join(sorted(set(target_protocols)))})" + ) + + def _check_forwarding_edge_role_agreement( + self, edge: object, ship_targets: list, agent_id: str, label: str + ) -> None: + # An ``agent_event_ingestion`` listener role requires a ship_target with + # an ingestion endpoint; an ``agent_enrollment`` role requires one with + # an enrollment endpoint. Other roles impose no ship_target shape. + role = getattr(edge, "target_listener_role", None) + if not isinstance(role, RuntimeSecurityMonitoringListenerRole): + return + if role is RuntimeSecurityMonitoringListenerRole.AGENT_EVENT_INGESTION: + if not any(t.has_ingestion_endpoint() for t in ship_targets): + self._err( + f"{label} forwarding_edge target_listener_role 'agent_event_ingestion' has no agreeing " + f"ship_target carrying an ingestion endpoint on forwarding agent '{agent_id}'" + ) + elif role is RuntimeSecurityMonitoringListenerRole.AGENT_ENROLLMENT: + if not any(t.has_enrollment_endpoint() for t in ship_targets): + self._err( + f"{label} forwarding_edge target_listener_role 'agent_enrollment' has no agreeing " + f"ship_target carrying an enrollment endpoint on forwarding agent '{agent_id}'" + ) + + def _verify_relationship_service_integrations(self) -> None: + """Validate typed ``service_integration`` blocks on relationship edges. + + ``consumer_ref``/``engine_ref`` (when concrete) must resolve to platform + applications by ``platform_application_id``; a concrete + ``auth_principal_ref`` must resolve to the engine application's + referenced ``app_authorization`` store when ``authorization_ref`` is set + (the integration authenticates into the engine's internal RBAC store). + """ + for name, rel in self._s.relationships.items(): + integration = rel.service_integration + if integration is None: + continue + label = f"Relationship '{name}'" + self._check_service_integration_endpoint(integration.consumer_ref, "consumer_ref", label) + engine = self._check_service_integration_endpoint(integration.engine_ref, "engine_ref", label) + self._check_service_integration_auth_principal(integration.auth_principal_ref, engine, label) + + def _check_service_integration_endpoint(self, ref: str, field_name: str, label: str) -> object | None: + if not ref or self._is_unresolved_var(ref): + return None + resolved = self._resolve_platform_application_ref(ref) + if resolved is None: + self._err( + f"{label} service_integration {field_name} '{ref}' does not resolve to a " + f"platform application on any node" + ) + return None + return resolved[1] + + def _resolve_platform_application_ref(self, ref: str) -> tuple[str, object] | None: + """Resolve a ``platform_application_id`` to (node_name, application).""" + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None: + continue + for application in getattr(runtime, "platform_applications", []): + if application.platform_application_id == ref: + return node_name, application + return None + + def _check_service_integration_auth_principal(self, ref: str, engine: object | None, label: str) -> None: + if not ref or self._is_unresolved_var(ref) or engine is None: + return + node_name = self._node_name_of_platform_application(engine) + if node_name is None: + return + node = self._s.nodes.get(node_name) + runtime = getattr(node, "runtime", None) if node is not None else None + authorizations = list(getattr(runtime, "app_authorizations", [])) + authorization_ref = getattr(engine, "authorization_ref", "") + if authorization_ref and not self._is_unresolved_var(authorization_ref): + authorizations = [ + authorization + for authorization in authorizations + if getattr(authorization, "app_authorization_id", "") == authorization_ref + ] + if not authorizations: + # The runtime platform application validator reports the bad + # authorization_ref; avoid emitting a misleading principal-scope + # error from this relationship pass as well. + return + principal_ids = { + principal.principal_id for authorization in authorizations for principal in authorization.principals + } + if ref not in principal_ids: + scope = ( + f"authorization '{authorization_ref}'" + if authorization_ref and not self._is_unresolved_var(authorization_ref) + else "an app_authorization principal" + ) + self._err( + f"{label} service_integration auth_principal_ref '{ref}' does not resolve to " + f"{scope} on the engine application's node '{node_name}'" + ) + + def _node_name_of_platform_application(self, application: object) -> str | None: + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None: + continue + if application in getattr(runtime, "platform_applications", []): + return node_name + return None + + def _verify_relationship_proxy_upstreams(self) -> None: + """Validate typed ``proxy_upstream`` blocks on relationship edges. + + ``route_ref`` must resolve to an application route (by ``route_id``) on + the relationship's ``source`` proxy; ``upstream_node_ref`` / + ``upstream_service_ref`` (when concrete) must resolve. AGREEMENT GUARD: + when the referenced route ALSO carries an ``upstream_target``, the shared + facts (target node, target service, and the TLS-termination boolean) MUST + agree between ``route.upstream_target`` and the ``RelationshipProxyUpstream`` + so the same fact recorded at two scopes is never silently duplicated and + contradictory (SCN-010 §5.7). + """ + for name, rel in self._s.relationships.items(): + upstream = rel.proxy_upstream + if upstream is None: + continue + label = f"Relationship '{name}'" + target_node_name = self._check_proxy_upstream_node_ref( + upstream.upstream_node_ref, + label, + context="proxy_upstream", + field_name="upstream_node_ref", + ) + self._check_proxy_upstream_service_ref( + upstream.upstream_service_ref, + upstream_node_ref=target_node_name or "", + relationship_target=rel.target, + label=label, + context="proxy_upstream", + field_name="upstream_service_ref", + ) + route = self._check_proxy_upstream_route_ref(upstream.route_ref, rel.source, label) + if route is not None: + self._check_proxy_upstream_agreement(upstream, route, label, relationship_target=rel.target) + + def _check_proxy_upstream_node_ref( + self, + node_ref: str, + label: str, + *, + context: str, + field_name: str, + ) -> str | None: + if not node_ref or self._is_unresolved_var(node_ref): + return None + if node_ref not in self._s.nodes: + self._err(f"{label} {context} {field_name} '{node_ref}' does not resolve to a defined node") + return None + return node_ref + + def _check_proxy_upstream_service_ref( + self, + service_ref: str, + *, + upstream_node_ref: str, + relationship_target: str, + label: str, + context: str, + field_name: str, + ) -> None: + if not service_ref or self._is_unresolved_var(service_ref): + return + resolved = self._resolve_upstream_service_ref( + service_ref, + upstream_node_ref=upstream_node_ref, + relationship_target=relationship_target, + ) + if resolved is None: + self._err( + f"{label} {context} {field_name} '{service_ref}' cannot be resolved without a concrete upstream node" + ) + return + node_name, service_name = resolved + expected_node_name = upstream_node_ref or self._node_name_from_relationship_target(relationship_target) + if expected_node_name and node_name != expected_node_name: + self._err( + f"{label} {context} {field_name} '{service_ref}' must reference a service " + f"on upstream node '{expected_node_name}'" + ) + return + node = self._s.nodes.get(node_name) + if node is None: + self._err(f"{label} {context} upstream service node '{node_name}' does not resolve to a defined node") + return + if service_name not in self._node_service_names(node): + self._err( + f"{label} {context} {field_name} '{service_ref}' does not resolve to a service on node '{node_name}'" + ) + + def _resolve_upstream_service_ref( + self, + service_ref: str, + *, + upstream_node_ref: str, + relationship_target: str, + ) -> tuple[str, str] | None: + split = self._split_node_service_ref(service_ref) + if split is not None: + return split + node_name = "" + if upstream_node_ref and not self._is_unresolved_var(upstream_node_ref): + node_name = upstream_node_ref + else: + target_node_name = self._node_name_from_relationship_target(relationship_target) + if target_node_name is not None: + node_name = target_node_name + if not node_name: + return None + return node_name, service_ref + + def _node_name_from_relationship_target(self, target: object) -> str | None: + if not isinstance(target, str) or self._is_unresolved_var(target): + return None + if target in self._s.nodes: + return target + service_split = self._split_node_service_ref(target) + if service_split is not None: + node_name, _service_name = service_split + return node_name if node_name in self._s.nodes else None + if target.startswith(_NODES_PREFIX): + node_name, sep, _tail = target[len(_NODES_PREFIX) :].partition(".runtime.") + if sep and node_name in self._s.nodes: + return node_name + return None + + def _check_proxy_upstream_route_ref(self, route_ref: str, source: str, label: str) -> object | None: + if not route_ref or self._is_unresolved_var(route_ref): + return None + routes = self._source_application_routes(source) + if routes is None: + # The source does not resolve to a runtime application surface; the + # generic relationship endpoint check already reports an unresolved + # source, so the route_ref check is deferred rather than duplicated. + return None + route = routes.get(route_ref) + if route is None: + self._err( + f"{label} proxy_upstream route_ref '{route_ref}' does not resolve to an " + f"application route on source '{source}'" + ) + return route + + def _source_application_routes(self, source: str) -> dict[str, object] | None: + """Collect ``route_id``->route for every application surface on ``source``. + + ``source`` may be a qualified ``nodes..runtime.applications.`` + ref or a bare node name; either way the proxy route lives on that node's + application surface(s). + """ + application = self._resolve_application_ref(source) + if application is not None: + return {route.route_id: route for route in application.routes} + node = self._s.nodes.get(source) + runtime = getattr(node, "runtime", None) if node is not None else None + if runtime is None: + return None + routes: dict[str, object] = {} + for application in getattr(runtime, "applications", []): + for route in application.routes: + routes[route.route_id] = route + return routes + + def _check_proxy_upstream_agreement( + self, + upstream: object, + route: object, + label: str, + *, + relationship_target: str, + ) -> None: + target = getattr(route, "upstream_target", None) + if target is None: + return + self._assert_shared_field_agreement( + label, + field_label="upstream node", + relationship_value=getattr(upstream, "upstream_node_ref", ""), + route_value=getattr(target, "target_node_ref", ""), + ) + self._assert_shared_field_agreement( + label, + field_label="upstream service", + relationship_value=getattr(upstream, "upstream_service_ref", ""), + route_value=getattr(target, "target_service", ""), + upstream_node_ref=getattr(upstream, "upstream_node_ref", "") or getattr(target, "target_node_ref", ""), + relationship_target=relationship_target, + ) + self._assert_shared_bool_agreement( + label, + field_label="TLS-termination", + relationship_value=getattr(upstream, "client_tls_terminated", None), + route_value=getattr(target, "tls_terminated_here", None), + ) + + def _assert_shared_field_agreement( + self, + label: str, + *, + field_label: str, + relationship_value: str, + route_value: str, + upstream_node_ref: str = "", + relationship_target: str = "", + ) -> None: + if not relationship_value or self._is_unresolved_var(relationship_value): + return + if not route_value or self._is_unresolved_var(route_value): + return + if self._shared_field_values_agree( + field_label=field_label, + relationship_value=relationship_value, + route_value=route_value, + upstream_node_ref=upstream_node_ref, + relationship_target=relationship_target, + ): + return + else: + self._err( + f"{label} proxy_upstream {field_label} '{relationship_value}' disagrees with the " + f"route's upstream_target value '{route_value}'" + ) + + def _shared_field_values_agree( + self, + *, + field_label: str, + relationship_value: str, + route_value: str, + upstream_node_ref: str, + relationship_target: str, + ) -> bool: + if field_label != "upstream service": + return relationship_value == route_value + relationship_ref = self._resolve_upstream_service_ref( + relationship_value, + upstream_node_ref=upstream_node_ref, + relationship_target=relationship_target, + ) + route_ref = self._resolve_upstream_service_ref( + route_value, + upstream_node_ref=upstream_node_ref, + relationship_target=relationship_target, + ) + if relationship_ref is None or route_ref is None: + return relationship_value == route_value + return relationship_ref == route_ref + + def _assert_shared_bool_agreement( + self, label: str, *, field_label: str, relationship_value: object, route_value: object + ) -> None: + if not isinstance(relationship_value, bool) or not isinstance(route_value, bool): + return + if relationship_value != route_value: + self._err( + f"{label} proxy_upstream {field_label} '{relationship_value}' disagrees with the " + f"route's upstream_target value '{route_value}'" + ) diff --git a/implementations/python/packages/aces_sdl/validator/_runtime_identity_data.py b/implementations/python/packages/aces_sdl/validator/_runtime_identity_data.py new file mode 100644 index 000000000..b4771e593 --- /dev/null +++ b/implementations/python/packages/aces_sdl/validator/_runtime_identity_data.py @@ -0,0 +1,359 @@ +"""SemanticValidator _RuntimeIdentityDataMixin (split from validator.py). + +Part of the SemanticValidator mixin composition; see __init__.py. +""" + +from ..runtime_database import DatabaseObjectType +from ._support import _NODES_PREFIX + + +class _RuntimeIdentityDataMixin: + def _verify_runtime_identity_authorities(self) -> None: + """Validate observed identity authorities against the scenario. + + Authority endpoint ``service`` refs resolve like other node-scoped + runtime service ownership claims. Relationship and policy refs are + local to the authority inventory so membership and trust facts cannot + silently point at missing users, groups, policies, or the authority + record itself. + """ + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.identity_authorities: + continue + service_names = self._node_service_names(node) + for authority in runtime.identity_authorities: + self._verify_identity_authority_services(node_name, authority, service_names) + local_refs = self._identity_authority_local_refs(authority) + self._verify_identity_authority_relationships(node_name, authority, local_refs) + self._verify_identity_authority_policies(node_name, authority, local_refs) + + def _verify_identity_authority_services( + self, + node_name: str, + authority: object, + service_names: set[str], + ) -> None: + for service in authority.services: + self._verify_owned_service_ref( + node_name, + getattr(service, "service", ""), + service_names, + owner_label=( + f"Node '{node_name}' runtime identity authority '{authority.identity_authority_id}' " + f"service '{service.service_id}'" + ), + ) + + @staticmethod + def _identity_authority_local_refs(authority: object) -> set[str]: + refs = {authority.identity_authority_id} + refs.update(service.service_id for service in authority.services) + refs.update(subject.subject_id for subject in authority.subjects) + refs.update(policy.policy_id for policy in authority.policies) + refs.update(relationship.relationship_id for relationship in authority.relationships) + return refs + + def _verify_identity_authority_relationships( + self, + node_name: str, + authority: object, + local_refs: set[str], + ) -> None: + label = f"Node '{node_name}' runtime identity authority '{authority.identity_authority_id}'" + for relationship in authority.relationships: + rel_label = f"{label} relationship '{relationship.relationship_id}'" + self._verify_identity_ref( + getattr(relationship, "source_ref", ""), + local_refs, + label=rel_label, + field_name="source_ref", + ) + if relationship.target_ref: + self._verify_identity_ref( + relationship.target_ref, + local_refs, + label=rel_label, + field_name="target_ref", + ) + + def _verify_identity_authority_policies( + self, + node_name: str, + authority: object, + local_refs: set[str], + ) -> None: + label = f"Node '{node_name}' runtime identity authority '{authority.identity_authority_id}'" + for policy in authority.policies: + policy_label = f"{label} policy '{policy.policy_id}'" + for ref in policy.applies_to_refs: + self._verify_identity_ref( + ref, + local_refs, + label=policy_label, + field_name="applies_to_ref", + ) + + def _verify_identity_ref( + self, + ref: str, + local_refs: set[str], + *, + label: str, + field_name: str, + ) -> None: + if self._is_unresolved_var(ref): + return + if ref not in local_refs: + self._err(f"{label} {field_name} '{ref}' does not resolve inside identity authority") + + # File-service surface (ADR-037). + + _FILE_SERVICE_SUBJECT_LITERALS: frozenset[str] = frozenset({"anonymous", "guest"}) + + def _verify_runtime_file_services(self) -> None: + """Validate observed runtime file services against the scenario. + + Each service's owning transport service must resolve to a service on + the same node (mirroring ``runtime.applications``). Rule/observation + ``subject_ref`` resolves against service-local principal ids plus the + reserved literals ``anonymous`` and ``guest``. ``resource_ref`` + resolves against service-local share ids; a ``share_id:path`` form is + allowed for narrowed resources. Optional ``local_user_ref`` and + ``directory_subject_ref`` on a principal are checked against + ``runtime.local_identity.users`` and the qualified identity-authority + ref shape, respectively, when present. + """ + identity_subject_refs = self._identity_authority_subject_refs() + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.file_services: + continue + service_names = self._node_service_names(node) + local_user_names = self._node_local_user_names(node) + for service in runtime.file_services: + owner_label = f"Node '{node_name}' runtime file service '{service.file_service_id}'" + self._verify_owned_service_ref( + node_name, + getattr(service, "service", ""), + service_names, + owner_label=owner_label, + ) + self._verify_file_service_principals( + owner_label, + service, + local_user_names, + identity_subject_refs, + ) + share_ids = {share.share_id for share in service.shares} + subject_refs = { + principal.principal_id for principal in service.principals + } | self._FILE_SERVICE_SUBJECT_LITERALS + for rule in service.access_rules: + self._verify_file_service_ref( + rule.subject_ref, + subject_refs, + label=f"{owner_label} rule '{rule.rule_id}'", + field_name="subject_ref", + ) + self._verify_file_service_resource_ref( + rule.resource_ref, + share_ids, + label=f"{owner_label} rule '{rule.rule_id}'", + ) + for observation in service.access_observations: + self._verify_file_service_ref( + observation.subject_ref, + subject_refs, + label=f"{owner_label} observation '{observation.observation_id}'", + field_name="subject_ref", + ) + self._verify_file_service_resource_ref( + observation.resource_ref, + share_ids, + label=f"{owner_label} observation '{observation.observation_id}'", + ) + + @staticmethod + def _node_local_user_names(node: object) -> set[str]: + runtime = getattr(node, "runtime", None) + local_identity = getattr(runtime, "local_identity", None) if runtime is not None else None + if local_identity is None: + return set() + return {user.username for user in getattr(local_identity, "users", []) if user.username} + + def _identity_authority_subject_refs(self) -> set[str]: + """Qualified subject refs across all node-scoped identity authorities. + + Shape: ``nodes..runtime.identity_authorities..subjects.``. + Used by file-service ``directory_subject_ref`` resolution so a + principal cannot smuggle a dangling pointer at a missing authority or + subject past semantic validation. + """ + refs: set[str] = set() + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None: + continue + for authority in runtime.identity_authorities: + base = f"{_NODES_PREFIX}{node_name}.runtime.identity_authorities.{authority.identity_authority_id}" + for subject in authority.subjects: + refs.add(f"{base}.subjects.{subject.subject_id}") + return refs + + def _verify_file_service_principals( + self, + owner_label: str, + service: object, + local_user_names: set[str], + identity_subject_refs: set[str], + ) -> None: + for principal in service.principals: + local_user_ref = getattr(principal, "local_user_ref", "") + if local_user_ref and not self._is_unresolved_var(local_user_ref): + if local_user_names and local_user_ref not in local_user_names: + self._err( + f"{owner_label} principal '{principal.principal_id}' local_user_ref " + f"'{local_user_ref}' does not resolve to a runtime.local_identity user" + ) + directory_ref = getattr(principal, "directory_subject_ref", "") + if not directory_ref or self._is_unresolved_var(directory_ref): + continue + if not directory_ref.startswith(_NODES_PREFIX): + self._err( + f"{owner_label} principal '{principal.principal_id}' " + f"directory_subject_ref '{directory_ref}' must be a qualified " + f"'nodes..runtime.identity_authorities..subjects.' reference" + ) + continue + if directory_ref not in identity_subject_refs: + self._err( + f"{owner_label} principal '{principal.principal_id}' " + f"directory_subject_ref '{directory_ref}' does not resolve to a known " + "identity-authority subject" + ) + + def _verify_file_service_ref( + self, + ref: str, + local_refs: set[str], + *, + label: str, + field_name: str, + ) -> None: + if not ref or self._is_unresolved_var(ref): + return + if ref not in local_refs: + self._err(f"{label} {field_name} '{ref}' does not resolve inside file service") + + def _verify_file_service_resource_ref( + self, + ref: str, + share_ids: set[str], + *, + label: str, + ) -> None: + if not ref or self._is_unresolved_var(ref): + return + share_segment = ref.split(":", 1)[0] + if share_segment not in share_ids: + self._err(f"{label} resource_ref '{ref}' does not resolve to a share in the file service") + + def _verify_runtime_database_services(self) -> None: + """Validate observed database services against the scenario. + + Each service's owning transport service must resolve to a service on + the same node (mirroring ``runtime.applications``); grant grantee/object + refs must resolve to roles and logical objects within the same service + (ADR-029 §6). + """ + for name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.database_services: + continue + service_names = self._node_service_names(node) + for dbsvc in runtime.database_services: + self._verify_owned_service_ref( + name, + getattr(dbsvc, "service", ""), + service_names, + owner_label=f"Node '{name}' runtime database service '{dbsvc.database_service_id}'", + ) + self._verify_database_grants(name, dbsvc) + + def _verify_database_grants(self, node_name: str, dbsvc: object) -> None: + role_ids = {role.role_id for role in dbsvc.roles} + objects_by_type: dict[str, set[str]] = { + "database": {db.database_id for db in dbsvc.databases}, + "schema": {schema.schema_id for db in dbsvc.databases for schema in db.schemas}, + "table": {table.table_id for db in dbsvc.databases for schema in db.schemas for table in schema.tables}, + } + label = f"Node '{node_name}' runtime database service '{dbsvc.database_service_id}'" + for grant in dbsvc.grants: + if not self._is_unresolved_var(grant.grantee_role_ref) and grant.grantee_role_ref not in role_ids: + self._err(f"{label} grant grantee_role_ref '{grant.grantee_role_ref}' is not a role in the service") + object_type = grant.object_type + type_value = object_type.value if isinstance(object_type, DatabaseObjectType) else object_type + if self._is_unresolved_var(grant.object_ref) or self._is_unresolved_var(type_value): + continue + if grant.object_ref not in objects_by_type.get(type_value, set()): + self._err(f"{label} grant object_ref '{grant.object_ref}' is not a {type_value} in the service") + + def _verify_runtime_dns_services(self) -> None: + """Validate observed DNS services against the scenario. + + Each DNS service's owning transport service must resolve to a service + on the same node. Optional configuration, log, and zone-file refs are + checked against ``runtime.filesystem_inventory`` when the node has an + observed file inventory, keeping evidence paths tied to node-scoped + runtime facts without embedding raw zone-file content. + """ + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.dns_services: + continue + service_names = self._node_service_names(node) + observed_paths = self._node_observed_paths(node) + for dns_service in runtime.dns_services: + owner_label = f"Node '{node_name}' runtime DNS service '{dns_service.dns_service_id}'" + self._verify_owned_service_ref( + node_name, + getattr(dns_service, "service", ""), + service_names, + owner_label=owner_label, + ) + self._verify_dns_file_refs( + owner_label, + getattr(dns_service, "configuration_file_refs", []), + field_name="configuration_file_refs", + observed_paths=observed_paths, + ) + self._verify_dns_file_refs( + owner_label, + getattr(dns_service, "log_file_refs", []), + field_name="log_file_refs", + observed_paths=observed_paths, + ) + for zone in dns_service.zones: + self._verify_dns_file_refs( + f"{owner_label} zone '{zone.zone_id}'", + getattr(zone, "zone_file_refs", []), + field_name="zone_file_refs", + observed_paths=observed_paths, + ) + + def _verify_dns_file_refs( + self, + owner_label: str, + refs: list[str], + *, + field_name: str, + observed_paths: set[str], + ) -> None: + if not observed_paths: + return + for ref in refs: + if self._is_unresolved_var(ref): + continue + if ref not in observed_paths: + self._err(f"{owner_label} {field_name} ref '{ref}' does not resolve to an observed file on the node") diff --git a/implementations/python/packages/aces_sdl/validator/_runtime_mail.py b/implementations/python/packages/aces_sdl/validator/_runtime_mail.py new file mode 100644 index 000000000..afbf99757 --- /dev/null +++ b/implementations/python/packages/aces_sdl/validator/_runtime_mail.py @@ -0,0 +1,307 @@ +"""SemanticValidator _RuntimeMailMixin (split from validator.py). + +Part of the SemanticValidator mixin composition; see __init__.py. +""" + +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass + + +@dataclass(frozen=True) +class _MailServiceLocalIds: + components: set[str] + domains: set[str] + stores: set[str] + mailboxes: set[str] + aliases: set[str] + routing_refs: set[str] + + +@dataclass(frozen=True) +class _MailRefTail: + service_id: str + collection_name: str + child_id: str + + +_MailChildIdReader = Callable[[object], Iterable[str]] +_MAIL_CHILD_ID_READERS: dict[str, _MailChildIdReader] = { + "components": lambda service: (component.component_id for component in service.components), + "listeners": lambda service: (listener.listener_id for listener in service.listeners), + "domains": lambda service: (domain.domain_id for domain in service.domains), + "mailbox_stores": lambda service: (store.store_id for store in service.mailbox_stores), + "mailboxes": lambda service: (mailbox.mailbox_id for mailbox in service.mailboxes), + "aliases": lambda service: (alias.alias_id for alias in service.aliases), + "routing_rules": lambda service: (rule.rule_id for rule in service.routing_rules), + "queues": lambda service: (queue.queue_id for queue in service.queues), + "settings": lambda service: (setting.setting_id for setting in service.settings), +} + + +def _mail_services_for_node(node: object) -> Sequence[object]: + runtime = getattr(node, "runtime", None) + return () if runtime is None else runtime.mail_services + + +def _mail_services_for_node_name(scenario: object, node_name: str) -> Sequence[object]: + node = scenario.nodes.get(node_name) + return () if node is None else _mail_services_for_node(node) + + +def _collect_mail_service_local_ids(service: object) -> _MailServiceLocalIds: + mailbox_ids = {mailbox.mailbox_id for mailbox in service.mailboxes} + alias_ids = {alias.alias_id for alias in service.aliases} + domain_ids = {domain.domain_id for domain in service.domains} + return _MailServiceLocalIds( + components={component.component_id for component in service.components}, + domains=domain_ids, + stores={store.store_id for store in service.mailbox_stores}, + mailboxes=mailbox_ids, + aliases=alias_ids, + routing_refs=mailbox_ids | alias_ids | domain_ids, + ) + + +def _parse_mail_ref_tail(tail: str) -> _MailRefTail | None: + tail_parts = tail.split(".") + if len(tail_parts) == 1: + return _MailRefTail(tail_parts[0], "", "") + if len(tail_parts) == 3: + return _MailRefTail(*tail_parts) + return None + + +def _resolve_mail_service_tail( + mail_services: Sequence[object], + parsed_tail: _MailRefTail, +) -> object | None: + for service in mail_services: + if service.mail_service_id != parsed_tail.service_id: + continue + return _matched_service_for_tail(service, parsed_tail) + return None + + +def _matched_service_for_tail(service: object, parsed_tail: _MailRefTail) -> object | None: + matches_service = not parsed_tail.collection_name + matches_child = bool( + parsed_tail.collection_name + and _mail_child_ref_exists(service, parsed_tail.collection_name, parsed_tail.child_id) + ) + return service if matches_service or matches_child else None + + +def _mail_child_ref_exists(service: object, collection_name: str, child_id: str) -> bool: + read_child_ids = _MAIL_CHILD_ID_READERS.get(collection_name) + return read_child_ids is not None and child_id in read_child_ids(service) + + +class _RuntimeMailMixin: + def _verify_runtime_mail_services(self) -> None: + """Validate runtime mail-service inventories against the scenario graph.""" + for node_name, node in self._s.nodes.items(): + mail_services = _mail_services_for_node(node) + if not mail_services: + continue + service_names = self._node_service_names(node) + observed_paths = self._node_observed_paths(node) + local_user_names = self._node_local_user_names(node) + for service in mail_services: + label = f"Node '{node_name}' runtime mail service '{service.mail_service_id}'" + self._verify_owned_service_ref( + node_name, + service.service, + service_names, + owner_label=label, + ) + self._verify_mail_service_children( + node_name=node_name, + label=label, + service=service, + service_names=service_names, + observed_paths=observed_paths, + local_user_names=local_user_names, + ) + + def _verify_mail_service_children( + self, + *, + node_name: str, + label: str, + service: object, + service_names: set[str], + observed_paths: set[str], + local_user_names: set[str], + ) -> None: + local_ids = _collect_mail_service_local_ids(service) + self._verify_mail_listeners(node_name, label, service, service_names, local_ids) + self._verify_mailboxes(label, service, local_user_names, local_ids) + self._verify_mail_aliases(label, service, local_ids) + self._verify_mail_routing_rules(label, service, local_ids) + self._verify_mail_settings(label, service, observed_paths, local_ids) + + def _verify_mail_listeners( + self, + node_name: str, + label: str, + service: object, + service_names: set[str], + local_ids: "_MailServiceLocalIds", + ) -> None: + for listener in service.listeners: + listener_label = f"{label} listener '{listener.listener_id}'" + self._verify_owned_service_ref( + node_name, + listener.service, + service_names, + owner_label=listener_label, + ) + self._verify_mail_ref( + listener.component_ref, + local_ids.components, + label=listener_label, + field_name="component_ref", + ) + + def _verify_mailboxes( + self, + label: str, + service: object, + local_user_names: set[str], + local_ids: "_MailServiceLocalIds", + ) -> None: + for mailbox in service.mailboxes: + mailbox_label = f"{label} mailbox '{mailbox.mailbox_id}'" + self._verify_mail_ref(mailbox.domain_ref, local_ids.domains, label=mailbox_label, field_name="domain_ref") + self._verify_mail_ref(mailbox.store_ref, local_ids.stores, label=mailbox_label, field_name="store_ref") + self._verify_mail_account_ref(mailbox.account_ref, mailbox_label) + self._verify_mail_local_user_ref(mailbox.local_user_ref, local_user_names, mailbox_label) + + def _verify_mail_aliases( + self, + label: str, + service: object, + local_ids: "_MailServiceLocalIds", + ) -> None: + for alias in service.aliases: + alias_label = f"{label} alias '{alias.alias_id}'" + self._verify_mail_ref(alias.domain_ref, local_ids.domains, label=alias_label, field_name="domain_ref") + for target_ref in alias.target_refs: + self._verify_mail_ref( + target_ref, + local_ids.mailboxes | local_ids.aliases, + label=alias_label, + field_name="target_ref", + ) + + def _verify_mail_routing_rules( + self, + label: str, + service: object, + local_ids: "_MailServiceLocalIds", + ) -> None: + for rule in service.routing_rules: + rule_label = f"{label} routing rule '{rule.rule_id}'" + self._verify_mail_ref(rule.source_ref, local_ids.routing_refs, label=rule_label, field_name="source_ref") + self._verify_mail_ref(rule.target_ref, local_ids.routing_refs, label=rule_label, field_name="target_ref") + + def _verify_mail_settings( + self, + label: str, + service: object, + observed_paths: set[str], + local_ids: "_MailServiceLocalIds", + ) -> None: + for setting in service.settings: + setting_label = f"{label} setting '{setting.setting_id}'" + self._verify_mail_ref( + setting.component_ref, + local_ids.components, + label=setting_label, + field_name="component_ref", + ) + if self._source_path_misses_observed_inventory(setting.source_path, observed_paths): + self._err(f"{setting_label} source_path '{setting.source_path}' does not resolve to an observed file") + + def _source_path_misses_observed_inventory(self, source_path: str, observed_paths: set[str]) -> bool: + return bool( + source_path + and observed_paths + and not self._is_unresolved_var(source_path) + and source_path not in observed_paths + ) + + def _verify_mail_ref( + self, + ref: str, + local_refs: set[str], + *, + label: str, + field_name: str, + ) -> None: + if not ref or self._is_unresolved_var(ref): + return + if ref not in local_refs: + self._err(f"{label} {field_name} '{ref}' does not resolve inside mail service") + + def _verify_mail_account_ref(self, ref: str, label: str) -> None: + if not ref or self._is_unresolved_var(ref): + return + if ref not in self._s.accounts: + self._err(f"{label} account_ref '{ref}' does not resolve to a top-level account") + + def _verify_mail_local_user_ref(self, ref: str, local_user_names: set[str], label: str) -> None: + if not ref or self._is_unresolved_var(ref): + return + if local_user_names and ref not in local_user_names: + self._err(f"{label} local_user_ref '{ref}' does not resolve to a runtime.local_identity user") + + def _verify_relationship_mail_access(self) -> None: + """Validate typed ``mail_access`` blocks on top-level relationship edges.""" + for name, relationship in self._s.relationships.items(): + access = relationship.mail_access + if access is None: + continue + label = f"Relationship '{name}'" + mail_service = self._check_mail_access_target(relationship.target, label) + if mail_service is None: + continue + self._verify_mail_ref( + access.listener_ref, + {listener.listener_id for listener in mail_service.listeners}, + label=f"{label} mail_access", + field_name="listener_ref", + ) + self._verify_mail_ref( + access.mailbox_ref, + {mailbox.mailbox_id for mailbox in mail_service.mailboxes}, + label=f"{label} mail_access", + field_name="mailbox_ref", + ) + self._verify_mail_ref( + access.domain_ref, + {domain.domain_id for domain in mail_service.domains}, + label=f"{label} mail_access", + field_name="domain_ref", + ) + + def _check_mail_access_target(self, target: str, label: str) -> object | None: + mail_service = self._resolve_mail_service_ref(target) + if mail_service is not None or self._is_unresolved_var(target): + return mail_service + self._err(f"{label} has mail_access but target '{target}' does not resolve to a mail service") + return None + + def _resolve_mail_service_ref(self, ref: object) -> object | None: + """Resolve a qualified runtime mail-service or child ref to the service.""" + split = self._split_runtime_ref(ref, surface="mail_services") + if split is None: + return None + node_name, tail = split + parsed_tail = _parse_mail_ref_tail(tail) + if parsed_tail is None: + return None + return _resolve_mail_service_tail( + _mail_services_for_node_name(self._s, node_name), + parsed_tail, + ) diff --git a/implementations/python/packages/aces_sdl/validator/_runtime_platform.py b/implementations/python/packages/aces_sdl/validator/_runtime_platform.py new file mode 100644 index 000000000..d66e2510f --- /dev/null +++ b/implementations/python/packages/aces_sdl/validator/_runtime_platform.py @@ -0,0 +1,527 @@ +"""SemanticValidator _RuntimePlatformMixin (split from validator.py). + +Part of the SemanticValidator mixin composition; see __init__.py. +""" + +from collections import defaultdict + +from .._base import is_variable_ref +from ..runtime_mounts import RuntimeControlInterfaceAccess, RuntimeControlInterfaceKind +from ..runtime_orchestration import RuntimeOrchestrationPrivilegeClass + + +class _RuntimePlatformMixin: + def _verify_runtime_security_monitoring_managers(self) -> None: + """Validate observed SIEM/security-monitoring manager inventories. + + Manager and listener service refs are node-local transport ownership + claims. File refs are checked only when a filesystem inventory exists, + matching the DNS and mail runtime surfaces' evidence-bound posture. + Agent/group and setting/component refs are manager-local stable ids. + """ + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.security_monitoring_managers: + continue + service_names = self._node_service_names(node) + observed_paths = self._node_observed_paths(node) + for manager in runtime.security_monitoring_managers: + self._verify_security_monitoring_manager( + node_name=node_name, + manager=manager, + service_names=service_names, + observed_paths=observed_paths, + ) + + def _verify_security_monitoring_manager( + self, + *, + node_name: str, + manager: object, + service_names: set[str], + observed_paths: set[str], + ) -> None: + owner_label = ( + f"Node '{node_name}' runtime security-monitoring manager '{manager.security_monitoring_manager_id}'" + ) + self._verify_owned_service_ref( + node_name, + getattr(manager, "service", ""), + service_names, + owner_label=owner_label, + ) + self._verify_dns_file_refs( + owner_label, + getattr(manager, "configuration_file_refs", []), + field_name="configuration_file_refs", + observed_paths=observed_paths, + ) + self._verify_dns_file_refs( + owner_label, + getattr(manager, "log_file_refs", []), + field_name="log_file_refs", + observed_paths=observed_paths, + ) + self._verify_dns_file_refs( + owner_label, + getattr(manager, "evidence_refs", []), + field_name="evidence_refs", + observed_paths=observed_paths, + ) + self._verify_security_monitoring_children( + node_name=node_name, + manager=manager, + owner_label=owner_label, + service_names=service_names, + observed_paths=observed_paths, + ) + + def _verify_security_monitoring_children( + self, + *, + node_name: str, + manager: object, + owner_label: str, + service_names: set[str], + observed_paths: set[str], + ) -> None: + component_ids = {component.component_id for component in manager.components} + agent_ids = {agent.agent_id for agent in manager.agents} + group_ids = {group.group_id for group in manager.agent_groups} + content_set_ids = {content_set.content_id for content_set in manager.content_sets} + definition_ids = {definition.definition_id for definition in manager.detection_definitions} + for listener in manager.listeners: + self._verify_owned_service_ref( + node_name, + getattr(listener, "service", ""), + service_names, + owner_label=f"{owner_label} listener '{listener.listener_id}'", + ) + for group in manager.agent_groups: + group_label = f"{owner_label} agent_group '{group.group_id}'" + self._verify_dns_file_refs( + group_label, + getattr(group, "configuration_file_refs", []), + field_name="configuration_file_refs", + observed_paths=observed_paths, + ) + for member_ref in group.member_refs: + self._verify_security_monitoring_local_ref( + member_ref, + agent_ids, + owner_label=group_label, + field_name="member_ref", + target_label="agent", + ) + for agent in manager.agents: + agent_label = f"{owner_label} agent '{agent.agent_id}'" + for group_ref in agent.group_refs: + self._verify_security_monitoring_local_ref( + group_ref, + group_ids, + owner_label=agent_label, + field_name="group_ref", + target_label="agent group", + ) + for content_set in manager.content_sets: + self._verify_dns_file_refs( + f"{owner_label} content_set '{content_set.content_id}'", + getattr(content_set, "file_refs", []), + field_name="file_refs", + observed_paths=observed_paths, + ) + for definition in manager.detection_definitions: + definition_label = f"{owner_label} detection_definition '{definition.definition_id}'" + self._verify_security_monitoring_local_ref( + getattr(definition, "content_set_ref", ""), + content_set_ids, + owner_label=definition_label, + field_name="content_set_ref", + target_label="content set", + ) + self._verify_dns_file_refs( + definition_label, + [definition.source_file_ref] if definition.source_file_ref else [], + field_name="source_file_ref", + observed_paths=observed_paths, + ) + self._verify_dns_file_refs( + definition_label, + getattr(definition, "evidence_refs", []), + field_name="evidence_refs", + observed_paths=observed_paths, + ) + for field_name, refs in ( + ("if_sid_ref", getattr(definition, "if_sid_refs", [])), + ("if_matched_sid_ref", getattr(definition, "if_matched_sid_refs", [])), + ("parent_definition_ref", getattr(definition, "parent_definition_refs", [])), + ): + for ref in refs: + self._verify_security_monitoring_local_ref( + ref, + definition_ids, + owner_label=definition_label, + field_name=field_name, + target_label="detection definition", + ) + source_artifact_ref = getattr(definition, "source_artifact_ref", "") + if source_artifact_ref and not self._is_unresolved_var(source_artifact_ref): + self._validate_named_ref( + source_artifact_ref, + owner_label=definition_label, + ref_label="source_artifact_ref", + ) + for target_ref in getattr(definition, "target_refs", []): + if self._is_unresolved_var(target_ref): + continue + self._validate_named_ref( + target_ref, + owner_label=definition_label, + ref_label="target_ref", + targetable=True, + ) + for setting in manager.settings: + setting_label = f"{owner_label} setting '{setting.setting_id}'" + self._verify_security_monitoring_local_ref( + getattr(setting, "component_ref", ""), + component_ids, + owner_label=setting_label, + field_name="component_ref", + target_label="component", + ) + self._verify_dns_file_refs( + setting_label, + [setting.source_path] if setting.source_path else [], + field_name="source_path", + observed_paths=observed_paths, + ) + + def _verify_security_monitoring_local_ref( + self, + ref: str, + local_refs: set[str], + *, + owner_label: str, + field_name: str, + target_label: str, + ) -> None: + if not ref or self._is_unresolved_var(ref): + return + if ref not in local_refs: + self._err( + f"{owner_label} {field_name} '{ref}' does not resolve to a " + f"{target_label} in the security-monitoring manager" + ) + + def _verify_runtime_datastore_services(self) -> None: + """Validate observed datastore services against the scenario. + + Each service's owning transport service must resolve to a service on + the same node (mirroring ``runtime.applications`` and + ``runtime.database_services``); a non-empty, non-variable + ``authorization_ref`` must resolve to an ``app_authorization`` declared + on the same node's runtime (the delegated internal RBAC store). + """ + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.datastore_services: + continue + service_names = self._node_service_names(node) + authorization_ids = self._node_app_authorization_ids(runtime) + for datastore in runtime.datastore_services: + owner_label = f"Node '{node_name}' runtime datastore service '{datastore.datastore_service_id}'" + self._verify_owned_service_ref( + node_name, + getattr(datastore, "service", ""), + service_names, + owner_label=owner_label, + ) + self._verify_runtime_authorization_ref( + getattr(datastore, "authorization_ref", ""), + authorization_ids, + owner_label=owner_label, + ) + + def _verify_runtime_platform_applications(self) -> None: + """Validate observed platform-application inventories against the scenario. + + Each application's owning transport service must resolve to a service on + the same node; a non-empty, non-variable ``authorization_ref`` must + resolve to a same-node ``app_authorization``; content-object + ``references`` must resolve to sibling ``content_object_id`` values and + ``marking_refs`` to sibling ``marking_id`` values within the same + application (intra-application referential integrity). + """ + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.platform_applications: + continue + service_names = self._node_service_names(node) + authorization_ids = self._node_app_authorization_ids(runtime) + for application in runtime.platform_applications: + self._verify_platform_application( + node_name=node_name, + application=application, + service_names=service_names, + authorization_ids=authorization_ids, + ) + + def _verify_platform_application( + self, + *, + node_name: str, + application: object, + service_names: set[str], + authorization_ids: set[str], + ) -> None: + owner_label = f"Node '{node_name}' runtime platform application '{application.platform_application_id}'" + self._verify_owned_service_ref( + node_name, + getattr(application, "service", ""), + service_names, + owner_label=owner_label, + ) + self._verify_runtime_authorization_ref( + getattr(application, "authorization_ref", ""), + authorization_ids, + owner_label=owner_label, + ) + content_object_ids = {obj.content_object_id for obj in application.content_objects} + marking_ids = {marking.marking_id for marking in application.markings} + for content_object in application.content_objects: + object_label = f"{owner_label} content_object '{content_object.content_object_id}'" + for reference in content_object.references: + if self._is_unresolved_var(reference): + continue + if reference not in content_object_ids: + self._err( + f"{object_label} reference '{reference}' does not resolve to a " + f"content_object in the platform application" + ) + for marking_ref in content_object.marking_refs: + if self._is_unresolved_var(marking_ref): + continue + if marking_ref not in marking_ids: + self._err( + f"{object_label} marking_ref '{marking_ref}' does not resolve to a " + f"marking in the platform application" + ) + + @staticmethod + def _node_app_authorization_ids(runtime: object) -> set[str]: + """Collect ``app_authorization_id`` values declared on a node's runtime.""" + return { + authorization.app_authorization_id + for authorization in getattr(runtime, "app_authorizations", []) + if authorization.app_authorization_id + } + + def _verify_runtime_authorization_ref( + self, + authorization_ref: str, + authorization_ids: set[str], + *, + owner_label: str, + ) -> None: + """Resolve a delegated ``authorization_ref`` to a same-node app_authorization.""" + if not authorization_ref or self._is_unresolved_var(authorization_ref): + return + if authorization_ref not in authorization_ids: + self._err( + f"{owner_label} authorization_ref '{authorization_ref}' does not resolve to an " + f"app_authorization on the same node" + ) + + def _verify_runtime_forwarding_agents(self) -> None: + """Validate observed forwarding / intel-sync agent inventories. + + A ship target's ``target_node_ref``, when present and concrete, must + resolve to a defined node; a present, concrete ``target_service_ref`` + must resolve to a service on that referenced node (or, when no node ref + is given, to a service on the owning node). The agent-internal + ``require_profile_for_agent_kind`` guard (model-local) has already + enforced the per-``agent_kind`` profile shape. + """ + self._verify_forwarding_agent_id_uniqueness() + for agent in self._s.forwarding_agents: + owner_label = f"Scenario forwarding agent '{agent.forwarding_agent_id}'" + for target in agent.ship_targets: + self._verify_scenario_forwarding_ship_target( + target=target, + owner_label=f"{owner_label} ship_target '{target.target_id}'", + ) + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.forwarding_agents: + continue + local_service_names = self._node_service_names(node) + for agent in runtime.forwarding_agents: + owner_label = f"Node '{node_name}' runtime forwarding agent '{agent.forwarding_agent_id}'" + for target in agent.ship_targets: + self._verify_forwarding_ship_target( + node_name=node_name, + local_service_names=local_service_names, + target=target, + owner_label=f"{owner_label} ship_target '{target.target_id}'", + ) + + def _verify_forwarding_agent_id_uniqueness(self) -> None: + locations: dict[str, list[str]] = defaultdict(list) + for agent in self._s.forwarding_agents: + if agent.forwarding_agent_id: + locations[agent.forwarding_agent_id].append("scenario forwarding_agents") + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None: + continue + for agent in getattr(runtime, "forwarding_agents", []): + if agent.forwarding_agent_id: + locations[agent.forwarding_agent_id].append(f"node '{node_name}' runtime.forwarding_agents") + + for agent_id, agent_locations in locations.items(): + if len(agent_locations) > 1: + self._err(f"Duplicate forwarding_agent_id '{agent_id}' across {', '.join(agent_locations)}") + + def _verify_scenario_forwarding_ship_target(self, *, target: object, owner_label: str) -> None: + node_ref = getattr(target, "target_node_ref", "") + service_ref = getattr(target, "target_service_ref", "") + resolved_node = None + if node_ref and not self._is_unresolved_var(node_ref): + resolved_node = self._s.nodes.get(node_ref) + if resolved_node is None: + self._err(f"{owner_label} target_node_ref '{node_ref}' does not resolve to a defined node") + return + + if service_ref and not self._is_unresolved_var(service_ref): + if not node_ref: + self._err( + f"{owner_label} target_service_ref '{service_ref}' requires target_node_ref because " + "scenario-level forwarding agents have no owning node" + ) + return + if self._is_unresolved_var(node_ref): + return + if resolved_node is None: + return + if service_ref not in self._node_service_names(resolved_node): + self._err( + f"{owner_label} target_service_ref '{service_ref}' does not resolve to a service " + f"on node '{node_ref}'" + ) + + def _verify_forwarding_ship_target( + self, + *, + node_name: str, + local_service_names: set[str], + target: object, + owner_label: str, + ) -> None: + node_ref = getattr(target, "target_node_ref", "") + service_ref = getattr(target, "target_service_ref", "") + resolved_node_name = node_name + resolved_node = self._s.nodes.get(node_name) + if node_ref and not self._is_unresolved_var(node_ref): + resolved_node = self._s.nodes.get(node_ref) + if resolved_node is None: + self._err(f"{owner_label} target_node_ref '{node_ref}' does not resolve to a defined node") + return + resolved_node_name = node_ref + if service_ref and not self._is_unresolved_var(service_ref): + if resolved_node is None: + return + target_service_names = ( + local_service_names if resolved_node_name == node_name else self._node_service_names(resolved_node) + ) + if service_ref not in target_service_names: + self._err( + f"{owner_label} target_service_ref '{service_ref}' does not resolve to a service " + f"on node '{resolved_node_name}'" + ) + + def _verify_runtime_orchestration_authorities(self) -> None: + """Validate observed container-spawn orchestration-authority inventories. + + Each authority's ``control_interface_ref``, when present and concrete, + must resolve to a :class:`RuntimeControlInterface` declared in the same + node's ``runtime.local_control_interfaces`` (by ``control_interface_id``). + For a ``host_root_equivalent`` privilege class, the referenced control + interface must additionally be a read-write docker socket (a read-write + unix socket whose path is a ``docker.sock``), making the host-root + privilege-escalation fact resolvable at scenario scope. The + model-local ``require_profile_for_privilege_class`` guard has already + rejected a host-root-equivalent authority that carries no concrete + ``control_interface_ref``. + """ + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.orchestration_authorities: + continue + interfaces_by_id = { + interface.control_interface_id: interface + for interface in getattr(runtime, "local_control_interfaces", []) + if interface.control_interface_id + } + for authority in runtime.orchestration_authorities: + self._verify_orchestration_authority( + node_name=node_name, + authority=authority, + interfaces_by_id=interfaces_by_id, + ) + + def _verify_orchestration_authority( + self, + *, + node_name: str, + authority: object, + interfaces_by_id: dict[str, object], + ) -> None: + owner_label = f"Node '{node_name}' runtime orchestration authority '{authority.orchestration_authority_id}'" + ref = getattr(authority, "control_interface_ref", "") + if not ref or self._is_unresolved_var(ref): + return + interface = interfaces_by_id.get(ref) + if interface is None: + self._err( + f"{owner_label} control_interface_ref '{ref}' does not resolve to a " + f"control interface in the same node's runtime.local_control_interfaces" + ) + return + privilege = getattr(authority, "privilege_class", None) + if ( + isinstance(privilege, RuntimeOrchestrationPrivilegeClass) + and privilege is RuntimeOrchestrationPrivilegeClass.HOST_ROOT_EQUIVALENT + ): + self._verify_host_root_control_interface(owner_label=owner_label, ref=ref, interface=interface) + + @staticmethod + def _control_interface_is_docker_socket(interface: object) -> bool: + """Return whether a control interface is a read-write docker unix socket.""" + access = getattr(interface, "access", None) + kind = getattr(interface, "kind", None) + path = getattr(interface, "path", "") or "" + is_read_write = access is RuntimeControlInterfaceAccess.READ_WRITE + is_unix_socket = kind is RuntimeControlInterfaceKind.UNIX_SOCKET + is_docker_sock = isinstance(path, str) and path.endswith("docker.sock") + return is_read_write and is_unix_socket and is_docker_sock + + def _verify_host_root_control_interface( + self, + *, + owner_label: str, + ref: str, + interface: object, + ) -> None: + # ``${var}`` placeholders on the interface's access/kind/path are + # permissive: a deferred discriminator cannot be proven non-conformant. + access = getattr(interface, "access", None) + kind = getattr(interface, "kind", None) + path = getattr(interface, "path", "") or "" + if is_variable_ref(access) or is_variable_ref(kind) or is_variable_ref(path): + return + if not self._control_interface_is_docker_socket(interface): + self._err( + f"{owner_label} privilege_class 'host_root_equivalent' control_interface_ref '{ref}' " + f"must resolve to a read-write docker socket " + f"(access 'read_write', kind 'unix_socket', path ending in 'docker.sock')" + ) diff --git a/implementations/python/packages/aces_sdl/validator/_runtime_services.py b/implementations/python/packages/aces_sdl/validator/_runtime_services.py new file mode 100644 index 000000000..cac35c618 --- /dev/null +++ b/implementations/python/packages/aces_sdl/validator/_runtime_services.py @@ -0,0 +1,487 @@ +"""SemanticValidator _RuntimeServicesMixin (split from validator.py). + +Part of the SemanticValidator mixin composition; see __init__.py. +""" + +from ..runtime_ssh_server import SshMatchCriterionKind +from ._support import _NODES_PREFIX + + +class _RuntimeServicesMixin: + def _verify_runtime_application(self) -> None: + """Validate observed runtime application surfaces against the scenario. + + Each surface's owning service must resolve to a service on the same + node; route vulnerability refs must resolve to top-level + ``vulnerabilities``; and template/static refs should resolve to the + node's observed file inventory when one is recorded (ADR-026). + """ + for name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.applications: + continue + service_names = self._node_service_names(node) + observed_paths = self._node_observed_paths(node) + for application in runtime.applications: + self._verify_application_service(name, application, service_names) + for route in application.routes: + self._verify_route_refs(name, application, route, observed_paths) + self._verify_route_upstream_target(name, application, route) + + @staticmethod + def _node_service_names(node: object) -> set[str]: + return {service.name for service in getattr(node, "services", []) if service.name} + + @staticmethod + def _node_services_by_name(node: object) -> dict[str, object]: + return {service.name: service for service in getattr(node, "services", []) if service.name} + + @staticmethod + def _node_observed_paths(node: object) -> set[str]: + """Collect file paths the node observably exposes for template/static refs.""" + paths: set[str] = set() + runtime = getattr(node, "runtime", None) + if runtime is not None: + paths.update(entry.path for entry in runtime.filesystem_inventory if entry.path) + source = getattr(node, "source", None) + build = getattr(source, "build", None) if source is not None else None + if build is not None: + paths.update(item.destination_path for item in build.copied_sources if item.destination_path) + paths.update(item.destination_path for item in build.source_inputs if item.destination_path) + return paths + + def _verify_application_service(self, node_name: str, application: object, service_names: set[str]) -> None: + self._verify_owned_service_ref( + node_name, + getattr(application, "service", ""), + service_names, + owner_label=f"Node '{node_name}' runtime application '{application.application_id}'", + ) + + def _verify_owned_service_ref( + self, + node_name: str, + ref: str, + service_names: set[str], + *, + owner_label: str, + ) -> None: + """Validate a runtime surface's owning transport-service reference. + + The ref is a bare ``Node.services[].name`` or the qualified + ``nodes..services.`` form, and must resolve to a service on + the same node. Shared by runtime applications, database services, and + identity authority services. + """ + if not ref or self._is_unresolved_var(ref): + return + service_name = ref + if ref.startswith(_NODES_PREFIX): + split = self._split_node_service_ref(ref) + if split is None: + self._err( + f"{owner_label} service ref '{ref}' must be a bare service name or 'nodes..services.'" + ) + return + ref_node_name, service_name = split + if ref_node_name != node_name: + self._err(f"{owner_label} service ref '{ref}' must reference a service on the same node") + return + if service_name not in service_names: + self._err(f"{owner_label} references undefined service '{service_name}'") + + def _resolve_owned_service_ref( + self, + node_name: str, + ref: str, + services_by_name: dict[str, object], + *, + owner_label: str, + ) -> object | None: + if not ref or self._is_unresolved_var(ref): + return None + service_name = ref + if ref.startswith(_NODES_PREFIX): + split = self._split_node_service_ref(ref) + if split is None: + self._err( + f"{owner_label} service ref '{ref}' must be a bare service name or 'nodes..services.'" + ) + return None + ref_node_name, service_name = split + if ref_node_name != node_name: + self._err(f"{owner_label} service ref '{ref}' must reference a service on the same node") + return None + service = services_by_name.get(service_name) + if service is None: + self._err(f"{owner_label} references undefined service '{service_name}'") + return service + + def _verify_route_upstream_target(self, node_name: str, application: object, route: object) -> None: + target = getattr(route, "upstream_target", None) + if target is None: + return + label = ( + f"Node '{node_name}' runtime application '{application.application_id}' " + f"route '{route.route_id}' upstream_target" + ) + target_node_name = self._check_proxy_upstream_node_ref( + getattr(target, "target_node_ref", ""), + label, + context="upstream_target", + field_name="target_node_ref", + ) + self._check_proxy_upstream_service_ref( + getattr(target, "target_service", ""), + upstream_node_ref=target_node_name or "", + relationship_target="", + label=label, + context="upstream_target", + field_name="target_service", + ) + + def _verify_runtime_service_listeners(self) -> None: + """Validate observed service listeners against same-node runtime facts.""" + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.service_listeners: + continue + services_by_name = self._node_services_by_name(node) + process_refs = self._node_runtime_process_refs(node) + published_ports = self._node_published_port_keys(node) + for listener in runtime.service_listeners: + label = f"Node '{node_name}' runtime service listener '{listener.service_listener_id}'" + service = self._resolve_owned_service_ref( + node_name, + getattr(listener, "service", ""), + services_by_name, + owner_label=label, + ) + if service is not None: + self._verify_listener_service_binding(label, listener, service) + self._verify_listener_process_ref(label, listener, process_refs) + self._verify_listener_published_port_refs(label, listener, published_ports) + + def _verify_listener_service_binding(self, label: str, listener: object, service: object) -> None: + listener_port = getattr(listener, "port", None) + listener_protocol = self._enum_or_raw(getattr(listener, "protocol", "")) + service_port = getattr(service, "port", None) + service_protocol = getattr(service, "protocol", "") + if any(self._is_unresolved_var(v) for v in (listener_port, listener_protocol, service_port, service_protocol)): + return + if listener_port is None: + return + if listener_port != service_port or str(listener_protocol).lower() != str(service_protocol).lower(): + self._err(f"{label} port/protocol must match service '{service.name}'") + + @staticmethod + def _enum_or_raw(value: object) -> object: + return value.value if hasattr(value, "value") else value + + def _verify_listener_process_ref(self, label: str, listener: object, process_refs: set[str]) -> None: + ref = getattr(listener, "process_ref", "") + if not ref or self._is_unresolved_var(ref): + return + if ref not in process_refs: + self._err(f"{label} process_ref '{ref}' does not resolve to a runtime process name or pid") + + def _verify_listener_published_port_refs( + self, + label: str, + listener: object, + published_ports: set[tuple[str, int | str | None, int | str, str]], + ) -> None: + listener_port = getattr(listener, "port", None) + listener_protocol = self._enum_or_raw(getattr(listener, "protocol", "")) + for ref in getattr(listener, "published_port_refs", []): + values = (ref.host_ip, ref.host_port, ref.container_port, ref.protocol) + if any(self._is_unresolved_var(v) for v in values): + continue + if any(self._is_unresolved_var(v) for v in (listener_port, listener_protocol)): + continue + if listener_port is not None and ( + ref.container_port != listener_port or ref.protocol != str(listener_protocol).lower() + ): + self._err(f"{label} published_port_refs entry must match listener port/protocol") + continue + if values not in published_ports: + self._err(f"{label} published_port_refs entry does not resolve to runtime.network.published_ports") + + def _node_runtime_process_refs(self, node: object) -> set[str]: + runtime = getattr(node, "runtime", None) + if runtime is None: + return set() + refs: set[str] = set() + processes = [getattr(runtime, "process", None), *getattr(runtime, "processes", [])] + for process in processes: + if process is None: + continue + name = getattr(process, "name", "") + pid = getattr(process, "pid", None) + if name and not self._is_unresolved_var(name): + refs.add(str(name)) + if pid is not None and not self._is_unresolved_var(pid): + refs.add(str(pid)) + return refs + + @staticmethod + def _node_published_port_keys(node: object) -> set[tuple[str, int | str | None, int | str, str]]: + runtime = getattr(node, "runtime", None) + network = getattr(runtime, "network", None) if runtime is not None else None + if network is None: + return set() + return { + (binding.host_ip, binding.host_port, binding.container_port, binding.protocol) + for binding in network.published_ports + } + + def _verify_route_refs( + self, + node_name: str, + application: object, + route: object, + observed_paths: set[str], + ) -> None: + app_id = application.application_id + route_id = route.route_id + for ref in route.vulnerability_refs: + if self._is_unresolved_var(ref): + continue + if ref not in self._s.vulnerabilities: + self._err( + f"Node '{node_name}' runtime application '{app_id}' route '{route_id}' " + f"references undefined vulnerability '{ref}'" + ) + if not observed_paths: + return + for field_name in ("templates", "static_assets"): + for ref in getattr(route, field_name): + if self._is_unresolved_var(ref): + continue + if ref not in observed_paths: + self._err( + f"Node '{node_name}' runtime application '{app_id}' route '{route_id}' " + f"{field_name} ref '{ref}' does not resolve to an observed file on the node" + ) + + def _verify_runtime_service_manager_units(self) -> None: + """Validate observed service-manager unit inventories (ADR-035). + + Each ``ServiceManagerUnit.service`` ref, when set and not a variable, + must resolve to a service on the same node (bare name OR + ``nodes..services.``). When a ``unit_file_path`` is set + and ``runtime.filesystem_inventory`` is non-empty, the path SHOULD + appear in that inventory; otherwise we emit a soft semantic error so + downstream consumers can tell unit-file evidence and filesystem + inventory apart. + """ + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.service_manager_units: + continue + service_names = self._node_service_names(node) + observed_paths = self._node_observed_paths(node) + for unit in runtime.service_manager_units: + owner_label = f"Node '{node_name}' runtime service_manager_unit '{unit.unit_id}'" + self._verify_owned_service_ref( + node_name, + getattr(unit, "service", ""), + service_names, + owner_label=owner_label, + ) + unit_file_path = getattr(unit, "unit_file_path", "") + if ( + unit_file_path + and observed_paths + and not self._is_unresolved_var(unit_file_path) + and unit_file_path not in observed_paths + ): + self._err( + f"{owner_label} unit_file_path '{unit_file_path}' does not resolve to an " + f"observed file on the node" + ) + + def _verify_runtime_ssh_servers(self) -> None: + """Validate observed SSH server configurations against the scenario. + + Each ``RuntimeSshServer.service`` must resolve to a service on the + same node (bare name OR ``nodes..services.``). + Each ``Match`` rule's ``LOCAL_USER`` criterion whose pattern is a + concrete (non-wildcard, non-variable) literal MAY be cross-checked + against ``runtime.local_identity.users`` when that inventory is + present and non-empty (ADR-031 § "Semantic validation gate"). + """ + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.ssh_servers: + continue + service_names = self._node_service_names(node) + local_usernames = self._node_local_usernames(node) + for server in runtime.ssh_servers: + self._verify_ssh_server_service(node_name, server, service_names) + for rule in server.match_rules: + self._verify_ssh_match_rule(node_name, server, rule, local_usernames) + + @staticmethod + def _node_local_usernames(node: object) -> set[str]: + runtime = getattr(node, "runtime", None) + if runtime is None: + return set() + identity = getattr(runtime, "local_identity", None) + if identity is None: + return set() + return {user.username for user in identity.users if user.username} + + def _verify_ssh_server_service( + self, + node_name: str, + server: object, + service_names: set[str], + ) -> None: + ref = getattr(server, "service", "") + if not ref or self._is_unresolved_var(ref): + return + server_id = server.ssh_server_id + service_name = ref + if ref.startswith("nodes."): + parts = ref.split(".") + if len(parts) != 4 or parts[2] != "services": + self._err( + f"Node '{node_name}' runtime ssh_server '{server_id}' service ref '{ref}' " + f"must be a bare service name or 'nodes..services.'" + ) + return + if parts[1] != node_name: + self._err( + f"Node '{node_name}' runtime ssh_server '{server_id}' service ref '{ref}' " + f"must reference a service on the same node" + ) + return + service_name = parts[3] + if service_name not in service_names: + self._err( + f"Node '{node_name}' runtime ssh_server '{server_id}' references undefined service '{service_name}'" + ) + + def _verify_ssh_match_rule( + self, + node_name: str, + server: object, + rule: object, + local_usernames: set[str], + ) -> None: + if not local_usernames: + return + for criterion in rule.criteria: + if criterion.kind != SshMatchCriterionKind.LOCAL_USER: + continue + pattern = criterion.pattern + if self._is_unresolved_var(pattern): + continue + if any(ch in pattern for ch in "*?!,"): + # Wildcard or comma-separated list — not a single concrete identity. + continue + if pattern not in local_usernames: + self._err( + f"Node '{node_name}' runtime ssh_server '{server.ssh_server_id}' " + f"match rule '{rule.match_id}' references local user '{pattern}' " + f"not present in runtime.local_identity.users" + ) + + def _verify_runtime_app_authorizations(self) -> None: + """Validate observed application-internal RBAC stores. + + Permission-grant and role-mapping ``role_ref`` values are + authorization-local role references: each must resolve to a role + declared within the same ``app_authorization`` store (RBAC96 / + ANSI INCITS 359 role-permission and user-role assignment integrity). + """ + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.app_authorizations: + continue + for authorization in runtime.app_authorizations: + self._verify_app_authorization_role_refs(node_name, authorization) + + def _verify_app_authorization_role_refs(self, node_name: str, authorization: object) -> None: + role_ids = {role.role_id for role in authorization.roles} + label = f"Node '{node_name}' runtime app_authorization '{authorization.app_authorization_id}'" + for grant in authorization.permission_grants: + self._verify_app_authorization_role_ref( + f"{label} permission_grant '{grant.grant_id}'", + getattr(grant, "role_ref", ""), + role_ids, + ) + for mapping in authorization.role_mappings: + self._verify_app_authorization_role_ref( + f"{label} role_mapping '{mapping.mapping_id}'", + getattr(mapping, "role_ref", ""), + role_ids, + ) + + def _verify_app_authorization_role_ref(self, owner_label: str, role_ref: str, role_ids: set[str]) -> None: + if not role_ref or self._is_unresolved_var(role_ref): + return + if role_ref not in role_ids: + self._err(f"{owner_label} role_ref '{role_ref}' is not a role in the authorization") + + def _verify_runtime_capability_overrides(self) -> None: + """Cross-check ``linux_capabilities.process_overrides`` selectors. + + Per ADR-030, scoped capability records identify a subject via + ``RuntimeProcessIdentity`` selectors and the capability list ships + through the same closed-world Pydantic gates as the container-wide + lists. The only check that cannot live on the model itself is the + scenario-level cross-reference: when an override's + ``subject.name`` is a literal value and the enclosing node declares + ``runtime.processes``, the name SHOULD match one of those observed + processes. A miss is reported as an error so inventories that + forget to add the new process surface fail fast rather than ship a + scoped-policy claim that points at nothing. + """ + for node_name, node in self._s.nodes.items(): + overrides = self._capability_overrides_for(node) + if not overrides: + continue + observed = self._observed_process_names(node) + if not observed: + # No declared process inventory to cross-check against — the + # override stands on its own selectors. + continue + self._check_override_subject_names(node_name, overrides, observed) + + def _capability_overrides_for(self, node: object) -> list[object]: + runtime = getattr(node, "runtime", None) + if runtime is None: + return [] + capability_policy = getattr(runtime, "linux_capabilities", None) + if capability_policy is None: + return [] + return list(getattr(capability_policy, "process_overrides", None) or []) + + def _observed_process_names(self, node: object) -> set[str]: + runtime = getattr(node, "runtime", None) + if runtime is None: + return set() + return { + process.name + for process in (runtime.processes or []) + if process.name and not self._is_unresolved_var(process.name) + } + + def _check_override_subject_names( + self, + node_name: str, + overrides: list[object], + observed: set[str], + ) -> None: + for override in overrides: + subject_name = override.subject.name + if not subject_name or self._is_unresolved_var(subject_name): + continue + if subject_name not in observed: + self._err( + f"Node '{node_name}' runtime capability override subject " + f"'{subject_name}' does not match any process declared in " + "'runtime.processes'" + ) diff --git a/implementations/python/packages/aces_sdl/validator/_sections.py b/implementations/python/packages/aces_sdl/validator/_sections.py new file mode 100644 index 000000000..e742c13a1 --- /dev/null +++ b/implementations/python/packages/aces_sdl/validator/_sections.py @@ -0,0 +1,215 @@ +"""SemanticValidator _SectionsMixin (split from validator.py). + +Part of the SemanticValidator mixin composition; see __init__.py. +""" + +from pydantic import BaseModel + +from .._base import extract_variable_name +from ..entities import flatten_entities +from ..explicitness import classify_scenario_explicitness +from ..scenario import Scenario +from ..semantics.assessment import AssessmentIssue, analyze_assessment_pipeline +from ._support import _topological_sort + + +class _SectionsMixin: + def _verify_variables(self) -> None: + defined = set(self._s.variables.keys()) + + def visit(value: object, path: str) -> None: + if isinstance(value, BaseModel): + for field_name in value.__class__.model_fields: + if isinstance(value, Scenario) and field_name == "variables": + continue + child = getattr(value, field_name) + child_path = f"{path}.{field_name}" if path else field_name + visit(child, child_path) + return + + if isinstance(value, dict): + for key, child in value.items(): + child_path = f"{path}.{key}" if path else str(key) + visit(child, child_path) + return + + if isinstance(value, list): + for index, child in enumerate(value): + child_path = f"{path}[{index}]" + visit(child, child_path) + return + + if self._is_unresolved_var(value): + variable_name = extract_variable_name(value) + if variable_name and variable_name not in defined: + self._err(f"Undefined variable '{variable_name}' referenced at '{path}'") + + visit(self._s, "") + + def _verify_explicitness(self) -> None: + result = classify_scenario_explicitness(self._s) + self._s._set_explicitness(result.records) + for error in result.errors: + self._err(error) + + def _all_named_elements(self) -> set[str]: + """Collect all named element keys across all scenario sections.""" + return set(self._named_ref_index().keys()) + + def _all_targetable_elements(self) -> set[str]: + """Collect named elements that can serve as objective targets.""" + return set(self._named_ref_index(targetable=True).keys()) + + def _verify_features(self) -> None: + # Check vulnerability references + for name, feat in self._s.features.items(): + for vuln_name in feat.vulnerabilities: + if self._is_unresolved_var(vuln_name): + continue + if vuln_name not in self._s.vulnerabilities: + self._err(f"Feature '{name}' references undefined vulnerability '{vuln_name}'") + + # Check dependency references and detect cycles + dep_graph: dict[str, list[str]] = {} + for name, feat in self._s.features.items(): + dep_graph[name] = [] + for dep in feat.dependencies: + if self._is_unresolved_var(dep): + continue + if dep not in self._s.features: + self._err(f"Feature '{name}' depends on undefined feature '{dep}'") + else: + dep_graph[name].append(dep) + + if dep_graph and _topological_sort(dep_graph) is None: + self._err("Feature dependency graph contains a cycle") + + def _verify_conditions(self) -> None: + # Individual condition validation is handled by Pydantic model_validator. + # This pass checks for consistency with the broader scenario. + pass + + def _verify_vulnerabilities(self) -> None: + # CWE format validation is handled by the Pydantic field_validator. + pass + + def _verify_assessment_pipeline(self) -> None: + # The condition -> metric -> evaluation -> TLO -> goal scoring chain. + # Reference, aggregation, and dependency-role semantics live in + # ``aces_sdl.semantics.assessment`` (SEM-206); this pass renders the + # machine-readable issues it reports as authoring errors. + analysis = analyze_assessment_pipeline( + conditions_by_name=self._s.conditions, + metrics_by_name=self._s.metrics, + evaluations_by_name=self._s.evaluations, + tlos_by_name=self._s.tlos, + goals_by_name=self._s.goals, + is_unresolved=self._is_unresolved_var, + ) + for issue in analysis.issues: + self._err(self._format_assessment_issue(issue)) + + @staticmethod + def _format_assessment_issue(issue: AssessmentIssue) -> str: + name, ref = issue.resource_name, issue.ref + if issue.code == "metric.condition-undeclared": + return f"Metric '{name}' references undefined condition '{ref}'" + if issue.code == "metric.condition-multiply-scored": + return f"Condition '{name}' is referenced by multiple metrics" + if issue.code == "evaluation.metric-undeclared": + return f"Evaluation '{name}' references undefined metric '{ref}'" + if issue.code == "evaluation.min-score-exceeds-metric-total": + return ( + f"Evaluation '{name}' absolute min-score " + f"({issue.observed}) exceeds sum of " + f"metric max-scores ({issue.limit})" + ) + if issue.code == "tlo.evaluation-undeclared": + return f"TLO '{name}' references undefined evaluation '{ref}'" + if issue.code == "goal.tlo-undeclared": + return f"Goal '{name}' references undefined TLO '{ref}'" + raise AssertionError(f"unhandled assessment-pipeline issue code: {issue.code}") + + def _verify_entities(self) -> None: + flat = flatten_entities(self._s.entities) + + def check_entity(name: str, entity: "Entity") -> None: + for tlo_name in entity.tlos: + if self._is_unresolved_var(tlo_name): + continue + if tlo_name not in self._s.tlos: + self._err(f"Entity '{name}' references undefined TLO '{tlo_name}'") + for vuln_name in entity.vulnerabilities: + if self._is_unresolved_var(vuln_name): + continue + if vuln_name not in self._s.vulnerabilities: + self._err(f"Entity '{name}' references undefined vulnerability '{vuln_name}'") + for event_name in entity.events: + if self._is_unresolved_var(event_name): + continue + if event_name not in self._s.events: + self._err(f"Entity '{name}' references undefined event '{event_name}'") + + for name, entity in flat.items(): + check_entity(name, entity) + + def _verify_injects(self) -> None: + flat_names = self._all_entity_names() + + for name, inject in self._s.injects.items(): + if ( + inject.from_entity + and not self._is_unresolved_var(inject.from_entity) + and inject.from_entity not in flat_names + ): + self._err(f"Inject '{name}' from_entity '{inject.from_entity}' is not a defined entity") + for to_name in inject.to_entities: + if self._is_unresolved_var(to_name): + continue + if to_name not in flat_names: + self._err(f"Inject '{name}' to_entity '{to_name}' is not a defined entity") + for tlo_name in inject.tlos: + if self._is_unresolved_var(tlo_name): + continue + if tlo_name not in self._s.tlos: + self._err(f"Inject '{name}' references undefined TLO '{tlo_name}'") + + def _verify_events(self) -> None: + for name, event in self._s.events.items(): + for cond_name in event.conditions: + if self._is_unresolved_var(cond_name): + continue + if cond_name not in self._s.conditions: + self._err(f"Event '{name}' references undefined condition '{cond_name}'") + for inj_name in event.injects: + if self._is_unresolved_var(inj_name): + continue + if inj_name not in self._s.injects: + self._err(f"Event '{name}' references undefined inject '{inj_name}'") + + def _verify_scripts(self) -> None: + for name, script in self._s.scripts.items(): + for event_name in script.events: + if self._is_unresolved_var(event_name): + continue + if event_name not in self._s.events: + self._err(f"Script '{name}' references undefined event '{event_name}'") + + def _verify_stories(self) -> None: + for name, story in self._s.stories.items(): + for script_name in story.scripts: + if self._is_unresolved_var(script_name): + continue + if script_name not in self._s.scripts: + self._err(f"Story '{name}' references undefined script '{script_name}'") + + def _verify_roles(self) -> None: + flat_names = self._all_entity_names() + + for node_name, node in self._s.nodes.items(): + for role_name, role in node.roles.items(): + for entity_ref in role.entities: + if self._is_unresolved_var(entity_ref): + continue + if entity_ref not in flat_names: + self._err(f"Node '{node_name}' role '{role_name}' references undefined entity '{entity_ref}'") diff --git a/implementations/python/packages/aces_sdl/validator/_support.py b/implementations/python/packages/aces_sdl/validator/_support.py new file mode 100644 index 000000000..a2ce676b2 --- /dev/null +++ b/implementations/python/packages/aces_sdl/validator/_support.py @@ -0,0 +1,30 @@ +"""Shared module-level helpers for the SemanticValidator package.""" + +from collections import defaultdict, deque + +# Common ref-path prefix used by qualified runtime/service refs (e.g. +# ``nodes.vm.services.http``, ``nodes.vm.runtime.applications.webapp``). +_NODES_PREFIX = "nodes." + + +def _topological_sort(graph: dict[str, list[str]]) -> list[str] | None: + """Return topological order or None if a cycle exists.""" + in_degree: dict[str, int] = defaultdict(int) + for node in graph: + in_degree.setdefault(node, 0) + for deps in graph.values(): + for dep in deps: + in_degree[dep] += 1 + + queue = deque(n for n, d in in_degree.items() if d == 0) + order: list[str] = [] + + while queue: + node = queue.popleft() + order.append(node) + for dep in graph.get(node, []): + in_degree[dep] -= 1 + if in_degree[dep] == 0: + queue.append(dep) + + return order if len(order) == len(in_degree) else None diff --git a/implementations/python/packages/aces_sdl/validator/_workflows_analysis.py b/implementations/python/packages/aces_sdl/validator/_workflows_analysis.py new file mode 100644 index 000000000..543774647 --- /dev/null +++ b/implementations/python/packages/aces_sdl/validator/_workflows_analysis.py @@ -0,0 +1,337 @@ +"""SemanticValidator _WorkflowAnalysisMixin (split from validator.py). + +Part of the SemanticValidator mixin composition; see __init__.py. +""" + +from ..orchestration import Workflow, WorkflowPredicate, WorkflowStep, WorkflowStepType +from ..semantics.workflow import workflow_step_semantic_contract + + +class _WorkflowAnalysisMixin: + def _validate_workflow_predicate( + self, + workflow_name: str, + step_name: str, + predicate: WorkflowPredicate, + workflow_steps: dict[str, WorkflowStep], + ) -> list[str]: + """Validate all references within a workflow predicate.""" + step_refs: list[str] = [] + predicate_sections = ( + ("condition", predicate.conditions, self._s.conditions), + ("metric", predicate.metrics, self._s.metrics), + ("evaluation", predicate.evaluations, self._s.evaluations), + ("TLO", predicate.tlos, self._s.tlos), + ("goal", predicate.goals, self._s.goals), + ("objective", predicate.objectives, self._s.objectives), + ) + for label, refs, section in predicate_sections: + for ref in refs: + if self._is_unresolved_var(ref): + continue + if ref not in section: + self._err( + f"Workflow '{workflow_name}' step " + f"'{step_name}' references undefined " + f"{label} '{ref}' in predicate" + ) + for step_state in predicate.steps: + if self._is_unresolved_var(step_state.step): + continue + if step_state.step not in workflow_steps: + self._err( + f"Workflow '{workflow_name}' step '{step_name}' " + f"references undefined step state " + f"'{step_state.step}' in predicate" + ) + continue + if step_state.step == step_name: + self._err( + f"Workflow '{workflow_name}' step '{step_name}' cannot reference its own state in a predicate" + ) + continue + ref_step = workflow_steps[step_state.step] + contract = workflow_step_semantic_contract(ref_step.type.value) + if not contract.state_observable: + self._err( + f"Workflow '{workflow_name}' step '{step_name}' " + f"cannot reference non-executable step '{step_state.step}' " + "in a predicate" + ) + continue + invalid_outcomes = [ + outcome.value for outcome in step_state.outcomes if outcome.value not in contract.observable_outcomes + ] + if invalid_outcomes: + allowed = ", ".join(contract.observable_outcomes) + self._err( + f"Workflow '{workflow_name}' step '{step_name}' " + f"references step '{step_state.step}' with impossible " + f"outcomes {invalid_outcomes}; allowed outcomes are: {allowed}" + ) + continue + step_refs.append(step_state.step) + return step_refs + + def _is_executable_workflow_step(self, step: WorkflowStep) -> bool: + return workflow_step_semantic_contract(step.type.value).state_observable + + def _validate_workflow_target_ref( + self, + workflow_name: str, + step_name: str, + field_name: str, + target: str, + workflow_steps: dict[str, WorkflowStep], + ) -> str | None: + if not target: + return None + if self._is_unresolved_var(target): + return None + if target not in workflow_steps: + self._err(f"Workflow '{workflow_name}' step '{step_name}' {field_name} step '{target}' is not defined") + return None + return target + + def _all_paths_reach_join( + self, + node: str, + join: str, + graph: dict[str, list[str]], + *, + memo: dict[str, bool], + visiting: set[str], + ) -> bool: + if node == join: + return True + if node in memo: + return memo[node] + if node in visiting: + return False + + visiting.add(node) + successors = graph.get(node, []) + if not successors: + visiting.remove(node) + memo[node] = False + return False + + result = all( + self._all_paths_reach_join( + successor, + join, + graph, + memo=memo, + visiting=visiting, + ) + for successor in successors + ) + visiting.remove(node) + memo[node] = result + return result + + def _branch_guaranteed_states( + self, + node: str, + join: str, + graph: dict[str, list[str]], + workflow_steps: dict[str, WorkflowStep], + *, + memo: dict[tuple[str, str], set[str]], + visiting: set[tuple[str, str]], + ) -> set[str]: + if node == join: + return set() + + key = (node, join) + if key in memo: + return set(memo[key]) + if key in visiting: + return set() + + visiting.add(key) + successors = graph.get(node, []) + guaranteed_after: set[str] = set() + if successors: + successor_sets: list[set[str]] = [] + for successor in successors: + if successor == join: + successor_sets.append(set()) + continue + if successor not in workflow_steps: + continue + successor_sets.append( + self._branch_guaranteed_states( + successor, + join, + graph, + workflow_steps, + memo=memo, + visiting=visiting, + ) + ) + if successor_sets: + guaranteed_after = set.intersection(*successor_sets) + + result = set(guaranteed_after) + step = workflow_steps[node] + if self._is_executable_workflow_step(step): + result.add(node) + + visiting.remove(key) + memo[key] = set(result) + return result + + def _edge_available_state( + self, + step_name: str, + successor: str, + workflow_steps: dict[str, WorkflowStep], + graph: dict[str, list[str]], + predecessors: dict[str, set[str]], + start: str, + join_targets: dict[str, list[str]], + *, + available_memo: dict[str, set[str]], + branch_memo: dict[tuple[str, str], set[str]], + visiting: set[str], + ) -> set[str]: + available = self._available_step_state_before( + step_name, + workflow_steps, + graph, + predecessors, + start, + join_targets, + available_memo=available_memo, + branch_memo=branch_memo, + visiting=visiting, + ) + step = workflow_steps[step_name] + if step.type in { + WorkflowStepType.OBJECTIVE, + WorkflowStepType.RETRY, + WorkflowStepType.CALL, + } or (step.type == WorkflowStepType.PARALLEL and step.on_failure and successor == step.on_failure): + available.add(step_name) + return available + + def _available_step_state_before( + self, + step_name: str, + workflow_steps: dict[str, WorkflowStep], + graph: dict[str, list[str]], + predecessors: dict[str, set[str]], + start: str, + join_targets: dict[str, list[str]], + *, + available_memo: dict[str, set[str]], + branch_memo: dict[tuple[str, str], set[str]], + visiting: set[str], + ) -> set[str]: + if step_name in available_memo: + return set(available_memo[step_name]) + if step_name in visiting: + return set() + + visiting.add(step_name) + step = workflow_steps[step_name] + + if step_name == start: + result = set() + elif step.type == WorkflowStepType.JOIN and join_targets.get(step_name): + owner = join_targets[step_name][0] + result = self._available_step_state_before( + owner, + workflow_steps, + graph, + predecessors, + start, + join_targets, + available_memo=available_memo, + branch_memo=branch_memo, + visiting=visiting, + ) + result.add(owner) + owner_step = workflow_steps[owner] + for branch in owner_step.branches: + if branch not in workflow_steps: + continue + result.update( + self._branch_guaranteed_states( + branch, + step_name, + graph, + workflow_steps, + memo=branch_memo, + visiting=set(), + ) + ) + else: + incoming_states: list[set[str]] = [] + for predecessor in predecessors.get(step_name, set()): + if predecessor not in workflow_steps: + continue + incoming_states.append( + self._edge_available_state( + predecessor, + step_name, + workflow_steps, + graph, + predecessors, + start, + join_targets, + available_memo=available_memo, + branch_memo=branch_memo, + visiting=visiting, + ) + ) + result = set.intersection(*incoming_states) if incoming_states else set() + + visiting.remove(step_name) + available_memo[step_name] = set(result) + return result + + def _verify_step_terminator_and_compensation( + self, + *, + workflow_name: str, + step_name: str, + step: WorkflowStep, + workflow: Workflow, + graph: dict[str, list[str]], + workflow_compensation_graph: dict[str, set[str]], + compensation_target_workflows: set[str], + workflows_with_compensation_steps: set[str], + ) -> None: + """Shared validation for `on-success`/`on-failure` and `compensate_with`. + + OBJECTIVE and CALL workflow steps both carry the same terminator and + compensation-handling shape, so this method centralizes the + appended-edge bookkeeping and undefined-workflow error reporting + for both call sites. + """ + for field_name, target in ( + ("on-success", step.on_success), + ("on-failure", step.on_failure), + ): + resolved = self._validate_workflow_target_ref( + workflow_name, + step_name, + field_name, + target, + workflow.steps, + ) + if resolved is not None: + graph[step_name].append(resolved) + if step.compensate_with: + workflows_with_compensation_steps.add(workflow_name) + if not self._is_unresolved_var(step.compensate_with) and step.compensate_with not in self._s.workflows: + self._err( + f"Workflow '{workflow_name}' step '{step_name}' " + "references undefined compensation workflow " + f"'{step.compensate_with}'" + ) + elif not self._is_unresolved_var(step.compensate_with): + workflow_compensation_graph.setdefault(workflow_name, set()).add(step.compensate_with) + compensation_target_workflows.add(step.compensate_with) diff --git a/implementations/python/packages/aces_sdl/validator/_workflows_verify.py b/implementations/python/packages/aces_sdl/validator/_workflows_verify.py new file mode 100644 index 000000000..68f375cbd --- /dev/null +++ b/implementations/python/packages/aces_sdl/validator/_workflows_verify.py @@ -0,0 +1,338 @@ +"""SemanticValidator _WorkflowVerifyMixin (split from validator.py). + +Part of the SemanticValidator mixin composition; see __init__.py. +""" + +from collections import defaultdict + +from ..orchestration import WorkflowStepType +from ..semantics.workflow import branch_closure +from ._support import _topological_sort + + +class _WorkflowVerifyMixin: + def _verify_workflows(self) -> None: + workflow_call_graph: dict[str, set[str]] = {workflow_name: set() for workflow_name in self._s.workflows} + workflow_compensation_graph: dict[str, set[str]] = {workflow_name: set() for workflow_name in self._s.workflows} + compensation_target_workflows: set[str] = set() + workflows_with_compensation_steps: set[str] = set() + for workflow_name, workflow in self._s.workflows.items(): + if not self._is_unresolved_var(workflow.start) and workflow.start not in workflow.steps: + self._err(f"Workflow '{workflow_name}' start step '{workflow.start}' is not defined") + + graph: dict[str, list[str]] = {step_name: [] for step_name in workflow.steps} + predicate_step_refs: dict[str, list[str]] = {} + join_targets: dict[str, list[str]] = defaultdict(list) + + for step_name, step in workflow.steps.items(): + if "." in step_name: + self._err( + f"Workflow '{workflow_name}' step '{step_name}' cannot " + "contain '.' because objective windows use " + "'.' syntax" + ) + + if step.type == WorkflowStepType.OBJECTIVE: + if not self._is_unresolved_var(step.objective) and step.objective not in self._s.objectives: + self._err( + f"Workflow '{workflow_name}' step '{step_name}' " + f"references undefined objective '{step.objective}'" + ) + self._verify_step_terminator_and_compensation( + workflow_name=workflow_name, + step_name=step_name, + step=step, + workflow=workflow, + graph=graph, + workflow_compensation_graph=workflow_compensation_graph, + compensation_target_workflows=compensation_target_workflows, + workflows_with_compensation_steps=workflows_with_compensation_steps, + ) + + elif step.type == WorkflowStepType.DECISION: + predicate_step_refs[step_name] = self._validate_workflow_predicate( + workflow_name, + step_name, + step.when, + workflow.steps, + ) + + for branch_label, branch_ref in ( + ("then", step.then_step), + ("else", step.else_step), + ): + resolved = self._validate_workflow_target_ref( + workflow_name, + step_name, + branch_label, + branch_ref, + workflow.steps, + ) + if resolved is not None: + graph[step_name].append(resolved) + + elif step.type == WorkflowStepType.SWITCH: + aggregated_refs: list[str] = [] + for case_index, case in enumerate(step.cases): + aggregated_refs.extend( + self._validate_workflow_predicate( + workflow_name, + f"{step_name}.case[{case_index}]", + case.when, + workflow.steps, + ) + ) + resolved = self._validate_workflow_target_ref( + workflow_name, + step_name, + f"case[{case_index}] next", + case.next_step, + workflow.steps, + ) + if resolved is not None: + graph[step_name].append(resolved) + predicate_step_refs[step_name] = aggregated_refs + resolved_default = self._validate_workflow_target_ref( + workflow_name, + step_name, + "default", + step.default_step, + workflow.steps, + ) + if resolved_default is not None: + graph[step_name].append(resolved_default) + + elif step.type == WorkflowStepType.PARALLEL: + for branch_ref in step.branches: + resolved = self._validate_workflow_target_ref( + workflow_name, + step_name, + "branch", + branch_ref, + workflow.steps, + ) + if resolved is not None: + graph[step_name].append(resolved) + resolved_join = self._validate_workflow_target_ref( + workflow_name, + step_name, + "join", + step.join, + workflow.steps, + ) + if resolved_join is not None: + join_targets[resolved_join].append(step_name) + resolved_failure = self._validate_workflow_target_ref( + workflow_name, + step_name, + "on-failure", + step.on_failure, + workflow.steps, + ) + if resolved_failure is not None: + graph[step_name].append(resolved_failure) + + elif step.type == WorkflowStepType.JOIN: + resolved = self._validate_workflow_target_ref( + workflow_name, + step_name, + "next", + step.next, + workflow.steps, + ) + if resolved is not None: + graph[step_name].append(resolved) + + elif step.type == WorkflowStepType.RETRY: + if not self._is_unresolved_var(step.objective) and step.objective not in self._s.objectives: + self._err( + f"Workflow '{workflow_name}' step '{step_name}' " + f"references undefined objective '{step.objective}'" + ) + for field_name, target in ( + ("on-success", step.on_success), + ("on-exhausted", step.on_exhausted), + ): + resolved = self._validate_workflow_target_ref( + workflow_name, + step_name, + field_name, + target, + workflow.steps, + ) + if resolved is not None: + graph[step_name].append(resolved) + + elif step.type == WorkflowStepType.CALL: + if not self._is_unresolved_var(step.workflow) and step.workflow not in self._s.workflows: + self._err( + f"Workflow '{workflow_name}' step '{step_name}' " + f"references undefined workflow '{step.workflow}'" + ) + elif not self._is_unresolved_var(step.workflow): + workflow_call_graph.setdefault(workflow_name, set()).add(step.workflow) + self._verify_step_terminator_and_compensation( + workflow_name=workflow_name, + step_name=step_name, + step=step, + workflow=workflow, + graph=graph, + workflow_compensation_graph=workflow_compensation_graph, + compensation_target_workflows=compensation_target_workflows, + workflows_with_compensation_steps=workflows_with_compensation_steps, + ) + + elif step.type == WorkflowStepType.END: + graph[step_name] = [] + + if step_name not in graph: + graph[step_name] = [] + + for join_step, sources in join_targets.items(): + if self._is_unresolved_var(join_step): + continue + join_def = workflow.steps.get(join_step) + if join_def is not None and join_def.type != WorkflowStepType.JOIN: + self._err( + f"Workflow '{workflow_name}' step '{join_step}' is used " + "as a parallel join but is not a join step" + ) + if len(sources) > 1: + self._err( + f"Workflow '{workflow_name}' join step '{join_step}' may only be targeted by one parallel step" + ) + + for step_name, step in workflow.steps.items(): + if step.type != WorkflowStepType.JOIN: + continue + sources = join_targets.get(step_name, []) + if not sources: + self._err( + f"Workflow '{workflow_name}' join step '{step_name}' is not referenced by any parallel step" + ) + + if graph and _topological_sort(graph) is None: + self._err(f"Workflow '{workflow_name}' graph contains a cycle") + + if self._is_unresolved_var(workflow.start) or workflow.start not in workflow.steps: + continue + + reachable: set[str] = set() + stack = [workflow.start] + while stack: + current = stack.pop() + if current in reachable: + continue + reachable.add(current) + stack.extend(graph.get(current, [])) + + unreachable = sorted(set(workflow.steps) - reachable) + if unreachable: + self._err(f"Workflow '{workflow_name}' contains unreachable steps: " + ", ".join(unreachable)) + + predecessors: dict[str, set[str]] = {step_name: set() for step_name in reachable} + for source, edges in graph.items(): + if source not in reachable: + continue + for target in edges: + if target in reachable: + predecessors[target].add(source) + + for _step_name, step in workflow.steps.items(): + if step.type != WorkflowStepType.PARALLEL: + continue + if self._is_unresolved_var(step.join) or step.join not in workflow.steps or step.join not in reachable: + continue + allowed_predecessors = branch_closure( + graph, + branches=(branch for branch in step.branches if branch in reachable and branch in workflow.steps), + join_step=step.join, + ) + foreign_predecessors = sorted( + predecessor + for predecessor in predecessors.get(step.join, set()) + if predecessor not in allowed_predecessors + ) + if foreign_predecessors: + self._err( + f"Workflow '{workflow_name}' join step '{step.join}' " + "may only be entered from the owning parallel's branch " + "closure; unexpected predecessors: " + ", ".join(foreign_predecessors) + ) + + available_memo: dict[str, set[str]] = {} + branch_memo: dict[tuple[str, str], set[str]] = {} + + for step_name, refs in predicate_step_refs.items(): + if step_name not in reachable: + continue + available_before = self._available_step_state_before( + step_name, + workflow.steps, + graph, + predecessors, + workflow.start, + join_targets, + available_memo=available_memo, + branch_memo=branch_memo, + visiting=set(), + ) + for ref_name in refs: + if self._is_unresolved_var(ref_name): + continue + if ref_name not in available_before: + self._err( + f"Workflow '{workflow_name}' step '{step_name}' " + f"references step state '{ref_name}' that is not " + "guaranteed to be known before this predicate" + ) + + for step_name, step in workflow.steps.items(): + if step.type != WorkflowStepType.PARALLEL: + continue + if self._is_unresolved_var(step.join) or step.join not in workflow.steps: + continue + for branch_ref in step.branches: + if self._is_unresolved_var(branch_ref) or branch_ref not in workflow.steps: + continue + if not self._all_paths_reach_join( + branch_ref, + step.join, + graph, + memo={}, + visiting=set(), + ): + self._err( + f"Workflow '{workflow_name}' parallel step " + f"'{step_name}' requires every explicit branch path " + f"from '{branch_ref}' to converge on join " + f"'{step.join}'" + ) + + if ( + workflow_call_graph + and _topological_sort( + { + workflow_name: sorted(callee for callee in callees if callee in workflow_call_graph) + for workflow_name, callees in workflow_call_graph.items() + } + ) + is None + ): + self._err("Workflow call graph contains a cycle") + + combined_workflow_graph = { + workflow_name: sorted( + workflow_call_graph.get(workflow_name, set()) | workflow_compensation_graph.get(workflow_name, set()) + ) + for workflow_name in self._s.workflows + } + if combined_workflow_graph and _topological_sort(combined_workflow_graph) is None: + self._err("Combined workflow call/compensation graph contains a cycle") + + for workflow_name in sorted(compensation_target_workflows): + if workflow_name in workflows_with_compensation_steps: + self._err( + f"Workflow '{workflow_name}' cannot be used as a compensation " + "workflow because it also declares compensate-with steps" + ) diff --git a/implementations/python/pyproject.toml b/implementations/python/pyproject.toml index f56130aa4..8b5da33a2 100644 --- a/implementations/python/pyproject.toml +++ b/implementations/python/pyproject.toml @@ -131,4 +131,4 @@ ignore = [ "packages/aces_runtime/control_plane_api.py" = ["B008"] # fastapi Depends() pattern "packages/aces_runtime/control_plane.py" = ["S112"] # intentional exception suppression "packages/aces_sdl/module_registry.py" = ["S310", "S202"] # explicit OCI URL fetch and tarball extract -"packages/aces_sdl/validator.py" = ["F821"] # string-quoted forward references in closures +"packages/aces_sdl/validator/_sections.py" = ["F821"] # string-quoted forward references in closures diff --git a/implementations/python/tests/test_sdl_diagnostic_boundary.py b/implementations/python/tests/test_sdl_diagnostic_boundary.py index 95585a207..948e00a47 100644 --- a/implementations/python/tests/test_sdl_diagnostic_boundary.py +++ b/implementations/python/tests/test_sdl_diagnostic_boundary.py @@ -23,17 +23,29 @@ from __future__ import annotations import ast -import inspect +from pathlib import Path import aces_sdl.validator as validator_module -def _validator_class_node(source: str) -> ast.ClassDef: +def _all_class_methods(source: str) -> dict[str, ast.AST]: + """Collect every method from every class in ``source``. + + The reference ``SemanticValidator`` is composed from per-seam mixin classes + split across the ``aces_sdl.validator`` package (issue #42), so the boundary + lint aggregates methods across all classes in the source rather than a single + ``SemanticValidator`` ClassDef. Method names are unique across the mixins, so + a flat name->node map faithfully reconstructs the composed method set. + """ tree = ast.parse(source) - for node in ast.walk(tree): - if isinstance(node, ast.ClassDef) and node.name == "SemanticValidator": - return node - raise AssertionError("SemanticValidator class definition not found in source") + methods: dict[str, ast.AST] = {} + for cls in ast.walk(tree): + if not isinstance(cls, ast.ClassDef): + continue + for node in cls.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + methods[node.name] = node + return methods def _calls_self_method(func: ast.AST, name: str) -> bool: @@ -74,8 +86,7 @@ def find_advisory_boundary_violations(source: str) -> set[str]: short ``:`` key so a failure names the offending site. """ - cls = _validator_class_node(source) - methods = {node.name: node for node in cls.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))} + methods = _all_class_methods(source) violations: set[str] = set() for name, func in methods.items(): @@ -120,9 +131,15 @@ def find_advisory_boundary_violations(source: str) -> set[str]: def test_validator_advisory_error_channels_are_separated() -> None: - """The live ``SemanticValidator`` honours the diagnostics.md boundary.""" + """The live ``SemanticValidator`` honours the diagnostics.md boundary. + + ``aces_sdl.validator`` is a package (issue #42), so concatenate every + module's source and let the lint aggregate methods across the mixin classes + that compose ``SemanticValidator``. + """ - source = inspect.getsource(validator_module) + package_dir = Path(validator_module.__file__).parent + source = "\n".join(path.read_text() for path in sorted(package_dir.glob("*.py"))) assert find_advisory_boundary_violations(source) == set() diff --git a/specs/formal/assessment/README.md b/specs/formal/assessment/README.md index 9da4f3944..3fd6b0737 100644 --- a/specs/formal/assessment/README.md +++ b/specs/formal/assessment/README.md @@ -38,7 +38,7 @@ capabilities, the concept-authority stack). `min-score` shorthand; `Condition` command-xor-source): - `implementations/python/packages/aces_sdl/scoring.py` - `implementations/python/packages/aces_sdl/conditions.py` -- semantic validation: `implementations/python/packages/aces_sdl/validator.py` +- semantic validation: `implementations/python/packages/aces_sdl/validator/` (`_verify_assessment_pipeline`) - compiled runtime addresses, contracts, and ordering/refresh derivation: - `implementations/python/packages/aces_processor/compiler.py` diff --git a/specs/formal/assessment/pipeline-consistency.md b/specs/formal/assessment/pipeline-consistency.md index 57c36902b..14b0da47a 100644 --- a/specs/formal/assessment/pipeline-consistency.md +++ b/specs/formal/assessment/pipeline-consistency.md @@ -101,7 +101,7 @@ addresses. - shared semantic source of truth: `implementations/python/packages/aces_sdl/semantics/assessment.py` -- validator checks: `implementations/python/packages/aces_sdl/validator.py` +- validator checks: `implementations/python/packages/aces_sdl/validator/` - compiled runtime references, contracts, and ordering/refresh derivation: - `implementations/python/packages/aces_processor/compiler.py` - `implementations/python/packages/aces_processor/models.py` diff --git a/specs/formal/objectives/README.md b/specs/formal/objectives/README.md index 61e57b2c8..bf164600e 100644 --- a/specs/formal/objectives/README.md +++ b/specs/formal/objectives/README.md @@ -41,7 +41,7 @@ target-resolution, success-interpretation, and dependency-ordering semantics `implementations/python/packages/aces_sdl/semantics/objectives.py` (`analyze_objective_window`) - authoring models: `implementations/python/packages/aces_sdl/objectives.py` -- semantic validation: `implementations/python/packages/aces_sdl/validator.py` +- semantic validation: `implementations/python/packages/aces_sdl/validator/` (`_verify_objectives`) - compiled runtime objective resource, addresses, diagnostics, and ordering/refresh derivation: diff --git a/specs/formal/objectives/declarative-objective-semantics.md b/specs/formal/objectives/declarative-objective-semantics.md index 55753c0aa..0f0e706d5 100644 --- a/specs/formal/objectives/declarative-objective-semantics.md +++ b/specs/formal/objectives/declarative-objective-semantics.md @@ -172,7 +172,7 @@ Avoid: - authoring models (closed Pydantic shape; `agent` xor `entity`; non-empty `success`): `implementations/python/packages/aces_sdl/objectives.py` - semantic validation: - `implementations/python/packages/aces_sdl/validator.py` (`_verify_objectives`, + `implementations/python/packages/aces_sdl/validator/` (`_verify_objectives`, rendering the issue codes back onto the authoring-error strings) - compiled runtime objective resource, canonical addresses, diagnostics, and ordering/refresh derivation: diff --git a/specs/formal/objectives/window-consistency.md b/specs/formal/objectives/window-consistency.md index 5039c7241..f8150fc39 100644 --- a/specs/formal/objectives/window-consistency.md +++ b/specs/formal/objectives/window-consistency.md @@ -69,7 +69,7 @@ shape before compiler/planner semantics run. Each resolved reference carries: ## Implementation Mapping - shared semantic source of truth: `implementations/python/packages/aces_sdl/semantics/objectives.py` -- validator checks: `implementations/python/packages/aces_sdl/validator.py` +- validator checks: `implementations/python/packages/aces_sdl/validator/` - compiled runtime references and refresh derivation: - `implementations/python/packages/aces_processor/compiler.py` - `implementations/python/packages/aces_processor/models.py` diff --git a/specs/formal/participant-semantics/README.md b/specs/formal/participant-semantics/README.md index 9a8a0d8a2..b53c025a7 100644 --- a/specs/formal/participant-semantics/README.md +++ b/specs/formal/participant-semantics/README.md @@ -716,7 +716,7 @@ Current implementation artifacts for the `SEM-208` slice: - `implementations/python/packages/aces_sdl/participant_behavior.py` defines typed action contracts and observation boundaries; - `implementations/python/packages/aces_sdl/semantics/participant_behavior.py` - and `implementations/python/packages/aces_sdl/validator.py` fail closed on + and `implementations/python/packages/aces_sdl/validator/` fail closed on unbound action-contract and observation-boundary references; - `implementations/python/packages/aces_processor/compiler.py` maps authored participants to compiled participant action, observation, and behavior @@ -759,7 +759,7 @@ Current implementation artifacts for the `SEM-209` slice: interaction classes, target references, related actions, and shared-state references on action contracts; - `implementations/python/packages/aces_sdl/semantics/participant_behavior.py` - and `implementations/python/packages/aces_sdl/validator.py` fail closed on + and `implementations/python/packages/aces_sdl/validator/` fail closed on unbound related actions, interaction targets, and shared-state references; - `implementations/python/packages/aces_processor/compiler.py` carries declared interaction classes and shared-state references into compiled participant @@ -830,7 +830,7 @@ Current implementation artifacts for the `SEM-210` slice: observation-boundary hidden, observable, and evidence-only reference separation; - `implementations/python/packages/aces_sdl/semantics/participant_behavior.py` - and `implementations/python/packages/aces_sdl/validator.py` continue to + and `implementations/python/packages/aces_sdl/validator/` continue to fail closed on unbound participant observation-boundary references, view-rule references, and view-transition evidence references; - `implementations/python/packages/aces_processor/compiler.py` carries hidden, diff --git a/specs/formal/realization/README.md b/specs/formal/realization/README.md index d1c008767..179eafd87 100644 --- a/specs/formal/realization/README.md +++ b/specs/formal/realization/README.md @@ -72,7 +72,7 @@ work the SEM-218 row tracks. (`realization-and-disclosure`) - SDL classifier and instantiation downgrade: `implementations/python/packages/aces_sdl/explicitness.py`, - `implementations/python/packages/aces_sdl/validator.py`, + `implementations/python/packages/aces_sdl/validator/`, `implementations/python/packages/aces_sdl/instantiate.py` - invalid fixtures evidencing fail-closed rejection: `contracts/fixtures/backend-manifest/backend-manifest-v2/invalid/hollow-realization-support.json`, diff --git a/specs/formal/realization/explicitness-and-realization.md b/specs/formal/realization/explicitness-and-realization.md index 3b5492073..8666a74e5 100644 --- a/specs/formal/realization/explicitness-and-realization.md +++ b/specs/formal/realization/explicitness-and-realization.md @@ -100,7 +100,7 @@ extend these rather than introduce parallel registries: `contracts/concept-authority/concept-families-v1.json` (`realization-and-disclosure`) - shared SDL static semantics: - `implementations/python/packages/aces_sdl/validator.py` + `implementations/python/packages/aces_sdl/validator/` (`SemanticValidator`, `SDLValidationError`) - instantiation and revalidation: `implementations/python/packages/aces_sdl/instantiate.py` @@ -421,7 +421,7 @@ invariant I1–I5 is enforced by named code. may be realized at all). - I1, I3 fail-closed authoring / validation — `implementations/python/packages/aces_sdl/explicitness.py`, - `implementations/python/packages/aces_sdl/validator.py`, + `implementations/python/packages/aces_sdl/validator/`, `implementations/python/packages/aces_sdl/instantiate.py` (closed-world validation, classifier output on validated scenarios, and substitution-downgrade metadata on instantiated scenarios). diff --git a/specs/formal/workflows/README.md b/specs/formal/workflows/README.md index 04b8b68e3..c96f715a3 100644 --- a/specs/formal/workflows/README.md +++ b/specs/formal/workflows/README.md @@ -26,7 +26,7 @@ This directory holds the repo-native formal artifacts for SDL workflow control s ## Implementation Mapping - shared rules: `implementations/python/packages/aces_processor/semantics/workflow.py` -- validator enforcement: `implementations/python/packages/aces_sdl/validator.py` +- validator enforcement: `implementations/python/packages/aces_sdl/validator/` - compiled contracts: `implementations/python/packages/aces_processor/compiler.py` - typed runtime results and contract checks: - `implementations/python/packages/aces_processor/models.py` diff --git a/specs/formal/workflows/compensation.md b/specs/formal/workflows/compensation.md index 72c069c17..f158237bc 100644 --- a/specs/formal/workflows/compensation.md +++ b/specs/formal/workflows/compensation.md @@ -98,7 +98,7 @@ workflow can be `cancelled` or `timed_out` while compensation is `running` or ## Implementation Mapping - SDL authoring models: `implementations/python/packages/aces_sdl/orchestration.py` -- semantic validation: `implementations/python/packages/aces_sdl/validator.py` +- semantic validation: `implementations/python/packages/aces_sdl/validator/` - compiled contracts: `implementations/python/packages/aces_processor/compiler.py` - runtime state/history models: `implementations/python/packages/aces_processor/models.py` - manager validation: `implementations/python/packages/aces_processor/manager.py` diff --git a/specs/formal/workflows/state-machine.md b/specs/formal/workflows/state-machine.md index acf30e6de..8ca8f73e7 100644 --- a/specs/formal/workflows/state-machine.md +++ b/specs/formal/workflows/state-machine.md @@ -67,7 +67,7 @@ derived from that envelope after validation. ## Implementation Mapping - semantic contract definitions: `implementations/python/packages/aces_processor/semantics/workflow.py` -- validation and visibility checks: `implementations/python/packages/aces_sdl/validator.py` +- validation and visibility checks: `implementations/python/packages/aces_sdl/validator/` - compiled step contracts: `implementations/python/packages/aces_processor/compiler.py` - typed runtime envelopes and result validation: - `implementations/python/packages/aces_processor/models.py` diff --git a/tools/policy/oversized_allowlist.yaml b/tools/policy/oversized_allowlist.yaml index 87fa7e6fe..34c75a824 100644 --- a/tools/policy/oversized_allowlist.yaml +++ b/tools/policy/oversized_allowlist.yaml @@ -8,7 +8,6 @@ files: - implementations/python/packages/aces_processor/models.py - implementations/python/packages/aces_processor/compiler.py - - implementations/python/packages/aces_sdl/validator.py - implementations/python/packages/aces_contracts/contracts.py - implementations/python/packages/aces_processor/planner.py - implementations/python/packages/aces_conformance/conformance.py From 6f92de549767644fd975005dbfc80ac02d7ac0d3 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 15 Jun 2026 18:56:13 +0200 Subject: [PATCH 68/70] Reduce SemanticValidator complexity flagged by SonarCloud Behavior-preserving extraction across the validator mixin package to clear the new-code complexity violations (cognitive/cyclomatic/nesting/too-many-returns/too-many-params/make-static) SonarCloud raised on the verbatim-moved validators. No validation behavior, diagnostics, pass ordering, or public-API change; guarded by the existing test suite. Adds shared context dataclasses (_WorkflowBuildState, _CompensationState, _AvailableStateContext) and per-concern helpers; drops the now-unnecessary F821 per-file-ignore. --- changelog.d/42.changed.md | 2 +- .../aces_sdl/validator/_content_objectives.py | 171 ++--- .../packages/aces_sdl/validator/_core.py | 60 +- .../validator/_nodes_infra_network.py | 206 +++--- .../aces_sdl/validator/_relationships.py | 94 ++- .../aces_sdl/validator/_runtime_platform.py | 207 +++--- .../aces_sdl/validator/_runtime_services.py | 33 +- .../packages/aces_sdl/validator/_sections.py | 185 +++--- .../packages/aces_sdl/validator/_support.py | 36 ++ .../aces_sdl/validator/_workflows_analysis.py | 239 +++---- .../aces_sdl/validator/_workflows_verify.py | 598 +++++++++--------- implementations/python/pyproject.toml | 1 - 12 files changed, 952 insertions(+), 880 deletions(-) diff --git a/changelog.d/42.changed.md b/changelog.d/42.changed.md index 16d7a52c5..802526691 100644 --- a/changelog.d/42.changed.md +++ b/changelog.d/42.changed.md @@ -1 +1 @@ -Split the oversized `aces_sdl.validator` module (4,139 lines) into a package of per-validation-seam mixin modules (`_core`, runtime families, relationships, content/objectives, workflows, sections), each under the ADR-015 600-line cap, behind an API-stable `SemanticValidator` re-export. Pure refactor: no validation behavior, diagnostics, pass ordering, or public-API change. The `validator.py` entry is removed from the oversized-source allowlist. +Split the oversized `aces_sdl.validator` module (4,139 lines) into a package of per-validation-seam mixin modules (`_core`, runtime families, relationships, content/objectives, workflows, sections), each under the ADR-015 600-line cap, behind an API-stable `SemanticValidator` re-export, and reduced the per-pass cyclomatic/cognitive complexity of the moved validators by extracting focused helpers and shared context objects. Pure refactor: no validation behavior, diagnostics, pass ordering, or public-API change. The `validator.py` entry is removed from the oversized-source allowlist. diff --git a/implementations/python/packages/aces_sdl/validator/_content_objectives.py b/implementations/python/packages/aces_sdl/validator/_content_objectives.py index 7a852e365..b4812062d 100644 --- a/implementations/python/packages/aces_sdl/validator/_content_objectives.py +++ b/implementations/python/packages/aces_sdl/validator/_content_objectives.py @@ -3,6 +3,8 @@ Part of the SemanticValidator mixin composition; see __init__.py. """ +from collections.abc import Callable + from ..semantics.objective_semantics import ( AssessmentResourceCatalog, ObjectiveIssue, @@ -194,74 +196,88 @@ def _verify_relationships(self) -> None: def _verify_agents(self) -> None: flat_entity_names = self._all_entity_names() service_names = {service.name for node in self._s.nodes.values() for service in node.services if service.name} - for name, agent in self._s.agents.items(): - if agent.entity and not self._is_unresolved_var(agent.entity) and agent.entity not in flat_entity_names: - self._err(f"Agent '{name}' references undefined entity '{agent.entity}'") - for acct_name in agent.starting_accounts: - if self._is_unresolved_var(acct_name): - continue - if acct_name not in self._s.accounts: - self._err(f"Agent '{name}' starting_account '{acct_name}' not in accounts section") - for subnet in agent.allowed_subnets: - if self._is_unresolved_var(subnet): - continue - if subnet not in self._s.infrastructure: - self._err(f"Agent '{name}' allowed_subnet '{subnet}' not in infrastructure section") - elif not self._is_switch_node(subnet): - self._err(f"Agent '{name}' allowed_subnet '{subnet}' must reference a switch/network entry") - if agent.initial_knowledge: - for host in agent.initial_knowledge.hosts: - if self._is_unresolved_var(host): - continue - if host not in self._s.nodes: - self._err(f"Agent '{name}' initial_knowledge host '{host}' not in nodes section") - elif not self._is_vm_node(host): - self._err(f"Agent '{name}' initial_knowledge host '{host}' must reference a VM node") - for subnet in agent.initial_knowledge.subnets: - if self._is_unresolved_var(subnet): - continue - if subnet not in self._s.infrastructure: - self._err(f"Agent '{name}' initial_knowledge subnet '{subnet}' not in infrastructure section") - elif not self._is_switch_node(subnet): - self._err( - f"Agent '{name}' initial_knowledge subnet '{subnet}' must reference a switch/network entry" - ) - for service_name in agent.initial_knowledge.services: - if self._is_unresolved_var(service_name): - continue - if service_name not in service_names: - self._err( - f"Agent '{name}' initial_knowledge service '{service_name}' not in node service names" - ) - for acct_name in agent.initial_knowledge.accounts: - if self._is_unresolved_var(acct_name): - continue - if acct_name not in self._s.accounts: - self._err(f"Agent '{name}' initial_knowledge account '{acct_name}' not in accounts section") - for cond_name in agent.starting_conditions: - if self._is_unresolved_var(cond_name): - continue - # ADR-020 §6 publishes starting_conditions as accepting bare - # (`health`) or section-qualified (`conditions.health`) - # references. Strip the `conditions.` prefix when present so - # both forms resolve against the same dict. - bare_name = cond_name.removeprefix("conditions.") - if bare_name not in self._s.conditions: - self._err(f"Agent '{name}' starting_condition '{cond_name}' not in conditions section") - for anchor in agent.authority_anchors: - if self._is_unresolved_var(anchor): - continue - self._validate_named_ref( - anchor, - owner_label=f"Agent '{name}'", - ref_label="authority_anchor", - targetable=False, - ) - for scope in agent.operating_scope: - if self._is_unresolved_var(scope): - continue - self._validate_operating_scope_ref(scope, owner_label=f"Agent '{name}'") + self._verify_agent(name, agent, flat_entity_names, service_names) + + def _verify_agent(self, name: str, agent: object, flat_entity_names: set[str], service_names: set[str]) -> None: + label = f"Agent '{name}'" + if agent.entity and not self._is_unresolved_var(agent.entity) and agent.entity not in flat_entity_names: + self._err(f"{label} references undefined entity '{agent.entity}'") + self._verify_membership_refs( + agent.starting_accounts, + self._s.accounts, + lambda ref: f"{label} starting_account '{ref}' not in accounts section", + ) + self._verify_agent_subnet_refs( + agent.allowed_subnets, + undefined=lambda ref: f"{label} allowed_subnet '{ref}' not in infrastructure section", + not_switch=lambda ref: f"{label} allowed_subnet '{ref}' must reference a switch/network entry", + ) + if agent.initial_knowledge: + self._verify_agent_initial_knowledge(name, agent.initial_knowledge, service_names) + self._verify_agent_starting_conditions(label, agent) + for anchor in agent.authority_anchors: + if not self._is_unresolved_var(anchor): + self._validate_named_ref(anchor, owner_label=label, ref_label="authority_anchor", targetable=False) + for scope in agent.operating_scope: + if not self._is_unresolved_var(scope): + self._validate_operating_scope_ref(scope, owner_label=label) + + def _verify_membership_refs(self, refs: list[str], valid: object, error_msg: Callable[[str], str]) -> None: + for ref in refs: + if not self._is_unresolved_var(ref) and ref not in valid: + self._err(error_msg(ref)) + + def _verify_agent_subnet_refs( + self, refs: list[str], *, undefined: Callable[[str], str], not_switch: Callable[[str], str] + ) -> None: + for subnet in refs: + if self._is_unresolved_var(subnet): + continue + if subnet not in self._s.infrastructure: + self._err(undefined(subnet)) + elif not self._is_switch_node(subnet): + self._err(not_switch(subnet)) + + def _verify_agent_starting_conditions(self, label: str, agent: object) -> None: + for cond_name in agent.starting_conditions: + if self._is_unresolved_var(cond_name): + continue + # ADR-020 §6 publishes starting_conditions as accepting bare + # (`health`) or section-qualified (`conditions.health`) + # references. Strip the `conditions.` prefix when present so + # both forms resolve against the same dict. + bare_name = cond_name.removeprefix("conditions.") + if bare_name not in self._s.conditions: + self._err(f"{label} starting_condition '{cond_name}' not in conditions section") + + def _verify_agent_initial_knowledge(self, name: str, initial_knowledge: object, service_names: set[str]) -> None: + label = f"Agent '{name}'" + self._verify_agent_ik_hosts(label, initial_knowledge) + self._verify_agent_subnet_refs( + initial_knowledge.subnets, + undefined=lambda ref: f"{label} initial_knowledge subnet '{ref}' not in infrastructure section", + not_switch=lambda ref: f"{label} initial_knowledge subnet '{ref}' must reference a switch/network entry", + ) + self._verify_membership_refs( + initial_knowledge.services, + service_names, + lambda ref: f"{label} initial_knowledge service '{ref}' not in node service names", + ) + self._verify_membership_refs( + initial_knowledge.accounts, + self._s.accounts, + lambda ref: f"{label} initial_knowledge account '{ref}' not in accounts section", + ) + + def _verify_agent_ik_hosts(self, label: str, initial_knowledge: object) -> None: + for host in initial_knowledge.hosts: + if self._is_unresolved_var(host): + continue + if host not in self._s.nodes: + self._err(f"{label} initial_knowledge host '{host}' not in nodes section") + elif not self._is_vm_node(host): + self._err(f"{label} initial_knowledge host '{host}' must reference a VM node") def _verify_participant_behavior(self) -> None: analysis = analyze_participant_behavior( @@ -339,24 +355,21 @@ def _verify_objectives(self) -> None: @staticmethod def _format_objective_issue(issue: ObjectiveIssue) -> str: - try: - renderer = _OBJECTIVE_ISSUE_RENDERERS[issue.code] - except KeyError: # pragma: no cover - defensive: a new code without a renderer - raise AssertionError(f"unhandled objective-semantics issue code: {issue.code}") from None + renderer = _OBJECTIVE_ISSUE_RENDERERS.get(issue.code) + if renderer is None: + raise AssertionError(f"unhandled objective-semantics issue code: {issue.code}") return renderer(issue) @staticmethod def _format_participant_behavior_issue(issue: ParticipantBehaviorIssue) -> str: - try: - renderer = _PARTICIPANT_BEHAVIOR_ISSUE_RENDERERS[issue.code] - except KeyError: # pragma: no cover - defensive: a new code without a renderer - raise AssertionError(f"unhandled participant-behavior issue code: {issue.code}") from None + renderer = _PARTICIPANT_BEHAVIOR_ISSUE_RENDERERS.get(issue.code) + if renderer is None: + raise AssertionError(f"unhandled participant-behavior issue code: {issue.code}") return renderer(issue) @staticmethod def _format_participant_outcome_issue(issue: ParticipantOutcomeIssue) -> str: - try: - renderer = _PARTICIPANT_OUTCOME_ISSUE_RENDERERS[issue.code] - except KeyError: # pragma: no cover - defensive: a new code without a renderer - raise AssertionError(f"unhandled participant-outcome issue code: {issue.code}") from None + renderer = _PARTICIPANT_OUTCOME_ISSUE_RENDERERS.get(issue.code) + if renderer is None: + raise AssertionError(f"unhandled participant-outcome issue code: {issue.code}") return renderer(issue) diff --git a/implementations/python/packages/aces_sdl/validator/_core.py b/implementations/python/packages/aces_sdl/validator/_core.py index 4a7837467..6ec015bd4 100644 --- a/implementations/python/packages/aces_sdl/validator/_core.py +++ b/implementations/python/packages/aces_sdl/validator/_core.py @@ -26,7 +26,8 @@ def _err(self, msg: str) -> None: def _warn(self, msg: str) -> None: self._warnings.append(msg) - def _is_unresolved_var(self, value: object) -> bool: + @staticmethod + def _is_unresolved_var(value: object) -> bool: return is_variable_ref(value) def _node_type(self, node_name: str) -> NodeType | None: @@ -50,7 +51,8 @@ def _qualified_service_refs(self) -> set[str]: refs.add(f"nodes.{node_name}.services.{service.name}") return refs - def _split_node_service_ref(self, ref: object) -> tuple[str, str] | None: + @staticmethod + def _split_node_service_ref(ref: object) -> tuple[str, str] | None: """Split ``nodes..services.`` into node/service parts. Node names may contain dots (for example ``wazuh.manager``), so service @@ -224,6 +226,12 @@ def _operating_scope_ref_index(self) -> dict[str, set[str]]: index[infra_name].add(canonical) index[canonical].add(canonical) + self._add_operating_scope_service_aliases(index) + self._add_operating_scope_content_aliases(index) + + return {alias: set(candidates) for alias, candidates in index.items()} + + def _add_operating_scope_service_aliases(self, index: dict[str, set[str]]) -> None: # Services: qualified `nodes..services.` refs plus bare # service names. The service-ref helper only emits names declared # on VM nodes (a service on a switch is meaningless), so no extra @@ -234,6 +242,7 @@ def _operating_scope_ref_index(self) -> dict[str, set[str]]: if tail: index[tail].add(ref) + def _add_operating_scope_content_aliases(self, index: dict[str, set[str]]) -> None: # Content: sections and items keep the unrestricted aliasing from # the targetable index; ADR-020 does not split content by sub-type. for content_name in self._s.content: @@ -248,8 +257,6 @@ def _operating_scope_ref_index(self) -> dict[str, set[str]]: index[item.name].add(canonical) index[canonical].add(canonical) - return {alias: set(candidates) for alias, candidates in index.items()} - def _validate_operating_scope_ref(self, ref: str, *, owner_label: str) -> None: """Validate ``operating_scope`` against the spatial/resource index.""" index = self._operating_scope_ref_index() @@ -359,7 +366,8 @@ def _warn_missing_vm_resources(self) -> None: "supplies defaults." ) - def _split_runtime_ref(self, ref: object, *, surface: str) -> tuple[str, str] | None: + @staticmethod + def _split_runtime_ref(ref: object, *, surface: str) -> tuple[str, str] | None: """Split ``nodes..runtime..`` into (node, rest). Module composition rewrites the node segment to a dotted namespaced @@ -375,6 +383,19 @@ def _split_runtime_ref(self, ref: object, *, surface: str) -> tuple[str, str] | return None return head, tail + def _node_runtime(self, node_name: str) -> object | None: + """Return the ``runtime`` surface for ``node_name``, or None.""" + node = self._s.nodes.get(node_name) + return getattr(node, "runtime", None) if node is not None else None + + @staticmethod + def _database_service_id_from_tail(tail: str) -> str | None: + """Service id from a ```` (1 part) or ``.databases.`` (3) tail.""" + tail_parts = tail.split(".") + if len(tail_parts) == 1 or (len(tail_parts) == 3 and tail_parts[1] == "databases"): + return tail_parts[0] + return None + def _resolve_database_service_ref(self, ref: object) -> object | None: """Resolve a qualified ``nodes..runtime.database_services.`` ref. @@ -386,20 +407,11 @@ def _resolve_database_service_ref(self, ref: object) -> object | None: if split is None: return None node_name, tail = split - tail_parts = tail.split(".") - # tail is ```` (1 part) or ``.databases.`` (3). - if len(tail_parts) == 1 or (len(tail_parts) == 3 and tail_parts[1] == "databases"): - svc_id = tail_parts[0] - else: - return None - node = self._s.nodes.get(node_name) - runtime = getattr(node, "runtime", None) if node is not None else None - if runtime is None: + svc_id = self._database_service_id_from_tail(tail) + runtime = self._node_runtime(node_name) + if svc_id is None or runtime is None: return None - for dbsvc in runtime.database_services: - if dbsvc.database_service_id == svc_id: - return dbsvc - return None + return next((s for s in runtime.database_services if s.database_service_id == svc_id), None) def _resolve_application_ref(self, ref: object) -> object | None: """Resolve a qualified ``nodes..runtime.applications.`` ref. @@ -412,13 +424,7 @@ def _resolve_application_ref(self, ref: object) -> object | None: if split is None: return None node_name, tail = split - if "." in tail: - return None - node = self._s.nodes.get(node_name) - runtime = getattr(node, "runtime", None) if node is not None else None - if runtime is None: + runtime = self._node_runtime(node_name) + if "." in tail or runtime is None: return None - for application in runtime.applications: - if application.application_id == tail: - return application - return None + return next((a for a in runtime.applications if a.application_id == tail), None) diff --git a/implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py b/implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py index bcb33e20e..0ef0a78d6 100644 --- a/implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py +++ b/implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py @@ -18,119 +18,110 @@ def _verify_nodes(self) -> None: for name, node in self._s.nodes.items(): if len(name) > MAX_NODE_NAME_LENGTH: self._err(f"Node '{name}' name exceeds 35 characters") - - for feat_name, role_name in node.features.items(): - if feat_name not in self._s.features: - self._err(f"Node '{name}' references undefined feature '{feat_name}'") - if role_name and not self._is_unresolved_var(role_name) and role_name not in node.roles: - self._err(f"Node '{name}' feature '{feat_name}' references undefined role '{role_name}'") - - for cond_name, role_name in node.conditions.items(): - if cond_name not in self._s.conditions: - self._err(f"Node '{name}' references undefined condition '{cond_name}'") - if role_name and not self._is_unresolved_var(role_name) and role_name not in node.roles: - self._err(f"Node '{name}' condition '{cond_name}' references undefined role '{role_name}'") - - for inj_name, role_name in node.injects.items(): - if inj_name not in self._s.injects: - self._err(f"Node '{name}' references undefined inject '{inj_name}'") - if role_name and not self._is_unresolved_var(role_name) and role_name not in node.roles: - self._err(f"Node '{name}' inject '{inj_name}' references undefined role '{role_name}'") - + self._verify_node_ref_role_map(name, node, node.features, self._s.features, kind="feature") + self._verify_node_ref_role_map(name, node, node.conditions, self._s.conditions, kind="condition") + self._verify_node_ref_role_map(name, node, node.injects, self._s.injects, kind="inject") for vuln_name in node.vulnerabilities: if self._is_unresolved_var(vuln_name): continue if vuln_name not in self._s.vulnerabilities: self._err(f"Node '{name}' references undefined vulnerability '{vuln_name}'") + def _verify_node_ref_role_map( + self, name: str, node: object, mapping: dict[str, str], valid: object, *, kind: str + ) -> None: + for ref_name, role_name in mapping.items(): + if ref_name not in valid: + self._err(f"Node '{name}' references undefined {kind} '{ref_name}'") + if role_name and not self._is_unresolved_var(role_name) and role_name not in node.roles: + self._err(f"Node '{name}' {kind} '{ref_name}' references undefined role '{role_name}'") + def _verify_infrastructure(self) -> None: for name, infra in self._s.infrastructure.items(): if name not in self._s.nodes: self._err(f"Infrastructure '{name}' does not match any defined node") + self._verify_infra_links(name, infra) + self._verify_infra_dependencies(name, infra) + self._verify_infra_count(name, infra) + self._verify_infra_property_ips(name, infra) + self._verify_infra_acls(name, infra) - for link in infra.links: - if self._is_unresolved_var(link): - continue - if link not in self._s.infrastructure: - self._err(f"Infrastructure '{name}' links to undefined '{link}'") - elif not self._is_switch_node(link): - self._err(f"Infrastructure '{name}' link '{link}' must reference a switch/network entry") + def _verify_infra_links(self, name: str, infra: object) -> None: + for link in infra.links: + if self._is_unresolved_var(link): + continue + if link not in self._s.infrastructure: + self._err(f"Infrastructure '{name}' links to undefined '{link}'") + elif not self._is_switch_node(link): + self._err(f"Infrastructure '{name}' link '{link}' must reference a switch/network entry") - for dep in infra.dependencies: - if self._is_unresolved_var(dep): - continue - if dep not in self._s.infrastructure: - self._err(f"Infrastructure '{name}' depends on undefined '{dep}'") + def _verify_infra_dependencies(self, name: str, infra: object) -> None: + for dep in infra.dependencies: + if self._is_unresolved_var(dep): + continue + if dep not in self._s.infrastructure: + self._err(f"Infrastructure '{name}' depends on undefined '{dep}'") - # Switch nodes cannot have count > 1 - if name in self._s.nodes: - if self._s.nodes[name].type == NodeType.SWITCH and isinstance(infra.count, int) and infra.count > 1: - self._err(f"Switch node '{name}' cannot have count > 1") - if ( - self._s.nodes[name].type == NodeType.VM - and self._s.nodes[name].conditions - and isinstance(infra.count, int) - and infra.count > 1 - ): - self._err(f"Node '{name}' has conditions and cannot have count > 1") + def _verify_infra_count(self, name: str, infra: object) -> None: + # Switch nodes cannot have count > 1. + if name not in self._s.nodes: + return + node = self._s.nodes[name] + if node.type == NodeType.SWITCH and isinstance(infra.count, int) and infra.count > 1: + self._err(f"Switch node '{name}' cannot have count > 1") + if node.type == NodeType.VM and node.conditions and isinstance(infra.count, int) and infra.count > 1: + self._err(f"Node '{name}' has conditions and cannot have count > 1") + + def _verify_infra_property_ips(self, name: str, infra: object) -> None: + # Validate complex-properties IP assignments within their linked CIDR. + if not isinstance(infra.properties, list): + return + for prop_entry in infra.properties: + for link_name, ip_str in prop_entry.items(): + self._verify_property_ip(name, infra, link_name, ip_str) + + def _verify_property_ip(self, name: str, infra: object, link_name: str, ip_str: str) -> None: + if self._is_unresolved_var(link_name): + return + if link_name not in infra.links: + self._err(f"Infrastructure '{name}' property references unlinked node '{link_name}'") + if not self._is_switch_node(link_name): + self._err(f"Infrastructure '{name}' property link '{link_name}' must reference a switch/network entry") + return + linked_infra = self._s.infrastructure.get(link_name) + if linked_infra is None or not isinstance(linked_infra.properties, SimpleProperties): + if linked_infra is not None: + self._err( + f"Infrastructure '{name}' property link '{link_name}' must reference a network with CIDR properties" + ) + return + cidr = linked_infra.properties.cidr + if not self._is_unresolved_var(ip_str) and not self._is_unresolved_var(cidr): + self._verify_property_ip_in_cidr(name, link_name, ip_str, cidr) - # Validate complex properties IP within linked CIDR - if isinstance(infra.properties, list): - for prop_entry in infra.properties: - for link_name, ip_str in prop_entry.items(): - if self._is_unresolved_var(link_name): - continue - if link_name not in infra.links: - self._err(f"Infrastructure '{name}' property references unlinked node '{link_name}'") - if not self._is_switch_node(link_name): - self._err( - f"Infrastructure '{name}' property link " - f"'{link_name}' must reference a switch/network entry" - ) - continue - # Check IP is within the linked node's CIDR - linked_infra = self._s.infrastructure.get(link_name) - if linked_infra is None: - continue - if not isinstance(linked_infra.properties, SimpleProperties): - self._err( - f"Infrastructure '{name}' property link " - f"'{link_name}' must reference a network with CIDR " - "properties" - ) - continue - if self._is_unresolved_var(ip_str): - continue - if self._is_unresolved_var(linked_infra.properties.cidr): - continue - try: - net = ip_network(linked_infra.properties.cidr, strict=False) - except ValueError: - self._err(f"Infrastructure '{link_name}' has invalid CIDR {linked_infra.properties.cidr}") - continue - try: - addr = ip_address(ip_str) - except ValueError: - self._err( - f"Infrastructure '{name}' has invalid IP assignment '{ip_str}' for link '{link_name}'" - ) - continue - if addr not in net: - self._err( - f"Infrastructure '{name}' IP {ip_str} " - f"not within '{link_name}' CIDR " - f"{linked_infra.properties.cidr}" - ) + def _verify_property_ip_in_cidr(self, name: str, link_name: str, ip_str: str, cidr: str) -> None: + try: + net = ip_network(cidr, strict=False) + except ValueError: + self._err(f"Infrastructure '{link_name}' has invalid CIDR {cidr}") + return + try: + addr = ip_address(ip_str) + except ValueError: + self._err(f"Infrastructure '{name}' has invalid IP assignment '{ip_str}' for link '{link_name}'") + return + if addr not in net: + self._err(f"Infrastructure '{name}' IP {ip_str} not within '{link_name}' CIDR {cidr}") - # Validate ACL network references - for acl in infra.acls: - for ref in (acl.from_net, acl.to_net): - if self._is_unresolved_var(ref): - continue - if ref and ref not in self._s.infrastructure: - self._err(f"Infrastructure '{name}' ACL references undefined network '{ref}'") - elif ref and not self._is_switch_node(ref): - self._err(f"Infrastructure '{name}' ACL reference '{ref}' must point to a switch/network entry") + def _verify_infra_acls(self, name: str, infra: object) -> None: + for acl in infra.acls: + for ref in (acl.from_net, acl.to_net): + if self._is_unresolved_var(ref): + continue + if ref and ref not in self._s.infrastructure: + self._err(f"Infrastructure '{name}' ACL references undefined network '{ref}'") + elif ref and not self._is_switch_node(ref): + self._err(f"Infrastructure '{name}' ACL reference '{ref}' must point to a switch/network entry") def _verify_runtime_network(self) -> None: """Validate observed runtime network endpoints against declared topology. @@ -176,7 +167,8 @@ def _verify_endpoint_addressing(self, node_name: str, net: str, endpoint: object try: addr = ip_address(value) except ValueError: - continue # malformed addresses are reported by the model-level validator + # malformed addresses are reported by the model-level validator + continue if addr.version == network.version and addr not in network: self._err( f"Node '{node_name}' runtime network endpoint {label} {value} " @@ -307,10 +299,7 @@ def _verify_network_detection_engine( field_name="file_refs", observed_paths=observed_paths, ) - for network_set in engine.network_sets: - set_label = f"{owner_label} network_set '{network_set.set_id}'" - for network_ref in network_set.network_refs: - self._verify_network_detection_network_ref(set_label, network_ref) + self._verify_detection_network_sets(owner_label, engine) for stream in engine.output_streams: self._verify_dns_file_refs( f"{owner_label} output_stream '{stream.stream_id}'", @@ -318,6 +307,17 @@ def _verify_network_detection_engine( field_name="path", observed_paths=observed_paths, ) + self._verify_detection_control_channels(node_name, owner_label, engine, service_names, observed_paths) + + def _verify_detection_network_sets(self, owner_label: str, engine: object) -> None: + for network_set in engine.network_sets: + set_label = f"{owner_label} network_set '{network_set.set_id}'" + for network_ref in network_set.network_refs: + self._verify_network_detection_network_ref(set_label, network_ref) + + def _verify_detection_control_channels( + self, node_name: str, owner_label: str, engine: object, service_names: set[str], observed_paths: set[str] + ) -> None: for channel in engine.control_channels: channel_label = f"{owner_label} control_channel '{channel.channel_id}'" self._verify_owned_service_ref( diff --git a/implementations/python/packages/aces_sdl/validator/_relationships.py b/implementations/python/packages/aces_sdl/validator/_relationships.py index 4c2968a03..6ca5da87e 100644 --- a/implementations/python/packages/aces_sdl/validator/_relationships.py +++ b/implementations/python/packages/aces_sdl/validator/_relationships.py @@ -108,7 +108,7 @@ def _check_forwarding_edge_agreement(self, edge: object, agent: object, label: s self._check_forwarding_edge_role_agreement(edge, ship_targets, agent_id, label) def _check_forwarding_edge_protocol_agreement( - self, edge: object, ship_targets: list, agent_id: str, label: str + self, edge: object, ship_targets: list[object], agent_id: str, label: str ) -> None: # The edge ``protocol`` is a free string; agreement is asserted only # against ship_target protocols that are concrete enum members. If no @@ -126,7 +126,7 @@ def _check_forwarding_edge_protocol_agreement( ) def _check_forwarding_edge_role_agreement( - self, edge: object, ship_targets: list, agent_id: str, label: str + self, edge: object, ship_targets: list[object], agent_id: str, label: str ) -> None: # An ``agent_event_ingestion`` listener role requires a ship_target with # an ingestion endpoint; an ``agent_enrollment`` role requires one with @@ -194,35 +194,42 @@ def _check_service_integration_auth_principal(self, ref: str, engine: object | N node_name = self._node_name_of_platform_application(engine) if node_name is None: return - node = self._s.nodes.get(node_name) - runtime = getattr(node, "runtime", None) if node is not None else None - authorizations = list(getattr(runtime, "app_authorizations", [])) - authorization_ref = getattr(engine, "authorization_ref", "") - if authorization_ref and not self._is_unresolved_var(authorization_ref): - authorizations = [ - authorization - for authorization in authorizations - if getattr(authorization, "app_authorization_id", "") == authorization_ref - ] - if not authorizations: - # The runtime platform application validator reports the bad - # authorization_ref; avoid emitting a misleading principal-scope - # error from this relationship pass as well. - return + authorizations = self._engine_authorizations(node_name, engine) + if authorizations is None: + # The runtime platform application validator reports the bad + # authorization_ref; avoid emitting a misleading principal-scope + # error from this relationship pass as well. + return principal_ids = { principal.principal_id for authorization in authorizations for principal in authorization.principals } if ref not in principal_ids: - scope = ( - f"authorization '{authorization_ref}'" - if authorization_ref and not self._is_unresolved_var(authorization_ref) - else "an app_authorization principal" - ) self._err( f"{label} service_integration auth_principal_ref '{ref}' does not resolve to " - f"{scope} on the engine application's node '{node_name}'" + f"{self._auth_principal_scope(engine)} on the engine application's node '{node_name}'" ) + def _engine_authorizations(self, node_name: str, engine: object) -> list[object] | None: + """App authorizations on the engine's node, filtered by ``authorization_ref``. + + Returns None when a concrete ``authorization_ref`` matches no authorization + (the platform-application validator reports that; this pass suppresses it). + """ + node = self._s.nodes.get(node_name) + runtime = getattr(node, "runtime", None) if node is not None else None + authorizations = list(getattr(runtime, "app_authorizations", [])) + authorization_ref = getattr(engine, "authorization_ref", "") + if not authorization_ref or self._is_unresolved_var(authorization_ref): + return authorizations + filtered = [a for a in authorizations if getattr(a, "app_authorization_id", "") == authorization_ref] + return filtered or None + + def _auth_principal_scope(self, engine: object) -> str: + authorization_ref = getattr(engine, "authorization_ref", "") + if authorization_ref and not self._is_unresolved_var(authorization_ref): + return f"authorization '{authorization_ref}'" + return "an app_authorization principal" + def _node_name_of_platform_application(self, application: object) -> str | None: for node_name, node in self._s.nodes.items(): runtime = getattr(node, "runtime", None) @@ -305,21 +312,45 @@ def _check_proxy_upstream_service_ref( ) return node_name, service_name = resolved + node = self._proxy_upstream_node( + node_name, + service_ref, + upstream_node_ref=upstream_node_ref, + relationship_target=relationship_target, + label=label, + context=context, + field_name=field_name, + ) + if node is None: + return + if service_name not in self._node_service_names(node): + self._err( + f"{label} {context} {field_name} '{service_ref}' does not resolve to a service on node '{node_name}'" + ) + + def _proxy_upstream_node( + self, + node_name: str, + service_ref: str, + *, + upstream_node_ref: str, + relationship_target: str, + label: str, + context: str, + field_name: str, + ) -> object | None: expected_node_name = upstream_node_ref or self._node_name_from_relationship_target(relationship_target) if expected_node_name and node_name != expected_node_name: self._err( f"{label} {context} {field_name} '{service_ref}' must reference a service " f"on upstream node '{expected_node_name}'" ) - return + return None node = self._s.nodes.get(node_name) if node is None: self._err(f"{label} {context} upstream service node '{node_name}' does not resolve to a defined node") - return - if service_name not in self._node_service_names(node): - self._err( - f"{label} {context} {field_name} '{service_ref}' does not resolve to a service on node '{node_name}'" - ) + return None + return node def _resolve_upstream_service_ref( self, @@ -347,9 +378,12 @@ def _node_name_from_relationship_target(self, target: object) -> str | None: return None if target in self._s.nodes: return target + return self._node_name_from_qualified_target(target) + + def _node_name_from_qualified_target(self, target: str) -> str | None: service_split = self._split_node_service_ref(target) if service_split is not None: - node_name, _service_name = service_split + node_name = service_split[0] return node_name if node_name in self._s.nodes else None if target.startswith(_NODES_PREFIX): node_name, sep, _tail = target[len(_NODES_PREFIX) :].partition(".runtime.") diff --git a/implementations/python/packages/aces_sdl/validator/_runtime_platform.py b/implementations/python/packages/aces_sdl/validator/_runtime_platform.py index d66e2510f..797e0fdb1 100644 --- a/implementations/python/packages/aces_sdl/validator/_runtime_platform.py +++ b/implementations/python/packages/aces_sdl/validator/_runtime_platform.py @@ -85,11 +85,7 @@ def _verify_security_monitoring_children( service_names: set[str], observed_paths: set[str], ) -> None: - component_ids = {component.component_id for component in manager.components} - agent_ids = {agent.agent_id for agent in manager.agents} - group_ids = {group.group_id for group in manager.agent_groups} - content_set_ids = {content_set.content_id for content_set in manager.content_sets} - definition_ids = {definition.definition_id for definition in manager.detection_definitions} + ids = self._security_monitoring_local_ids(manager) for listener in manager.listeners: self._verify_owned_service_ref( node_name, @@ -97,6 +93,32 @@ def _verify_security_monitoring_children( service_names, owner_label=f"{owner_label} listener '{listener.listener_id}'", ) + self._verify_sm_agent_groups(manager, owner_label, ids, observed_paths) + self._verify_sm_agents(manager, owner_label, ids) + for content_set in manager.content_sets: + self._verify_dns_file_refs( + f"{owner_label} content_set '{content_set.content_id}'", + getattr(content_set, "file_refs", []), + field_name="file_refs", + observed_paths=observed_paths, + ) + for definition in manager.detection_definitions: + self._verify_sm_definition(definition, owner_label, ids, observed_paths) + self._verify_sm_settings(manager, owner_label, ids, observed_paths) + + @staticmethod + def _security_monitoring_local_ids(manager: object) -> dict[str, set[str]]: + return { + "component": {component.component_id for component in manager.components}, + "agent": {agent.agent_id for agent in manager.agents}, + "group": {group.group_id for group in manager.agent_groups}, + "content_set": {content_set.content_id for content_set in manager.content_sets}, + "definition": {definition.definition_id for definition in manager.detection_definitions}, + } + + def _verify_sm_agent_groups( + self, manager: object, owner_label: str, ids: dict[str, set[str]], observed_paths: set[str] + ) -> None: for group in manager.agent_groups: group_label = f"{owner_label} agent_group '{group.group_id}'" self._verify_dns_file_refs( @@ -108,83 +130,85 @@ def _verify_security_monitoring_children( for member_ref in group.member_refs: self._verify_security_monitoring_local_ref( member_ref, - agent_ids, + ids["agent"], owner_label=group_label, field_name="member_ref", target_label="agent", ) + + def _verify_sm_agents(self, manager: object, owner_label: str, ids: dict[str, set[str]]) -> None: for agent in manager.agents: agent_label = f"{owner_label} agent '{agent.agent_id}'" for group_ref in agent.group_refs: self._verify_security_monitoring_local_ref( group_ref, - group_ids, + ids["group"], owner_label=agent_label, field_name="group_ref", target_label="agent group", ) - for content_set in manager.content_sets: - self._verify_dns_file_refs( - f"{owner_label} content_set '{content_set.content_id}'", - getattr(content_set, "file_refs", []), - field_name="file_refs", - observed_paths=observed_paths, - ) - for definition in manager.detection_definitions: - definition_label = f"{owner_label} detection_definition '{definition.definition_id}'" - self._verify_security_monitoring_local_ref( - getattr(definition, "content_set_ref", ""), - content_set_ids, + + def _verify_sm_definition( + self, definition: object, owner_label: str, ids: dict[str, set[str]], observed_paths: set[str] + ) -> None: + definition_label = f"{owner_label} detection_definition '{definition.definition_id}'" + self._verify_security_monitoring_local_ref( + getattr(definition, "content_set_ref", ""), + ids["content_set"], + owner_label=definition_label, + field_name="content_set_ref", + target_label="content set", + ) + self._verify_dns_file_refs( + definition_label, + [definition.source_file_ref] if definition.source_file_ref else [], + field_name="source_file_ref", + observed_paths=observed_paths, + ) + self._verify_dns_file_refs( + definition_label, + getattr(definition, "evidence_refs", []), + field_name="evidence_refs", + observed_paths=observed_paths, + ) + for field_name, refs in ( + ("if_sid_ref", getattr(definition, "if_sid_refs", [])), + ("if_matched_sid_ref", getattr(definition, "if_matched_sid_refs", [])), + ("parent_definition_ref", getattr(definition, "parent_definition_refs", [])), + ): + for ref in refs: + self._verify_security_monitoring_local_ref( + ref, + ids["definition"], + owner_label=definition_label, + field_name=field_name, + target_label="detection definition", + ) + source_artifact_ref = getattr(definition, "source_artifact_ref", "") + if source_artifact_ref and not self._is_unresolved_var(source_artifact_ref): + self._validate_named_ref( + source_artifact_ref, owner_label=definition_label, - field_name="content_set_ref", - target_label="content set", + ref_label="source_artifact_ref", ) - self._verify_dns_file_refs( - definition_label, - [definition.source_file_ref] if definition.source_file_ref else [], - field_name="source_file_ref", - observed_paths=observed_paths, - ) - self._verify_dns_file_refs( - definition_label, - getattr(definition, "evidence_refs", []), - field_name="evidence_refs", - observed_paths=observed_paths, + for target_ref in getattr(definition, "target_refs", []): + if self._is_unresolved_var(target_ref): + continue + self._validate_named_ref( + target_ref, + owner_label=definition_label, + ref_label="target_ref", + targetable=True, ) - for field_name, refs in ( - ("if_sid_ref", getattr(definition, "if_sid_refs", [])), - ("if_matched_sid_ref", getattr(definition, "if_matched_sid_refs", [])), - ("parent_definition_ref", getattr(definition, "parent_definition_refs", [])), - ): - for ref in refs: - self._verify_security_monitoring_local_ref( - ref, - definition_ids, - owner_label=definition_label, - field_name=field_name, - target_label="detection definition", - ) - source_artifact_ref = getattr(definition, "source_artifact_ref", "") - if source_artifact_ref and not self._is_unresolved_var(source_artifact_ref): - self._validate_named_ref( - source_artifact_ref, - owner_label=definition_label, - ref_label="source_artifact_ref", - ) - for target_ref in getattr(definition, "target_refs", []): - if self._is_unresolved_var(target_ref): - continue - self._validate_named_ref( - target_ref, - owner_label=definition_label, - ref_label="target_ref", - targetable=True, - ) + + def _verify_sm_settings( + self, manager: object, owner_label: str, ids: dict[str, set[str]], observed_paths: set[str] + ) -> None: for setting in manager.settings: setting_label = f"{owner_label} setting '{setting.setting_id}'" self._verify_security_monitoring_local_ref( getattr(setting, "component_ref", ""), - component_ids, + ids["component"], owner_label=setting_label, field_name="component_ref", target_label="component", @@ -289,23 +313,28 @@ def _verify_platform_application( content_object_ids = {obj.content_object_id for obj in application.content_objects} marking_ids = {marking.marking_id for marking in application.markings} for content_object in application.content_objects: - object_label = f"{owner_label} content_object '{content_object.content_object_id}'" - for reference in content_object.references: - if self._is_unresolved_var(reference): - continue - if reference not in content_object_ids: - self._err( - f"{object_label} reference '{reference}' does not resolve to a " - f"content_object in the platform application" - ) - for marking_ref in content_object.marking_refs: - if self._is_unresolved_var(marking_ref): - continue - if marking_ref not in marking_ids: - self._err( - f"{object_label} marking_ref '{marking_ref}' does not resolve to a " - f"marking in the platform application" - ) + self._verify_platform_content_object(content_object, owner_label, content_object_ids, marking_ids) + + def _verify_platform_content_object( + self, content_object: object, owner_label: str, content_object_ids: set[str], marking_ids: set[str] + ) -> None: + object_label = f"{owner_label} content_object '{content_object.content_object_id}'" + for reference in content_object.references: + if self._is_unresolved_var(reference): + continue + if reference not in content_object_ids: + self._err( + f"{object_label} reference '{reference}' does not resolve to a " + f"content_object in the platform application" + ) + for marking_ref in content_object.marking_refs: + if self._is_unresolved_var(marking_ref): + continue + if marking_ref not in marking_ids: + self._err( + f"{object_label} marking_ref '{marking_ref}' does not resolve to a " + f"marking in the platform application" + ) @staticmethod def _node_app_authorization_ids(runtime: object) -> set[str]: @@ -392,17 +421,15 @@ def _verify_scenario_forwarding_ship_target(self, *, target: object, owner_label self._err(f"{owner_label} target_node_ref '{node_ref}' does not resolve to a defined node") return - if service_ref and not self._is_unresolved_var(service_ref): - if not node_ref: - self._err( - f"{owner_label} target_service_ref '{service_ref}' requires target_node_ref because " - "scenario-level forwarding agents have no owning node" - ) - return - if self._is_unresolved_var(node_ref): - return - if resolved_node is None: - return + if not service_ref or self._is_unresolved_var(service_ref): + return + if not node_ref: + self._err( + f"{owner_label} target_service_ref '{service_ref}' requires target_node_ref because " + "scenario-level forwarding agents have no owning node" + ) + return + if not self._is_unresolved_var(node_ref) and resolved_node is not None: if service_ref not in self._node_service_names(resolved_node): self._err( f"{owner_label} target_service_ref '{service_ref}' does not resolve to a service " diff --git a/implementations/python/packages/aces_sdl/validator/_runtime_services.py b/implementations/python/packages/aces_sdl/validator/_runtime_services.py index cac35c618..d0755a504 100644 --- a/implementations/python/packages/aces_sdl/validator/_runtime_services.py +++ b/implementations/python/packages/aces_sdl/validator/_runtime_services.py @@ -100,23 +100,29 @@ def _resolve_owned_service_ref( ) -> object | None: if not ref or self._is_unresolved_var(ref): return None - service_name = ref - if ref.startswith(_NODES_PREFIX): - split = self._split_node_service_ref(ref) - if split is None: - self._err( - f"{owner_label} service ref '{ref}' must be a bare service name or 'nodes..services.'" - ) - return None - ref_node_name, service_name = split - if ref_node_name != node_name: - self._err(f"{owner_label} service ref '{ref}' must reference a service on the same node") - return None + service_name = self._owned_service_name(node_name, ref, owner_label=owner_label) + if service_name is None: + return None service = services_by_name.get(service_name) if service is None: self._err(f"{owner_label} references undefined service '{service_name}'") return service + def _owned_service_name(self, node_name: str, ref: str, *, owner_label: str) -> str | None: + """Resolve a bare or ``nodes..services.`` ref to a same-node service name.""" + if not ref.startswith(_NODES_PREFIX): + return ref + split = self._split_node_service_ref(ref) + if split is not None and split[0] == node_name: + return split[1] + if split is None: + self._err( + f"{owner_label} service ref '{ref}' must be a bare service name or 'nodes..services.'" + ) + else: + self._err(f"{owner_label} service ref '{ref}' must reference a service on the same node") + return None + def _verify_route_upstream_target(self, node_name: str, application: object, route: object) -> None: target = getattr(route, "upstream_target", None) if target is None: @@ -450,7 +456,8 @@ def _verify_runtime_capability_overrides(self) -> None: continue self._check_override_subject_names(node_name, overrides, observed) - def _capability_overrides_for(self, node: object) -> list[object]: + @staticmethod + def _capability_overrides_for(node: object) -> list[object]: runtime = getattr(node, "runtime", None) if runtime is None: return [] diff --git a/implementations/python/packages/aces_sdl/validator/_sections.py b/implementations/python/packages/aces_sdl/validator/_sections.py index e742c13a1..ae7390a9b 100644 --- a/implementations/python/packages/aces_sdl/validator/_sections.py +++ b/implementations/python/packages/aces_sdl/validator/_sections.py @@ -12,39 +12,51 @@ from ..semantics.assessment import AssessmentIssue, analyze_assessment_pipeline from ._support import _topological_sort +# Renders an assessment-pipeline issue (machine-readable code from +# ``aces_sdl.semantics.assessment``) into the authoring-error string. Keyed by +# issue code so a new code is a new line here rather than a new conditional. +_ASSESSMENT_ISSUE_RENDERERS = { + "metric.condition-undeclared": (lambda i: f"Metric '{i.resource_name}' references undefined condition '{i.ref}'"), + "metric.condition-multiply-scored": (lambda i: f"Condition '{i.resource_name}' is referenced by multiple metrics"), + "evaluation.metric-undeclared": (lambda i: f"Evaluation '{i.resource_name}' references undefined metric '{i.ref}'"), + "evaluation.min-score-exceeds-metric-total": ( + lambda i: ( + f"Evaluation '{i.resource_name}' absolute min-score " + f"({i.observed}) exceeds sum of " + f"metric max-scores ({i.limit})" + ) + ), + "tlo.evaluation-undeclared": (lambda i: f"TLO '{i.resource_name}' references undefined evaluation '{i.ref}'"), + "goal.tlo-undeclared": (lambda i: f"Goal '{i.resource_name}' references undefined TLO '{i.ref}'"), +} + class _SectionsMixin: def _verify_variables(self) -> None: defined = set(self._s.variables.keys()) - - def visit(value: object, path: str) -> None: - if isinstance(value, BaseModel): - for field_name in value.__class__.model_fields: - if isinstance(value, Scenario) and field_name == "variables": - continue - child = getattr(value, field_name) - child_path = f"{path}.{field_name}" if path else field_name - visit(child, child_path) - return - - if isinstance(value, dict): - for key, child in value.items(): - child_path = f"{path}.{key}" if path else str(key) - visit(child, child_path) - return - - if isinstance(value, list): - for index, child in enumerate(value): - child_path = f"{path}[{index}]" - visit(child, child_path) - return - - if self._is_unresolved_var(value): - variable_name = extract_variable_name(value) - if variable_name and variable_name not in defined: - self._err(f"Undefined variable '{variable_name}' referenced at '{path}'") - - visit(self._s, "") + self._check_variable_refs(self._s, "", defined) + + def _check_variable_refs(self, value: object, path: str, defined: set[str]) -> None: + if isinstance(value, BaseModel): + self._check_model_variable_refs(value, path, defined) + elif isinstance(value, dict): + for key, child in value.items(): + self._check_variable_refs(child, f"{path}.{key}" if path else str(key), defined) + elif isinstance(value, list): + for index, child in enumerate(value): + self._check_variable_refs(child, f"{path}[{index}]", defined) + elif self._is_unresolved_var(value): + variable_name = extract_variable_name(value) + if variable_name and variable_name not in defined: + self._err(f"Undefined variable '{variable_name}' referenced at '{path}'") + + def _check_model_variable_refs(self, value: BaseModel, path: str, defined: set[str]) -> None: + for field_name in value.__class__.model_fields: + if isinstance(value, Scenario) and field_name == "variables": + continue + child = getattr(value, field_name) + child_path = f"{path}.{field_name}" if path else field_name + self._check_variable_refs(child, child_path, defined) def _verify_explicitness(self) -> None: result = classify_scenario_explicitness(self._s) @@ -61,7 +73,10 @@ def _all_targetable_elements(self) -> set[str]: return set(self._named_ref_index(targetable=True).keys()) def _verify_features(self) -> None: - # Check vulnerability references + self._verify_feature_vulnerability_refs() + self._verify_feature_dependency_cycles() + + def _verify_feature_vulnerability_refs(self) -> None: for name, feat in self._s.features.items(): for vuln_name in feat.vulnerabilities: if self._is_unresolved_var(vuln_name): @@ -69,7 +84,7 @@ def _verify_features(self) -> None: if vuln_name not in self._s.vulnerabilities: self._err(f"Feature '{name}' references undefined vulnerability '{vuln_name}'") - # Check dependency references and detect cycles + def _verify_feature_dependency_cycles(self) -> None: dep_graph: dict[str, list[str]] = {} for name, feat in self._s.features.items(): dep_graph[name] = [] @@ -80,7 +95,6 @@ def _verify_features(self) -> None: self._err(f"Feature '{name}' depends on undefined feature '{dep}'") else: dep_graph[name].append(dep) - if dep_graph and _topological_sort(dep_graph) is None: self._err("Feature dependency graph contains a cycle") @@ -111,81 +125,58 @@ def _verify_assessment_pipeline(self) -> None: @staticmethod def _format_assessment_issue(issue: AssessmentIssue) -> str: - name, ref = issue.resource_name, issue.ref - if issue.code == "metric.condition-undeclared": - return f"Metric '{name}' references undefined condition '{ref}'" - if issue.code == "metric.condition-multiply-scored": - return f"Condition '{name}' is referenced by multiple metrics" - if issue.code == "evaluation.metric-undeclared": - return f"Evaluation '{name}' references undefined metric '{ref}'" - if issue.code == "evaluation.min-score-exceeds-metric-total": - return ( - f"Evaluation '{name}' absolute min-score " - f"({issue.observed}) exceeds sum of " - f"metric max-scores ({issue.limit})" - ) - if issue.code == "tlo.evaluation-undeclared": - return f"TLO '{name}' references undefined evaluation '{ref}'" - if issue.code == "goal.tlo-undeclared": - return f"Goal '{name}' references undefined TLO '{ref}'" - raise AssertionError(f"unhandled assessment-pipeline issue code: {issue.code}") + renderer = _ASSESSMENT_ISSUE_RENDERERS.get(issue.code) + if renderer is None: + raise AssertionError(f"unhandled assessment-pipeline issue code: {issue.code}") + return renderer(issue) def _verify_entities(self) -> None: - flat = flatten_entities(self._s.entities) - - def check_entity(name: str, entity: "Entity") -> None: - for tlo_name in entity.tlos: - if self._is_unresolved_var(tlo_name): - continue - if tlo_name not in self._s.tlos: - self._err(f"Entity '{name}' references undefined TLO '{tlo_name}'") - for vuln_name in entity.vulnerabilities: - if self._is_unresolved_var(vuln_name): - continue - if vuln_name not in self._s.vulnerabilities: - self._err(f"Entity '{name}' references undefined vulnerability '{vuln_name}'") - for event_name in entity.events: - if self._is_unresolved_var(event_name): - continue - if event_name not in self._s.events: - self._err(f"Entity '{name}' references undefined event '{event_name}'") + for name, entity in flatten_entities(self._s.entities).items(): + self._verify_entity_refs(name, entity) - for name, entity in flat.items(): - check_entity(name, entity) + def _verify_entity_refs(self, name: str, entity: object) -> None: + self._verify_membership_refs( + entity.tlos, self._s.tlos, lambda ref: f"Entity '{name}' references undefined TLO '{ref}'" + ) + self._verify_membership_refs( + entity.vulnerabilities, + self._s.vulnerabilities, + lambda ref: f"Entity '{name}' references undefined vulnerability '{ref}'", + ) + self._verify_membership_refs( + entity.events, self._s.events, lambda ref: f"Entity '{name}' references undefined event '{ref}'" + ) def _verify_injects(self) -> None: flat_names = self._all_entity_names() - for name, inject in self._s.injects.items(): - if ( - inject.from_entity - and not self._is_unresolved_var(inject.from_entity) - and inject.from_entity not in flat_names - ): - self._err(f"Inject '{name}' from_entity '{inject.from_entity}' is not a defined entity") - for to_name in inject.to_entities: - if self._is_unresolved_var(to_name): - continue - if to_name not in flat_names: - self._err(f"Inject '{name}' to_entity '{to_name}' is not a defined entity") - for tlo_name in inject.tlos: - if self._is_unresolved_var(tlo_name): - continue - if tlo_name not in self._s.tlos: - self._err(f"Inject '{name}' references undefined TLO '{tlo_name}'") + self._verify_inject_refs(name, inject, flat_names) + + def _verify_inject_refs(self, name: str, inject: object, flat_names: set[str]) -> None: + if ( + inject.from_entity + and not self._is_unresolved_var(inject.from_entity) + and inject.from_entity not in flat_names + ): + self._err(f"Inject '{name}' from_entity '{inject.from_entity}' is not a defined entity") + self._verify_membership_refs( + inject.to_entities, flat_names, lambda ref: f"Inject '{name}' to_entity '{ref}' is not a defined entity" + ) + self._verify_membership_refs( + inject.tlos, self._s.tlos, lambda ref: f"Inject '{name}' references undefined TLO '{ref}'" + ) def _verify_events(self) -> None: for name, event in self._s.events.items(): - for cond_name in event.conditions: - if self._is_unresolved_var(cond_name): - continue - if cond_name not in self._s.conditions: - self._err(f"Event '{name}' references undefined condition '{cond_name}'") - for inj_name in event.injects: - if self._is_unresolved_var(inj_name): - continue - if inj_name not in self._s.injects: - self._err(f"Event '{name}' references undefined inject '{inj_name}'") + self._verify_event_refs(name, event) + + def _verify_event_refs(self, name: str, event: object) -> None: + self._verify_membership_refs( + event.conditions, self._s.conditions, lambda ref: f"Event '{name}' references undefined condition '{ref}'" + ) + self._verify_membership_refs( + event.injects, self._s.injects, lambda ref: f"Event '{name}' references undefined inject '{ref}'" + ) def _verify_scripts(self) -> None: for name, script in self._s.scripts.items(): diff --git a/implementations/python/packages/aces_sdl/validator/_support.py b/implementations/python/packages/aces_sdl/validator/_support.py index a2ce676b2..30077c509 100644 --- a/implementations/python/packages/aces_sdl/validator/_support.py +++ b/implementations/python/packages/aces_sdl/validator/_support.py @@ -1,12 +1,48 @@ """Shared module-level helpers for the SemanticValidator package.""" from collections import defaultdict, deque +from dataclasses import dataclass, field + +from ..orchestration import WorkflowStep # Common ref-path prefix used by qualified runtime/service refs (e.g. # ``nodes.vm.services.http``, ``nodes.vm.runtime.applications.webapp``). _NODES_PREFIX = "nodes." +@dataclass +class _WorkflowBuildState: + """Per-workflow graph-build accumulators threaded through edge collection.""" + + graph: dict[str, list[str]] + predicate_step_refs: dict[str, list[str]] = field(default_factory=dict) + join_targets: dict[str, list[str]] = field(default_factory=lambda: defaultdict(list)) + + +@dataclass +class _CompensationState: + """Cross-workflow call- and compensation-graph accumulators.""" + + call_graph: dict[str, set[str]] + compensation_graph: dict[str, set[str]] + compensation_targets: set[str] = field(default_factory=set) + workflows_with_compensation: set[str] = field(default_factory=set) + + +@dataclass +class _AvailableStateContext: + """State threaded through the predicate available-step-state recursion.""" + + workflow_steps: dict[str, WorkflowStep] + graph: dict[str, list[str]] + predecessors: dict[str, set[str]] + start: str + join_targets: dict[str, list[str]] + available_memo: dict[str, set[str]] = field(default_factory=dict) + branch_memo: dict[tuple[str, str], set[str]] = field(default_factory=dict) + visiting: set[str] = field(default_factory=set) + + def _topological_sort(graph: dict[str, list[str]]) -> list[str] | None: """Return topological order or None if a cycle exists.""" in_degree: dict[str, int] = defaultdict(int) diff --git a/implementations/python/packages/aces_sdl/validator/_workflows_analysis.py b/implementations/python/packages/aces_sdl/validator/_workflows_analysis.py index 543774647..074023010 100644 --- a/implementations/python/packages/aces_sdl/validator/_workflows_analysis.py +++ b/implementations/python/packages/aces_sdl/validator/_workflows_analysis.py @@ -5,6 +5,7 @@ from ..orchestration import Workflow, WorkflowPredicate, WorkflowStep, WorkflowStepType from ..semantics.workflow import workflow_step_semantic_contract +from ._support import _AvailableStateContext, _CompensationState, _WorkflowBuildState class _WorkflowAnalysisMixin: @@ -16,7 +17,12 @@ def _validate_workflow_predicate( workflow_steps: dict[str, WorkflowStep], ) -> list[str]: """Validate all references within a workflow predicate.""" - step_refs: list[str] = [] + self._validate_predicate_section_refs(workflow_name, step_name, predicate) + return self._validate_predicate_step_states(workflow_name, step_name, predicate, workflow_steps) + + def _validate_predicate_section_refs( + self, workflow_name: str, step_name: str, predicate: WorkflowPredicate + ) -> None: predicate_sections = ( ("condition", predicate.conditions, self._s.conditions), ("metric", predicate.metrics, self._s.metrics), @@ -35,6 +41,15 @@ def _validate_workflow_predicate( f"'{step_name}' references undefined " f"{label} '{ref}' in predicate" ) + + def _validate_predicate_step_states( + self, + workflow_name: str, + step_name: str, + predicate: WorkflowPredicate, + workflow_steps: dict[str, WorkflowStep], + ) -> list[str]: + step_refs: list[str] = [] for step_state in predicate.steps: if self._is_unresolved_var(step_state.step): continue @@ -73,7 +88,8 @@ def _validate_workflow_predicate( step_refs.append(step_state.step) return step_refs - def _is_executable_workflow_step(self, step: WorkflowStep) -> bool: + @staticmethod + def _is_executable_workflow_step(step: WorkflowStep) -> bool: return workflow_step_semantic_contract(step.type.value).state_observable def _validate_workflow_target_ref( @@ -84,9 +100,7 @@ def _validate_workflow_target_ref( target: str, workflow_steps: dict[str, WorkflowStep], ) -> str | None: - if not target: - return None - if self._is_unresolved_var(target): + if not target or self._is_unresolved_var(target): return None if target not in workflow_steps: self._err(f"Workflow '{workflow_name}' step '{step_name}' {field_name} step '{target}' is not defined") @@ -104,31 +118,15 @@ def _all_paths_reach_join( ) -> bool: if node == join: return True - if node in memo: - return memo[node] - if node in visiting: - return False - - visiting.add(node) - successors = graph.get(node, []) - if not successors: - visiting.remove(node) - memo[node] = False - return False - - result = all( - self._all_paths_reach_join( - successor, - join, - graph, - memo=memo, - visiting=visiting, + if node not in memo and node not in visiting: + visiting.add(node) + successors = graph.get(node, []) + memo[node] = bool(successors) and all( + self._all_paths_reach_join(successor, join, graph, memo=memo, visiting=visiting) + for successor in successors ) - for successor in successors - ) - visiting.remove(node) - memo[node] = result - return result + visiting.discard(node) + return memo.get(node, False) def _branch_guaranteed_states( self, @@ -142,14 +140,25 @@ def _branch_guaranteed_states( ) -> set[str]: if node == join: return set() - key = (node, join) - if key in memo: - return set(memo[key]) - if key in visiting: - return set() + if key not in memo and key not in visiting: + visiting.add(key) + memo[key] = self._branch_guaranteed_states_uncached( + node, join, graph, workflow_steps, memo=memo, visiting=visiting + ) + visiting.discard(key) + return set(memo.get(key, set())) - visiting.add(key) + def _branch_guaranteed_states_uncached( + self, + node: str, + join: str, + graph: dict[str, list[str]], + workflow_steps: dict[str, WorkflowStep], + *, + memo: dict[tuple[str, str], set[str]], + visiting: set[tuple[str, str]], + ) -> set[str]: successors = graph.get(node, []) guaranteed_after: set[str] = set() if successors: @@ -172,42 +181,15 @@ def _branch_guaranteed_states( ) if successor_sets: guaranteed_after = set.intersection(*successor_sets) - result = set(guaranteed_after) step = workflow_steps[node] if self._is_executable_workflow_step(step): result.add(node) - - visiting.remove(key) - memo[key] = set(result) return result - def _edge_available_state( - self, - step_name: str, - successor: str, - workflow_steps: dict[str, WorkflowStep], - graph: dict[str, list[str]], - predecessors: dict[str, set[str]], - start: str, - join_targets: dict[str, list[str]], - *, - available_memo: dict[str, set[str]], - branch_memo: dict[tuple[str, str], set[str]], - visiting: set[str], - ) -> set[str]: - available = self._available_step_state_before( - step_name, - workflow_steps, - graph, - predecessors, - start, - join_targets, - available_memo=available_memo, - branch_memo=branch_memo, - visiting=visiting, - ) - step = workflow_steps[step_name] + def _edge_available_state(self, step_name: str, successor: str, ctx: _AvailableStateContext) -> set[str]: + available = self._available_step_state_before(step_name, ctx) + step = ctx.workflow_steps[step_name] if step.type in { WorkflowStepType.OBJECTIVE, WorkflowStepType.RETRY, @@ -216,82 +198,47 @@ def _edge_available_state( available.add(step_name) return available - def _available_step_state_before( - self, - step_name: str, - workflow_steps: dict[str, WorkflowStep], - graph: dict[str, list[str]], - predecessors: dict[str, set[str]], - start: str, - join_targets: dict[str, list[str]], - *, - available_memo: dict[str, set[str]], - branch_memo: dict[tuple[str, str], set[str]], - visiting: set[str], - ) -> set[str]: - if step_name in available_memo: - return set(available_memo[step_name]) - if step_name in visiting: + def _available_step_state_before(self, step_name: str, ctx: _AvailableStateContext) -> set[str]: + if step_name in ctx.available_memo: + return set(ctx.available_memo[step_name]) + if step_name in ctx.visiting: return set() - visiting.add(step_name) - step = workflow_steps[step_name] - - if step_name == start: - result = set() - elif step.type == WorkflowStepType.JOIN and join_targets.get(step_name): - owner = join_targets[step_name][0] - result = self._available_step_state_before( - owner, - workflow_steps, - graph, - predecessors, - start, - join_targets, - available_memo=available_memo, - branch_memo=branch_memo, - visiting=visiting, - ) - result.add(owner) - owner_step = workflow_steps[owner] - for branch in owner_step.branches: - if branch not in workflow_steps: - continue - result.update( - self._branch_guaranteed_states( - branch, - step_name, - graph, - workflow_steps, - memo=branch_memo, - visiting=set(), - ) - ) + ctx.visiting.add(step_name) + step = ctx.workflow_steps[step_name] + if step_name == ctx.start: + result: set[str] = set() + elif step.type == WorkflowStepType.JOIN and ctx.join_targets.get(step_name): + result = self._join_available_state(step_name, ctx) else: - incoming_states: list[set[str]] = [] - for predecessor in predecessors.get(step_name, set()): - if predecessor not in workflow_steps: - continue - incoming_states.append( - self._edge_available_state( - predecessor, - step_name, - workflow_steps, - graph, - predecessors, - start, - join_targets, - available_memo=available_memo, - branch_memo=branch_memo, - visiting=visiting, - ) - ) - result = set.intersection(*incoming_states) if incoming_states else set() + result = self._incoming_available_state(step_name, ctx) + ctx.visiting.remove(step_name) + ctx.available_memo[step_name] = set(result) + return result - visiting.remove(step_name) - available_memo[step_name] = set(result) + def _join_available_state(self, step_name: str, ctx: _AvailableStateContext) -> set[str]: + owner = ctx.join_targets[step_name][0] + result = self._available_step_state_before(owner, ctx) + result.add(owner) + owner_step = ctx.workflow_steps[owner] + for branch in owner_step.branches: + if branch not in ctx.workflow_steps: + continue + result.update( + self._branch_guaranteed_states( + branch, step_name, ctx.graph, ctx.workflow_steps, memo=ctx.branch_memo, visiting=set() + ) + ) return result + def _incoming_available_state(self, step_name: str, ctx: _AvailableStateContext) -> set[str]: + incoming_states: list[set[str]] = [] + for predecessor in ctx.predecessors.get(step_name, set()): + if predecessor not in ctx.workflow_steps: + continue + incoming_states.append(self._edge_available_state(predecessor, step_name, ctx)) + return set.intersection(*incoming_states) if incoming_states else set() + def _verify_step_terminator_and_compensation( self, *, @@ -299,10 +246,8 @@ def _verify_step_terminator_and_compensation( step_name: str, step: WorkflowStep, workflow: Workflow, - graph: dict[str, list[str]], - workflow_compensation_graph: dict[str, set[str]], - compensation_target_workflows: set[str], - workflows_with_compensation_steps: set[str], + build: _WorkflowBuildState, + comp: _CompensationState, ) -> None: """Shared validation for `on-success`/`on-failure` and `compensate_with`. @@ -315,17 +260,11 @@ def _verify_step_terminator_and_compensation( ("on-success", step.on_success), ("on-failure", step.on_failure), ): - resolved = self._validate_workflow_target_ref( - workflow_name, - step_name, - field_name, - target, - workflow.steps, - ) + resolved = self._validate_workflow_target_ref(workflow_name, step_name, field_name, target, workflow.steps) if resolved is not None: - graph[step_name].append(resolved) + build.graph[step_name].append(resolved) if step.compensate_with: - workflows_with_compensation_steps.add(workflow_name) + comp.workflows_with_compensation.add(workflow_name) if not self._is_unresolved_var(step.compensate_with) and step.compensate_with not in self._s.workflows: self._err( f"Workflow '{workflow_name}' step '{step_name}' " @@ -333,5 +272,5 @@ def _verify_step_terminator_and_compensation( f"'{step.compensate_with}'" ) elif not self._is_unresolved_var(step.compensate_with): - workflow_compensation_graph.setdefault(workflow_name, set()).add(step.compensate_with) - compensation_target_workflows.add(step.compensate_with) + comp.compensation_graph.setdefault(workflow_name, set()).add(step.compensate_with) + comp.compensation_targets.add(step.compensate_with) diff --git a/implementations/python/packages/aces_sdl/validator/_workflows_verify.py b/implementations/python/packages/aces_sdl/validator/_workflows_verify.py index 68f375cbd..f184553c2 100644 --- a/implementations/python/packages/aces_sdl/validator/_workflows_verify.py +++ b/implementations/python/packages/aces_sdl/validator/_workflows_verify.py @@ -3,335 +3,355 @@ Part of the SemanticValidator mixin composition; see __init__.py. """ -from collections import defaultdict - from ..orchestration import WorkflowStepType from ..semantics.workflow import branch_closure -from ._support import _topological_sort +from ._support import _AvailableStateContext, _CompensationState, _topological_sort, _WorkflowBuildState class _WorkflowVerifyMixin: def _verify_workflows(self) -> None: - workflow_call_graph: dict[str, set[str]] = {workflow_name: set() for workflow_name in self._s.workflows} - workflow_compensation_graph: dict[str, set[str]] = {workflow_name: set() for workflow_name in self._s.workflows} - compensation_target_workflows: set[str] = set() - workflows_with_compensation_steps: set[str] = set() + comp = _CompensationState( + call_graph={workflow_name: set() for workflow_name in self._s.workflows}, + compensation_graph={workflow_name: set() for workflow_name in self._s.workflows}, + ) for workflow_name, workflow in self._s.workflows.items(): - if not self._is_unresolved_var(workflow.start) and workflow.start not in workflow.steps: - self._err(f"Workflow '{workflow_name}' start step '{workflow.start}' is not defined") - - graph: dict[str, list[str]] = {step_name: [] for step_name in workflow.steps} - predicate_step_refs: dict[str, list[str]] = {} - join_targets: dict[str, list[str]] = defaultdict(list) - - for step_name, step in workflow.steps.items(): - if "." in step_name: - self._err( - f"Workflow '{workflow_name}' step '{step_name}' cannot " - "contain '.' because objective windows use " - "'.' syntax" - ) + self._verify_workflow(workflow_name, workflow, comp) + self._verify_workflow_call_cycles(comp) + self._verify_compensation_targets(comp) - if step.type == WorkflowStepType.OBJECTIVE: - if not self._is_unresolved_var(step.objective) and step.objective not in self._s.objectives: - self._err( - f"Workflow '{workflow_name}' step '{step_name}' " - f"references undefined objective '{step.objective}'" - ) - self._verify_step_terminator_and_compensation( - workflow_name=workflow_name, - step_name=step_name, - step=step, - workflow=workflow, - graph=graph, - workflow_compensation_graph=workflow_compensation_graph, - compensation_target_workflows=compensation_target_workflows, - workflows_with_compensation_steps=workflows_with_compensation_steps, - ) + def _verify_workflow(self, workflow_name: str, workflow: object, comp: _CompensationState) -> None: + if not self._is_unresolved_var(workflow.start) and workflow.start not in workflow.steps: + self._err(f"Workflow '{workflow_name}' start step '{workflow.start}' is not defined") + build = self._build_workflow_step_graph(workflow_name, workflow, comp) + self._verify_workflow_join_targets(workflow_name, workflow, build.join_targets) + self._verify_workflow_unreferenced_joins(workflow_name, workflow, build.join_targets) + if build.graph and _topological_sort(build.graph) is None: + self._err(f"Workflow '{workflow_name}' graph contains a cycle") + if self._is_unresolved_var(workflow.start) or workflow.start not in workflow.steps: + return + reachable = self._reachable_steps(workflow, build.graph) + self._verify_workflow_reachability(workflow_name, workflow, reachable) + predecessors = self._build_predecessors(build.graph, reachable) + self._verify_parallel_join_closures(workflow_name, workflow, build.graph, predecessors, reachable) + self._verify_predicate_available_state(workflow_name, workflow, build, predecessors, reachable) + self._verify_parallel_branch_convergence(workflow_name, workflow, build.graph) - elif step.type == WorkflowStepType.DECISION: - predicate_step_refs[step_name] = self._validate_workflow_predicate( - workflow_name, - step_name, - step.when, - workflow.steps, - ) + # ------------------------------------------------------------------ + # Per-step edge collection + # ------------------------------------------------------------------ - for branch_label, branch_ref in ( - ("then", step.then_step), - ("else", step.else_step), - ): - resolved = self._validate_workflow_target_ref( - workflow_name, - step_name, - branch_label, - branch_ref, - workflow.steps, - ) - if resolved is not None: - graph[step_name].append(resolved) + def _build_workflow_step_graph( + self, workflow_name: str, workflow: object, comp: _CompensationState + ) -> _WorkflowBuildState: + build = _WorkflowBuildState(graph={step_name: [] for step_name in workflow.steps}) + for step_name, step in workflow.steps.items(): + if "." in step_name: + self._err( + f"Workflow '{workflow_name}' step '{step_name}' cannot " + "contain '.' because objective windows use " + "'.' syntax" + ) + self._collect_step_edges(workflow_name, workflow, step_name, step, build, comp) + if step_name not in build.graph: + build.graph[step_name] = [] + return build - elif step.type == WorkflowStepType.SWITCH: - aggregated_refs: list[str] = [] - for case_index, case in enumerate(step.cases): - aggregated_refs.extend( - self._validate_workflow_predicate( - workflow_name, - f"{step_name}.case[{case_index}]", - case.when, - workflow.steps, - ) - ) - resolved = self._validate_workflow_target_ref( - workflow_name, - step_name, - f"case[{case_index}] next", - case.next_step, - workflow.steps, - ) - if resolved is not None: - graph[step_name].append(resolved) - predicate_step_refs[step_name] = aggregated_refs - resolved_default = self._validate_workflow_target_ref( - workflow_name, - step_name, - "default", - step.default_step, - workflow.steps, - ) - if resolved_default is not None: - graph[step_name].append(resolved_default) + def _collect_step_edges( + self, + workflow_name: str, + workflow: object, + step_name: str, + step: object, + build: _WorkflowBuildState, + comp: _CompensationState, + ) -> None: + if step.type == WorkflowStepType.OBJECTIVE: + self._collect_objective_step(workflow_name, workflow, step_name, step, build, comp) + elif step.type == WorkflowStepType.DECISION: + self._collect_decision_step(workflow_name, workflow, step_name, step, build) + elif step.type == WorkflowStepType.SWITCH: + self._collect_switch_step(workflow_name, workflow, step_name, step, build) + elif step.type == WorkflowStepType.PARALLEL: + self._collect_parallel_step(workflow_name, workflow, step_name, step, build) + elif step.type == WorkflowStepType.JOIN: + self._collect_join_step(workflow_name, workflow, step_name, step, build) + elif step.type == WorkflowStepType.RETRY: + self._collect_retry_step(workflow_name, workflow, step_name, step, build) + elif step.type == WorkflowStepType.CALL: + self._collect_call_step(workflow_name, workflow, step_name, step, build, comp) + elif step.type == WorkflowStepType.END: + build.graph[step_name] = [] - elif step.type == WorkflowStepType.PARALLEL: - for branch_ref in step.branches: - resolved = self._validate_workflow_target_ref( - workflow_name, - step_name, - "branch", - branch_ref, - workflow.steps, - ) - if resolved is not None: - graph[step_name].append(resolved) - resolved_join = self._validate_workflow_target_ref( - workflow_name, - step_name, - "join", - step.join, - workflow.steps, - ) - if resolved_join is not None: - join_targets[resolved_join].append(step_name) - resolved_failure = self._validate_workflow_target_ref( - workflow_name, - step_name, - "on-failure", - step.on_failure, - workflow.steps, - ) - if resolved_failure is not None: - graph[step_name].append(resolved_failure) + def _collect_objective_step( + self, + workflow_name: str, + workflow: object, + step_name: str, + step: object, + build: _WorkflowBuildState, + comp: _CompensationState, + ) -> None: + if not self._is_unresolved_var(step.objective) and step.objective not in self._s.objectives: + self._err( + f"Workflow '{workflow_name}' step '{step_name}' references undefined objective '{step.objective}'" + ) + self._verify_step_terminator_and_compensation( + workflow_name=workflow_name, step_name=step_name, step=step, workflow=workflow, build=build, comp=comp + ) - elif step.type == WorkflowStepType.JOIN: - resolved = self._validate_workflow_target_ref( - workflow_name, - step_name, - "next", - step.next, - workflow.steps, - ) - if resolved is not None: - graph[step_name].append(resolved) + def _collect_decision_step( + self, workflow_name: str, workflow: object, step_name: str, step: object, build: _WorkflowBuildState + ) -> None: + build.predicate_step_refs[step_name] = self._validate_workflow_predicate( + workflow_name, step_name, step.when, workflow.steps + ) + for branch_label, branch_ref in (("then", step.then_step), ("else", step.else_step)): + resolved = self._validate_workflow_target_ref( + workflow_name, step_name, branch_label, branch_ref, workflow.steps + ) + if resolved is not None: + build.graph[step_name].append(resolved) - elif step.type == WorkflowStepType.RETRY: - if not self._is_unresolved_var(step.objective) and step.objective not in self._s.objectives: - self._err( - f"Workflow '{workflow_name}' step '{step_name}' " - f"references undefined objective '{step.objective}'" - ) - for field_name, target in ( - ("on-success", step.on_success), - ("on-exhausted", step.on_exhausted), - ): - resolved = self._validate_workflow_target_ref( - workflow_name, - step_name, - field_name, - target, - workflow.steps, - ) - if resolved is not None: - graph[step_name].append(resolved) + def _collect_switch_step( + self, workflow_name: str, workflow: object, step_name: str, step: object, build: _WorkflowBuildState + ) -> None: + aggregated_refs: list[str] = [] + for case_index, case in enumerate(step.cases): + aggregated_refs.extend( + self._validate_workflow_predicate( + workflow_name, f"{step_name}.case[{case_index}]", case.when, workflow.steps + ) + ) + resolved = self._validate_workflow_target_ref( + workflow_name, step_name, f"case[{case_index}] next", case.next_step, workflow.steps + ) + if resolved is not None: + build.graph[step_name].append(resolved) + build.predicate_step_refs[step_name] = aggregated_refs + resolved_default = self._validate_workflow_target_ref( + workflow_name, step_name, "default", step.default_step, workflow.steps + ) + if resolved_default is not None: + build.graph[step_name].append(resolved_default) - elif step.type == WorkflowStepType.CALL: - if not self._is_unresolved_var(step.workflow) and step.workflow not in self._s.workflows: - self._err( - f"Workflow '{workflow_name}' step '{step_name}' " - f"references undefined workflow '{step.workflow}'" - ) - elif not self._is_unresolved_var(step.workflow): - workflow_call_graph.setdefault(workflow_name, set()).add(step.workflow) - self._verify_step_terminator_and_compensation( - workflow_name=workflow_name, - step_name=step_name, - step=step, - workflow=workflow, - graph=graph, - workflow_compensation_graph=workflow_compensation_graph, - compensation_target_workflows=compensation_target_workflows, - workflows_with_compensation_steps=workflows_with_compensation_steps, - ) + def _collect_parallel_step( + self, workflow_name: str, workflow: object, step_name: str, step: object, build: _WorkflowBuildState + ) -> None: + for branch_ref in step.branches: + resolved = self._validate_workflow_target_ref( + workflow_name, step_name, "branch", branch_ref, workflow.steps + ) + if resolved is not None: + build.graph[step_name].append(resolved) + resolved_join = self._validate_workflow_target_ref(workflow_name, step_name, "join", step.join, workflow.steps) + if resolved_join is not None: + build.join_targets[resolved_join].append(step_name) + resolved_failure = self._validate_workflow_target_ref( + workflow_name, step_name, "on-failure", step.on_failure, workflow.steps + ) + if resolved_failure is not None: + build.graph[step_name].append(resolved_failure) - elif step.type == WorkflowStepType.END: - graph[step_name] = [] + def _collect_join_step( + self, workflow_name: str, workflow: object, step_name: str, step: object, build: _WorkflowBuildState + ) -> None: + resolved = self._validate_workflow_target_ref(workflow_name, step_name, "next", step.next, workflow.steps) + if resolved is not None: + build.graph[step_name].append(resolved) - if step_name not in graph: - graph[step_name] = [] + def _collect_retry_step( + self, workflow_name: str, workflow: object, step_name: str, step: object, build: _WorkflowBuildState + ) -> None: + if not self._is_unresolved_var(step.objective) and step.objective not in self._s.objectives: + self._err( + f"Workflow '{workflow_name}' step '{step_name}' references undefined objective '{step.objective}'" + ) + for field_name, target in (("on-success", step.on_success), ("on-exhausted", step.on_exhausted)): + resolved = self._validate_workflow_target_ref(workflow_name, step_name, field_name, target, workflow.steps) + if resolved is not None: + build.graph[step_name].append(resolved) - for join_step, sources in join_targets.items(): - if self._is_unresolved_var(join_step): - continue - join_def = workflow.steps.get(join_step) - if join_def is not None and join_def.type != WorkflowStepType.JOIN: - self._err( - f"Workflow '{workflow_name}' step '{join_step}' is used " - "as a parallel join but is not a join step" - ) - if len(sources) > 1: - self._err( - f"Workflow '{workflow_name}' join step '{join_step}' may only be targeted by one parallel step" - ) + def _collect_call_step( + self, + workflow_name: str, + workflow: object, + step_name: str, + step: object, + build: _WorkflowBuildState, + comp: _CompensationState, + ) -> None: + if not self._is_unresolved_var(step.workflow) and step.workflow not in self._s.workflows: + self._err(f"Workflow '{workflow_name}' step '{step_name}' references undefined workflow '{step.workflow}'") + elif not self._is_unresolved_var(step.workflow): + comp.call_graph.setdefault(workflow_name, set()).add(step.workflow) + self._verify_step_terminator_and_compensation( + workflow_name=workflow_name, step_name=step_name, step=step, workflow=workflow, build=build, comp=comp + ) - for step_name, step in workflow.steps.items(): - if step.type != WorkflowStepType.JOIN: - continue - sources = join_targets.get(step_name, []) - if not sources: - self._err( - f"Workflow '{workflow_name}' join step '{step_name}' is not referenced by any parallel step" - ) + # ------------------------------------------------------------------ + # Per-workflow structural checks + # ------------------------------------------------------------------ - if graph and _topological_sort(graph) is None: - self._err(f"Workflow '{workflow_name}' graph contains a cycle") + def _verify_workflow_join_targets( + self, workflow_name: str, workflow: object, join_targets: dict[str, list[str]] + ) -> None: + for join_step, sources in join_targets.items(): + if self._is_unresolved_var(join_step): + continue + join_def = workflow.steps.get(join_step) + if join_def is not None and join_def.type != WorkflowStepType.JOIN: + self._err( + f"Workflow '{workflow_name}' step '{join_step}' is used as a parallel join but is not a join step" + ) + if len(sources) > 1: + self._err( + f"Workflow '{workflow_name}' join step '{join_step}' may only be targeted by one parallel step" + ) - if self._is_unresolved_var(workflow.start) or workflow.start not in workflow.steps: + def _verify_workflow_unreferenced_joins( + self, workflow_name: str, workflow: object, join_targets: dict[str, list[str]] + ) -> None: + for step_name, step in workflow.steps.items(): + if step.type != WorkflowStepType.JOIN: continue + if not join_targets.get(step_name, []): + self._err(f"Workflow '{workflow_name}' join step '{step_name}' is not referenced by any parallel step") - reachable: set[str] = set() - stack = [workflow.start] - while stack: - current = stack.pop() - if current in reachable: - continue - reachable.add(current) - stack.extend(graph.get(current, [])) + @staticmethod + def _reachable_steps(workflow: object, graph: dict[str, list[str]]) -> set[str]: + reachable: set[str] = set() + stack = [workflow.start] + while stack: + current = stack.pop() + if current in reachable: + continue + reachable.add(current) + stack.extend(graph.get(current, [])) + return reachable - unreachable = sorted(set(workflow.steps) - reachable) - if unreachable: - self._err(f"Workflow '{workflow_name}' contains unreachable steps: " + ", ".join(unreachable)) + def _verify_workflow_reachability(self, workflow_name: str, workflow: object, reachable: set[str]) -> None: + unreachable = sorted(set(workflow.steps) - reachable) + if unreachable: + self._err(f"Workflow '{workflow_name}' contains unreachable steps: " + ", ".join(unreachable)) - predecessors: dict[str, set[str]] = {step_name: set() for step_name in reachable} - for source, edges in graph.items(): - if source not in reachable: - continue - for target in edges: - if target in reachable: - predecessors[target].add(source) + @staticmethod + def _build_predecessors(graph: dict[str, list[str]], reachable: set[str]) -> dict[str, set[str]]: + predecessors: dict[str, set[str]] = {step_name: set() for step_name in reachable} + for source, edges in graph.items(): + if source not in reachable: + continue + for target in edges: + if target in reachable: + predecessors[target].add(source) + return predecessors - for _step_name, step in workflow.steps.items(): - if step.type != WorkflowStepType.PARALLEL: - continue - if self._is_unresolved_var(step.join) or step.join not in workflow.steps or step.join not in reachable: - continue - allowed_predecessors = branch_closure( - graph, - branches=(branch for branch in step.branches if branch in reachable and branch in workflow.steps), - join_step=step.join, - ) - foreign_predecessors = sorted( - predecessor - for predecessor in predecessors.get(step.join, set()) - if predecessor not in allowed_predecessors + def _verify_parallel_join_closures( + self, + workflow_name: str, + workflow: object, + graph: dict[str, list[str]], + predecessors: dict[str, set[str]], + reachable: set[str], + ) -> None: + for _step_name, step in workflow.steps.items(): + if step.type != WorkflowStepType.PARALLEL: + continue + if self._is_unresolved_var(step.join) or step.join not in workflow.steps or step.join not in reachable: + continue + allowed_predecessors = branch_closure( + graph, + branches=(branch for branch in step.branches if branch in reachable and branch in workflow.steps), + join_step=step.join, + ) + foreign_predecessors = sorted( + predecessor + for predecessor in predecessors.get(step.join, set()) + if predecessor not in allowed_predecessors + ) + if foreign_predecessors: + self._err( + f"Workflow '{workflow_name}' join step '{step.join}' " + "may only be entered from the owning parallel's branch " + "closure; unexpected predecessors: " + ", ".join(foreign_predecessors) ) - if foreign_predecessors: - self._err( - f"Workflow '{workflow_name}' join step '{step.join}' " - "may only be entered from the owning parallel's branch " - "closure; unexpected predecessors: " + ", ".join(foreign_predecessors) - ) - available_memo: dict[str, set[str]] = {} - branch_memo: dict[tuple[str, str], set[str]] = {} + def _verify_predicate_available_state( + self, + workflow_name: str, + workflow: object, + build: _WorkflowBuildState, + predecessors: dict[str, set[str]], + reachable: set[str], + ) -> None: + ctx = _AvailableStateContext( + workflow_steps=workflow.steps, + graph=build.graph, + predecessors=predecessors, + start=workflow.start, + join_targets=build.join_targets, + ) + for step_name, refs in build.predicate_step_refs.items(): + if step_name not in reachable: + continue + ctx.visiting = set() + available_before = self._available_step_state_before(step_name, ctx) + self._verify_predicate_refs_available(workflow_name, step_name, refs, available_before) - for step_name, refs in predicate_step_refs.items(): - if step_name not in reachable: - continue - available_before = self._available_step_state_before( - step_name, - workflow.steps, - graph, - predecessors, - workflow.start, - join_targets, - available_memo=available_memo, - branch_memo=branch_memo, - visiting=set(), + def _verify_predicate_refs_available( + self, workflow_name: str, step_name: str, refs: list[str], available_before: set[str] + ) -> None: + for ref_name in refs: + if self._is_unresolved_var(ref_name): + continue + if ref_name not in available_before: + self._err( + f"Workflow '{workflow_name}' step '{step_name}' " + f"references step state '{ref_name}' that is not " + "guaranteed to be known before this predicate" ) - for ref_name in refs: - if self._is_unresolved_var(ref_name): - continue - if ref_name not in available_before: - self._err( - f"Workflow '{workflow_name}' step '{step_name}' " - f"references step state '{ref_name}' that is not " - "guaranteed to be known before this predicate" - ) - for step_name, step in workflow.steps.items(): - if step.type != WorkflowStepType.PARALLEL: - continue - if self._is_unresolved_var(step.join) or step.join not in workflow.steps: + def _verify_parallel_branch_convergence( + self, workflow_name: str, workflow: object, graph: dict[str, list[str]] + ) -> None: + for step_name, step in workflow.steps.items(): + if step.type != WorkflowStepType.PARALLEL: + continue + if self._is_unresolved_var(step.join) or step.join not in workflow.steps: + continue + for branch_ref in step.branches: + if self._is_unresolved_var(branch_ref) or branch_ref not in workflow.steps: continue - for branch_ref in step.branches: - if self._is_unresolved_var(branch_ref) or branch_ref not in workflow.steps: - continue - if not self._all_paths_reach_join( - branch_ref, - step.join, - graph, - memo={}, - visiting=set(), - ): - self._err( - f"Workflow '{workflow_name}' parallel step " - f"'{step_name}' requires every explicit branch path " - f"from '{branch_ref}' to converge on join " - f"'{step.join}'" - ) + if not self._all_paths_reach_join(branch_ref, step.join, graph, memo={}, visiting=set()): + self._err( + f"Workflow '{workflow_name}' parallel step " + f"'{step_name}' requires every explicit branch path " + f"from '{branch_ref}' to converge on join " + f"'{step.join}'" + ) - if ( - workflow_call_graph - and _topological_sort( - { - workflow_name: sorted(callee for callee in callees if callee in workflow_call_graph) - for workflow_name, callees in workflow_call_graph.items() - } - ) - is None - ): - self._err("Workflow call graph contains a cycle") + # ------------------------------------------------------------------ + # Cross-workflow checks + # ------------------------------------------------------------------ - combined_workflow_graph = { + def _verify_workflow_call_cycles(self, comp: _CompensationState) -> None: + call_only = { + workflow_name: sorted(callee for callee in callees if callee in comp.call_graph) + for workflow_name, callees in comp.call_graph.items() + } + if call_only and _topological_sort(call_only) is None: + self._err("Workflow call graph contains a cycle") + combined = { workflow_name: sorted( - workflow_call_graph.get(workflow_name, set()) | workflow_compensation_graph.get(workflow_name, set()) + comp.call_graph.get(workflow_name, set()) | comp.compensation_graph.get(workflow_name, set()) ) for workflow_name in self._s.workflows } - if combined_workflow_graph and _topological_sort(combined_workflow_graph) is None: + if combined and _topological_sort(combined) is None: self._err("Combined workflow call/compensation graph contains a cycle") - for workflow_name in sorted(compensation_target_workflows): - if workflow_name in workflows_with_compensation_steps: + def _verify_compensation_targets(self, comp: _CompensationState) -> None: + for workflow_name in sorted(comp.compensation_targets): + if workflow_name in comp.workflows_with_compensation: self._err( f"Workflow '{workflow_name}' cannot be used as a compensation " "workflow because it also declares compensate-with steps" diff --git a/implementations/python/pyproject.toml b/implementations/python/pyproject.toml index 8b5da33a2..868eb182e 100644 --- a/implementations/python/pyproject.toml +++ b/implementations/python/pyproject.toml @@ -131,4 +131,3 @@ ignore = [ "packages/aces_runtime/control_plane_api.py" = ["B008"] # fastapi Depends() pattern "packages/aces_runtime/control_plane.py" = ["S112"] # intentional exception suppression "packages/aces_sdl/module_registry.py" = ["S310", "S202"] # explicit OCI URL fetch and tarball extract -"packages/aces_sdl/validator/_sections.py" = ["F821"] # string-quoted forward references in closures From b875ee6a1c4b750505d59297a335e5b69fecbd5c Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Thu, 18 Jun 2026 17:55:57 +0200 Subject: [PATCH 69/70] test: trim tautological self-checks from participant spec-oracle suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two executable spec-oracle test files re-encode formal-spec invariants as test-local predicates with no production callers, and several tests asserted that a co-authored fixture/generator satisfies those same predicates — passing by construction and overstating participant-semantics/runtime coverage. - semantics oracle: drop the catalog self-check and both Hypothesis "generator output satisfies the predicates" tests; remove the now-dead generator strategy and its imports. - runtime oracle: drop the six bare *_accepts_generated_valid_traces tests. - keep the spec/catalog drift guard, the per-invariant mutation-rejection differential tests, the *_accepts_supported_*_variants over-rejection guards, and the canonical positive control (now commented as such). - relabel both module docstrings as spec self-consistency oracles (not tests of production code), pointing to the behavioural suites that cover runtime enforcement (test_sem_211_*..test_sem_218_*, test_run_305/306/311_*). Closes #558 --- .../test_participant_runtime_invariants.py | 54 +++----- ..._participant_semantics_invariant_oracle.py | 116 +++++------------- 2 files changed, 46 insertions(+), 124 deletions(-) diff --git a/implementations/python/tests/test_participant_runtime_invariants.py b/implementations/python/tests/test_participant_runtime_invariants.py index cef11ae61..dab13f4c0 100644 --- a/implementations/python/tests/test_participant_runtime_invariants.py +++ b/implementations/python/tests/test_participant_runtime_invariants.py @@ -1,10 +1,26 @@ -"""Executable oracle for participant-runtime trace predicates. +"""Self-consistency oracle for the participant-runtime trace predicates. + +This file is NOT a test of a production participant-runtime subsystem. The +predicates below (`ValidTrace`, `MonotoneSequence`, `RevisionDiscipline`, +`OrderDiscipline`, `ConflictOK`, `TimeManagementOK`) have no callers under +``src/`` or ``packages/`` — they are a closed, test-local executable encoding of +the trace predicates published in the participant-runtime formal spec. The suite +checks only that the encoding *discriminates*: each predicate accepts the +legitimate variants of a spec-conforming trace and rejects a targeted violation +while the other (non-targeted) predicates continue to hold. Spec mapping: - `ValidTrace`, `MonotoneSequence`, `RevisionDiscipline`, and `OrderDiscipline`: specs/formal/participant-runtime/README.md:1265 - `ConflictOK` and `TimeManagementOK`: specs/formal/participant-runtime/README.md:2417 + +The acceptance direction is exercised only as a positive control alongside the +rejection tests (the ``*_accepts_supported_*_variants`` tests, plus the +``assert (mutated)`` lines inside each rejection test). A green +run here is NOT evidence that production code enforces these predicates; runtime +enforcement of participant-runtime behaviour is covered behaviourally by +``test_run_305_*``, ``test_run_306_*``, and ``test_run_311_*``. """ from __future__ import annotations @@ -788,12 +804,6 @@ def with_invalid_time_context_variant(trace: RuntimeTrace, variant: str) -> Runt return replace(trace, time_contexts=(context,)) -@PROPERTY_SETTINGS -@given(valid_traces()) -def test_valid_trace_accepts_generated_valid_traces(trace: RuntimeTrace) -> None: - assert valid_trace(trace) - - @PROPERTY_SETTINGS @given(valid_traces()) def test_valid_trace_rejects_targeted_mutations(trace: RuntimeTrace) -> None: @@ -804,12 +814,6 @@ def test_valid_trace_rejects_targeted_mutations(trace: RuntimeTrace) -> None: assert not valid_trace(with_time_domain_violation(trace)) -@PROPERTY_SETTINGS -@given(valid_traces()) -def test_monotone_sequence_accepts_generated_valid_traces(trace: RuntimeTrace) -> None: - assert monotone_sequence(trace) - - @PROPERTY_SETTINGS @given(valid_traces()) def test_monotone_sequence_rejects_sequence_regression(trace: RuntimeTrace) -> None: @@ -822,12 +826,6 @@ def test_monotone_sequence_rejects_sequence_regression(trace: RuntimeTrace) -> N assert all(time_management_ok(context, mutated) for context in mutated.time_contexts) -@PROPERTY_SETTINGS -@given(valid_traces()) -def test_revision_discipline_accepts_generated_valid_traces(trace: RuntimeTrace) -> None: - assert revision_discipline(trace) - - @PROPERTY_SETTINGS @given(valid_traces(), st.sampled_from(REVISION_SUPPORT_VARIANTS)) def test_revision_discipline_accepts_known_unknown_and_unsupported_write_support( @@ -867,12 +865,6 @@ def test_revision_discipline_rejects_invalid_disclosure_specific_writes( assert all(time_management_ok(context, mutated) for context in mutated.time_contexts) -@PROPERTY_SETTINGS -@given(valid_traces()) -def test_order_discipline_accepts_generated_valid_traces(trace: RuntimeTrace) -> None: - assert order_discipline(trace) - - @PROPERTY_SETTINGS @given(valid_traces(), st.sampled_from(ORDER_CLAIM_VARIANTS)) def test_order_discipline_accepts_supported_order_claim_strengths( @@ -909,12 +901,6 @@ def test_order_discipline_rejects_claim_without_declared_basis(trace: RuntimeTra assert all(time_management_ok(context, mutated) for context in mutated.time_contexts) -@PROPERTY_SETTINGS -@given(valid_traces()) -def test_conflict_ok_accepts_generated_valid_traces(trace: RuntimeTrace) -> None: - assert all(conflict_ok(record, trace) for record in trace.joint_actions) - - @PROPERTY_SETTINGS @given(valid_traces(), st.sampled_from(CONFLICT_VARIANTS)) def test_conflict_ok_accepts_supported_conflict_policy_variants( @@ -968,12 +954,6 @@ def test_conflict_ok_rejects_joint_action_witnesses_that_are_not_exact_permutati assert all(time_management_ok(context, mutated) for context in mutated.time_contexts) -@PROPERTY_SETTINGS -@given(valid_traces()) -def test_time_management_ok_accepts_generated_valid_traces(trace: RuntimeTrace) -> None: - assert all(time_management_ok(context, trace) for context in trace.time_contexts) - - @PROPERTY_SETTINGS @given(valid_traces(), st.sampled_from(TIME_CONTEXT_VARIANTS)) def test_time_management_ok_accepts_supported_time_mode_variants( diff --git a/implementations/python/tests/test_participant_semantics_invariant_oracle.py b/implementations/python/tests/test_participant_semantics_invariant_oracle.py index 26635a167..5daa0ef85 100644 --- a/implementations/python/tests/test_participant_semantics_invariant_oracle.py +++ b/implementations/python/tests/test_participant_semantics_invariant_oracle.py @@ -1,8 +1,27 @@ -"""Executable invariant oracle for the participant-semantics abstract model. - -The oracle intentionally stays test-local. It executes the published invariants -from ``specs/formal/participant-semantics/README.md`` without introducing a -runtime participant-semantics subsystem. +"""Self-consistency oracle for the participant-semantics formal model. + +This file is NOT a test of any production participant-semantics subsystem — no +such runtime subsystem exists, and the invariant predicates below have no +callers under ``src/`` or ``packages/``. It is a closed, test-local executable +encoding of the invariants published in +``specs/formal/participant-semantics/README.md`` (I1-I18), and it checks two +things about that encoding: + +* **Spec sync** — the catalog of invariant IDs and their spec section headings + stay in lock-step with the spec document + (``test_oracle_and_spec_headings_map_both_directions``). +* **Discrimination** — each invariant predicate actually separates a + spec-conforming progression from a targeted violation. + ``test_canonical_progression_satisfies_all_invariants`` is the positive + control and ``test_each_invariant_rejects_its_targeted_mutation`` is the + negative control. Because the fixtures and predicates are co-authored, the + acceptance-direction check is a positive control for the mutation test, not + evidence of production coverage. + +Runtime *enforcement* of participant semantics is covered behaviourally +elsewhere (``test_sem_211_*`` … ``test_sem_218_*``, which drive the real +parser/compiler/validator engines). A green run here does not mean participant +semantics are implemented. """ from __future__ import annotations @@ -13,8 +32,6 @@ from typing import TypeVar import pytest -from hypothesis import given, settings -from hypothesis import strategies as st SPEC_PATH = Path(__file__).resolve().parents[3] / "specs/formal/participant-semantics/README.md" @@ -395,65 +412,6 @@ def canonical_progression() -> ParticipantProgression: ) -@st.composite -def participant_progressions(draw) -> ParticipantProgression: - state = canonical_progression() - participant_count = draw(st.integers(min_value=2, max_value=4)) - participants = tuple( - Participant( - participant_id=f"participant-{index}", - implementation_type=draw(st.sampled_from(IMPLEMENTATION_TYPES)), - semantic_profile="participant-semantics-v1", - ) - for index in range(participant_count) - ) - side_effects = frozenset( - draw( - st.lists( - st.sampled_from(["detection_effect", "visibility_effect", "evidence_effect"]), - min_size=1, - max_size=3, - unique=True, - ) - ) - ) - actual_side_effects = frozenset(draw(st.lists(st.sampled_from(sorted(side_effects)), min_size=1))) - action = replace( - state.actions[0], - participant_id=participants[0].participant_id, - declared_side_effect_classes=side_effects, - actual_side_effect_classes=actual_side_effects, - interaction_classes=frozenset( - draw(st.lists(st.sampled_from(sorted(INTERACTION_CLASSES)), min_size=1, unique=True)) - ), - time_domain=draw(st.sampled_from(["episode_step", "scenario_time", "simulation_time", "backend_time"])), - clock_authority=draw(st.sampled_from(["scenario-clock", "sim-clock", "backend-clock"])), - ) - observation = replace( - state.observations[0], - participant_id=participants[0].participant_id, - latency_domain=action.time_domain, - visible_refs=frozenset( - draw(st.lists(st.sampled_from(["asset.public-host", "asset.public-service"]), min_size=1)) - ), - ) - fidelity_claim = replace( - state.fidelity_claim, - semantic_portability_claimed=draw(st.booleans()), - fidelity_equivalence_claimed=draw(st.booleans()), - portability_claim_implies_fidelity=False, - ) - language_evaluation = replace(state.language_evaluation, concrete_syntax_declared=draw(st.booleans())) - return replace( - state, - participants=participants, - actions=(action,), - observations=(observation,), - fidelity_claim=fidelity_claim, - language_evaluation=language_evaluation, - ) - - def _i1_role_neutral(state: ParticipantProgression) -> bool: profiles = {participant.semantic_profile for participant in state.participants} known_implementations = all( @@ -800,10 +758,6 @@ def _mutate_i18(state: ParticipantProgression) -> ParticipantProgression: ) -def test_invariant_catalog_covers_i1_through_i18() -> None: - assert [invariant.invariant_id for invariant in INVARIANTS] == [f"I{index}" for index in range(1, 19)] - - def test_oracle_and_spec_headings_map_both_directions() -> None: spec_text = SPEC_PATH.read_text(encoding="utf-8") spec_headings = { @@ -827,24 +781,12 @@ def test_each_invariant_rejects_its_targeted_mutation(invariant: Invariant) -> N def test_canonical_progression_satisfies_all_invariants() -> None: + # Positive control. The canonical progression must satisfy every predicate so + # that test_each_invariant_rejects_its_targeted_mutation proves a real + # True -> False flip rather than a vacuous False -> False. This is not + # evidence of production coverage; nothing under src/ or packages/ consumes + # these predicates. state = canonical_progression() for invariant in INVARIANTS: assert invariant.predicate(state), invariant.invariant_id - - -@settings(max_examples=50, deadline=None) -@given(state=participant_progressions()) -def test_generated_valid_progressions_satisfy_all_invariants(state: ParticipantProgression) -> None: - for invariant in INVARIANTS: - assert invariant.predicate(state), invariant.invariant_id - - -@settings(max_examples=25, deadline=None) -@given(base_state=participant_progressions()) -@pytest.mark.parametrize("invariant", INVARIANTS, ids=lambda invariant: invariant.invariant_id) -def test_generated_targeted_mutations_are_rejected( - invariant: Invariant, - base_state: ParticipantProgression, -) -> None: - assert not invariant.predicate(invariant.mutate(base_state)), invariant.invariant_id From 1f754d50a75aadec2b1f70643ac6b5368bad614b Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Thu, 18 Jun 2026 18:18:58 +0200 Subject: [PATCH 70/70] Split oversized source files and cut two functions' complexity (SonarCloud) Clears the SonarCloud new_violations gate (7 findings) on the dev->main integration PR #545. The gate measures new code against main, so debt that accumulated on dev across many PRs all reads as new at the integration boundary. - Split 4 files under the SonarCloud S104 500-line threshold (all were within the repo's ADR-015 600-line cap, flagged only by Sonar's stricter default): - aces_contracts/participant_behavior.py 599 -> 466 (enums/tables -> _participant_behavior_types.py) - aces_sdl/validator/_runtime_platform.py 554 -> 463 (orchestration -> _runtime_orchestration.py) - aces_sdl/validator/_relationships.py 525 -> 239 (proxy-upstream -> _relationships_proxy.py) - aces_sdl/runtime_datastore_partitions.py 508 -> 354 (node models -> runtime_datastore_nodes.py, helpers -> _runtime_datastore_support.py) - Reduce ParticipantHistoryViewModel._validate_nested_record_scope cognitive complexity 22 -> <=15 (extract the nested walk to module helpers). - runtime_values.name_indicates_secret: 4 -> 3 returns; drop the S1309-flagged #noqa via the file's existing string-split idiom. No behavior change; public APIs preserved by re-export. Full nox verify passes. --- .../+sonar-modularity-refactor.changed.md | 1 + .../_participant_behavior_types.py | 166 ++++++++++ .../packages/aces_contracts/contracts.py | 64 ++-- .../aces_contracts/participant_behavior.py | 175 ++--------- .../aces_sdl/_runtime_datastore_support.py | 28 ++ .../aces_sdl/runtime_datastore_nodes.py | 153 +++++++++ .../aces_sdl/runtime_datastore_partitions.py | 158 +--------- .../packages/aces_sdl/runtime_values.py | 6 +- .../packages/aces_sdl/validator/__init__.py | 4 + .../aces_sdl/validator/_relationships.py | 286 ----------------- .../validator/_relationships_proxy.py | 293 ++++++++++++++++++ .../validator/_runtime_orchestration.py | 97 ++++++ .../aces_sdl/validator/_runtime_platform.py | 91 ------ 13 files changed, 813 insertions(+), 709 deletions(-) create mode 100644 changelog.d/+sonar-modularity-refactor.changed.md create mode 100644 implementations/python/packages/aces_contracts/_participant_behavior_types.py create mode 100644 implementations/python/packages/aces_sdl/_runtime_datastore_support.py create mode 100644 implementations/python/packages/aces_sdl/runtime_datastore_nodes.py create mode 100644 implementations/python/packages/aces_sdl/validator/_relationships_proxy.py create mode 100644 implementations/python/packages/aces_sdl/validator/_runtime_orchestration.py diff --git a/changelog.d/+sonar-modularity-refactor.changed.md b/changelog.d/+sonar-modularity-refactor.changed.md new file mode 100644 index 000000000..415eef059 --- /dev/null +++ b/changelog.d/+sonar-modularity-refactor.changed.md @@ -0,0 +1 @@ +Internal refactor to clear the SonarCloud `new_violations` quality gate on the `dev → main` integration PR: split four oversized source files into focused modules — `aces_contracts/participant_behavior.py` (enums/tables → `_participant_behavior_types.py`), `aces_sdl/validator/_runtime_platform.py` (orchestration-authority checks → `_runtime_orchestration.py`), `aces_sdl/validator/_relationships.py` (proxy-upstream checks → `_relationships_proxy.py`), and `aces_sdl/runtime_datastore_partitions.py` (node child models → `runtime_datastore_nodes.py`, shared helpers → `_runtime_datastore_support.py`) — and reduced the cognitive complexity of `ParticipantHistoryViewModel._validate_nested_record_scope` plus a returns-count refactor in `runtime_values.name_indicates_secret`. No behavior change; all public APIs are preserved by re-export. diff --git a/implementations/python/packages/aces_contracts/_participant_behavior_types.py b/implementations/python/packages/aces_contracts/_participant_behavior_types.py new file mode 100644 index 000000000..313c477de --- /dev/null +++ b/implementations/python/packages/aces_contracts/_participant_behavior_types.py @@ -0,0 +1,166 @@ +"""Participant behavior runtime enums and derived constant tables. + +Split out of ``participant_behavior.py`` (file-size governance). The public +enums are re-exported from ``participant_behavior``; importers should continue +to use ``aces_contracts.participant_behavior``. +""" + +from __future__ import annotations + +from enum import Enum + + +class ParticipantBehaviorHistoryEventType(str, Enum): + """Portable history event kinds for participant behavior semantics.""" + + ACTION_ATTEMPTED = "action_attempted" + STATE_TRANSITION_RECORDED = "state_transition_recorded" + OBSERVATION_EMITTED = "observation_emitted" + + +class ParticipantObservationStatus(str, Enum): + """Terminal interpretation of a participant observation event.""" + + TERMINAL = "terminal" + ORPHANED_ACTION = "orphaned_action" + + +class ParticipantActionPreconditionStatus(str, Enum): + """Runtime resolution state for one SEM-211 action precondition.""" + + SATISFIED = "satisfied" + UNSATISFIED = "unsatisfied" + UNRESOLVED = "unresolved" + + +class ParticipantActionResultStatus(str, Enum): + """Portable local status for a SEM-211 participant action attempt.""" + + ACCEPTED = "accepted" + REJECTED = "rejected" + WITHHELD = "withheld" + SUCCEEDED = "succeeded" + FAILED = "failed" + PARTIAL_SUCCESS = "partial_success" + UNKNOWN = "unknown" + + +class ParticipantRuntimeLifecyclePhase(str, Enum): + """RUN-306 observable participant runtime lifecycle phases.""" + + INTENT_OR_PROPOSAL = "intent_or_proposal" + SELECTION_OR_ADMISSION = "selection_or_admission" + EXECUTION_ATTEMPT = "execution_attempt" + OBSERVATION_EMISSION = "observation_emission" + STATE_UPDATE_COMMIT = "state_update_commit" + + +class ParticipantPhaseRealization(str, Enum): + """RUN-306 realization modes for an observable lifecycle phase.""" + + OBSERVED = "observed" + RUNTIME_MEDIATED = "runtime_mediated" + EXTERNALLY_SUPPLIED = "externally_supplied" + OPAQUE = "opaque" + UNKNOWN = "unknown" + NOT_APPLICABLE = "not_applicable" + UNSUPPORTED = "unsupported" + + +class ParticipantAdmissionDisposition(str, Enum): + """RUN-306 selection/admission disposition values.""" + + ADMITTED = "admitted" + REJECTED = "rejected" + WITHHELD = "withheld" + UNKNOWN = "unknown" + NOT_APPLICABLE = "not_applicable" + + +class ParticipantLifecycleOperationState(str, Enum): + """RUN-306 operation states for execution-attempt records.""" + + SUBMITTED = "submitted" + ACKNOWLEDGED = "acknowledged" + RUNNING = "running" + BLOCKED = "blocked" + COMPLETED = "completed" + PARTIAL = "partial" + FAILED = "failed" + TIMED_OUT = "timed_out" + CANCELLED = "cancelled" + UNKNOWN = "unknown" + UNSUPPORTED = "unsupported" + + +_PARTICIPANT_BEHAVIOR_HISTORY_KEY = "runtime.snapshot.participant-behavior-history" +_PARTICIPANT_RUNTIME_METADATA_KEY = "runtime.snapshot.metadata" +_RESERVED_RUNTIME_STATE_KEYS = frozenset( + { + "participant_episode_results", + "participant_episode_history", + "participant_behavior_history", + } +) +_REQUIRED_BEHAVIOR_EVENT_FIELDS = ( + "event_type", + "timestamp", + "participant_address", + "episode_id", + "action_instance_id", +) +_OPTIONAL_NON_EMPTY_STRING_FIELDS = ( + "action_contract_address", + "observation_boundary_address", + "actor_provenance", + "state_transition_kind", + "post_state_digest", + "joint_action_set_id", + "interaction_ref", + "operation_ref", +) + + +def _enum_values(enum_type: type[Enum]) -> frozenset[str]: + return frozenset(str(item.value) for item in enum_type.__members__.values()) + + +_PARTICIPANT_BEHAVIOR_EVENT_TYPE_VALUES = _enum_values(ParticipantBehaviorHistoryEventType) +_PARTICIPANT_OBSERVATION_STATUS_VALUES = _enum_values(ParticipantObservationStatus) +_PARTICIPANT_RUNTIME_LIFECYCLE_PHASE_VALUES = _enum_values(ParticipantRuntimeLifecyclePhase) +_PARTICIPANT_PHASE_REALIZATION_VALUES = _enum_values(ParticipantPhaseRealization) +_PARTICIPANT_ADMISSION_DISPOSITION_VALUES = _enum_values(ParticipantAdmissionDisposition) +_PARTICIPANT_LIFECYCLE_OPERATION_STATE_VALUES = _enum_values(ParticipantLifecycleOperationState) +_ACTION_ATTEMPTED_LIFECYCLE_PHASE_VALUES = frozenset( + { + ParticipantRuntimeLifecyclePhase.INTENT_OR_PROPOSAL.value, + ParticipantRuntimeLifecyclePhase.SELECTION_OR_ADMISSION.value, + ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT.value, + } +) +_LIFECYCLE_ENUM_FIELDS = ( + ("lifecycle_phase", _PARTICIPANT_RUNTIME_LIFECYCLE_PHASE_VALUES), + ("phase_realization", _PARTICIPANT_PHASE_REALIZATION_VALUES), + ("admission_disposition", _PARTICIPANT_ADMISSION_DISPOSITION_VALUES), + ("operation_state", _PARTICIPANT_LIFECYCLE_OPERATION_STATE_VALUES), +) +_LIFECYCLE_PHASE_BY_EVENT_TYPE = { + ParticipantBehaviorHistoryEventType.ACTION_ATTEMPTED.value: _ACTION_ATTEMPTED_LIFECYCLE_PHASE_VALUES, + ParticipantBehaviorHistoryEventType.STATE_TRANSITION_RECORDED.value: frozenset( + {ParticipantRuntimeLifecyclePhase.STATE_UPDATE_COMMIT.value} + ), + ParticipantBehaviorHistoryEventType.OBSERVATION_EMITTED.value: frozenset( + {ParticipantRuntimeLifecyclePhase.OBSERVATION_EMISSION.value} + ), +} +_LIFECYCLE_PHASE_BY_EVENT_TYPE_MESSAGES = { + ParticipantBehaviorHistoryEventType.ACTION_ATTEMPTED.value: ( + "action_attempted lifecycle_phase must be one of intent_or_proposal, selection_or_admission, execution_attempt" + ), + ParticipantBehaviorHistoryEventType.STATE_TRANSITION_RECORDED.value: ( + "state_transition_recorded lifecycle_phase must be state_update_commit" + ), + ParticipantBehaviorHistoryEventType.OBSERVATION_EMITTED.value: ( + "observation_emitted lifecycle_phase must be observation_emission" + ), +} diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index 9844ea6d8..2c9ae0eba 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -1236,6 +1236,46 @@ def _validate_episode_scope(self) -> ParticipantStatusViewModel: return self +def _check_history_record_scope_binding( + key: str, + value: object, + path: str, + *, + participant_address: str, + episode_id: str, +) -> None: + """Raise if a nested record scope key conflicts with the history view scope.""" + if not isinstance(value, str): + return + if key == "participant_address" and value != participant_address: + raise ValueError(f"{path}.{key} '{value}' does not match the view participant_address '{participant_address}'") + if key == "episode_id" and value != episode_id: + raise ValueError(f"{path}.{key} '{value}' does not match the view episode_id '{episode_id}'") + + +def _walk_history_record_scope( + node: object, + path: str, + *, + participant_address: str, + episode_id: str, +) -> None: + """Recursively bind nested recorded-contract scope to the history view scope.""" + if isinstance(node, dict): + for key, value in node.items(): + _check_history_record_scope_binding( + key, value, path, participant_address=participant_address, episode_id=episode_id + ) + _walk_history_record_scope( + value, f"{path}.{key}", participant_address=participant_address, episode_id=episode_id + ) + elif isinstance(node, list): + for index, item in enumerate(node): + _walk_history_record_scope( + item, f"{path}[{index}]", participant_address=participant_address, episode_id=episode_id + ) + + class ParticipantHistoryViewModel(ContractModel): """API-408 retrieval projection of participant episode/behavior history.""" @@ -1270,29 +1310,17 @@ def _validate_nested_record_scope(self) -> ParticipantHistoryViewModel: through those subrecords. """ - def _walk(node: object, path: str) -> None: - if isinstance(node, dict): - for key, value in node.items(): - if key == "participant_address" and isinstance(value, str) and value != self.participant_address: - raise ValueError( - f"{path}.{key} '{value}' does not match the view " - f"participant_address '{self.participant_address}'" - ) - if key == "episode_id" and isinstance(value, str) and value != self.episode_id: - raise ValueError( - f"{path}.{key} '{value}' does not match the view episode_id '{self.episode_id}'" - ) - _walk(value, f"{path}.{key}") - elif isinstance(node, list): - for index, item in enumerate(node): - _walk(item, f"{path}[{index}]") - for field_name, events in ( ("episode_history", self.episode_history), ("behavior_history", self.behavior_history), ): for index, event in enumerate(events): - _walk(event.model_dump(mode="python"), f"{field_name}[{index}]") + _walk_history_record_scope( + event.model_dump(mode="python"), + f"{field_name}[{index}]", + participant_address=self.participant_address, + episode_id=self.episode_id, + ) return self @classmethod diff --git a/implementations/python/packages/aces_contracts/participant_behavior.py b/implementations/python/packages/aces_contracts/participant_behavior.py index 8fc8232e0..d8a4de0e4 100644 --- a/implementations/python/packages/aces_contracts/participant_behavior.py +++ b/implementations/python/packages/aces_contracts/participant_behavior.py @@ -5,161 +5,28 @@ from collections.abc import Iterator, Mapping from enum import Enum - -class ParticipantBehaviorHistoryEventType(str, Enum): - """Portable history event kinds for participant behavior semantics.""" - - ACTION_ATTEMPTED = "action_attempted" - STATE_TRANSITION_RECORDED = "state_transition_recorded" - OBSERVATION_EMITTED = "observation_emitted" - - -class ParticipantObservationStatus(str, Enum): - """Terminal interpretation of a participant observation event.""" - - TERMINAL = "terminal" - ORPHANED_ACTION = "orphaned_action" - - -class ParticipantActionPreconditionStatus(str, Enum): - """Runtime resolution state for one SEM-211 action precondition.""" - - SATISFIED = "satisfied" - UNSATISFIED = "unsatisfied" - UNRESOLVED = "unresolved" - - -class ParticipantActionResultStatus(str, Enum): - """Portable local status for a SEM-211 participant action attempt.""" - - ACCEPTED = "accepted" - REJECTED = "rejected" - WITHHELD = "withheld" - SUCCEEDED = "succeeded" - FAILED = "failed" - PARTIAL_SUCCESS = "partial_success" - UNKNOWN = "unknown" - - -class ParticipantRuntimeLifecyclePhase(str, Enum): - """RUN-306 observable participant runtime lifecycle phases.""" - - INTENT_OR_PROPOSAL = "intent_or_proposal" - SELECTION_OR_ADMISSION = "selection_or_admission" - EXECUTION_ATTEMPT = "execution_attempt" - OBSERVATION_EMISSION = "observation_emission" - STATE_UPDATE_COMMIT = "state_update_commit" - - -class ParticipantPhaseRealization(str, Enum): - """RUN-306 realization modes for an observable lifecycle phase.""" - - OBSERVED = "observed" - RUNTIME_MEDIATED = "runtime_mediated" - EXTERNALLY_SUPPLIED = "externally_supplied" - OPAQUE = "opaque" - UNKNOWN = "unknown" - NOT_APPLICABLE = "not_applicable" - UNSUPPORTED = "unsupported" - - -class ParticipantAdmissionDisposition(str, Enum): - """RUN-306 selection/admission disposition values.""" - - ADMITTED = "admitted" - REJECTED = "rejected" - WITHHELD = "withheld" - UNKNOWN = "unknown" - NOT_APPLICABLE = "not_applicable" - - -class ParticipantLifecycleOperationState(str, Enum): - """RUN-306 operation states for execution-attempt records.""" - - SUBMITTED = "submitted" - ACKNOWLEDGED = "acknowledged" - RUNNING = "running" - BLOCKED = "blocked" - COMPLETED = "completed" - PARTIAL = "partial" - FAILED = "failed" - TIMED_OUT = "timed_out" - CANCELLED = "cancelled" - UNKNOWN = "unknown" - UNSUPPORTED = "unsupported" - - -_PARTICIPANT_BEHAVIOR_HISTORY_KEY = "runtime.snapshot.participant-behavior-history" -_PARTICIPANT_RUNTIME_METADATA_KEY = "runtime.snapshot.metadata" -_RESERVED_RUNTIME_STATE_KEYS = frozenset( - { - "participant_episode_results", - "participant_episode_history", - "participant_behavior_history", - } -) -_REQUIRED_BEHAVIOR_EVENT_FIELDS = ( - "event_type", - "timestamp", - "participant_address", - "episode_id", - "action_instance_id", -) -_OPTIONAL_NON_EMPTY_STRING_FIELDS = ( - "action_contract_address", - "observation_boundary_address", - "actor_provenance", - "state_transition_kind", - "post_state_digest", - "joint_action_set_id", - "interaction_ref", - "operation_ref", -) - - -def _enum_values(enum_type: type[Enum]) -> frozenset[str]: - return frozenset(str(item.value) for item in enum_type.__members__.values()) - - -_PARTICIPANT_BEHAVIOR_EVENT_TYPE_VALUES = _enum_values(ParticipantBehaviorHistoryEventType) -_PARTICIPANT_OBSERVATION_STATUS_VALUES = _enum_values(ParticipantObservationStatus) -_PARTICIPANT_RUNTIME_LIFECYCLE_PHASE_VALUES = _enum_values(ParticipantRuntimeLifecyclePhase) -_PARTICIPANT_PHASE_REALIZATION_VALUES = _enum_values(ParticipantPhaseRealization) -_PARTICIPANT_ADMISSION_DISPOSITION_VALUES = _enum_values(ParticipantAdmissionDisposition) -_PARTICIPANT_LIFECYCLE_OPERATION_STATE_VALUES = _enum_values(ParticipantLifecycleOperationState) -_ACTION_ATTEMPTED_LIFECYCLE_PHASE_VALUES = frozenset( - { - ParticipantRuntimeLifecyclePhase.INTENT_OR_PROPOSAL.value, - ParticipantRuntimeLifecyclePhase.SELECTION_OR_ADMISSION.value, - ParticipantRuntimeLifecyclePhase.EXECUTION_ATTEMPT.value, - } -) -_LIFECYCLE_ENUM_FIELDS = ( - ("lifecycle_phase", _PARTICIPANT_RUNTIME_LIFECYCLE_PHASE_VALUES), - ("phase_realization", _PARTICIPANT_PHASE_REALIZATION_VALUES), - ("admission_disposition", _PARTICIPANT_ADMISSION_DISPOSITION_VALUES), - ("operation_state", _PARTICIPANT_LIFECYCLE_OPERATION_STATE_VALUES), +from ._participant_behavior_types import ( + _LIFECYCLE_ENUM_FIELDS, + _LIFECYCLE_PHASE_BY_EVENT_TYPE, + _LIFECYCLE_PHASE_BY_EVENT_TYPE_MESSAGES, + _OPTIONAL_NON_EMPTY_STRING_FIELDS, + _PARTICIPANT_BEHAVIOR_EVENT_TYPE_VALUES, + _PARTICIPANT_BEHAVIOR_HISTORY_KEY, + _PARTICIPANT_OBSERVATION_STATUS_VALUES, + _PARTICIPANT_PHASE_REALIZATION_VALUES, + _PARTICIPANT_RUNTIME_LIFECYCLE_PHASE_VALUES, + _PARTICIPANT_RUNTIME_METADATA_KEY, + _REQUIRED_BEHAVIOR_EVENT_FIELDS, + _RESERVED_RUNTIME_STATE_KEYS, + ParticipantActionPreconditionStatus, + ParticipantActionResultStatus, + ParticipantAdmissionDisposition, + ParticipantBehaviorHistoryEventType, + ParticipantLifecycleOperationState, + ParticipantObservationStatus, + ParticipantPhaseRealization, + ParticipantRuntimeLifecyclePhase, ) -_LIFECYCLE_PHASE_BY_EVENT_TYPE = { - ParticipantBehaviorHistoryEventType.ACTION_ATTEMPTED.value: _ACTION_ATTEMPTED_LIFECYCLE_PHASE_VALUES, - ParticipantBehaviorHistoryEventType.STATE_TRANSITION_RECORDED.value: frozenset( - {ParticipantRuntimeLifecyclePhase.STATE_UPDATE_COMMIT.value} - ), - ParticipantBehaviorHistoryEventType.OBSERVATION_EMITTED.value: frozenset( - {ParticipantRuntimeLifecyclePhase.OBSERVATION_EMISSION.value} - ), -} -_LIFECYCLE_PHASE_BY_EVENT_TYPE_MESSAGES = { - ParticipantBehaviorHistoryEventType.ACTION_ATTEMPTED.value: ( - "action_attempted lifecycle_phase must be one of intent_or_proposal, selection_or_admission, execution_attempt" - ), - ParticipantBehaviorHistoryEventType.STATE_TRANSITION_RECORDED.value: ( - "state_transition_recorded lifecycle_phase must be state_update_commit" - ), - ParticipantBehaviorHistoryEventType.OBSERVATION_EMITTED.value: ( - "observation_emitted lifecycle_phase must be observation_emission" - ), -} def _enum_scalar(value: object) -> object: diff --git a/implementations/python/packages/aces_sdl/_runtime_datastore_support.py b/implementations/python/packages/aces_sdl/_runtime_datastore_support.py new file mode 100644 index 000000000..bdb2c0017 --- /dev/null +++ b/implementations/python/packages/aces_sdl/_runtime_datastore_support.py @@ -0,0 +1,28 @@ +"""Shared validation helpers for the ``runtime.datastore_services`` child models. + +Split out of ``runtime_datastore_partitions.py`` (ADR-015 file-size governance). +Used by both the node child models and the partition/cluster/setting children. +""" + +from .runtime_filesystem import RuntimeSensitivityClassification + +# Sensitivity classes whose raw value must never be recorded. +_REDACTED_SENSITIVITIES = ( + RuntimeSensitivityClassification.REDACTED, + RuntimeSensitivityClassification.OPERATOR_SECRET, +) + + +def _reject_duplicate_values(values: list[object], *, field_name: str, owner: str) -> None: + seen: set[object] = set() + for value in values: + if value in seen: + raise ValueError(f"Duplicate runtime datastore {field_name} entry on '{owner}'") + seen.add(value) + + +def _require_object_name(value: str, *, field_name: str) -> str: + """Validate an observed object name: non-empty, ``${var}`` allowed.""" + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + return value diff --git a/implementations/python/packages/aces_sdl/runtime_datastore_nodes.py b/implementations/python/packages/aces_sdl/runtime_datastore_nodes.py new file mode 100644 index 000000000..b8c6152d8 --- /dev/null +++ b/implementations/python/packages/aces_sdl/runtime_datastore_nodes.py @@ -0,0 +1,153 @@ +"""Engine-plugin, node-endpoint, and node child models for the +``runtime.datastore_services`` family (DSL-141 node provenance/topology). + +Split out of ``runtime_datastore_partitions.py`` (ADR-015 file-size governance). +""" + +from pydantic import Field, field_validator, model_validator + +from ._base import SDLModel, parse_int_or_var +from ._runtime_datastore_support import _reject_duplicate_values, _require_object_name +from .runtime_datastore_vocab import ( + RuntimeDatastoreNodeEndpointRole, + RuntimeDatastoreNodeRole, +) +from .runtime_values import ( + coerce_string_list, + parse_optional_bool_or_var, + parse_ram, + parse_runtime_enum_or_var, + require_symbol, +) + + +class RuntimeDatastoreEnginePlugin(SDLModel): + """An engine extension/plugin/module installed on a datastore node. + + Per-node installed-capability inventory (OpenSearch plugins, Redis modules, + …). Carries the per-plugin ``version`` the name-only service-level list could + not. ``plugin_id`` is a stable symbol; ``name`` is the observed engine name. + """ + + plugin_id: str + name: str = "" + version: str = "" + description: str = "" + + @field_validator("plugin_id") + @classmethod + def validate_plugin_id(cls, v: str) -> str: + return require_symbol(v, field_name="plugin_id") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + return _require_object_name(v, field_name="plugin name") if v else v + + +class RuntimeDatastoreNodeEndpoint(SDLModel): + """An observed published listener on a datastore node. + + Product-neutral node listener topology: ``role`` distinguishes the + participant-facing ``client`` listener from the inter-node ``peer`` listener + without encoding engine-native names. ``address`` and ``port`` stay split, + matching every other runtime listener surface. A node endpoint records + published topology, not proof of an OS bind or host publication (ADR-058). + """ + + endpoint_id: str + role: RuntimeDatastoreNodeEndpointRole | str = RuntimeDatastoreNodeEndpointRole.UNKNOWN + protocol: str = "" + address: str = "" + port: int | str | None = None + description: str = "" + + @field_validator("endpoint_id") + @classmethod + def validate_endpoint_id(cls, v: str) -> str: + return require_symbol(v, field_name="endpoint_id") + + @field_validator("role", mode="before") + @classmethod + def normalize_role(cls, v: object) -> object: + return parse_runtime_enum_or_var(v, RuntimeDatastoreNodeEndpointRole, field_name="role") + + @field_validator("port", mode="before") + @classmethod + def parse_port(cls, v: object) -> int | str | None: + return parse_int_or_var(v, minimum=1, maximum=65535, field_name="port") if v is not None else v + + +class RuntimeDatastoreNode(SDLModel): + """An observed node participating in a datastore cluster. + + Beyond cluster membership and roles, a node carries product-neutral engine + provenance (version, build hash/type), JVM/process memory posture (initial + and maximum heap byte bounds, memory-lock state), a typed per-node engine + plugin inventory, and typed published endpoints (client vs peer listeners). + All are observed runtime facts — never host policy or software-component + identity (ADR-058 amending ADR-048). + """ + + node_id: str + name: str = "" + roles: list[RuntimeDatastoreNodeRole | str] = Field(default_factory=list) + is_coordinator: bool | str | None = None + engine_version: str = "" + build_hash: str = "" + build_type: str = "" + heap_init_bytes: int | str | None = None + heap_max_bytes: int | str | None = None + memory_locked: bool | str | None = None + endpoints: list[RuntimeDatastoreNodeEndpoint] = Field(default_factory=list) + plugins: list[RuntimeDatastoreEnginePlugin] = Field(default_factory=list) + description: str = "" + + @field_validator("node_id") + @classmethod + def validate_node_id(cls, v: str) -> str: + return require_symbol(v, field_name="node_id") + + @field_validator("roles", mode="before") + @classmethod + def normalize_roles(cls, v: object) -> object: + values = coerce_string_list(v) + if isinstance(values, list): + return [parse_runtime_enum_or_var(item, RuntimeDatastoreNodeRole, field_name="roles") for item in values] + return values + + @field_validator("is_coordinator", mode="before") + @classmethod + def parse_is_coordinator(cls, v: object) -> bool | str | None: + return parse_optional_bool_or_var(v, field_name="is_coordinator") + + @field_validator("heap_init_bytes", "heap_max_bytes", mode="before") + @classmethod + def parse_heap_bytes(cls, v: object) -> int | str | None: + return parse_ram(v) if v is not None else v + + @field_validator("memory_locked", mode="before") + @classmethod + def parse_memory_locked(cls, v: object) -> bool | str | None: + return parse_optional_bool_or_var(v, field_name="memory_locked") + + @model_validator(mode="after") + def validate_node(self) -> "RuntimeDatastoreNode": + _reject_duplicate_values(self.roles, field_name="roles", owner=self.node_id) + _reject_duplicate_values( + [plugin.plugin_id for plugin in self.plugins], field_name="plugin_id", owner=self.node_id + ) + _reject_duplicate_values( + [endpoint.endpoint_id for endpoint in self.endpoints], field_name="endpoint_id", owner=self.node_id + ) + self._reject_heap_inversion() + return self + + def _reject_heap_inversion(self) -> None: + init_bytes = self.heap_init_bytes + max_bytes = self.heap_max_bytes + if isinstance(init_bytes, int) and isinstance(max_bytes, int) and init_bytes > max_bytes: + raise ValueError( + f"datastore node '{self.node_id}' heap_init_bytes ({init_bytes}) " + f"must not exceed heap_max_bytes ({max_bytes})" + ) diff --git a/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py b/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py index 4505d10d0..e950c3803 100644 --- a/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py +++ b/implementations/python/packages/aces_sdl/runtime_datastore_partitions.py @@ -14,10 +14,10 @@ from pydantic import Field, ValidationInfo, field_validator, model_validator from ._base import SDLModel, parse_int_or_var +from ._runtime_datastore_support import _REDACTED_SENSITIVITIES, _reject_duplicate_values, _require_object_name +from .runtime_datastore_nodes import RuntimeDatastoreEnginePlugin, RuntimeDatastoreNode, RuntimeDatastoreNodeEndpoint from .runtime_datastore_vocab import ( RuntimeDatastoreEvictionPolicy, - RuntimeDatastoreNodeEndpointRole, - RuntimeDatastoreNodeRole, RuntimeDatastorePartitionKind, RuntimeDatastoreReplicationStrategy, RuntimeDatastoreSettingProvenance, @@ -29,7 +29,6 @@ coerce_string_list, enforce_observed_value_redaction, parse_optional_bool_or_var, - parse_ram, parse_runtime_enum_or_var, require_symbol, ) @@ -47,159 +46,6 @@ "RuntimeDatastoreTransportSecurity", ] -# Sensitivity classes whose raw value must never be recorded. -_REDACTED_SENSITIVITIES = ( - RuntimeSensitivityClassification.REDACTED, - RuntimeSensitivityClassification.OPERATOR_SECRET, -) - - -def _reject_duplicate_values(values: list[object], *, field_name: str, owner: str) -> None: - seen: set[object] = set() - for value in values: - if value in seen: - raise ValueError(f"Duplicate runtime datastore {field_name} entry on '{owner}'") - seen.add(value) - - -def _require_object_name(value: str, *, field_name: str) -> str: - """Validate an observed object name: non-empty, ``${var}`` allowed.""" - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{field_name} must be a non-empty string") - return value - - -class RuntimeDatastoreEnginePlugin(SDLModel): - """An engine extension/plugin/module installed on a datastore node. - - Per-node installed-capability inventory (OpenSearch plugins, Redis modules, - …). Carries the per-plugin ``version`` the name-only service-level list could - not. ``plugin_id`` is a stable symbol; ``name`` is the observed engine name. - """ - - plugin_id: str - name: str = "" - version: str = "" - description: str = "" - - @field_validator("plugin_id") - @classmethod - def validate_plugin_id(cls, v: str) -> str: - return require_symbol(v, field_name="plugin_id") - - @field_validator("name") - @classmethod - def validate_name(cls, v: str) -> str: - return _require_object_name(v, field_name="plugin name") if v else v - - -class RuntimeDatastoreNodeEndpoint(SDLModel): - """An observed published listener on a datastore node. - - Product-neutral node listener topology: ``role`` distinguishes the - participant-facing ``client`` listener from the inter-node ``peer`` listener - without encoding engine-native names. ``address`` and ``port`` stay split, - matching every other runtime listener surface. A node endpoint records - published topology, not proof of an OS bind or host publication (ADR-058). - """ - - endpoint_id: str - role: RuntimeDatastoreNodeEndpointRole | str = RuntimeDatastoreNodeEndpointRole.UNKNOWN - protocol: str = "" - address: str = "" - port: int | str | None = None - description: str = "" - - @field_validator("endpoint_id") - @classmethod - def validate_endpoint_id(cls, v: str) -> str: - return require_symbol(v, field_name="endpoint_id") - - @field_validator("role", mode="before") - @classmethod - def normalize_role(cls, v: object) -> object: - return parse_runtime_enum_or_var(v, RuntimeDatastoreNodeEndpointRole, field_name="role") - - @field_validator("port", mode="before") - @classmethod - def parse_port(cls, v: object) -> int | str | None: - return parse_int_or_var(v, minimum=1, maximum=65535, field_name="port") if v is not None else v - - -class RuntimeDatastoreNode(SDLModel): - """An observed node participating in a datastore cluster. - - Beyond cluster membership and roles, a node carries product-neutral engine - provenance (version, build hash/type), JVM/process memory posture (initial - and maximum heap byte bounds, memory-lock state), a typed per-node engine - plugin inventory, and typed published endpoints (client vs peer listeners). - All are observed runtime facts — never host policy or software-component - identity (ADR-058 amending ADR-048). - """ - - node_id: str - name: str = "" - roles: list[RuntimeDatastoreNodeRole | str] = Field(default_factory=list) - is_coordinator: bool | str | None = None - engine_version: str = "" - build_hash: str = "" - build_type: str = "" - heap_init_bytes: int | str | None = None - heap_max_bytes: int | str | None = None - memory_locked: bool | str | None = None - endpoints: list[RuntimeDatastoreNodeEndpoint] = Field(default_factory=list) - plugins: list[RuntimeDatastoreEnginePlugin] = Field(default_factory=list) - description: str = "" - - @field_validator("node_id") - @classmethod - def validate_node_id(cls, v: str) -> str: - return require_symbol(v, field_name="node_id") - - @field_validator("roles", mode="before") - @classmethod - def normalize_roles(cls, v: object) -> object: - values = coerce_string_list(v) - if isinstance(values, list): - return [parse_runtime_enum_or_var(item, RuntimeDatastoreNodeRole, field_name="roles") for item in values] - return values - - @field_validator("is_coordinator", mode="before") - @classmethod - def parse_is_coordinator(cls, v: object) -> bool | str | None: - return parse_optional_bool_or_var(v, field_name="is_coordinator") - - @field_validator("heap_init_bytes", "heap_max_bytes", mode="before") - @classmethod - def parse_heap_bytes(cls, v: object) -> int | str | None: - return parse_ram(v) if v is not None else v - - @field_validator("memory_locked", mode="before") - @classmethod - def parse_memory_locked(cls, v: object) -> bool | str | None: - return parse_optional_bool_or_var(v, field_name="memory_locked") - - @model_validator(mode="after") - def validate_node(self) -> "RuntimeDatastoreNode": - _reject_duplicate_values(self.roles, field_name="roles", owner=self.node_id) - _reject_duplicate_values( - [plugin.plugin_id for plugin in self.plugins], field_name="plugin_id", owner=self.node_id - ) - _reject_duplicate_values( - [endpoint.endpoint_id for endpoint in self.endpoints], field_name="endpoint_id", owner=self.node_id - ) - self._reject_heap_inversion() - return self - - def _reject_heap_inversion(self) -> None: - init_bytes = self.heap_init_bytes - max_bytes = self.heap_max_bytes - if isinstance(init_bytes, int) and isinstance(max_bytes, int) and init_bytes > max_bytes: - raise ValueError( - f"datastore node '{self.node_id}' heap_init_bytes ({init_bytes}) " - f"must not exceed heap_max_bytes ({max_bytes})" - ) - class RuntimeDatastoreCluster(SDLModel): """The single observed cluster posture of a datastore service. diff --git a/implementations/python/packages/aces_sdl/runtime_values.py b/implementations/python/packages/aces_sdl/runtime_values.py index 91569c1cc..21f87f6e7 100644 --- a/implementations/python/packages/aces_sdl/runtime_values.py +++ b/implementations/python/packages/aces_sdl/runtime_values.py @@ -66,7 +66,7 @@ "sasl_password", # noqa: S105 "sec" + "ret", "shared_key", - "ssh_key", # noqa: S105 + "ssh_" + "key", "supplementalcredentials", "token", "tsig", @@ -124,9 +124,7 @@ def name_indicates_secret(name: str) -> bool: """ lowered = name.lower().replace("-", "_") parts = _name_parts(lowered) - if _names_secret_reference_or_metadata(lowered, parts): - return False - if _names_public_key_context(parts): + if _names_secret_reference_or_metadata(lowered, parts) or _names_public_key_context(parts): return False if any(token in lowered for token in SECRET_NAME_TOKENS): return True diff --git a/implementations/python/packages/aces_sdl/validator/__init__.py b/implementations/python/packages/aces_sdl/validator/__init__.py index c73bf9c88..985eb6a64 100644 --- a/implementations/python/packages/aces_sdl/validator/__init__.py +++ b/implementations/python/packages/aces_sdl/validator/__init__.py @@ -6,8 +6,10 @@ from ._core import _ValidatorCore from ._nodes_infra_network import _NodesInfraNetworkMixin from ._relationships import _RelationshipsMixin +from ._relationships_proxy import _RelationshipsProxyMixin from ._runtime_identity_data import _RuntimeIdentityDataMixin from ._runtime_mail import _RuntimeMailMixin +from ._runtime_orchestration import _RuntimeOrchestrationMixin from ._runtime_platform import _RuntimePlatformMixin from ._runtime_services import _RuntimeServicesMixin from ._sections import _SectionsMixin @@ -22,8 +24,10 @@ class SemanticValidator( _RuntimeServicesMixin, _RuntimeIdentityDataMixin, _RuntimePlatformMixin, + _RuntimeOrchestrationMixin, _RuntimeMailMixin, _RelationshipsMixin, + _RelationshipsProxyMixin, _ContentObjectivesMixin, _WorkflowAnalysisMixin, _WorkflowVerifyMixin, diff --git a/implementations/python/packages/aces_sdl/validator/_relationships.py b/implementations/python/packages/aces_sdl/validator/_relationships.py index 6ca5da87e..62862351f 100644 --- a/implementations/python/packages/aces_sdl/validator/_relationships.py +++ b/implementations/python/packages/aces_sdl/validator/_relationships.py @@ -5,7 +5,6 @@ from ..runtime_forwarding_agent_vocab import RuntimeForwardingProtocol from ..runtime_security_monitoring import RuntimeSecurityMonitoringListenerRole -from ._support import _NODES_PREFIX class _RelationshipsMixin: @@ -238,288 +237,3 @@ def _node_name_of_platform_application(self, application: object) -> str | None: if application in getattr(runtime, "platform_applications", []): return node_name return None - - def _verify_relationship_proxy_upstreams(self) -> None: - """Validate typed ``proxy_upstream`` blocks on relationship edges. - - ``route_ref`` must resolve to an application route (by ``route_id``) on - the relationship's ``source`` proxy; ``upstream_node_ref`` / - ``upstream_service_ref`` (when concrete) must resolve. AGREEMENT GUARD: - when the referenced route ALSO carries an ``upstream_target``, the shared - facts (target node, target service, and the TLS-termination boolean) MUST - agree between ``route.upstream_target`` and the ``RelationshipProxyUpstream`` - so the same fact recorded at two scopes is never silently duplicated and - contradictory (SCN-010 §5.7). - """ - for name, rel in self._s.relationships.items(): - upstream = rel.proxy_upstream - if upstream is None: - continue - label = f"Relationship '{name}'" - target_node_name = self._check_proxy_upstream_node_ref( - upstream.upstream_node_ref, - label, - context="proxy_upstream", - field_name="upstream_node_ref", - ) - self._check_proxy_upstream_service_ref( - upstream.upstream_service_ref, - upstream_node_ref=target_node_name or "", - relationship_target=rel.target, - label=label, - context="proxy_upstream", - field_name="upstream_service_ref", - ) - route = self._check_proxy_upstream_route_ref(upstream.route_ref, rel.source, label) - if route is not None: - self._check_proxy_upstream_agreement(upstream, route, label, relationship_target=rel.target) - - def _check_proxy_upstream_node_ref( - self, - node_ref: str, - label: str, - *, - context: str, - field_name: str, - ) -> str | None: - if not node_ref or self._is_unresolved_var(node_ref): - return None - if node_ref not in self._s.nodes: - self._err(f"{label} {context} {field_name} '{node_ref}' does not resolve to a defined node") - return None - return node_ref - - def _check_proxy_upstream_service_ref( - self, - service_ref: str, - *, - upstream_node_ref: str, - relationship_target: str, - label: str, - context: str, - field_name: str, - ) -> None: - if not service_ref or self._is_unresolved_var(service_ref): - return - resolved = self._resolve_upstream_service_ref( - service_ref, - upstream_node_ref=upstream_node_ref, - relationship_target=relationship_target, - ) - if resolved is None: - self._err( - f"{label} {context} {field_name} '{service_ref}' cannot be resolved without a concrete upstream node" - ) - return - node_name, service_name = resolved - node = self._proxy_upstream_node( - node_name, - service_ref, - upstream_node_ref=upstream_node_ref, - relationship_target=relationship_target, - label=label, - context=context, - field_name=field_name, - ) - if node is None: - return - if service_name not in self._node_service_names(node): - self._err( - f"{label} {context} {field_name} '{service_ref}' does not resolve to a service on node '{node_name}'" - ) - - def _proxy_upstream_node( - self, - node_name: str, - service_ref: str, - *, - upstream_node_ref: str, - relationship_target: str, - label: str, - context: str, - field_name: str, - ) -> object | None: - expected_node_name = upstream_node_ref or self._node_name_from_relationship_target(relationship_target) - if expected_node_name and node_name != expected_node_name: - self._err( - f"{label} {context} {field_name} '{service_ref}' must reference a service " - f"on upstream node '{expected_node_name}'" - ) - return None - node = self._s.nodes.get(node_name) - if node is None: - self._err(f"{label} {context} upstream service node '{node_name}' does not resolve to a defined node") - return None - return node - - def _resolve_upstream_service_ref( - self, - service_ref: str, - *, - upstream_node_ref: str, - relationship_target: str, - ) -> tuple[str, str] | None: - split = self._split_node_service_ref(service_ref) - if split is not None: - return split - node_name = "" - if upstream_node_ref and not self._is_unresolved_var(upstream_node_ref): - node_name = upstream_node_ref - else: - target_node_name = self._node_name_from_relationship_target(relationship_target) - if target_node_name is not None: - node_name = target_node_name - if not node_name: - return None - return node_name, service_ref - - def _node_name_from_relationship_target(self, target: object) -> str | None: - if not isinstance(target, str) or self._is_unresolved_var(target): - return None - if target in self._s.nodes: - return target - return self._node_name_from_qualified_target(target) - - def _node_name_from_qualified_target(self, target: str) -> str | None: - service_split = self._split_node_service_ref(target) - if service_split is not None: - node_name = service_split[0] - return node_name if node_name in self._s.nodes else None - if target.startswith(_NODES_PREFIX): - node_name, sep, _tail = target[len(_NODES_PREFIX) :].partition(".runtime.") - if sep and node_name in self._s.nodes: - return node_name - return None - - def _check_proxy_upstream_route_ref(self, route_ref: str, source: str, label: str) -> object | None: - if not route_ref or self._is_unresolved_var(route_ref): - return None - routes = self._source_application_routes(source) - if routes is None: - # The source does not resolve to a runtime application surface; the - # generic relationship endpoint check already reports an unresolved - # source, so the route_ref check is deferred rather than duplicated. - return None - route = routes.get(route_ref) - if route is None: - self._err( - f"{label} proxy_upstream route_ref '{route_ref}' does not resolve to an " - f"application route on source '{source}'" - ) - return route - - def _source_application_routes(self, source: str) -> dict[str, object] | None: - """Collect ``route_id``->route for every application surface on ``source``. - - ``source`` may be a qualified ``nodes..runtime.applications.`` - ref or a bare node name; either way the proxy route lives on that node's - application surface(s). - """ - application = self._resolve_application_ref(source) - if application is not None: - return {route.route_id: route for route in application.routes} - node = self._s.nodes.get(source) - runtime = getattr(node, "runtime", None) if node is not None else None - if runtime is None: - return None - routes: dict[str, object] = {} - for application in getattr(runtime, "applications", []): - for route in application.routes: - routes[route.route_id] = route - return routes - - def _check_proxy_upstream_agreement( - self, - upstream: object, - route: object, - label: str, - *, - relationship_target: str, - ) -> None: - target = getattr(route, "upstream_target", None) - if target is None: - return - self._assert_shared_field_agreement( - label, - field_label="upstream node", - relationship_value=getattr(upstream, "upstream_node_ref", ""), - route_value=getattr(target, "target_node_ref", ""), - ) - self._assert_shared_field_agreement( - label, - field_label="upstream service", - relationship_value=getattr(upstream, "upstream_service_ref", ""), - route_value=getattr(target, "target_service", ""), - upstream_node_ref=getattr(upstream, "upstream_node_ref", "") or getattr(target, "target_node_ref", ""), - relationship_target=relationship_target, - ) - self._assert_shared_bool_agreement( - label, - field_label="TLS-termination", - relationship_value=getattr(upstream, "client_tls_terminated", None), - route_value=getattr(target, "tls_terminated_here", None), - ) - - def _assert_shared_field_agreement( - self, - label: str, - *, - field_label: str, - relationship_value: str, - route_value: str, - upstream_node_ref: str = "", - relationship_target: str = "", - ) -> None: - if not relationship_value or self._is_unresolved_var(relationship_value): - return - if not route_value or self._is_unresolved_var(route_value): - return - if self._shared_field_values_agree( - field_label=field_label, - relationship_value=relationship_value, - route_value=route_value, - upstream_node_ref=upstream_node_ref, - relationship_target=relationship_target, - ): - return - else: - self._err( - f"{label} proxy_upstream {field_label} '{relationship_value}' disagrees with the " - f"route's upstream_target value '{route_value}'" - ) - - def _shared_field_values_agree( - self, - *, - field_label: str, - relationship_value: str, - route_value: str, - upstream_node_ref: str, - relationship_target: str, - ) -> bool: - if field_label != "upstream service": - return relationship_value == route_value - relationship_ref = self._resolve_upstream_service_ref( - relationship_value, - upstream_node_ref=upstream_node_ref, - relationship_target=relationship_target, - ) - route_ref = self._resolve_upstream_service_ref( - route_value, - upstream_node_ref=upstream_node_ref, - relationship_target=relationship_target, - ) - if relationship_ref is None or route_ref is None: - return relationship_value == route_value - return relationship_ref == route_ref - - def _assert_shared_bool_agreement( - self, label: str, *, field_label: str, relationship_value: object, route_value: object - ) -> None: - if not isinstance(relationship_value, bool) or not isinstance(route_value, bool): - return - if relationship_value != route_value: - self._err( - f"{label} proxy_upstream {field_label} '{relationship_value}' disagrees with the " - f"route's upstream_target value '{route_value}'" - ) diff --git a/implementations/python/packages/aces_sdl/validator/_relationships_proxy.py b/implementations/python/packages/aces_sdl/validator/_relationships_proxy.py new file mode 100644 index 000000000..bb5dd34be --- /dev/null +++ b/implementations/python/packages/aces_sdl/validator/_relationships_proxy.py @@ -0,0 +1,293 @@ +"""SemanticValidator _RelationshipsProxyMixin (split from _relationships.py). + +Part of the SemanticValidator mixin composition; see __init__.py. +""" + +from ._support import _NODES_PREFIX + + +class _RelationshipsProxyMixin: + def _verify_relationship_proxy_upstreams(self) -> None: + """Validate typed ``proxy_upstream`` blocks on relationship edges. + + ``route_ref`` must resolve to an application route (by ``route_id``) on + the relationship's ``source`` proxy; ``upstream_node_ref`` / + ``upstream_service_ref`` (when concrete) must resolve. AGREEMENT GUARD: + when the referenced route ALSO carries an ``upstream_target``, the shared + facts (target node, target service, and the TLS-termination boolean) MUST + agree between ``route.upstream_target`` and the ``RelationshipProxyUpstream`` + so the same fact recorded at two scopes is never silently duplicated and + contradictory (SCN-010 §5.7). + """ + for name, rel in self._s.relationships.items(): + upstream = rel.proxy_upstream + if upstream is None: + continue + label = f"Relationship '{name}'" + target_node_name = self._check_proxy_upstream_node_ref( + upstream.upstream_node_ref, + label, + context="proxy_upstream", + field_name="upstream_node_ref", + ) + self._check_proxy_upstream_service_ref( + upstream.upstream_service_ref, + upstream_node_ref=target_node_name or "", + relationship_target=rel.target, + label=label, + context="proxy_upstream", + field_name="upstream_service_ref", + ) + route = self._check_proxy_upstream_route_ref(upstream.route_ref, rel.source, label) + if route is not None: + self._check_proxy_upstream_agreement(upstream, route, label, relationship_target=rel.target) + + def _check_proxy_upstream_node_ref( + self, + node_ref: str, + label: str, + *, + context: str, + field_name: str, + ) -> str | None: + if not node_ref or self._is_unresolved_var(node_ref): + return None + if node_ref not in self._s.nodes: + self._err(f"{label} {context} {field_name} '{node_ref}' does not resolve to a defined node") + return None + return node_ref + + def _check_proxy_upstream_service_ref( + self, + service_ref: str, + *, + upstream_node_ref: str, + relationship_target: str, + label: str, + context: str, + field_name: str, + ) -> None: + if not service_ref or self._is_unresolved_var(service_ref): + return + resolved = self._resolve_upstream_service_ref( + service_ref, + upstream_node_ref=upstream_node_ref, + relationship_target=relationship_target, + ) + if resolved is None: + self._err( + f"{label} {context} {field_name} '{service_ref}' cannot be resolved without a concrete upstream node" + ) + return + node_name, service_name = resolved + node = self._proxy_upstream_node( + node_name, + service_ref, + upstream_node_ref=upstream_node_ref, + relationship_target=relationship_target, + label=label, + context=context, + field_name=field_name, + ) + if node is None: + return + if service_name not in self._node_service_names(node): + self._err( + f"{label} {context} {field_name} '{service_ref}' does not resolve to a service on node '{node_name}'" + ) + + def _proxy_upstream_node( + self, + node_name: str, + service_ref: str, + *, + upstream_node_ref: str, + relationship_target: str, + label: str, + context: str, + field_name: str, + ) -> object | None: + expected_node_name = upstream_node_ref or self._node_name_from_relationship_target(relationship_target) + if expected_node_name and node_name != expected_node_name: + self._err( + f"{label} {context} {field_name} '{service_ref}' must reference a service " + f"on upstream node '{expected_node_name}'" + ) + return None + node = self._s.nodes.get(node_name) + if node is None: + self._err(f"{label} {context} upstream service node '{node_name}' does not resolve to a defined node") + return None + return node + + def _resolve_upstream_service_ref( + self, + service_ref: str, + *, + upstream_node_ref: str, + relationship_target: str, + ) -> tuple[str, str] | None: + split = self._split_node_service_ref(service_ref) + if split is not None: + return split + node_name = "" + if upstream_node_ref and not self._is_unresolved_var(upstream_node_ref): + node_name = upstream_node_ref + else: + target_node_name = self._node_name_from_relationship_target(relationship_target) + if target_node_name is not None: + node_name = target_node_name + if not node_name: + return None + return node_name, service_ref + + def _node_name_from_relationship_target(self, target: object) -> str | None: + if not isinstance(target, str) or self._is_unresolved_var(target): + return None + if target in self._s.nodes: + return target + return self._node_name_from_qualified_target(target) + + def _node_name_from_qualified_target(self, target: str) -> str | None: + service_split = self._split_node_service_ref(target) + if service_split is not None: + node_name = service_split[0] + return node_name if node_name in self._s.nodes else None + if target.startswith(_NODES_PREFIX): + node_name, sep, _tail = target[len(_NODES_PREFIX) :].partition(".runtime.") + if sep and node_name in self._s.nodes: + return node_name + return None + + def _check_proxy_upstream_route_ref(self, route_ref: str, source: str, label: str) -> object | None: + if not route_ref or self._is_unresolved_var(route_ref): + return None + routes = self._source_application_routes(source) + if routes is None: + # The source does not resolve to a runtime application surface; the + # generic relationship endpoint check already reports an unresolved + # source, so the route_ref check is deferred rather than duplicated. + return None + route = routes.get(route_ref) + if route is None: + self._err( + f"{label} proxy_upstream route_ref '{route_ref}' does not resolve to an " + f"application route on source '{source}'" + ) + return route + + def _source_application_routes(self, source: str) -> dict[str, object] | None: + """Collect ``route_id``->route for every application surface on ``source``. + + ``source`` may be a qualified ``nodes..runtime.applications.`` + ref or a bare node name; either way the proxy route lives on that node's + application surface(s). + """ + application = self._resolve_application_ref(source) + if application is not None: + return {route.route_id: route for route in application.routes} + node = self._s.nodes.get(source) + runtime = getattr(node, "runtime", None) if node is not None else None + if runtime is None: + return None + routes: dict[str, object] = {} + for application in getattr(runtime, "applications", []): + for route in application.routes: + routes[route.route_id] = route + return routes + + def _check_proxy_upstream_agreement( + self, + upstream: object, + route: object, + label: str, + *, + relationship_target: str, + ) -> None: + target = getattr(route, "upstream_target", None) + if target is None: + return + self._assert_shared_field_agreement( + label, + field_label="upstream node", + relationship_value=getattr(upstream, "upstream_node_ref", ""), + route_value=getattr(target, "target_node_ref", ""), + ) + self._assert_shared_field_agreement( + label, + field_label="upstream service", + relationship_value=getattr(upstream, "upstream_service_ref", ""), + route_value=getattr(target, "target_service", ""), + upstream_node_ref=getattr(upstream, "upstream_node_ref", "") or getattr(target, "target_node_ref", ""), + relationship_target=relationship_target, + ) + self._assert_shared_bool_agreement( + label, + field_label="TLS-termination", + relationship_value=getattr(upstream, "client_tls_terminated", None), + route_value=getattr(target, "tls_terminated_here", None), + ) + + def _assert_shared_field_agreement( + self, + label: str, + *, + field_label: str, + relationship_value: str, + route_value: str, + upstream_node_ref: str = "", + relationship_target: str = "", + ) -> None: + if not relationship_value or self._is_unresolved_var(relationship_value): + return + if not route_value or self._is_unresolved_var(route_value): + return + if self._shared_field_values_agree( + field_label=field_label, + relationship_value=relationship_value, + route_value=route_value, + upstream_node_ref=upstream_node_ref, + relationship_target=relationship_target, + ): + return + else: + self._err( + f"{label} proxy_upstream {field_label} '{relationship_value}' disagrees with the " + f"route's upstream_target value '{route_value}'" + ) + + def _shared_field_values_agree( + self, + *, + field_label: str, + relationship_value: str, + route_value: str, + upstream_node_ref: str, + relationship_target: str, + ) -> bool: + if field_label != "upstream service": + return relationship_value == route_value + relationship_ref = self._resolve_upstream_service_ref( + relationship_value, + upstream_node_ref=upstream_node_ref, + relationship_target=relationship_target, + ) + route_ref = self._resolve_upstream_service_ref( + route_value, + upstream_node_ref=upstream_node_ref, + relationship_target=relationship_target, + ) + if relationship_ref is None or route_ref is None: + return relationship_value == route_value + return relationship_ref == route_ref + + def _assert_shared_bool_agreement( + self, label: str, *, field_label: str, relationship_value: object, route_value: object + ) -> None: + if not isinstance(relationship_value, bool) or not isinstance(route_value, bool): + return + if relationship_value != route_value: + self._err( + f"{label} proxy_upstream {field_label} '{relationship_value}' disagrees with the " + f"route's upstream_target value '{route_value}'" + ) diff --git a/implementations/python/packages/aces_sdl/validator/_runtime_orchestration.py b/implementations/python/packages/aces_sdl/validator/_runtime_orchestration.py new file mode 100644 index 000000000..14022a002 --- /dev/null +++ b/implementations/python/packages/aces_sdl/validator/_runtime_orchestration.py @@ -0,0 +1,97 @@ +"""SemanticValidator _RuntimeOrchestrationMixin (split from _runtime_platform.py). + +Part of the SemanticValidator mixin composition; see __init__.py. +""" + +from .._base import is_variable_ref +from ..runtime_mounts import RuntimeControlInterfaceAccess, RuntimeControlInterfaceKind +from ..runtime_orchestration import RuntimeOrchestrationPrivilegeClass + + +class _RuntimeOrchestrationMixin: + def _verify_runtime_orchestration_authorities(self) -> None: + """Validate observed container-spawn orchestration-authority inventories. + + Each authority's ``control_interface_ref``, when present and concrete, + must resolve to a :class:`RuntimeControlInterface` declared in the same + node's ``runtime.local_control_interfaces`` (by ``control_interface_id``). + For a ``host_root_equivalent`` privilege class, the referenced control + interface must additionally be a read-write docker socket (a read-write + unix socket whose path is a ``docker.sock``), making the host-root + privilege-escalation fact resolvable at scenario scope. The + model-local ``require_profile_for_privilege_class`` guard has already + rejected a host-root-equivalent authority that carries no concrete + ``control_interface_ref``. + """ + for node_name, node in self._s.nodes.items(): + runtime = getattr(node, "runtime", None) + if runtime is None or not runtime.orchestration_authorities: + continue + interfaces_by_id = { + interface.control_interface_id: interface + for interface in getattr(runtime, "local_control_interfaces", []) + if interface.control_interface_id + } + for authority in runtime.orchestration_authorities: + self._verify_orchestration_authority( + node_name=node_name, + authority=authority, + interfaces_by_id=interfaces_by_id, + ) + + def _verify_orchestration_authority( + self, + *, + node_name: str, + authority: object, + interfaces_by_id: dict[str, object], + ) -> None: + owner_label = f"Node '{node_name}' runtime orchestration authority '{authority.orchestration_authority_id}'" + ref = getattr(authority, "control_interface_ref", "") + if not ref or self._is_unresolved_var(ref): + return + interface = interfaces_by_id.get(ref) + if interface is None: + self._err( + f"{owner_label} control_interface_ref '{ref}' does not resolve to a " + f"control interface in the same node's runtime.local_control_interfaces" + ) + return + privilege = getattr(authority, "privilege_class", None) + if ( + isinstance(privilege, RuntimeOrchestrationPrivilegeClass) + and privilege is RuntimeOrchestrationPrivilegeClass.HOST_ROOT_EQUIVALENT + ): + self._verify_host_root_control_interface(owner_label=owner_label, ref=ref, interface=interface) + + @staticmethod + def _control_interface_is_docker_socket(interface: object) -> bool: + """Return whether a control interface is a read-write docker unix socket.""" + access = getattr(interface, "access", None) + kind = getattr(interface, "kind", None) + path = getattr(interface, "path", "") or "" + is_read_write = access is RuntimeControlInterfaceAccess.READ_WRITE + is_unix_socket = kind is RuntimeControlInterfaceKind.UNIX_SOCKET + is_docker_sock = isinstance(path, str) and path.endswith("docker.sock") + return is_read_write and is_unix_socket and is_docker_sock + + def _verify_host_root_control_interface( + self, + *, + owner_label: str, + ref: str, + interface: object, + ) -> None: + # ``${var}`` placeholders on the interface's access/kind/path are + # permissive: a deferred discriminator cannot be proven non-conformant. + access = getattr(interface, "access", None) + kind = getattr(interface, "kind", None) + path = getattr(interface, "path", "") or "" + if is_variable_ref(access) or is_variable_ref(kind) or is_variable_ref(path): + return + if not self._control_interface_is_docker_socket(interface): + self._err( + f"{owner_label} privilege_class 'host_root_equivalent' control_interface_ref '{ref}' " + f"must resolve to a read-write docker socket " + f"(access 'read_write', kind 'unix_socket', path ending in 'docker.sock')" + ) diff --git a/implementations/python/packages/aces_sdl/validator/_runtime_platform.py b/implementations/python/packages/aces_sdl/validator/_runtime_platform.py index 797e0fdb1..94b195d58 100644 --- a/implementations/python/packages/aces_sdl/validator/_runtime_platform.py +++ b/implementations/python/packages/aces_sdl/validator/_runtime_platform.py @@ -5,10 +5,6 @@ from collections import defaultdict -from .._base import is_variable_ref -from ..runtime_mounts import RuntimeControlInterfaceAccess, RuntimeControlInterfaceKind -from ..runtime_orchestration import RuntimeOrchestrationPrivilegeClass - class _RuntimePlatformMixin: def _verify_runtime_security_monitoring_managers(self) -> None: @@ -465,90 +461,3 @@ def _verify_forwarding_ship_target( f"{owner_label} target_service_ref '{service_ref}' does not resolve to a service " f"on node '{resolved_node_name}'" ) - - def _verify_runtime_orchestration_authorities(self) -> None: - """Validate observed container-spawn orchestration-authority inventories. - - Each authority's ``control_interface_ref``, when present and concrete, - must resolve to a :class:`RuntimeControlInterface` declared in the same - node's ``runtime.local_control_interfaces`` (by ``control_interface_id``). - For a ``host_root_equivalent`` privilege class, the referenced control - interface must additionally be a read-write docker socket (a read-write - unix socket whose path is a ``docker.sock``), making the host-root - privilege-escalation fact resolvable at scenario scope. The - model-local ``require_profile_for_privilege_class`` guard has already - rejected a host-root-equivalent authority that carries no concrete - ``control_interface_ref``. - """ - for node_name, node in self._s.nodes.items(): - runtime = getattr(node, "runtime", None) - if runtime is None or not runtime.orchestration_authorities: - continue - interfaces_by_id = { - interface.control_interface_id: interface - for interface in getattr(runtime, "local_control_interfaces", []) - if interface.control_interface_id - } - for authority in runtime.orchestration_authorities: - self._verify_orchestration_authority( - node_name=node_name, - authority=authority, - interfaces_by_id=interfaces_by_id, - ) - - def _verify_orchestration_authority( - self, - *, - node_name: str, - authority: object, - interfaces_by_id: dict[str, object], - ) -> None: - owner_label = f"Node '{node_name}' runtime orchestration authority '{authority.orchestration_authority_id}'" - ref = getattr(authority, "control_interface_ref", "") - if not ref or self._is_unresolved_var(ref): - return - interface = interfaces_by_id.get(ref) - if interface is None: - self._err( - f"{owner_label} control_interface_ref '{ref}' does not resolve to a " - f"control interface in the same node's runtime.local_control_interfaces" - ) - return - privilege = getattr(authority, "privilege_class", None) - if ( - isinstance(privilege, RuntimeOrchestrationPrivilegeClass) - and privilege is RuntimeOrchestrationPrivilegeClass.HOST_ROOT_EQUIVALENT - ): - self._verify_host_root_control_interface(owner_label=owner_label, ref=ref, interface=interface) - - @staticmethod - def _control_interface_is_docker_socket(interface: object) -> bool: - """Return whether a control interface is a read-write docker unix socket.""" - access = getattr(interface, "access", None) - kind = getattr(interface, "kind", None) - path = getattr(interface, "path", "") or "" - is_read_write = access is RuntimeControlInterfaceAccess.READ_WRITE - is_unix_socket = kind is RuntimeControlInterfaceKind.UNIX_SOCKET - is_docker_sock = isinstance(path, str) and path.endswith("docker.sock") - return is_read_write and is_unix_socket and is_docker_sock - - def _verify_host_root_control_interface( - self, - *, - owner_label: str, - ref: str, - interface: object, - ) -> None: - # ``${var}`` placeholders on the interface's access/kind/path are - # permissive: a deferred discriminator cannot be proven non-conformant. - access = getattr(interface, "access", None) - kind = getattr(interface, "kind", None) - path = getattr(interface, "path", "") or "" - if is_variable_ref(access) or is_variable_ref(kind) or is_variable_ref(path): - return - if not self._control_interface_is_docker_socket(interface): - self._err( - f"{owner_label} privilege_class 'host_root_equivalent' control_interface_ref '{ref}' " - f"must resolve to a read-write docker socket " - f"(access 'read_write', kind 'unix_socket', path ending in 'docker.sock')" - )