diff --git a/.ground-control.yaml b/.ground-control.yaml index 35764b894..56179c5bf 100644 --- a/.ground-control.yaml +++ b/.ground-control.yaml @@ -6,12 +6,16 @@ workflow: completion_command: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s verify lint_command: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s lint format_command: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s hygiene + codex_review: + pre_push_cap: 1 + test_quality_review: + pre_push_cap: 1 review_disposition: - enabled: true - mode: authoritative - max_auto_overrides: 1 + enabled: false + mode: shadow + max_auto_overrides: 0 judge: - enabled: true + enabled: false docs: adr_dir: docs/decisions/adrs/ example_paths: @@ -25,7 +29,6 @@ requirements: routing: enabled: true default_provider: claude - default_fallback: parent stages: {} sonarcloud: project_key: Brad-Edwards_aces diff --git a/contracts/fixtures/experiment-core/experiment-binding-descriptors-v1/invalid/duplicate-canonical-target.json b/contracts/fixtures/experiment-core/experiment-binding-descriptors-v1/invalid/duplicate-canonical-target.json new file mode 100644 index 000000000..22904be56 --- /dev/null +++ b/contracts/fixtures/experiment-core/experiment-binding-descriptors-v1/invalid/duplicate-canonical-target.json @@ -0,0 +1,51 @@ +{ + "schema_version": "experiment-binding-descriptors/v1", + "descriptors": [ + { + "binding_id": "binding.worker-count.first", + "source_factor_id": "factor.worker-count", + "source_factor_level_id": "four", + "source_condition_id": "condition.four-workers", + "target": { + "plane": "scenario", + "scenario_family_id": "family.techvault", + "variation_point_id": "variation.worker-count", + "target_id": "variables.worker_count" + }, + "value_type": "integer", + "value": { + "kind": "literal", + "value": 4 + }, + "owner": { + "contract_id": "sdl-authoring-input-v1", + "contract_version": "1", + "validator_id": "raes-sdl-instantiation", + "validator_version": "1" + } + }, + { + "binding_id": "binding.worker-count.second", + "source_factor_id": "factor.worker-count", + "source_factor_level_id": "four", + "source_condition_id": "condition.four-workers", + "target": { + "plane": "scenario", + "scenario_family_id": "family.techvault", + "variation_point_id": "variation.worker-count", + "target_id": "variables.worker_count" + }, + "value_type": "integer", + "value": { + "kind": "literal", + "value": 4 + }, + "owner": { + "contract_id": "sdl-authoring-input-v1", + "contract_version": "1", + "validator_id": "raes-sdl-instantiation", + "validator_version": "1" + } + } + ] +} diff --git a/contracts/fixtures/experiment-core/experiment-binding-descriptors-v1/valid/reference.json b/contracts/fixtures/experiment-core/experiment-binding-descriptors-v1/valid/reference.json new file mode 100644 index 000000000..33ec2c6c1 --- /dev/null +++ b/contracts/fixtures/experiment-core/experiment-binding-descriptors-v1/valid/reference.json @@ -0,0 +1,28 @@ +{ + "schema_version": "experiment-binding-descriptors/v1", + "descriptors": [ + { + "binding_id": "binding.worker-count", + "source_factor_id": "factor.worker-count", + "source_factor_level_id": "four", + "source_condition_id": "condition.four-workers", + "target": { + "plane": "scenario", + "scenario_family_id": "family.techvault", + "variation_point_id": "variation.worker-count", + "target_id": "variables.worker_count" + }, + "value_type": "integer", + "value": { + "kind": "literal", + "value": 4 + }, + "owner": { + "contract_id": "sdl-authoring-input-v1", + "contract_version": "1", + "validator_id": "raes-sdl-instantiation", + "validator_version": "1" + } + } + ] +} diff --git a/contracts/fixtures/participant-implementation-configuration/participant-configuration-result-v1/invalid/digest-mismatch.json b/contracts/fixtures/participant-implementation-configuration/participant-configuration-result-v1/invalid/digest-mismatch.json new file mode 100644 index 000000000..dd1cd1708 --- /dev/null +++ b/contracts/fixtures/participant-implementation-configuration/participant-configuration-result-v1/invalid/digest-mismatch.json @@ -0,0 +1,31 @@ +{ + "schema_version": "participant-configuration-result/v1", + "participant_address": "participants.red", + "manifest_ref": "manifests/reference-red-agent.json", + "manifest_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "configuration": { + "implementation_identity": { + "name": "reference-red-agent", + "version": "1.0.0" + }, + "manifest_version": "participant-implementation-manifest/v1", + "owner": { + "contract_id": "participant-configuration-result-v1", + "contract_version": "1", + "validator_id": "reference-participant-configuration", + "validator_version": "1" + }, + "values": [ + { + "target_id": "policy.mode", + "value_type": "string", + "origin": "override", + "value": { + "kind": "literal", + "value": "deterministic" + } + } + ] + }, + "configuration_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000" +} diff --git a/contracts/fixtures/participant-implementation-configuration/participant-configuration-result-v1/valid/reference.json b/contracts/fixtures/participant-implementation-configuration/participant-configuration-result-v1/valid/reference.json new file mode 100644 index 000000000..51992fe97 --- /dev/null +++ b/contracts/fixtures/participant-implementation-configuration/participant-configuration-result-v1/valid/reference.json @@ -0,0 +1,49 @@ +{ + "schema_version": "participant-configuration-result/v1", + "participant_address": "participants.red", + "manifest_ref": "manifests/reference-red-agent.json", + "manifest_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "configuration": { + "implementation_identity": { + "name": "reference-red-agent", + "version": "1.0.0" + }, + "manifest_version": "participant-implementation-manifest/v1", + "owner": { + "contract_id": "participant-configuration-result-v1", + "contract_version": "1", + "validator_id": "reference-participant-configuration", + "validator_version": "1" + }, + "values": [ + { + "target_id": "credentials.api", + "value_type": "string", + "origin": "override", + "value": { + "kind": "secret-reference", + "reference_id": "operator-secret.reference-red-api" + } + }, + { + "target_id": "policy.mode", + "value_type": "string", + "origin": "override", + "value": { + "kind": "literal", + "value": "deterministic" + } + }, + { + "target_id": "policy.temperature", + "value_type": "number", + "origin": "default", + "value": { + "kind": "literal", + "value": 0.25 + } + } + ] + }, + "configuration_digest": "sha256:3949cb272258d96d9506f23077c89cbb77d4dad2d0218fed74145bbd5a971655" +} diff --git a/contracts/fixtures/participant-implementation-manifest/participant-implementation-manifest-v1/valid/reference.json b/contracts/fixtures/participant-implementation-manifest/participant-implementation-manifest-v1/valid/reference.json index b0ed7a333..0b70db930 100644 --- a/contracts/fixtures/participant-implementation-manifest/participant-implementation-manifest-v1/valid/reference.json +++ b/contracts/fixtures/participant-implementation-manifest/participant-implementation-manifest-v1/valid/reference.json @@ -8,6 +8,8 @@ "supported_contract_versions": [ "participant-implementation-manifest-v1", "participant-implementation-provenance-v1", + "experiment-binding-descriptors-v1", + "participant-configuration-result-v1", "participant-episode-state-envelope-v1", "participant-episode-history-event-stream-v1", "participant-behavior-history-event-stream-v1" @@ -66,5 +68,50 @@ "task-statement", "observation-stream" ] + }, + "configuration_registry": { + "owner": { + "contract_id": "participant-configuration-result-v1", + "contract_version": "1", + "validator_id": "reference-participant-configuration", + "validator_version": "1" + }, + "targets": { + "policy.temperature": { + "target_id": "policy.temperature", + "value_type": "number", + "aliases": [ + "temperature" + ], + "allowed_value_kinds": [ + "literal" + ], + "sensitivity": "public", + "default": { + "kind": "literal", + "value": 0.25 + } + }, + "policy.mode": { + "target_id": "policy.mode", + "value_type": "string", + "aliases": [ + "mode" + ], + "allowed_value_kinds": [ + "literal" + ], + "sensitivity": "internal" + }, + "credentials.api": { + "target_id": "credentials.api", + "value_type": "string", + "aliases": [], + "allowed_value_kinds": [ + "secret-reference" + ], + "sensitivity": "secret" + } + } } } diff --git a/contracts/schema-publication/entries/backend-manifest-v2.json b/contracts/schema-publication/entries/backend-manifest-v2.json index 5316d983f..f5d62636a 100644 --- a/contracts/schema-publication/entries/backend-manifest-v2.json +++ b/contracts/schema-publication/entries/backend-manifest-v2.json @@ -2,9 +2,9 @@ "contract_id": "backend-manifest-v2", "schema_path": "contracts/schemas/backend-manifest/backend-manifest-v2.json", "stability": "draft", - "content_hash": "178db9d22d522e069bcad2bc91cfb985e58a22e01ee5dc2b8de506373c1952f0", + "content_hash": "8645c2b5e00cd595d2661959fe2dfa0ac91e5cca26d4455fe87dad8e6a97db48", "last_change": { - "summary": "Published service materialization, portable shared-time, and autonomous participant capability controls.", - "content_hash": "178db9d22d522e069bcad2bc91cfb985e58a22e01ee5dc2b8de506373c1952f0" + "summary": "Added a typed, owner-governed apparatus configuration target registry and experiment binding contract support.", + "content_hash": "8645c2b5e00cd595d2661959fe2dfa0ac91e5cca26d4455fe87dad8e6a97db48" } } diff --git a/contracts/schema-publication/entries/backend-profile-v1.json b/contracts/schema-publication/entries/backend-profile-v1.json index 1b8ccbf53..2998372de 100644 --- a/contracts/schema-publication/entries/backend-profile-v1.json +++ b/contracts/schema-publication/entries/backend-profile-v1.json @@ -2,9 +2,9 @@ "contract_id": "backend-profile-v1", "schema_path": "contracts/schemas/profiles/backend-profile-v1.json", "stability": "draft", - "content_hash": "05f507351c826558d03cec48b0e99501022acbbd5b6e1d816f86727e9f4ad78d", + "content_hash": "fd118e131b273684ac540675a8ba8d4b6ef1f10990ddd0d0dc5776c1b6c4a233", "last_change": { - "summary": "Added API-421 shared-time contract ids to the governed backend contract vocabulary.", - "content_hash": "05f507351c826558d03cec48b0e99501022acbbd5b6e1d816f86727e9f4ad78d" + "summary": "Added the authoritative experiment binding descriptor contract to the governed backend contract vocabulary.", + "content_hash": "fd118e131b273684ac540675a8ba8d4b6ef1f10990ddd0d0dc5776c1b6c4a233" } } diff --git a/contracts/schema-publication/entries/experiment-authoring-input-v1.json b/contracts/schema-publication/entries/experiment-authoring-input-v1.json index 2e471e6b3..e31bcdbde 100644 --- a/contracts/schema-publication/entries/experiment-authoring-input-v1.json +++ b/contracts/schema-publication/entries/experiment-authoring-input-v1.json @@ -2,9 +2,9 @@ "contract_id": "experiment-authoring-input-v1", "schema_path": "contracts/schemas/experiment-core/experiment-authoring-input-v1.json", "stability": "draft", - "content_hash": "829ac69c37136596506bf83e0f2fde6cd8c333a79d497c84b93b0497e37aabc9", + "content_hash": "47a2a28bd72c252aff22a651e58b216cd4700e9d7c4cabc87728bce0657183a6", "last_change": { - "summary": "Retargeted qualified Python validator references and current project descriptions from the retired ACES package identity to RAES as part of the #884 hard cut; schema identifiers and validation semantics are unchanged.", - "content_hash": "829ac69c37136596506bf83e0f2fde6cd8c333a79d497c84b93b0497e37aabc9" + "summary": "Added explicit binding semantics and authoritative cross-plane binding descriptors joined to factor levels and conditions.", + "content_hash": "47a2a28bd72c252aff22a651e58b216cd4700e9d7c4cabc87728bce0657183a6" } } diff --git a/contracts/schema-publication/entries/experiment-binding-descriptors-v1.json b/contracts/schema-publication/entries/experiment-binding-descriptors-v1.json new file mode 100644 index 000000000..7b77375c6 --- /dev/null +++ b/contracts/schema-publication/entries/experiment-binding-descriptors-v1.json @@ -0,0 +1,10 @@ +{ + "contract_id": "experiment-binding-descriptors-v1", + "schema_path": "contracts/schemas/experiment-core/experiment-binding-descriptors-v1.json", + "stability": "draft", + "content_hash": "88d57279e1d4467de97fa32893dfad58f3dccb5bf6a9d6e9610ce84e78265f2c", + "last_change": { + "summary": "Published explicit, typed, plane-specific experiment binding descriptors with factor and condition provenance.", + "content_hash": "88d57279e1d4467de97fa32893dfad58f3dccb5bf6a9d6e9610ce84e78265f2c" + } +} diff --git a/contracts/schema-publication/entries/experiment-run-v1.json b/contracts/schema-publication/entries/experiment-run-v1.json index e4ad3dece..a84977cc5 100644 --- a/contracts/schema-publication/entries/experiment-run-v1.json +++ b/contracts/schema-publication/entries/experiment-run-v1.json @@ -2,9 +2,9 @@ "contract_id": "experiment-run-v1", "schema_path": "contracts/schemas/experiment-core/experiment-run-v1.json", "stability": "draft", - "content_hash": "5c936907be31c0b1d0f7071ecd1785a39804afb52f6277671c11c95d325baf16", + "content_hash": "718d8b108bfc7cc7cee4d55970c12e7fd87c3873f13aaee51b2b4de468ba5db7", "last_change": { - "summary": "Retargeted qualified Python validator references and current project descriptions from the retired ACES package identity to RAES as part of the #884 hard cut; schema identifiers and validation semantics are unchanged.", - "content_hash": "5c936907be31c0b1d0f7071ecd1785a39804afb52f6277671c11c95d325baf16" + "summary": "Added realized cross-plane binding provenance with explicit source, target, value origin, and configuration digest.", + "content_hash": "718d8b108bfc7cc7cee4d55970c12e7fd87c3873f13aaee51b2b4de468ba5db7" } } diff --git a/contracts/schema-publication/entries/participant-configuration-result-v1.json b/contracts/schema-publication/entries/participant-configuration-result-v1.json new file mode 100644 index 000000000..7a421024d --- /dev/null +++ b/contracts/schema-publication/entries/participant-configuration-result-v1.json @@ -0,0 +1,10 @@ +{ + "contract_id": "participant-configuration-result-v1", + "schema_path": "contracts/schemas/participant-implementation-configuration/participant-configuration-result-v1.json", + "stability": "draft", + "content_hash": "915c2557c352fc5866fa0e5171abb02afb0b9eea485749aefa94ba0bc0dc3c51", + "last_change": { + "summary": "Published atomic normalized participant configuration results with value provenance and a canonical digest.", + "content_hash": "915c2557c352fc5866fa0e5171abb02afb0b9eea485749aefa94ba0bc0dc3c51" + } +} diff --git a/contracts/schema-publication/entries/participant-implementation-manifest-v1.json b/contracts/schema-publication/entries/participant-implementation-manifest-v1.json index e3029d604..f6578bf51 100644 --- a/contracts/schema-publication/entries/participant-implementation-manifest-v1.json +++ b/contracts/schema-publication/entries/participant-implementation-manifest-v1.json @@ -2,5 +2,9 @@ "contract_id": "participant-implementation-manifest-v1", "schema_path": "contracts/schemas/participant-implementation-manifest/participant-implementation-manifest-v1.json", "stability": "draft", - "content_hash": "93e30291adbc08b1969adb9b938f888b004d317ac864e90151d0f43a395948e4" + "content_hash": "5b7c636a98c540e1afc74ca74a1e9a421609f498c3b85c473a52c1516e398984", + "last_change": { + "summary": "Added typed participant configuration targets, aliases, defaults, sensitivity, and governed validator ownership.", + "content_hash": "5b7c636a98c540e1afc74ca74a1e9a421609f498c3b85c473a52c1516e398984" + } } diff --git a/contracts/schema-publication/entries/participant-implementation-provenance-v1.json b/contracts/schema-publication/entries/participant-implementation-provenance-v1.json index a84cc539e..7084e0330 100644 --- a/contracts/schema-publication/entries/participant-implementation-provenance-v1.json +++ b/contracts/schema-publication/entries/participant-implementation-provenance-v1.json @@ -2,5 +2,9 @@ "contract_id": "participant-implementation-provenance-v1", "schema_path": "contracts/schemas/participant-implementation-provenance/participant-implementation-provenance-v1.json", "stability": "draft", - "content_hash": "a4c050fb2a53129148f7d2960a7cb06d36c4682483064734e505acda4677d7ec" + "content_hash": "88076dc67b837b282a0efe482d4b74d5fb053ad7b44cb6bc0d862b4fe17eb30e", + "last_change": { + "summary": "Required participant configuration references and authoritative normalized configuration digests to be supplied together.", + "content_hash": "88076dc67b837b282a0efe482d4b74d5fb053ad7b44cb6bc0d862b4fe17eb30e" + } } diff --git a/contracts/schema-publication/entries/processor-manifest-v2.json b/contracts/schema-publication/entries/processor-manifest-v2.json index fc6e34b48..e33ec7646 100644 --- a/contracts/schema-publication/entries/processor-manifest-v2.json +++ b/contracts/schema-publication/entries/processor-manifest-v2.json @@ -2,9 +2,9 @@ "contract_id": "processor-manifest-v2", "schema_path": "contracts/schemas/processor-manifest/processor-manifest-v2.json", "stability": "draft", - "content_hash": "504f5dee2028bbcd49c023c1c57c8ad664f6ea57f51e8c2431e8a185ae275e87", + "content_hash": "bd2e67b226d6eb18eb1cfec69e8840e7d8349161f3357fa71832771f9b8e68dc", "last_change": { - "summary": "Added API-421 time-model-v1 to the processor contract surface.", - "content_hash": "504f5dee2028bbcd49c023c1c57c8ad664f6ea57f51e8c2431e8a185ae275e87" + "summary": "Added a typed, owner-governed apparatus configuration target registry and experiment binding contract support.", + "content_hash": "bd2e67b226d6eb18eb1cfec69e8840e7d8349161f3357fa71832771f9b8e68dc" } } diff --git a/contracts/schema-publication/entries/runtime-snapshot-v1.json b/contracts/schema-publication/entries/runtime-snapshot-v1.json index 51061278a..3ca616461 100644 --- a/contracts/schema-publication/entries/runtime-snapshot-v1.json +++ b/contracts/schema-publication/entries/runtime-snapshot-v1.json @@ -2,9 +2,9 @@ "contract_id": "runtime-snapshot-v1", "schema_path": "contracts/schemas/snapshots/runtime-snapshot-v1.json", "stability": "draft", - "content_hash": "8117be2036ef71aa51cbd5529121ec50d5354ef688d1cc54134f768ce7524df8", + "content_hash": "ce74f9bb39d44c18badc1451d7f4d363d58e3938de8e360dc020ea2d1e4bcdf7", "last_change": { - "summary": "Retargeted qualified Python validator references and current project descriptions from the retired ACES package identity to RAES as part of the #884 hard cut; schema identifiers and validation semantics are unchanged.", - "content_hash": "8117be2036ef71aa51cbd5529121ec50d5354ef688d1cc54134f768ce7524df8" + "summary": "Added first-class append-only API-409 participant control history for RUN-310 supervisory lifecycle persistence and replay.", + "content_hash": "ce74f9bb39d44c18badc1451d7f4d363d58e3938de8e360dc020ea2d1e4bcdf7" } } diff --git a/contracts/schemas/backend-manifest/backend-manifest-v2.json b/contracts/schemas/backend-manifest/backend-manifest-v2.json index 0918682f5..11b6c1de7 100644 --- a/contracts/schemas/backend-manifest/backend-manifest-v2.json +++ b/contracts/schemas/backend-manifest/backend-manifest-v2.json @@ -119,6 +119,52 @@ "title": "BackendCompatibilityModel", "type": "object" }, + "BindingOwnerModel": { + "additionalProperties": false, + "description": "Governed contract and validator profile that owns one binding.", + "properties": { + "contract_id": { + "minLength": 1, + "title": "Contract Id", + "type": "string" + }, + "contract_version": { + "minLength": 1, + "title": "Contract Version", + "type": "string" + }, + "validator_id": { + "minLength": 1, + "title": "Validator Id", + "type": "string" + }, + "validator_version": { + "minLength": 1, + "title": "Validator Version", + "type": "string" + } + }, + "required": [ + "contract_id", + "contract_version", + "validator_id", + "validator_version" + ], + "title": "BindingOwnerModel", + "type": "object" + }, + "BindingScalarType": { + "description": "Exact JSON scalar type declared by a binding owner.", + "enum": [ + "string", + "integer", + "number", + "boolean", + "null" + ], + "title": "BindingScalarType", + "type": "string" + }, "CleanupCapabilitiesModel": { "additionalProperties": false, "description": "Backend support for the portable SCE-007 cleanup contract family.", @@ -209,6 +255,95 @@ "title": "ConceptBindingEntryModel", "type": "object" }, + "ConfigurationTargetDeclarationModel": { + "additionalProperties": false, + "description": "One scalar target admitted by an owning portable manifest.", + "properties": { + "aliases": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Aliases", + "type": "array" + }, + "allowed_value_kinds": { + "items": { + "enum": [ + "literal", + "secret-reference" + ], + "type": "string" + }, + "minItems": 1, + "title": "Allowed Value Kinds", + "type": "array" + }, + "default": { + "anyOf": [ + { + "$ref": "#/$defs/LiteralBindingValueModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "sensitivity": { + "enum": [ + "public", + "internal", + "restricted", + "secret" + ], + "title": "Sensitivity", + "type": "string" + }, + "target_id": { + "minLength": 1, + "title": "Target Id", + "type": "string" + }, + "value_type": { + "$ref": "#/$defs/BindingScalarType" + } + }, + "required": [ + "target_id", + "value_type", + "allowed_value_kinds", + "sensitivity" + ], + "title": "ConfigurationTargetDeclarationModel", + "type": "object" + }, + "ConfigurationTargetRegistryModel": { + "additionalProperties": false, + "description": "Collision-free target registry published by one manifest owner.", + "properties": { + "owner": { + "$ref": "#/$defs/BindingOwnerModel" + }, + "targets": { + "additionalProperties": { + "$ref": "#/$defs/ConfigurationTargetDeclarationModel" + }, + "minProperties": 1, + "propertyNames": { + "minLength": 1 + }, + "title": "Targets", + "type": "object" + } + }, + "required": [ + "owner", + "targets" + ], + "title": "ConfigurationTargetRegistryModel", + "type": "object" + }, "EvaluatorCapabilitiesModel": { "additionalProperties": false, "allOf": [ @@ -324,6 +459,43 @@ "title": "EvaluatorCapabilitiesModel", "type": "object" }, + "LiteralBindingValueModel": { + "additionalProperties": false, + "description": "Portable literal value; strict type validation occurs at its descriptor.", + "properties": { + "kind": { + "const": "literal", + "title": "Kind", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "required": [ + "kind", + "value" + ], + "title": "LiteralBindingValueModel", + "type": "object" + }, "ObservationCapabilitiesModel": { "additionalProperties": false, "description": "EXP-715 backend observation and evidence-collection capability declaration.", @@ -1375,6 +1547,17 @@ "title": "Concept Bindings", "type": "array" }, + "configuration_registry": { + "anyOf": [ + { + "$ref": "#/$defs/ConfigurationTargetRegistryModel" + }, + { + "type": "null" + } + ], + "default": null + }, "constraints": { "additionalProperties": { "type": "string" @@ -1414,6 +1597,7 @@ "items": { "enum": [ "backend-manifest-v2", + "experiment-binding-descriptors-v1", "realization-envelope-v1", "provisioning-plan-v1", "orchestration-plan-v1", diff --git a/contracts/schemas/experiment-core/experiment-authoring-input-v1.json b/contracts/schemas/experiment-core/experiment-authoring-input-v1.json index 2fe4cc4d9..5fb4235e8 100644 --- a/contracts/schemas/experiment-core/experiment-authoring-input-v1.json +++ b/contracts/schemas/experiment-core/experiment-authoring-input-v1.json @@ -1,5 +1,102 @@ { "$defs": { + "ApparatusBindingTargetModel": { + "additionalProperties": false, + "description": "Canonical target declared by a selected portable apparatus manifest.", + "properties": { + "component_kind": { + "enum": [ + "processor", + "backend", + "participant-runtime", + "other" + ], + "title": "Component Kind", + "type": "string" + }, + "component_name": { + "minLength": 1, + "title": "Component Name", + "type": "string" + }, + "component_version": { + "minLength": 1, + "title": "Component Version", + "type": "string" + }, + "manifest_version": { + "minLength": 1, + "title": "Manifest Version", + "type": "string" + }, + "plane": { + "const": "apparatus", + "title": "Plane", + "type": "string" + }, + "target_id": { + "minLength": 1, + "title": "Target Id", + "type": "string" + } + }, + "required": [ + "plane", + "component_kind", + "component_name", + "component_version", + "manifest_version", + "target_id" + ], + "title": "ApparatusBindingTargetModel", + "type": "object" + }, + "BindingOwnerModel": { + "additionalProperties": false, + "description": "Governed contract and validator profile that owns one binding.", + "properties": { + "contract_id": { + "minLength": 1, + "title": "Contract Id", + "type": "string" + }, + "contract_version": { + "minLength": 1, + "title": "Contract Version", + "type": "string" + }, + "validator_id": { + "minLength": 1, + "title": "Validator Id", + "type": "string" + }, + "validator_version": { + "minLength": 1, + "title": "Validator Version", + "type": "string" + } + }, + "required": [ + "contract_id", + "contract_version", + "validator_id", + "validator_version" + ], + "title": "BindingOwnerModel", + "type": "object" + }, + "BindingScalarType": { + "description": "Exact JSON scalar type declared by a binding owner.", + "enum": [ + "string", + "integer", + "number", + "boolean", + "null" + ], + "title": "BindingScalarType", + "type": "string" + }, "ExperimentApparatusConstraintModel": { "additionalProperties": false, "anyOf": [ @@ -261,6 +358,129 @@ "title": "ExperimentBackendReferenceModel", "type": "object" }, + "ExperimentBindingDescriptorModel": { + "additionalProperties": false, + "description": "One explicit factor/condition value bound to one authoritative target.", + "properties": { + "binding_id": { + "minLength": 1, + "title": "Binding Id", + "type": "string" + }, + "owner": { + "$ref": "#/$defs/BindingOwnerModel" + }, + "source_condition_id": { + "minLength": 1, + "title": "Source Condition Id", + "type": "string" + }, + "source_factor_id": { + "minLength": 1, + "title": "Source Factor Id", + "type": "string" + }, + "source_factor_level_id": { + "minLength": 1, + "title": "Source Factor Level Id", + "type": "string" + }, + "target": { + "discriminator": { + "mapping": { + "apparatus": "#/$defs/ApparatusBindingTargetModel", + "participant-implementation": "#/$defs/ParticipantImplementationBindingTargetModel", + "scenario": "#/$defs/ScenarioBindingTargetModel" + }, + "propertyName": "plane" + }, + "oneOf": [ + { + "$ref": "#/$defs/ScenarioBindingTargetModel" + }, + { + "$ref": "#/$defs/ParticipantImplementationBindingTargetModel" + }, + { + "$ref": "#/$defs/ApparatusBindingTargetModel" + } + ], + "title": "Target" + }, + "value": { + "discriminator": { + "mapping": { + "literal": "#/$defs/LiteralBindingValueModel", + "secret-reference": "#/$defs/SecretReferenceBindingValueModel" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/LiteralBindingValueModel" + }, + { + "$ref": "#/$defs/SecretReferenceBindingValueModel" + } + ], + "title": "Value" + }, + "value_type": { + "$ref": "#/$defs/BindingScalarType" + } + }, + "required": [ + "binding_id", + "source_factor_id", + "source_factor_level_id", + "source_condition_id", + "target", + "value_type", + "value", + "owner" + ], + "title": "ExperimentBindingDescriptorModel", + "type": "object" + }, + "ExperimentBindingDescriptorSetModel": { + "additionalProperties": false, + "description": "Versioned, collision-free set of authoritative experiment bindings.", + "properties": { + "descriptors": { + "items": { + "$ref": "#/$defs/ExperimentBindingDescriptorModel" + }, + "minItems": 1, + "title": "Descriptors", + "type": "array" + }, + "schema_version": { + "const": "experiment-binding-descriptors/v1", + "default": "experiment-binding-descriptors/v1", + "title": "Schema Version", + "type": "string" + } + }, + "required": [ + "descriptors" + ], + "title": "ExperimentBindingDescriptorSetModel", + "type": "object", + "x-aces-invariants": [ + { + "description": "Binding ids must be unique and target resolution must be injective within each source condition.", + "id": "binding-descriptors-canonical-targets-injective", + "inputs": [ + { + "contract_id": "experiment-binding-descriptors-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "raes_contracts.contracts.ExperimentBindingDescriptorSetModel._validate_unique_bindings_and_targets" + } + ] + }, "ExperimentCaptureSpecReferenceModel": { "additionalProperties": false, "description": "Reference constrained to a declarative capture specification.", @@ -1781,6 +2001,89 @@ "title": "GovernedEntropyRefModel", "type": "object" }, + "LiteralBindingValueModel": { + "additionalProperties": false, + "description": "Portable literal value; strict type validation occurs at its descriptor.", + "properties": { + "kind": { + "const": "literal", + "title": "Kind", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "required": [ + "kind", + "value" + ], + "title": "LiteralBindingValueModel", + "type": "object" + }, + "ParticipantImplementationBindingTargetModel": { + "additionalProperties": false, + "description": "Canonical target declared by one selected participant implementation.", + "properties": { + "implementation_name": { + "minLength": 1, + "title": "Implementation Name", + "type": "string" + }, + "implementation_version": { + "minLength": 1, + "title": "Implementation Version", + "type": "string" + }, + "manifest_version": { + "minLength": 1, + "title": "Manifest Version", + "type": "string" + }, + "participant_address": { + "minLength": 1, + "title": "Participant Address", + "type": "string" + }, + "plane": { + "const": "participant-implementation", + "title": "Plane", + "type": "string" + }, + "target_id": { + "minLength": 1, + "title": "Target Id", + "type": "string" + } + }, + "required": [ + "plane", + "participant_address", + "implementation_name", + "implementation_version", + "manifest_version", + "target_id" + ], + "title": "ParticipantImplementationBindingTargetModel", + "type": "object" + }, "PublicSeedModel": { "additionalProperties": false, "description": "Inline public root-entropy seed: fixed-width canonical hex bytes.\n\nFixed-width lowercase hex removes leading-zero, integer-width, sign, and\nUnicode ambiguity from the executable entropy encoding (per the EXP-718\npreflight's \"Canonical Inputs\" section).", @@ -1915,11 +2218,116 @@ ], "title": "RandomStreamProfileReferenceModel", "type": "object" + }, + "ScenarioBindingTargetModel": { + "additionalProperties": false, + "description": "Canonical target owned by a composed SDL scenario family.", + "properties": { + "plane": { + "const": "scenario", + "title": "Plane", + "type": "string" + }, + "scenario_family_id": { + "minLength": 1, + "title": "Scenario Family Id", + "type": "string" + }, + "target_id": { + "minLength": 1, + "title": "Target Id", + "type": "string" + }, + "variation_point_id": { + "minLength": 1, + "title": "Variation Point Id", + "type": "string" + } + }, + "required": [ + "plane", + "scenario_family_id", + "variation_point_id", + "target_id" + ], + "title": "ScenarioBindingTargetModel", + "type": "object" + }, + "SecretReferenceBindingValueModel": { + "additionalProperties": false, + "description": "Non-sensitive identity of a secret resolved only at an authorized sink.", + "properties": { + "kind": { + "const": "secret-reference", + "title": "Kind", + "type": "string" + }, + "reference_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$", + "title": "Reference Id", + "type": "string" + } + }, + "required": [ + "kind", + "reference_id" + ], + "title": "SecretReferenceBindingValueModel", + "type": "object" } }, "$id": "https://aces.dev/schemas/experiment-authoring-input-v1.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "binding_semantics": { + "const": "explicit-required" + } + }, + "required": [ + "binding_semantics" + ] + }, + "then": { + "properties": { + "binding_descriptors": { + "not": { + "type": "null" + } + } + }, + "required": [ + "binding_descriptors" + ] + } + }, + { + "if": { + "properties": { + "binding_descriptors": { + "not": { + "type": "null" + } + } + }, + "required": [ + "binding_descriptors" + ] + }, + "then": { + "properties": { + "binding_semantics": { + "const": "explicit-required" + } + } + } + } + ], "description": "Pre-run experiment authoring input: a design that binds a task to a run plan.\n\nThis is the authoring/input counterpart to the archival experiment-core\noutputs (run/study/apparatus-context). It references the separately\nauthored task (and optionally a scenario snapshot) and declares the\npre-run experimental design \u2014 apparatus intent, run plan, factors,\nintended capture, and validity notes \u2014 before any run executes. It is\nnever a run, study, or apparatus-context record (ADR-055 / ADR-074).", "properties": { "apparatus_intent": { @@ -1940,6 +2348,26 @@ "title": "Artifact Refs", "type": "array" }, + "binding_descriptors": { + "anyOf": [ + { + "$ref": "#/$defs/ExperimentBindingDescriptorSetModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "binding_semantics": { + "default": "descriptive", + "enum": [ + "descriptive", + "explicit-required" + ], + "title": "Binding Semantics", + "type": "string" + }, "capture_spec_refs": { "items": { "$ref": "#/$defs/ExperimentCaptureSpecReferenceModel" @@ -2030,6 +2458,18 @@ ], "level": "error", "validator": "raes_contracts.contracts.ExperimentSpecModel._validate_experiment_spec" + }, + { + "description": "Explicit bindings must cover every compared condition and resolve exact declared factor levels.", + "id": "experiment-binding-source-joins-valid", + "inputs": [ + { + "contract_id": "experiment-authoring-input-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "raes_contracts.contracts.ExperimentSpecModel._validate_binding_descriptors" } ], "x-aces-semantic-profile": { diff --git a/contracts/schemas/experiment-core/experiment-binding-descriptors-v1.json b/contracts/schemas/experiment-core/experiment-binding-descriptors-v1.json new file mode 100644 index 000000000..dfa902c43 --- /dev/null +++ b/contracts/schemas/experiment-core/experiment-binding-descriptors-v1.json @@ -0,0 +1,374 @@ +{ + "$defs": { + "ApparatusBindingTargetModel": { + "additionalProperties": false, + "description": "Canonical target declared by a selected portable apparatus manifest.", + "properties": { + "component_kind": { + "enum": [ + "processor", + "backend", + "participant-runtime", + "other" + ], + "title": "Component Kind", + "type": "string" + }, + "component_name": { + "minLength": 1, + "title": "Component Name", + "type": "string" + }, + "component_version": { + "minLength": 1, + "title": "Component Version", + "type": "string" + }, + "manifest_version": { + "minLength": 1, + "title": "Manifest Version", + "type": "string" + }, + "plane": { + "const": "apparatus", + "title": "Plane", + "type": "string" + }, + "target_id": { + "minLength": 1, + "title": "Target Id", + "type": "string" + } + }, + "required": [ + "plane", + "component_kind", + "component_name", + "component_version", + "manifest_version", + "target_id" + ], + "title": "ApparatusBindingTargetModel", + "type": "object" + }, + "BindingOwnerModel": { + "additionalProperties": false, + "description": "Governed contract and validator profile that owns one binding.", + "properties": { + "contract_id": { + "minLength": 1, + "title": "Contract Id", + "type": "string" + }, + "contract_version": { + "minLength": 1, + "title": "Contract Version", + "type": "string" + }, + "validator_id": { + "minLength": 1, + "title": "Validator Id", + "type": "string" + }, + "validator_version": { + "minLength": 1, + "title": "Validator Version", + "type": "string" + } + }, + "required": [ + "contract_id", + "contract_version", + "validator_id", + "validator_version" + ], + "title": "BindingOwnerModel", + "type": "object" + }, + "BindingScalarType": { + "description": "Exact JSON scalar type declared by a binding owner.", + "enum": [ + "string", + "integer", + "number", + "boolean", + "null" + ], + "title": "BindingScalarType", + "type": "string" + }, + "ExperimentBindingDescriptorModel": { + "additionalProperties": false, + "description": "One explicit factor/condition value bound to one authoritative target.", + "properties": { + "binding_id": { + "minLength": 1, + "title": "Binding Id", + "type": "string" + }, + "owner": { + "$ref": "#/$defs/BindingOwnerModel" + }, + "source_condition_id": { + "minLength": 1, + "title": "Source Condition Id", + "type": "string" + }, + "source_factor_id": { + "minLength": 1, + "title": "Source Factor Id", + "type": "string" + }, + "source_factor_level_id": { + "minLength": 1, + "title": "Source Factor Level Id", + "type": "string" + }, + "target": { + "discriminator": { + "mapping": { + "apparatus": "#/$defs/ApparatusBindingTargetModel", + "participant-implementation": "#/$defs/ParticipantImplementationBindingTargetModel", + "scenario": "#/$defs/ScenarioBindingTargetModel" + }, + "propertyName": "plane" + }, + "oneOf": [ + { + "$ref": "#/$defs/ScenarioBindingTargetModel" + }, + { + "$ref": "#/$defs/ParticipantImplementationBindingTargetModel" + }, + { + "$ref": "#/$defs/ApparatusBindingTargetModel" + } + ], + "title": "Target" + }, + "value": { + "discriminator": { + "mapping": { + "literal": "#/$defs/LiteralBindingValueModel", + "secret-reference": "#/$defs/SecretReferenceBindingValueModel" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/LiteralBindingValueModel" + }, + { + "$ref": "#/$defs/SecretReferenceBindingValueModel" + } + ], + "title": "Value" + }, + "value_type": { + "$ref": "#/$defs/BindingScalarType" + } + }, + "required": [ + "binding_id", + "source_factor_id", + "source_factor_level_id", + "source_condition_id", + "target", + "value_type", + "value", + "owner" + ], + "title": "ExperimentBindingDescriptorModel", + "type": "object" + }, + "LiteralBindingValueModel": { + "additionalProperties": false, + "description": "Portable literal value; strict type validation occurs at its descriptor.", + "properties": { + "kind": { + "const": "literal", + "title": "Kind", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "required": [ + "kind", + "value" + ], + "title": "LiteralBindingValueModel", + "type": "object" + }, + "ParticipantImplementationBindingTargetModel": { + "additionalProperties": false, + "description": "Canonical target declared by one selected participant implementation.", + "properties": { + "implementation_name": { + "minLength": 1, + "title": "Implementation Name", + "type": "string" + }, + "implementation_version": { + "minLength": 1, + "title": "Implementation Version", + "type": "string" + }, + "manifest_version": { + "minLength": 1, + "title": "Manifest Version", + "type": "string" + }, + "participant_address": { + "minLength": 1, + "title": "Participant Address", + "type": "string" + }, + "plane": { + "const": "participant-implementation", + "title": "Plane", + "type": "string" + }, + "target_id": { + "minLength": 1, + "title": "Target Id", + "type": "string" + } + }, + "required": [ + "plane", + "participant_address", + "implementation_name", + "implementation_version", + "manifest_version", + "target_id" + ], + "title": "ParticipantImplementationBindingTargetModel", + "type": "object" + }, + "ScenarioBindingTargetModel": { + "additionalProperties": false, + "description": "Canonical target owned by a composed SDL scenario family.", + "properties": { + "plane": { + "const": "scenario", + "title": "Plane", + "type": "string" + }, + "scenario_family_id": { + "minLength": 1, + "title": "Scenario Family Id", + "type": "string" + }, + "target_id": { + "minLength": 1, + "title": "Target Id", + "type": "string" + }, + "variation_point_id": { + "minLength": 1, + "title": "Variation Point Id", + "type": "string" + } + }, + "required": [ + "plane", + "scenario_family_id", + "variation_point_id", + "target_id" + ], + "title": "ScenarioBindingTargetModel", + "type": "object" + }, + "SecretReferenceBindingValueModel": { + "additionalProperties": false, + "description": "Non-sensitive identity of a secret resolved only at an authorized sink.", + "properties": { + "kind": { + "const": "secret-reference", + "title": "Kind", + "type": "string" + }, + "reference_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$", + "title": "Reference Id", + "type": "string" + } + }, + "required": [ + "kind", + "reference_id" + ], + "title": "SecretReferenceBindingValueModel", + "type": "object" + } + }, + "$id": "https://aces.dev/schemas/experiment-binding-descriptors-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Versioned, collision-free set of authoritative experiment bindings.", + "properties": { + "descriptors": { + "items": { + "$ref": "#/$defs/ExperimentBindingDescriptorModel" + }, + "minItems": 1, + "title": "Descriptors", + "type": "array" + }, + "schema_version": { + "const": "experiment-binding-descriptors/v1", + "default": "experiment-binding-descriptors/v1", + "title": "Schema Version", + "type": "string" + } + }, + "required": [ + "descriptors" + ], + "title": "ExperimentBindingDescriptorSetModel", + "type": "object", + "x-aces-invariants": [ + { + "description": "Binding ids must be unique and target resolution must be injective within each source condition.", + "id": "binding-descriptors-canonical-targets-injective", + "inputs": [ + { + "contract_id": "experiment-binding-descriptors-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "raes_contracts.contracts.ExperimentBindingDescriptorSetModel._validate_unique_bindings_and_targets" + } + ], + "x-aces-semantic-profile": { + "contract_id": "experiment-binding-descriptors-v1", + "entry_schema_contract_id": "aces-semantic-invariants-v1", + "entry_schema_pointer": "#/$defs/AcesSemanticInvariantEntryModel", + "id": "aces-semantic-invariants-v1", + "keyword": "x-aces-invariants", + "required": true, + "uri": "https://aces.dev/schemas/semantic-invariants/v1" + } +} diff --git a/contracts/schemas/experiment-core/experiment-run-v1.json b/contracts/schemas/experiment-core/experiment-run-v1.json index ba7635c1a..d9f0a5889 100644 --- a/contracts/schemas/experiment-core/experiment-run-v1.json +++ b/contracts/schemas/experiment-core/experiment-run-v1.json @@ -1,5 +1,56 @@ { "$defs": { + "ApparatusBindingTargetModel": { + "additionalProperties": false, + "description": "Canonical target declared by a selected portable apparatus manifest.", + "properties": { + "component_kind": { + "enum": [ + "processor", + "backend", + "participant-runtime", + "other" + ], + "title": "Component Kind", + "type": "string" + }, + "component_name": { + "minLength": 1, + "title": "Component Name", + "type": "string" + }, + "component_version": { + "minLength": 1, + "title": "Component Version", + "type": "string" + }, + "manifest_version": { + "minLength": 1, + "title": "Manifest Version", + "type": "string" + }, + "plane": { + "const": "apparatus", + "title": "Plane", + "type": "string" + }, + "target_id": { + "minLength": 1, + "title": "Target Id", + "type": "string" + } + }, + "required": [ + "plane", + "component_kind", + "component_name", + "component_version", + "manifest_version", + "target_id" + ], + "title": "ApparatusBindingTargetModel", + "type": "object" + }, "ApparatusIdentityModel": { "additionalProperties": false, "properties": { @@ -21,6 +72,52 @@ "title": "ApparatusIdentityModel", "type": "object" }, + "BindingOwnerModel": { + "additionalProperties": false, + "description": "Governed contract and validator profile that owns one binding.", + "properties": { + "contract_id": { + "minLength": 1, + "title": "Contract Id", + "type": "string" + }, + "contract_version": { + "minLength": 1, + "title": "Contract Version", + "type": "string" + }, + "validator_id": { + "minLength": 1, + "title": "Validator Id", + "type": "string" + }, + "validator_version": { + "minLength": 1, + "title": "Validator Version", + "type": "string" + } + }, + "required": [ + "contract_id", + "contract_version", + "validator_id", + "validator_version" + ], + "title": "BindingOwnerModel", + "type": "object" + }, + "BindingScalarType": { + "description": "Exact JSON scalar type declared by a binding owner.", + "enum": [ + "string", + "integer", + "number", + "boolean", + "null" + ], + "title": "BindingScalarType", + "type": "string" + }, "ClockDeclarationModel": { "additionalProperties": false, "properties": { @@ -848,6 +945,90 @@ } ] }, + "ExperimentBindingDescriptorModel": { + "additionalProperties": false, + "description": "One explicit factor/condition value bound to one authoritative target.", + "properties": { + "binding_id": { + "minLength": 1, + "title": "Binding Id", + "type": "string" + }, + "owner": { + "$ref": "#/$defs/BindingOwnerModel" + }, + "source_condition_id": { + "minLength": 1, + "title": "Source Condition Id", + "type": "string" + }, + "source_factor_id": { + "minLength": 1, + "title": "Source Factor Id", + "type": "string" + }, + "source_factor_level_id": { + "minLength": 1, + "title": "Source Factor Level Id", + "type": "string" + }, + "target": { + "discriminator": { + "mapping": { + "apparatus": "#/$defs/ApparatusBindingTargetModel", + "participant-implementation": "#/$defs/ParticipantImplementationBindingTargetModel", + "scenario": "#/$defs/ScenarioBindingTargetModel" + }, + "propertyName": "plane" + }, + "oneOf": [ + { + "$ref": "#/$defs/ScenarioBindingTargetModel" + }, + { + "$ref": "#/$defs/ParticipantImplementationBindingTargetModel" + }, + { + "$ref": "#/$defs/ApparatusBindingTargetModel" + } + ], + "title": "Target" + }, + "value": { + "discriminator": { + "mapping": { + "literal": "#/$defs/LiteralBindingValueModel", + "secret-reference": "#/$defs/SecretReferenceBindingValueModel" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/LiteralBindingValueModel" + }, + { + "$ref": "#/$defs/SecretReferenceBindingValueModel" + } + ], + "title": "Value" + }, + "value_type": { + "$ref": "#/$defs/BindingScalarType" + } + }, + "required": [ + "binding_id", + "source_factor_id", + "source_factor_level_id", + "source_condition_id", + "target", + "value_type", + "value", + "owner" + ], + "title": "ExperimentBindingDescriptorModel", + "type": "object" + }, "ExperimentCaptureSpecReferenceModel": { "additionalProperties": false, "description": "Reference constrained to a declarative capture specification.", @@ -2277,6 +2458,43 @@ "title": "GovernedRandomOutcomeRefModel", "type": "object" }, + "LiteralBindingValueModel": { + "additionalProperties": false, + "description": "Portable literal value; strict type validation occurs at its descriptor.", + "properties": { + "kind": { + "const": "literal", + "title": "Kind", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "required": [ + "kind", + "value" + ], + "title": "LiteralBindingValueModel", + "type": "object" + }, "ParticipantExposurePolicyModel": { "additionalProperties": false, "properties": { @@ -2368,6 +2586,52 @@ "title": "ParticipantExposurePolicyModel", "type": "object" }, + "ParticipantImplementationBindingTargetModel": { + "additionalProperties": false, + "description": "Canonical target declared by one selected participant implementation.", + "properties": { + "implementation_name": { + "minLength": 1, + "title": "Implementation Name", + "type": "string" + }, + "implementation_version": { + "minLength": 1, + "title": "Implementation Version", + "type": "string" + }, + "manifest_version": { + "minLength": 1, + "title": "Manifest Version", + "type": "string" + }, + "participant_address": { + "minLength": 1, + "title": "Participant Address", + "type": "string" + }, + "plane": { + "const": "participant-implementation", + "title": "Plane", + "type": "string" + }, + "target_id": { + "minLength": 1, + "title": "Target Id", + "type": "string" + } + }, + "required": [ + "plane", + "participant_address", + "implementation_name", + "implementation_version", + "manifest_version", + "target_id" + ], + "title": "ParticipantImplementationBindingTargetModel", + "type": "object" + }, "ParticipantImplementationProvenanceModel": { "additionalProperties": false, "properties": { @@ -2431,6 +2695,36 @@ }, "ParticipantImplementationSelectionModel": { "additionalProperties": false, + "oneOf": [ + { + "properties": { + "configuration_digest": { + "not": { + "type": "null" + } + }, + "configuration_ref": { + "not": { + "type": "null" + } + } + }, + "required": [ + "configuration_ref", + "configuration_digest" + ] + }, + { + "properties": { + "configuration_digest": { + "type": "null" + }, + "configuration_ref": { + "type": "null" + } + } + } + ], "properties": { "configuration_digest": { "anyOf": [ @@ -2765,6 +3059,44 @@ "title": "RandomStreamProfileReferenceModel", "type": "object" }, + "RealizedBindingProvenanceModel": { + "additionalProperties": false, + "description": "Portable provenance for one binding actually realized by its owner.", + "properties": { + "configuration_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": "Configuration Digest" + }, + "descriptor": { + "$ref": "#/$defs/ExperimentBindingDescriptorModel" + }, + "origin": { + "enum": [ + "selection", + "default", + "override" + ], + "title": "Origin", + "type": "string" + } + }, + "required": [ + "descriptor", + "origin" + ], + "title": "RealizedBindingProvenanceModel", + "type": "object" + }, "RealizedTimeModelProvenanceModel": { "additionalProperties": false, "description": "Run-scoped declaration/realization comparison and apparatus evidence.", @@ -2854,6 +3186,64 @@ "title": "RealizedTimeModelProvenanceModel", "type": "object" }, + "ScenarioBindingTargetModel": { + "additionalProperties": false, + "description": "Canonical target owned by a composed SDL scenario family.", + "properties": { + "plane": { + "const": "scenario", + "title": "Plane", + "type": "string" + }, + "scenario_family_id": { + "minLength": 1, + "title": "Scenario Family Id", + "type": "string" + }, + "target_id": { + "minLength": 1, + "title": "Target Id", + "type": "string" + }, + "variation_point_id": { + "minLength": 1, + "title": "Variation Point Id", + "type": "string" + } + }, + "required": [ + "plane", + "scenario_family_id", + "variation_point_id", + "target_id" + ], + "title": "ScenarioBindingTargetModel", + "type": "object" + }, + "SecretReferenceBindingValueModel": { + "additionalProperties": false, + "description": "Non-sensitive identity of a secret resolved only at an authorized sink.", + "properties": { + "kind": { + "const": "secret-reference", + "title": "Kind", + "type": "string" + }, + "reference_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$", + "title": "Reference Id", + "type": "string" + } + }, + "required": [ + "kind", + "reference_id" + ], + "title": "SecretReferenceBindingValueModel", + "type": "object" + }, "StreamAddressModel": { "additionalProperties": false, "description": "Closed semantic random-draw address (SVR-013).\n\nA pure canonical function of the randomness namespace, logical trial\ncoordinate, selection-policy id, variation-point id, draw purpose, and a\nstable local draw coordinate. It is not a concatenated string, arbitrary\nmapping, JSON Pointer, or scheduler/compiled-runtime address, and it\nadmits no worker/process/thread/host, wall-time, retry, or aggregate\nexperiment-digest field (``extra=\"forbid\"`` closes the shape).", @@ -4222,6 +4612,13 @@ ], "default": null }, + "realized_bindings": { + "items": { + "$ref": "#/$defs/RealizedBindingProvenanceModel" + }, + "title": "Realized Bindings", + "type": "array" + }, "realized_form_disclosures": { "items": { "$ref": "#/$defs/ExperimentRealizedFormDisclosureModel" diff --git a/contracts/schemas/participant-implementation-configuration/participant-configuration-result-v1.json b/contracts/schemas/participant-implementation-configuration/participant-configuration-result-v1.json new file mode 100644 index 000000000..bd7c2c2ba --- /dev/null +++ b/contracts/schemas/participant-implementation-configuration/participant-configuration-result-v1.json @@ -0,0 +1,282 @@ +{ + "$defs": { + "ApparatusIdentityModel": { + "additionalProperties": false, + "properties": { + "name": { + "minLength": 1, + "title": "Name", + "type": "string" + }, + "version": { + "minLength": 1, + "title": "Version", + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "title": "ApparatusIdentityModel", + "type": "object" + }, + "BindingOwnerModel": { + "additionalProperties": false, + "description": "Governed contract and validator profile that owns one binding.", + "properties": { + "contract_id": { + "minLength": 1, + "title": "Contract Id", + "type": "string" + }, + "contract_version": { + "minLength": 1, + "title": "Contract Version", + "type": "string" + }, + "validator_id": { + "minLength": 1, + "title": "Validator Id", + "type": "string" + }, + "validator_version": { + "minLength": 1, + "title": "Validator Version", + "type": "string" + } + }, + "required": [ + "contract_id", + "contract_version", + "validator_id", + "validator_version" + ], + "title": "BindingOwnerModel", + "type": "object" + }, + "BindingScalarType": { + "description": "Exact JSON scalar type declared by a binding owner.", + "enum": [ + "string", + "integer", + "number", + "boolean", + "null" + ], + "title": "BindingScalarType", + "type": "string" + }, + "LiteralBindingValueModel": { + "additionalProperties": false, + "description": "Portable literal value; strict type validation occurs at its descriptor.", + "properties": { + "kind": { + "const": "literal", + "title": "Kind", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "required": [ + "kind", + "value" + ], + "title": "LiteralBindingValueModel", + "type": "object" + }, + "ParticipantConfigurationModel": { + "additionalProperties": false, + "description": "Canonical digest payload for one complete participant configuration.", + "properties": { + "implementation_identity": { + "$ref": "#/$defs/ApparatusIdentityModel" + }, + "manifest_version": { + "minLength": 1, + "title": "Manifest Version", + "type": "string" + }, + "owner": { + "$ref": "#/$defs/BindingOwnerModel" + }, + "values": { + "items": { + "$ref": "#/$defs/RealizedConfigurationValueModel" + }, + "minItems": 1, + "title": "Values", + "type": "array" + } + }, + "required": [ + "implementation_identity", + "manifest_version", + "owner", + "values" + ], + "title": "ParticipantConfigurationModel", + "type": "object" + }, + "RealizedConfigurationValueModel": { + "additionalProperties": false, + "description": "One normalized value in a complete participant configuration.", + "properties": { + "origin": { + "enum": [ + "default", + "override" + ], + "title": "Origin", + "type": "string" + }, + "target_id": { + "minLength": 1, + "title": "Target Id", + "type": "string" + }, + "value": { + "discriminator": { + "mapping": { + "literal": "#/$defs/LiteralBindingValueModel", + "secret-reference": "#/$defs/SecretReferenceBindingValueModel" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/LiteralBindingValueModel" + }, + { + "$ref": "#/$defs/SecretReferenceBindingValueModel" + } + ], + "title": "Value" + }, + "value_type": { + "$ref": "#/$defs/BindingScalarType" + } + }, + "required": [ + "target_id", + "value_type", + "origin", + "value" + ], + "title": "RealizedConfigurationValueModel", + "type": "object" + }, + "SecretReferenceBindingValueModel": { + "additionalProperties": false, + "description": "Non-sensitive identity of a secret resolved only at an authorized sink.", + "properties": { + "kind": { + "const": "secret-reference", + "title": "Kind", + "type": "string" + }, + "reference_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$", + "title": "Reference Id", + "type": "string" + } + }, + "required": [ + "kind", + "reference_id" + ], + "title": "SecretReferenceBindingValueModel", + "type": "object" + } + }, + "$id": "https://aces.dev/schemas/participant-configuration-result-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Portable result of one complete, atomic participant configuration validation.", + "properties": { + "configuration": { + "$ref": "#/$defs/ParticipantConfigurationModel" + }, + "configuration_digest": { + "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})$", + "title": "Configuration Digest", + "type": "string" + }, + "manifest_digest": { + "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})$", + "title": "Manifest Digest", + "type": "string" + }, + "manifest_ref": { + "minLength": 1, + "title": "Manifest Ref", + "type": "string" + }, + "participant_address": { + "minLength": 1, + "title": "Participant Address", + "type": "string" + }, + "schema_version": { + "const": "participant-configuration-result/v1", + "default": "participant-configuration-result/v1", + "title": "Schema Version", + "type": "string" + } + }, + "required": [ + "participant_address", + "manifest_ref", + "manifest_digest", + "configuration", + "configuration_digest" + ], + "title": "ParticipantConfigurationResultModel", + "type": "object", + "x-aces-invariants": [ + { + "description": "The configuration digest must be the RFC 8785/JCS digest of the complete normalized configuration.", + "id": "participant-configuration-digest-valid", + "inputs": [ + { + "contract_id": "participant-configuration-result-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "raes_contracts.contracts.ParticipantConfigurationResultModel._validate_configuration_digest" + } + ], + "x-aces-semantic-profile": { + "contract_id": "participant-configuration-result-v1", + "entry_schema_contract_id": "aces-semantic-invariants-v1", + "entry_schema_pointer": "#/$defs/AcesSemanticInvariantEntryModel", + "id": "aces-semantic-invariants-v1", + "keyword": "x-aces-invariants", + "required": true, + "uri": "https://aces.dev/schemas/semantic-invariants/v1" + } +} diff --git a/contracts/schemas/participant-implementation-manifest/participant-implementation-manifest-v1.json b/contracts/schemas/participant-implementation-manifest/participant-implementation-manifest-v1.json index 2cc58c56a..71adec30d 100644 --- a/contracts/schemas/participant-implementation-manifest/participant-implementation-manifest-v1.json +++ b/contracts/schemas/participant-implementation-manifest/participant-implementation-manifest-v1.json @@ -21,6 +21,52 @@ "title": "ApparatusIdentityModel", "type": "object" }, + "BindingOwnerModel": { + "additionalProperties": false, + "description": "Governed contract and validator profile that owns one binding.", + "properties": { + "contract_id": { + "minLength": 1, + "title": "Contract Id", + "type": "string" + }, + "contract_version": { + "minLength": 1, + "title": "Contract Version", + "type": "string" + }, + "validator_id": { + "minLength": 1, + "title": "Validator Id", + "type": "string" + }, + "validator_version": { + "minLength": 1, + "title": "Validator Version", + "type": "string" + } + }, + "required": [ + "contract_id", + "contract_version", + "validator_id", + "validator_version" + ], + "title": "BindingOwnerModel", + "type": "object" + }, + "BindingScalarType": { + "description": "Exact JSON scalar type declared by a binding owner.", + "enum": [ + "string", + "integer", + "number", + "boolean", + "null" + ], + "title": "BindingScalarType", + "type": "string" + }, "ConceptBindingEntryModel": { "additionalProperties": false, "description": "Binds a vocabulary surface in an artifact to a canonical concept family.", @@ -45,6 +91,132 @@ "title": "ConceptBindingEntryModel", "type": "object" }, + "ConfigurationTargetDeclarationModel": { + "additionalProperties": false, + "description": "One scalar target admitted by an owning portable manifest.", + "properties": { + "aliases": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Aliases", + "type": "array" + }, + "allowed_value_kinds": { + "items": { + "enum": [ + "literal", + "secret-reference" + ], + "type": "string" + }, + "minItems": 1, + "title": "Allowed Value Kinds", + "type": "array" + }, + "default": { + "anyOf": [ + { + "$ref": "#/$defs/LiteralBindingValueModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "sensitivity": { + "enum": [ + "public", + "internal", + "restricted", + "secret" + ], + "title": "Sensitivity", + "type": "string" + }, + "target_id": { + "minLength": 1, + "title": "Target Id", + "type": "string" + }, + "value_type": { + "$ref": "#/$defs/BindingScalarType" + } + }, + "required": [ + "target_id", + "value_type", + "allowed_value_kinds", + "sensitivity" + ], + "title": "ConfigurationTargetDeclarationModel", + "type": "object" + }, + "ConfigurationTargetRegistryModel": { + "additionalProperties": false, + "description": "Collision-free target registry published by one manifest owner.", + "properties": { + "owner": { + "$ref": "#/$defs/BindingOwnerModel" + }, + "targets": { + "additionalProperties": { + "$ref": "#/$defs/ConfigurationTargetDeclarationModel" + }, + "minProperties": 1, + "propertyNames": { + "minLength": 1 + }, + "title": "Targets", + "type": "object" + } + }, + "required": [ + "owner", + "targets" + ], + "title": "ConfigurationTargetRegistryModel", + "type": "object" + }, + "LiteralBindingValueModel": { + "additionalProperties": false, + "description": "Portable literal value; strict type validation occurs at its descriptor.", + "properties": { + "kind": { + "const": "literal", + "title": "Kind", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "required": [ + "kind", + "value" + ], + "title": "LiteralBindingValueModel", + "type": "object" + }, "ParticipantImplementationCapabilitiesModel": { "additionalProperties": false, "properties": { @@ -148,6 +320,17 @@ "title": "Concept Bindings", "type": "array" }, + "configuration_registry": { + "anyOf": [ + { + "$ref": "#/$defs/ConfigurationTargetRegistryModel" + }, + { + "type": "null" + } + ], + "default": null + }, "constraints": { "additionalProperties": { "type": "string" @@ -174,6 +357,8 @@ "enum": [ "participant-implementation-manifest-v1", "participant-implementation-provenance-v1", + "experiment-binding-descriptors-v1", + "participant-configuration-result-v1", "participant-episode-state-envelope-v1", "participant-episode-history-event-stream-v1", "participant-behavior-history-event-stream-v1" diff --git a/contracts/schemas/participant-implementation-provenance/participant-implementation-provenance-v1.json b/contracts/schemas/participant-implementation-provenance/participant-implementation-provenance-v1.json index 00d7d6d1a..84f26db6b 100644 --- a/contracts/schemas/participant-implementation-provenance/participant-implementation-provenance-v1.json +++ b/contracts/schemas/participant-implementation-provenance/participant-implementation-provenance-v1.json @@ -114,6 +114,36 @@ }, "ParticipantImplementationSelectionModel": { "additionalProperties": false, + "oneOf": [ + { + "properties": { + "configuration_digest": { + "not": { + "type": "null" + } + }, + "configuration_ref": { + "not": { + "type": "null" + } + } + }, + "required": [ + "configuration_ref", + "configuration_digest" + ] + }, + { + "properties": { + "configuration_digest": { + "type": "null" + }, + "configuration_ref": { + "type": "null" + } + } + } + ], "properties": { "configuration_digest": { "anyOf": [ diff --git a/contracts/schemas/processor-manifest/processor-manifest-v2.json b/contracts/schemas/processor-manifest/processor-manifest-v2.json index b145d7d62..71bb16ee9 100644 --- a/contracts/schemas/processor-manifest/processor-manifest-v2.json +++ b/contracts/schemas/processor-manifest/processor-manifest-v2.json @@ -21,6 +21,52 @@ "title": "ApparatusIdentityModel", "type": "object" }, + "BindingOwnerModel": { + "additionalProperties": false, + "description": "Governed contract and validator profile that owns one binding.", + "properties": { + "contract_id": { + "minLength": 1, + "title": "Contract Id", + "type": "string" + }, + "contract_version": { + "minLength": 1, + "title": "Contract Version", + "type": "string" + }, + "validator_id": { + "minLength": 1, + "title": "Validator Id", + "type": "string" + }, + "validator_version": { + "minLength": 1, + "title": "Validator Version", + "type": "string" + } + }, + "required": [ + "contract_id", + "contract_version", + "validator_id", + "validator_version" + ], + "title": "BindingOwnerModel", + "type": "object" + }, + "BindingScalarType": { + "description": "Exact JSON scalar type declared by a binding owner.", + "enum": [ + "string", + "integer", + "number", + "boolean", + "null" + ], + "title": "BindingScalarType", + "type": "string" + }, "ConceptBindingEntryModel": { "additionalProperties": false, "description": "Binds a vocabulary surface in an artifact to a canonical concept family.", @@ -45,6 +91,132 @@ "title": "ConceptBindingEntryModel", "type": "object" }, + "ConfigurationTargetDeclarationModel": { + "additionalProperties": false, + "description": "One scalar target admitted by an owning portable manifest.", + "properties": { + "aliases": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Aliases", + "type": "array" + }, + "allowed_value_kinds": { + "items": { + "enum": [ + "literal", + "secret-reference" + ], + "type": "string" + }, + "minItems": 1, + "title": "Allowed Value Kinds", + "type": "array" + }, + "default": { + "anyOf": [ + { + "$ref": "#/$defs/LiteralBindingValueModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "sensitivity": { + "enum": [ + "public", + "internal", + "restricted", + "secret" + ], + "title": "Sensitivity", + "type": "string" + }, + "target_id": { + "minLength": 1, + "title": "Target Id", + "type": "string" + }, + "value_type": { + "$ref": "#/$defs/BindingScalarType" + } + }, + "required": [ + "target_id", + "value_type", + "allowed_value_kinds", + "sensitivity" + ], + "title": "ConfigurationTargetDeclarationModel", + "type": "object" + }, + "ConfigurationTargetRegistryModel": { + "additionalProperties": false, + "description": "Collision-free target registry published by one manifest owner.", + "properties": { + "owner": { + "$ref": "#/$defs/BindingOwnerModel" + }, + "targets": { + "additionalProperties": { + "$ref": "#/$defs/ConfigurationTargetDeclarationModel" + }, + "minProperties": 1, + "propertyNames": { + "minLength": 1 + }, + "title": "Targets", + "type": "object" + } + }, + "required": [ + "owner", + "targets" + ], + "title": "ConfigurationTargetRegistryModel", + "type": "object" + }, + "LiteralBindingValueModel": { + "additionalProperties": false, + "description": "Portable literal value; strict type validation occurs at its descriptor.", + "properties": { + "kind": { + "const": "literal", + "title": "Kind", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "required": [ + "kind", + "value" + ], + "title": "LiteralBindingValueModel", + "type": "object" + }, "ProcessorCapabilitiesV2Model": { "additionalProperties": false, "properties": { @@ -129,6 +301,17 @@ "title": "Concept Bindings", "type": "array" }, + "configuration_registry": { + "anyOf": [ + { + "$ref": "#/$defs/ConfigurationTargetRegistryModel" + }, + { + "type": "null" + } + ], + "default": null + }, "constraints": { "additionalProperties": { "type": "string" @@ -149,6 +332,7 @@ "items": { "enum": [ "processor-manifest-v2", + "experiment-binding-descriptors-v1", "provisioning-plan-v1", "orchestration-plan-v1", "evaluation-plan-v1", diff --git a/contracts/schemas/profiles/backend-profile-v1.json b/contracts/schemas/profiles/backend-profile-v1.json index 42be64bfe..d35cde8a1 100644 --- a/contracts/schemas/profiles/backend-profile-v1.json +++ b/contracts/schemas/profiles/backend-profile-v1.json @@ -13,6 +13,7 @@ "items": { "enum": [ "backend-manifest-v2", + "experiment-binding-descriptors-v1", "realization-envelope-v1", "provisioning-plan-v1", "orchestration-plan-v1", diff --git a/contracts/schemas/snapshots/runtime-snapshot-v1.json b/contracts/schemas/snapshots/runtime-snapshot-v1.json index 35ab3b805..d6cd31459 100644 --- a/contracts/schemas/snapshots/runtime-snapshot-v1.json +++ b/contracts/schemas/snapshots/runtime-snapshot-v1.json @@ -599,6 +599,172 @@ "title": "ParticipantAdmissionDisposition", "type": "string" }, + "ParticipantApprovalOccurrenceModel": { + "additionalProperties": false, + "description": "Approval of exactly one proposal revision, before action admission.", + "properties": { + "authority_basis_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Authority Basis Refs", + "type": "array" + }, + "behavior_specification_ref": { + "minLength": 1, + "title": "Behavior Specification Ref", + "type": "string" + }, + "controlled_scope_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Controlled Scope Refs", + "type": "array" + }, + "controller_ref": { + "minLength": 1, + "title": "Controller Ref", + "type": "string" + }, + "controller_state_ref": { + "minLength": 1, + "title": "Controller State Ref", + "type": "string" + }, + "decision_ref": { + "minLength": 1, + "title": "Decision Ref", + "type": "string" + }, + "decision_revision": { + "minimum": 1, + "title": "Decision Revision", + "type": "integer" + }, + "declaration_ref": { + "minLength": 1, + "title": "Declaration Ref", + "type": "string" + }, + "disposition": { + "$ref": "#/$defs/ParticipantControlDisposition" + }, + "effective_order": { + "minimum": 0, + "title": "Effective Order", + "type": "integer" + }, + "expected_state_revision": { + "minimum": 0, + "title": "Expected State Revision", + "type": "integer" + }, + "kind": { + "const": "approval", + "title": "Kind", + "type": "string" + }, + "limitation_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Limitation Refs", + "type": "array" + }, + "mixed_control_policy_ref": { + "minLength": 1, + "title": "Mixed Control Policy Ref", + "type": "string" + }, + "occurrence_revision": { + "minimum": 1, + "title": "Occurrence Revision", + "type": "integer" + }, + "policy_revision": { + "minLength": 1, + "title": "Policy Revision", + "type": "string" + }, + "proposal_ref": { + "minLength": 1, + "title": "Proposal Ref", + "type": "string" + }, + "proposal_revision": { + "minimum": 1, + "title": "Proposal Revision", + "type": "integer" + }, + "reason_code": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason Code" + }, + "reason_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason Ref" + }, + "valid_from_order": { + "minimum": 0, + "title": "Valid From Order", + "type": "integer" + }, + "valid_until_order": { + "minimum": 0, + "title": "Valid Until Order", + "type": "integer" + } + }, + "required": [ + "declaration_ref", + "controller_ref", + "controller_state_ref", + "authority_basis_refs", + "controlled_scope_refs", + "behavior_specification_ref", + "mixed_control_policy_ref", + "policy_revision", + "expected_state_revision", + "effective_order", + "valid_from_order", + "valid_until_order", + "occurrence_revision", + "disposition", + "limitation_refs", + "kind", + "proposal_ref", + "proposal_revision", + "decision_ref", + "decision_revision" + ], + "title": "ParticipantApprovalOccurrenceModel", + "type": "object" + }, "ParticipantAttributionCandidateKind": { "description": "Portable candidate classes for participant attribution edges.", "enum": [ @@ -1229,60 +1395,107 @@ "title": "ParticipantBehaviorHistoryEventType", "type": "string" }, - "ParticipantEffectClass": { - "description": "SEM-211 effect classes for participant action results.", + "ParticipantCancellationEffect": { + "description": "What remained cancellable when the occurrence was recorded.", "enum": [ - "intended_effect", - "side_effect", - "observation_effect", - "visibility_effect", - "detection_effect", - "evidence_effect", - "no_effect", - "unknown_effect" + "prevented", + "partial-limitation", + "too-late" ], - "title": "ParticipantEffectClass", + "title": "ParticipantCancellationEffect", "type": "string" }, - "ParticipantEpisodeHistoryEventModel": { + "ParticipantCancellationOccurrenceModel": { "additionalProperties": false, + "description": "A cancellation with an explicit non-retroactive effect.", "properties": { - "control_action": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Control Action" + "authority_basis_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Authority Basis Refs", + "type": "array" }, - "details": { - "additionalProperties": true, - "title": "Details", - "type": "object" + "behavior_specification_ref": { + "minLength": 1, + "title": "Behavior Specification Ref", + "type": "string" }, - "episode_id": { - "title": "Episode Id", + "cancellation_effect": { + "$ref": "#/$defs/ParticipantCancellationEffect" + }, + "controlled_scope_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Controlled Scope Refs", + "type": "array" + }, + "controller_ref": { + "minLength": 1, + "title": "Controller Ref", "type": "string" }, - "event_type": { - "title": "Event Type", + "controller_state_ref": { + "minLength": 1, + "title": "Controller State Ref", "type": "string" }, - "participant_address": { - "title": "Participant Address", + "declaration_ref": { + "minLength": 1, + "title": "Declaration Ref", "type": "string" }, - "sequence_number": { - "title": "Sequence Number", + "disposition": { + "$ref": "#/$defs/ParticipantControlDisposition" + }, + "effective_order": { + "minimum": 0, + "title": "Effective Order", "type": "integer" }, - "terminal_reason": { + "expected_state_revision": { + "minimum": 0, + "title": "Expected State Revision", + "type": "integer" + }, + "kind": { + "const": "cancellation", + "title": "Kind", + "type": "string" + }, + "limitation_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Limitation Refs", + "type": "array" + }, + "mixed_control_policy_ref": { + "minLength": 1, + "title": "Mixed Control Policy Ref", + "type": "string" + }, + "occurrence_revision": { + "minimum": 1, + "title": "Occurrence Revision", + "type": "integer" + }, + "policy_revision": { + "minLength": 1, + "title": "Policy Revision", + "type": "string" + }, + "reason_code": { "anyOf": [ { + "minLength": 1, "type": "string" }, { @@ -1290,71 +1503,1296 @@ } ], "default": null, - "title": "Terminal Reason" + "title": "Reason Code" }, - "timestamp": { - "title": "Timestamp", + "reason_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason Ref" + }, + "target_kind": { + "enum": [ + "proposal", + "decision", + "admitted-action", + "attempt" + ], + "title": "Target Kind", + "type": "string" + }, + "target_ref": { + "minLength": 1, + "title": "Target Ref", "type": "string" + }, + "target_revision": { + "minimum": 1, + "title": "Target Revision", + "type": "integer" + }, + "valid_from_order": { + "minimum": 0, + "title": "Valid From Order", + "type": "integer" + }, + "valid_until_order": { + "minimum": 0, + "title": "Valid Until Order", + "type": "integer" } }, "required": [ - "event_type", - "timestamp", - "participant_address", - "episode_id", - "sequence_number" + "declaration_ref", + "controller_ref", + "controller_state_ref", + "authority_basis_refs", + "controlled_scope_refs", + "behavior_specification_ref", + "mixed_control_policy_ref", + "policy_revision", + "expected_state_revision", + "effective_order", + "valid_from_order", + "valid_until_order", + "occurrence_revision", + "disposition", + "limitation_refs", + "kind", + "target_kind", + "target_ref", + "target_revision", + "cancellation_effect" ], - "title": "ParticipantEpisodeHistoryEventModel", + "title": "ParticipantCancellationOccurrenceModel", "type": "object" }, - "ParticipantEpisodeStateModel": { + "ParticipantControlDisposition": { + "description": "Realized disposition of one portable control occurrence.", + "enum": [ + "recorded", + "accepted", + "rejected", + "limited", + "superseded", + "cancelled" + ], + "title": "ParticipantControlDisposition", + "type": "string" + }, + "ParticipantControlOccurrenceModel": { "additionalProperties": false, + "description": "Closed participant-runtime carrier for one API-409 control fact.", "properties": { - "episode_id": { - "title": "Episode Id", + "actor_ref": { + "minLength": 1, + "title": "Actor Ref", "type": "string" }, - "initialized_at": { - "title": "Initialized At", + "authorization_scope": { + "minLength": 1, + "title": "Authorization Scope", "type": "string" }, - "last_control_action": { - "title": "Last Control Action", + "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": { + "minLength": 1, + "title": "Episode Id", "type": "string" }, - "participant_address": { - "title": "Participant Address", + "event_classification": { + "anyOf": [ + { + "$ref": "#/$defs/EventClassificationModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "event_id": { + "minLength": 1, + "title": "Event Id", + "type": "string" + }, + "event_type": { + "const": "participant-control-occurrence", + "title": "Event Type", + "type": "string" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Evidence Refs", + "type": "array" + }, + "extension_policy": { + "const": "closed", + "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" + }, + "minItems": 1, + "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" + }, + "occurrence": { + "discriminator": { + "mapping": { + "approval": "#/$defs/ParticipantApprovalOccurrenceModel", + "cancellation": "#/$defs/ParticipantCancellationOccurrenceModel", + "denial": "#/$defs/ParticipantDenialOccurrenceModel", + "external-direction": "#/$defs/ParticipantExternalDirectionOccurrenceModel", + "handoff": "#/$defs/ParticipantHandoffOccurrenceModel", + "intervention": "#/$defs/ParticipantInterventionOccurrenceModel", + "override": "#/$defs/ParticipantOverrideOccurrenceModel", + "proposal": "#/$defs/ParticipantProposalOccurrenceModel" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/ParticipantProposalOccurrenceModel" + }, + { + "$ref": "#/$defs/ParticipantApprovalOccurrenceModel" + }, + { + "$ref": "#/$defs/ParticipantDenialOccurrenceModel" + }, + { + "$ref": "#/$defs/ParticipantExternalDirectionOccurrenceModel" + }, + { + "$ref": "#/$defs/ParticipantInterventionOccurrenceModel" + }, + { + "$ref": "#/$defs/ParticipantHandoffOccurrenceModel" + }, + { + "$ref": "#/$defs/ParticipantOverrideOccurrenceModel" + }, + { + "$ref": "#/$defs/ParticipantCancellationOccurrenceModel" + } + ], + "title": "Occurrence" + }, + "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": { + "minLength": 1, + "title": "Participant Address", + "type": "string" + }, + "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" + }, + "minItems": 1, + "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": { + "const": "participant-control-occurrence", + "title": "Schema Name", + "type": "string" + }, + "schema_version": { + "const": "1.0.0", + "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" + }, + "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", + "participant_address", + "episode_id", + "occurred_at", + "recorded_at", + "ingested_at", + "clock_authority", + "ordering_basis", + "actor_ref", + "producer_ref", + "provenance_refs", + "evidence_refs", + "object_marking_refs", + "authorization_scope", + "occurrence" + ], + "title": "ParticipantControlOccurrenceModel", + "type": "object", + "x-aces-invariants": [ + { + "description": "Every occurrence must resolve one matching compiled ACT-617 declaration and preserve participant, episode, controller, authority, policy revision, order, proposal, target, and semantic identity joins.", + "id": "participant-control-occurrence-context-agreement", + "inputs": [ + { + "contract_id": "participant-control-occurrence-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "raes_contracts.contracts.validate_participant_control_occurrence_context" + }, + { + "description": "Proposal, approval, denial, direction, intervention, handoff, override, and cancellation remain distinct from action admission, execution, delivery, observation, and audit evidence.", + "id": "participant-control-occurrence-not-lifecycle-evidence", + "inputs": [ + { + "contract_id": "participant-control-occurrence-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "raes_contracts.contracts.ParticipantControlOccurrenceModel" + } + ] + }, + "ParticipantDenialOccurrenceModel": { + "additionalProperties": false, + "description": "Denial of exactly one proposal revision.", + "properties": { + "authority_basis_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Authority Basis Refs", + "type": "array" + }, + "behavior_specification_ref": { + "minLength": 1, + "title": "Behavior Specification Ref", + "type": "string" + }, + "controlled_scope_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Controlled Scope Refs", + "type": "array" + }, + "controller_ref": { + "minLength": 1, + "title": "Controller Ref", + "type": "string" + }, + "controller_state_ref": { + "minLength": 1, + "title": "Controller State Ref", + "type": "string" + }, + "decision_ref": { + "minLength": 1, + "title": "Decision Ref", + "type": "string" + }, + "decision_revision": { + "minimum": 1, + "title": "Decision Revision", + "type": "integer" + }, + "declaration_ref": { + "minLength": 1, + "title": "Declaration Ref", + "type": "string" + }, + "disposition": { + "$ref": "#/$defs/ParticipantControlDisposition" + }, + "effective_order": { + "minimum": 0, + "title": "Effective Order", + "type": "integer" + }, + "expected_state_revision": { + "minimum": 0, + "title": "Expected State Revision", + "type": "integer" + }, + "kind": { + "const": "denial", + "title": "Kind", + "type": "string" + }, + "limitation_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Limitation Refs", + "type": "array" + }, + "mixed_control_policy_ref": { + "minLength": 1, + "title": "Mixed Control Policy Ref", + "type": "string" + }, + "occurrence_revision": { + "minimum": 1, + "title": "Occurrence Revision", + "type": "integer" + }, + "policy_revision": { + "minLength": 1, + "title": "Policy Revision", + "type": "string" + }, + "proposal_ref": { + "minLength": 1, + "title": "Proposal Ref", + "type": "string" + }, + "proposal_revision": { + "minimum": 1, + "title": "Proposal Revision", + "type": "integer" + }, + "reason_code": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason Code" + }, + "reason_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason Ref" + }, + "valid_from_order": { + "minimum": 0, + "title": "Valid From Order", + "type": "integer" + }, + "valid_until_order": { + "minimum": 0, + "title": "Valid Until Order", + "type": "integer" + } + }, + "required": [ + "declaration_ref", + "controller_ref", + "controller_state_ref", + "authority_basis_refs", + "controlled_scope_refs", + "behavior_specification_ref", + "mixed_control_policy_ref", + "policy_revision", + "expected_state_revision", + "effective_order", + "valid_from_order", + "valid_until_order", + "occurrence_revision", + "disposition", + "limitation_refs", + "kind", + "proposal_ref", + "proposal_revision", + "decision_ref", + "decision_revision" + ], + "title": "ParticipantDenialOccurrenceModel", + "type": "object" + }, + "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" + }, + "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" + }, + "ParticipantExternalDirectionOccurrenceModel": { + "additionalProperties": false, + "description": "A scoped direction that does not bypass proposal validation or admission.", + "properties": { + "authority_basis_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Authority Basis Refs", + "type": "array" + }, + "behavior_specification_ref": { + "minLength": 1, + "title": "Behavior Specification Ref", + "type": "string" + }, + "controlled_scope_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Controlled Scope Refs", + "type": "array" + }, + "controller_ref": { + "minLength": 1, + "title": "Controller Ref", + "type": "string" + }, + "controller_state_ref": { + "minLength": 1, + "title": "Controller State Ref", + "type": "string" + }, + "declaration_ref": { + "minLength": 1, + "title": "Declaration Ref", + "type": "string" + }, + "disposition": { + "$ref": "#/$defs/ParticipantControlDisposition" + }, + "effective_order": { + "minimum": 0, + "title": "Effective Order", + "type": "integer" + }, + "expected_state_revision": { + "minimum": 0, + "title": "Expected State Revision", + "type": "integer" + }, + "kind": { + "const": "external-direction", + "title": "Kind", + "type": "string" + }, + "limitation_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Limitation Refs", + "type": "array" + }, + "mixed_control_policy_ref": { + "minLength": 1, + "title": "Mixed Control Policy Ref", + "type": "string" + }, + "occurrence_revision": { + "minimum": 1, + "title": "Occurrence Revision", + "type": "integer" + }, + "policy_revision": { + "minLength": 1, + "title": "Policy Revision", + "type": "string" + }, + "reason_code": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason Code" + }, + "reason_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason Ref" + }, + "target_kind": { + "enum": [ + "proposal", + "action", + "control" + ], + "title": "Target Kind", + "type": "string" + }, + "target_ref": { + "minLength": 1, + "title": "Target Ref", + "type": "string" + }, + "target_revision": { + "minimum": 1, + "title": "Target Revision", + "type": "integer" + }, + "valid_from_order": { + "minimum": 0, + "title": "Valid From Order", + "type": "integer" + }, + "valid_until_order": { + "minimum": 0, + "title": "Valid Until Order", + "type": "integer" + } + }, + "required": [ + "declaration_ref", + "controller_ref", + "controller_state_ref", + "authority_basis_refs", + "controlled_scope_refs", + "behavior_specification_ref", + "mixed_control_policy_ref", + "policy_revision", + "expected_state_revision", + "effective_order", + "valid_from_order", + "valid_until_order", + "occurrence_revision", + "disposition", + "limitation_refs", + "kind", + "target_kind", + "target_ref", + "target_revision" + ], + "title": "ParticipantExternalDirectionOccurrenceModel", + "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" + }, + "ParticipantHandoffOccurrenceModel": { + "additionalProperties": false, + "description": "A controller-state handoff that preserves participant identity.", + "properties": { + "authority_basis_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Authority Basis Refs", + "type": "array" + }, + "behavior_specification_ref": { + "minLength": 1, + "title": "Behavior Specification Ref", + "type": "string" + }, + "completion_evidence_ref": { + "minLength": 1, + "title": "Completion Evidence Ref", + "type": "string" + }, + "controlled_scope_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Controlled Scope Refs", + "type": "array" + }, + "controller_ref": { + "minLength": 1, + "title": "Controller Ref", + "type": "string" + }, + "controller_state_ref": { + "minLength": 1, + "title": "Controller State Ref", + "type": "string" + }, + "declaration_ref": { + "minLength": 1, + "title": "Declaration Ref", + "type": "string" + }, + "disposition": { + "$ref": "#/$defs/ParticipantControlDisposition" + }, + "effective_order": { + "minimum": 0, + "title": "Effective Order", + "type": "integer" + }, + "expected_state_revision": { + "minimum": 0, + "title": "Expected State Revision", + "type": "integer" + }, + "kind": { + "const": "handoff", + "title": "Kind", + "type": "string" + }, + "limitation_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Limitation Refs", + "type": "array" + }, + "mixed_control_policy_ref": { + "minLength": 1, + "title": "Mixed Control Policy Ref", + "type": "string" + }, + "occurrence_revision": { + "minimum": 1, + "title": "Occurrence Revision", + "type": "integer" + }, + "policy_revision": { + "minLength": 1, + "title": "Policy Revision", + "type": "string" + }, + "prior_controller_state_ref": { + "minLength": 1, + "title": "Prior Controller State Ref", + "type": "string" + }, + "reason_code": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason Code" + }, + "reason_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason Ref" + }, + "resulting_controller_state_ref": { + "minLength": 1, + "title": "Resulting Controller State Ref", + "type": "string" + }, + "resulting_state_revision": { + "minimum": 1, + "title": "Resulting State Revision", + "type": "integer" + }, + "valid_from_order": { + "minimum": 0, + "title": "Valid From Order", + "type": "integer" + }, + "valid_until_order": { + "minimum": 0, + "title": "Valid Until Order", + "type": "integer" + } + }, + "required": [ + "declaration_ref", + "controller_ref", + "controller_state_ref", + "authority_basis_refs", + "controlled_scope_refs", + "behavior_specification_ref", + "mixed_control_policy_ref", + "policy_revision", + "expected_state_revision", + "effective_order", + "valid_from_order", + "valid_until_order", + "occurrence_revision", + "disposition", + "limitation_refs", + "kind", + "prior_controller_state_ref", + "resulting_controller_state_ref", + "resulting_state_revision", + "completion_evidence_ref" + ], + "title": "ParticipantHandoffOccurrenceModel", + "type": "object" + }, + "ParticipantInteractionClass": { + "description": "SEM-209 interaction classes for multi-participant behavior.", + "enum": [ + "coordination", + "contention", + "interference", + "shared_state_change" + ], + "title": "ParticipantInteractionClass", + "type": "string" + }, + "ParticipantInterventionOccurrenceModel": { + "additionalProperties": false, + "description": "An intervention against an existing control or action occurrence.", + "properties": { + "affected_occurrence_ref": { + "minLength": 1, + "title": "Affected Occurrence Ref", + "type": "string" + }, + "affected_revision": { + "minimum": 1, + "title": "Affected Revision", + "type": "integer" + }, + "affected_target_kind": { + "enum": [ + "action", + "control", + "attempt" + ], + "title": "Affected Target Kind", + "type": "string" + }, + "authority_basis_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Authority Basis Refs", + "type": "array" + }, + "behavior_specification_ref": { + "minLength": 1, + "title": "Behavior Specification Ref", + "type": "string" + }, + "controlled_scope_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Controlled Scope Refs", + "type": "array" + }, + "controller_ref": { + "minLength": 1, + "title": "Controller Ref", + "type": "string" + }, + "controller_state_ref": { + "minLength": 1, + "title": "Controller State Ref", + "type": "string" + }, + "declaration_ref": { + "minLength": 1, + "title": "Declaration Ref", + "type": "string" + }, + "disposition": { + "$ref": "#/$defs/ParticipantControlDisposition" + }, + "effective_order": { + "minimum": 0, + "title": "Effective Order", + "type": "integer" + }, + "expected_state_revision": { + "minimum": 0, + "title": "Expected State Revision", + "type": "integer" + }, + "intervention_ref": { + "minLength": 1, + "title": "Intervention Ref", + "type": "string" + }, + "kind": { + "const": "intervention", + "title": "Kind", + "type": "string" + }, + "limitation_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Limitation Refs", + "type": "array" + }, + "mixed_control_policy_ref": { + "minLength": 1, + "title": "Mixed Control Policy Ref", "type": "string" }, - "previous_episode_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Previous Episode Id" - }, - "sequence_number": { - "title": "Sequence Number", + "occurrence_revision": { + "minimum": 1, + "title": "Occurrence Revision", "type": "integer" }, - "state_schema_version": { - "const": "participant-episode-state/v1", - "default": "participant-episode-state/v1", - "title": "State Schema Version", - "type": "string" - }, - "status": { - "title": "Status", + "policy_revision": { + "minLength": 1, + "title": "Policy Revision", "type": "string" }, - "terminal_reason": { + "reason_code": { "anyOf": [ { + "minLength": 1, "type": "string" }, { @@ -1362,11 +2800,12 @@ } ], "default": null, - "title": "Terminal Reason" + "title": "Reason Code" }, - "terminated_at": { + "reason_ref": { "anyOf": [ { + "minLength": 1, "type": "string" }, { @@ -1374,55 +2813,44 @@ } ], "default": null, - "title": "Terminated At" + "title": "Reason Ref" }, - "updated_at": { - "title": "Updated At", - "type": "string" + "valid_from_order": { + "minimum": 0, + "title": "Valid From Order", + "type": "integer" + }, + "valid_until_order": { + "minimum": 0, + "title": "Valid Until Order", + "type": "integer" } }, "required": [ - "participant_address", - "episode_id", - "sequence_number", - "status", - "initialized_at", - "updated_at", - "last_control_action" + "declaration_ref", + "controller_ref", + "controller_state_ref", + "authority_basis_refs", + "controlled_scope_refs", + "behavior_specification_ref", + "mixed_control_policy_ref", + "policy_revision", + "expected_state_revision", + "effective_order", + "valid_from_order", + "valid_until_order", + "occurrence_revision", + "disposition", + "limitation_refs", + "kind", + "affected_target_kind", + "affected_occurrence_ref", + "affected_revision", + "intervention_ref" ], - "title": "ParticipantEpisodeStateModel", + "title": "ParticipantInterventionOccurrenceModel", "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" - }, "ParticipantJointActionAccessSetModel": { "additionalProperties": false, "description": "Read/write footprint for one member event in a joint action record.", @@ -2159,63 +3587,360 @@ "title": "Evidence Refs", "type": "array" }, - "observed_value": { + "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" + }, + "ParticipantOverrideOccurrenceModel": { + "additionalProperties": false, + "description": "An override that supersedes rather than rewrites an earlier occurrence.", + "properties": { + "authority_basis_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Authority Basis Refs", + "type": "array" + }, + "behavior_specification_ref": { + "minLength": 1, + "title": "Behavior Specification Ref", + "type": "string" + }, + "controlled_scope_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Controlled Scope Refs", + "type": "array" + }, + "controller_ref": { + "minLength": 1, + "title": "Controller Ref", + "type": "string" + }, + "controller_state_ref": { + "minLength": 1, + "title": "Controller State Ref", + "type": "string" + }, + "declaration_ref": { + "minLength": 1, + "title": "Declaration Ref", + "type": "string" + }, + "disposition": { + "$ref": "#/$defs/ParticipantControlDisposition" + }, + "effective_order": { + "minimum": 0, + "title": "Effective Order", + "type": "integer" + }, + "expected_state_revision": { + "minimum": 0, + "title": "Expected State Revision", + "type": "integer" + }, + "kind": { + "const": "override", + "title": "Kind", + "type": "string" + }, + "limitation_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Limitation Refs", + "type": "array" + }, + "mixed_control_policy_ref": { + "minLength": 1, + "title": "Mixed Control Policy Ref", + "type": "string" + }, + "occurrence_revision": { + "minimum": 1, + "title": "Occurrence Revision", + "type": "integer" + }, + "policy_revision": { + "minLength": 1, + "title": "Policy Revision", + "type": "string" + }, + "reason_code": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason Code" + }, + "reason_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason Ref" + }, + "replacement_ref": { "minLength": 1, - "title": "Observed Value", + "title": "Replacement Ref", "type": "string" }, - "provenance_refs": { - "items": { - "minLength": 1, - "type": "string" - }, - "title": "Provenance Refs", - "type": "array" - }, - "ref": { + "superseded_occurrence_ref": { "minLength": 1, - "title": "Ref", + "title": "Superseded Occurrence Ref", "type": "string" }, - "source_id": { - "minLength": 1, - "title": "Source Id", + "superseded_revision": { + "minimum": 1, + "title": "Superseded Revision", + "type": "integer" + }, + "superseded_target_kind": { + "enum": [ + "control", + "decision" + ], + "title": "Superseded Target Kind", "type": "string" }, - "source_layer": { - "$ref": "#/$defs/OutcomeInterpretationSourceLayer" + "valid_from_order": { + "minimum": 0, + "title": "Valid From Order", + "type": "integer" + }, + "valid_until_order": { + "minimum": 0, + "title": "Valid Until Order", + "type": "integer" } }, "required": [ - "source_id", - "source_layer", - "ref", - "observed_value" + "declaration_ref", + "controller_ref", + "controller_state_ref", + "authority_basis_refs", + "controlled_scope_refs", + "behavior_specification_ref", + "mixed_control_policy_ref", + "policy_revision", + "expected_state_revision", + "effective_order", + "valid_from_order", + "valid_until_order", + "occurrence_revision", + "disposition", + "limitation_refs", + "kind", + "superseded_target_kind", + "superseded_occurrence_ref", + "superseded_revision", + "replacement_ref" ], - "title": "ParticipantOutcomeSourceRecordModel", + "title": "ParticipantOverrideOccurrenceModel", "type": "object" }, - "ParticipantOutcomeTargetRecordModel": { + "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" + }, + "ParticipantProposalOccurrenceModel": { "additionalProperties": false, + "description": "A proposal fact that is neither a decision, admission, nor execution.", "properties": { - "diagnostics": { + "action_contract_ref": { + "minLength": 1, + "title": "Action Contract Ref", + "type": "string" + }, + "admission_status": { + "const": "not-admitted", + "title": "Admission Status", + "type": "string" + }, + "authority_basis_refs": { "items": { "minLength": 1, "type": "string" }, - "title": "Diagnostics", + "minItems": 1, + "title": "Authority Basis Refs", "type": "array" }, - "evidence_refs": { + "behavior_specification_ref": { + "minLength": 1, + "title": "Behavior Specification Ref", + "type": "string" + }, + "controlled_scope_refs": { "items": { "minLength": 1, "type": "string" }, "minItems": 1, - "title": "Evidence Refs", + "title": "Controlled Scope Refs", "type": "array" }, - "governance_ref": { + "controller_ref": { + "minLength": 1, + "title": "Controller Ref", + "type": "string" + }, + "controller_state_ref": { + "minLength": 1, + "title": "Controller State Ref", + "type": "string" + }, + "decision_surface_ref": { "anyOf": [ { "minLength": 1, @@ -2226,76 +3951,206 @@ } ], "default": null, - "title": "Governance Ref" + "title": "Decision Surface Ref" }, - "interpreted_value": { + "declaration_ref": { "minLength": 1, - "title": "Interpreted Value", + "title": "Declaration Ref", "type": "string" }, - "limitations": { + "disposition": { + "$ref": "#/$defs/ParticipantControlDisposition" + }, + "effective_order": { + "minimum": 0, + "title": "Effective Order", + "type": "integer" + }, + "expected_state_revision": { + "minimum": 0, + "title": "Expected State Revision", + "type": "integer" + }, + "kind": { + "const": "proposal", + "title": "Kind", + "type": "string" + }, + "limitation_refs": { "items": { "minLength": 1, "type": "string" }, "minItems": 1, - "title": "Limitations", + "title": "Limitation Refs", "type": "array" }, - "ref": { + "mixed_control_policy_ref": { "minLength": 1, - "title": "Ref", + "title": "Mixed Control Policy Ref", "type": "string" }, - "target_id": { + "occurrence_revision": { + "minimum": 1, + "title": "Occurrence Revision", + "type": "integer" + }, + "payload_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": "Payload Digest" + }, + "payload_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payload Ref" + }, + "policy_revision": { "minLength": 1, - "title": "Target Id", + "title": "Policy Revision", "type": "string" }, - "target_layer": { - "$ref": "#/$defs/OutcomeInterpretationTargetLayer" + "proposal_binding_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Proposal Binding Ref" + }, + "proposal_id": { + "minLength": 1, + "title": "Proposal Id", + "type": "string" + }, + "proposal_revision": { + "minimum": 1, + "title": "Proposal Revision", + "type": "integer" + }, + "reason_code": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason Code" + }, + "reason_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason Ref" + }, + "source_proposal_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Proposal Ref" + }, + "source_proposal_revision": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Proposal Revision" + }, + "transformation_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Transformation Ref" + }, + "valid_from_order": { + "minimum": 0, + "title": "Valid From Order", + "type": "integer" + }, + "valid_until_order": { + "minimum": 0, + "title": "Valid Until Order", + "type": "integer" } }, "required": [ - "target_id", - "target_layer", - "ref", - "interpreted_value", - "evidence_refs", - "limitations" + "declaration_ref", + "controller_ref", + "controller_state_ref", + "authority_basis_refs", + "controlled_scope_refs", + "behavior_specification_ref", + "mixed_control_policy_ref", + "policy_revision", + "expected_state_revision", + "effective_order", + "valid_from_order", + "valid_until_order", + "occurrence_revision", + "disposition", + "limitation_refs", + "kind", + "proposal_id", + "proposal_revision", + "admission_status", + "action_contract_ref" ], - "title": "ParticipantOutcomeTargetRecordModel", + "title": "ParticipantProposalOccurrenceModel", "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": [ @@ -4940,6 +6795,16 @@ "title": "Participant Behavior History", "type": "object" }, + "participant_control_history": { + "additionalProperties": { + "items": { + "$ref": "#/$defs/ParticipantControlOccurrenceModel" + }, + "type": "array" + }, + "title": "Participant Control History", + "type": "object" + }, "participant_episode_history": { "additionalProperties": { "items": { @@ -5025,5 +6890,14 @@ } }, "title": "RuntimeSnapshotEnvelopeModel", - "type": "object" + "type": "object", + "x-aces-semantic-profile": { + "contract_id": "runtime-snapshot-v1", + "entry_schema_contract_id": "aces-semantic-invariants-v1", + "entry_schema_pointer": "#/$defs/AcesSemanticInvariantEntryModel", + "id": "aces-semantic-invariants-v1", + "keyword": "x-aces-invariants", + "required": true, + "uri": "https://aces.dev/schemas/semantic-invariants/v1" + } } diff --git a/docs/decisions/adrs/README.md b/docs/decisions/adrs/README.md index 14a55979a..e4b740373 100644 --- a/docs/decisions/adrs/README.md +++ b/docs/decisions/adrs/README.md @@ -137,6 +137,7 @@ adr-090-shared-time-domain-clock-and-progression-authority adr-091-portable-time-capability-control-and-provenance-contracts adr-092-autonomous-benign-participants-under-shared-time adr-093-raes-rename-and-compatibility-boundaries +adr-094-authoritative-cross-plane-experiment-bindings ``` | ADR | Title | Status | Date | @@ -234,3 +235,4 @@ adr-093-raes-rename-and-compatibility-boundaries | [091](adr-091-portable-time-capability-control-and-provenance-contracts.md) | Portable Time Capability, Control, And Provenance Contracts | accepted | 2026-07-24 | | [092](adr-092-autonomous-benign-participants-under-shared-time.md) | Autonomous Benign Participants Under Shared Time | accepted | 2026-07-24 | | [093](adr-093-raes-rename-and-compatibility-boundaries.md) | RAES Rename and Compatibility Boundaries | accepted | 2026-07-23 | +| [094](adr-094-authoritative-cross-plane-experiment-bindings.md) | Authoritative Cross-Plane Experiment Bindings | accepted | 2026-07-26 | diff --git a/docs/decisions/adrs/adr-094-authoritative-cross-plane-experiment-bindings.md b/docs/decisions/adrs/adr-094-authoritative-cross-plane-experiment-bindings.md new file mode 100644 index 000000000..01fb51080 --- /dev/null +++ b/docs/decisions/adrs/adr-094-authoritative-cross-plane-experiment-bindings.md @@ -0,0 +1,123 @@ +# ADR-094: Authoritative Cross-Plane Experiment Bindings + +## Status + +accepted + +## Date + +2026-07-26 + +## Classification + +Classification: FM2 + +Required artifacts: ADR, published schemas, schema-publication records, +positive and negative fixtures, conformance registration, reference +implementation, and behavioral tests. + +Waivers: no runtime mutation, provider selection, scheduling, secret +resolution, HTTP endpoint, CLI command, or MCP tool is introduced. + +## Context + +`ExperimentParameterModel` and condition-assignment parameters record a name, +scalar value, value kind, and redaction posture. They do not identify an +authoritative configuration plane, canonical owner target, source factor and +condition, exact scalar type, validator, default, or realized-value +provenance. Inferring those facts from names, prefixes, free-text constraints, +or matching backend fields permits cross-plane configuration injection. + +ADR-084 already assigns scenario variation to SDL variation points and public +instantiation. ADR-041 assigns participant implementation declaration and +selection to participant manifests and provenance. Processor and backend +manifests separately own apparatus capability. The missing surface is a +portable, typed bridge between experiment intent and those existing owners. + +## Decision + +Publish `experiment-binding-descriptors-v1` as one closed descriptor family +with three planes: + +- `scenario`; +- `participant-implementation`; and +- `apparatus`. + +Each descriptor records an explicit factor id, factor-level id, condition id, +plane-specific target, exact JSON scalar type, literal or secret-reference +value, and governing contract/validator identity. Plane is discriminated data; +it is never inferred. Scenario targets identify a scenario family, variation +point, and owner target. Participant and apparatus targets identify the +selected manifest owner and a manifest-declared configuration target. + +Canonical target resolution is owner-specific. SDL variation authority +resolves scenario targets. Participant, processor, and backend manifests may +publish a typed `configuration_registry` containing canonical ids, bounded +aliases, scalar types, allowed value kinds, sensitivity, defaults, and a +governed validator identity. Aliases are accepted inputs, not identities. +Resolution preserves all inputs until collisions are checked. Two bindings in +one condition that resolve to the same canonical target fail even when their +values match. + +`ExperimentSpecModel` gains explicit binding semantics. The +`explicit-required` posture requires descriptors, rejects legacy +`required_parameters`, and verifies every descriptor's factor, level, and +condition join against the allocation. Existing descriptive authoring remains +valid only under the `descriptive` posture and makes no mutation claim. + +Participant configuration realization is a complete atomic operation: + +1. resolve all canonical ids and aliases; +2. reject unknown or duplicate canonical targets; +3. apply owner-declared defaults and overrides without coercion; +4. require every target with no default; +5. invoke an optional trusted complete-configuration validator; +6. preserve default/override provenance; and +7. emit one `participant-configuration-result-v1` with an RFC 8785/JCS digest. + +Normalization cannot change target identity or JSON scalar type. No partial +result is emitted on failure. + +Literal values and secret references are a discriminated union. Portable +contracts carry only a bounded, non-sensitive reference identity. They have no +field for a resolved value, provider credential, environment variable, file +path, command, or backend-native locator. Resolved secret material never +participates in canonicalization, diagnostics, fixtures, or provenance. + +`experiment-run-v1` may archive realized binding provenance. The provenance +embeds the admitted descriptor, default/override/selection origin, and the +authoritative configuration digest when the binding belongs to participant or +apparatus configuration. + +## Validation and compatibility + +Strict Pydantic scalars distinguish Boolean, integer, number, string, and null. +Strings are not parsed as numbers or booleans, Boolean is not integer, and +non-finite numbers fail before canonicalization. The reference implementation +uses `canonical_contract_digest()` rather than a binding-specific serializer. + +The affected published schemas are `draft` under ADR-061. Their in-line +changes remain reviewable through per-contract `last_change` hashes. The two +new roots are registered with the existing conformance runner and carry valid +and invalid fixtures. + +## Consequences + +Experiment authors can state binding intent without encoding authority in a +name. Participant and apparatus owners can publish a portable target surface +without exposing private backend schemas. Downstream trial compilation can +resolve every target before mutation and reuse the public SDL instantiation, +manifest, and provenance paths. + +Adding a target or validator version extends an owner registry. Adding an +authority plane requires a new contract-lineage and ADR change. The design +does not create a global configuration registry, generic patch language, +plugin dispatcher, secret resolver, or second SDL binder. + +## References + +- [ADR-041](adr-041-participant-implementation-manifest-and-provenance.md) +- [ADR-061](adr-061-published-schema-evolution-policy.md) +- [ADR-074](adr-074-experiment-authoring-input-contract-boundary.md) +- [ADR-084](adr-084-scenario-variation-and-deterministic-trial-realization.md) +- [Experiment binding contracts](../../explain/reference/experiment-binding-contracts.md) diff --git a/docs/decisions/adrs/adr-index.yaml b/docs/decisions/adrs/adr-index.yaml index 3f09fbb23..64d3fc73e 100644 --- a/docs/decisions/adrs/adr-index.yaml +++ b/docs/decisions/adrs/adr-index.yaml @@ -435,3 +435,6 @@ adrs: - ref: "#866-pypi-name-correction" - ref: "#884" - ref: "#894" + - id: ADR-094 + path: docs/decisions/adrs/adr-094-authoritative-cross-plane-experiment-bindings.md + pin: 90578754323795ca8775c47dd6095752ccb5a769a0f932ebe60abbe1d8954246 diff --git a/docs/decisions/issue-255-run-310-supervisory-lifecycle-preflight.md b/docs/decisions/issue-255-run-310-supervisory-lifecycle-preflight.md new file mode 100644 index 000000000..2410b2652 --- /dev/null +++ b/docs/decisions/issue-255-run-310-supervisory-lifecycle-preflight.md @@ -0,0 +1,380 @@ +# Issue #255 — RUN-310 Supervisory Lifecycle Preflight + +Date: 2026-07-26 + +Issue: #255. + +Requirement: RUN-310. + +This note records architecture boundaries and implementation guardrails for the +observable mixed-control supervisory lifecycle. It is guidance only. It does +not add a route, runtime transition, store field, contract, schema, capability, +backend behavior, or conformance claim. + +## Binding Authorities + +- Accepted ADR-085 and + `specs/formal/participant-semantics/information-flow-control.md` require + authenticated caller, target authorization, participant/controller + authority, action admission, and visibility to remain separate deny-first + gates. They also require policy, controller, authority, marking, and order + revisions to be evaluated at the occurrence's declared order point. +- ACT-617 is already authored and compiled through + `ParticipantBehaviorSpecification.mixed_control` and + `ParticipantBehaviorSpecificationRuntime.controller_states` / + `control_transitions`. Those are permitted policy declarations, not live + state or evidence that a transition occurred. +- API-409 already publishes `ParticipantControlOccurrenceModel` and + `validate_participant_control_occurrence_context()`. An API-409 record is an + immutable occurrence fact and outcome, not an untrusted command DTO. +- ADR-054 owns the append-only participant runtime lifecycle, ordering, + markings, projections, and evidence boundary. Admission, execution, + delivery, observation, and control remain distinct state owners. +- ADR-009, ADR-019, and ADR-061 govern closed contract models, published + schemas, generated-bundle parity, and compatibility classification. +- `.ground-control.yaml`, `.gc/plan-rules.md`, `noxfile.py`, and + `tools/verify_all.py` own repository workflow. RUN-310 needs no issue-local + runner, registry, schema generator, or verification script. + +These authorities already settle the cross-cutting architecture. RUN-310 does +not need a new ADR unless implementation discovers a conflict with an accepted +authority. This note specializes them at the live mediation boundary. + +## Architecture Decisions And Boundaries + +### The runtime mediates commands and emits occurrence facts + +The HTTP/application input and the persisted API-409 occurrence have different +trust and ownership: + +- a request carries only caller-supplied intent, stable client correlation, + the target participant/episode/proposal or typed target, expected + controller-state and policy revisions, and the references needed to evaluate + it; +- the runtime owns event identity, recorded/ingested time, realized + disposition and reason code, resulting controller-state revision, + authorization and evidence bindings, and persistence status; and +- the resulting immutable fact is + `ParticipantControlOccurrenceModel`, validated by the existing API-409 + contextual validator before commit. + +Do not accept a caller-supplied API-409 record as already accepted, use its +`actor_ref` as authentication, or let it choose its realized disposition. +Conversely, do not publish a second portable supervisory occurrence schema. +If the HTTP adapter needs a request body, keep it as a closed, bounded +application DTO containing only client-owned fields; share API-409 component +types and vocabularies rather than duplicating its occurrence model. + +Once a caller is authenticated and bound, stale, denied, late, conflicting, +limited, superseded, and too-late attempts are observable outcomes and must +append a bounded API-409 occurrence. Requests that fail before authenticated +subject binding are security-audit denials only: untrusted input cannot create +a participant occurrence. + +### One runtime transition owner over compiled policy and recorded history + +Extend the existing `RuntimeControlPlane` / `ParticipantControlMixin` boundary; +do not add a workflow engine, participant gateway, backend method, or parallel +controller service. The mediator consumes an admitted compiled +`ParticipantBehaviorSpecificationRuntime` supplied by trusted runtime setup, +not a declaration sent in the request. It indexes the existing controller +states and transitions into +`ParticipantControlDeclarationModel` inputs for the canonical API-409 +validator. + +The transition evaluation order is: + +1. bind the authenticated control-plane identity and exact runtime target; +2. bind the path/body target to one participant and episode; +3. bind that principal to the requested participant/controller subject; +4. resolve the exact compiled ACT-617 transition, controller, authority basis, + scope, policy revision, validity interval, and expected state revision; +5. read and validate the current append-only control history; +6. evaluate duplicate, stale, revoked, late, ordered-concurrent, and conflict + disposition rules; +7. when the occurrence would lead to an action, pass the separately bound + proposal/action through the existing SEM-211 + `ParticipantActionAdmissionRequest` path; and +8. append the validated occurrence and its operation receipt atomically, then + project only the authorized history/view. + +Unknown or unresolved required coordinates fail closed. `behavior_mode`, an +operator role, bearer-token possession, implementation identity, backend +support, request arrival time, and collection order never substitute for +controller state or participant authority. + +The current implementation may support ACT-617's +`total-effective-order` only. It must reject an unsupported order strategy +rather than linearize it silently. + +### Control history is first-class snapshot state + +The authoritative live state is an append-only +`participant_control_history`, keyed by participant in the same manner as +episode and behavior histories, with episode identity retained in every +API-409 occurrence. Current controller state and revision are a deterministic +fold of the admitted compiled initial state plus that history. Do not maintain +a second mutable controller-state truth. A derived in-memory index is +permitted only when it is rebuilt and checked against the authoritative +history after restart. + +Integrate the new history into all existing snapshot carriers and validators: + +- `RuntimeSnapshot`, `RuntimeSnapshot.with_entries()`, and its allowed update + keys; +- `RuntimeSnapshotEnvelopeModel`, `_snapshot_payload()`, + `_snapshot_from_payload()`, and `_snapshot_model()`; +- `participant_runtime_state_contract_diagnostics()` for full-snapshot + validity; and +- `participant_runtime_history_transition_diagnostics()` for append-only + prefix preservation and valid head transitions. + +Do not use `RuntimeSnapshot.metadata`, participant behavior `details`, +`AuditEvent.details`, operation diagnostics, or a gateway-local file as +controller state or occurrence history. + +API-408 history/status projections may expose control occurrences only through +an explicitly governed typed projection with participant/episode scope, +visibility, markings, redaction policy, completeness, and source-snapshot +binding. An auditor/operator read role permits control-plane retrieval; it +does not make the same event participant-visible. Participant egress still +passes SEM-226 exposure and ADR-085 visibility/marking gates. + +Any change to `runtime-snapshot-v1`, `participant-history-view-v1`, or another +published schema must update its owning `ContractModel`, all serializers, +fixtures, `schema_bundle()`, publication entry/manifest hash, and ADR-061 +compatibility evidence together. The published schema remains authority; +neither a Python-only field nor a hand-edited generated schema is sufficient. + +### Commit transition, receipt, and idempotency outcome atomically + +`ControlPlaneStore`, `InMemoryControlPlaneStore`, and +`LocalControlPlaneStore` remain the persistence owners. RUN-310 must extend +that boundary with one atomic control-transition commit that checks the +expected history head/revision and durably records: + +- the appended API-409 occurrence; +- the corresponding `ControlPlaneOperationRecord`; +- its scoped idempotency key and semantic request fingerprint; and +- the safe audit correlation needed to prove who requested the operation. + +Calling the existing `save_snapshot()` and `save_record()` independently is +not sufficient: a crash between them can reapply a transition after restart +or return no receipt for an already-committed event. `LocalControlPlaneStore`'s +atomic file replacement is an incumbent building block, not a transaction +across those separate files. + +The in-process mediator must serialize compare/validate/append/commit for one +participant/episode/controller history. If the first realization supports only +one control-plane writer, declare and test that bound. File replacement alone +does not provide multi-process compare-and-swap. A later distributed store must +implement the same expected-head atomic commit seam; RUN-310 must not claim +distributed linearizability without that evidence. + +Audit remains an append-only security/operations trail and carries safe ids, +operation ids, dispositions, and reason codes only. The API-409 occurrence and +its evidence/provenance references are the portable supervisory fact. An audit +event is not a substitute for lifecycle evidence or participant visibility. + +### Idempotency is scoped and state-bound + +Reuse the existing `Idempotency-Key`, request-fingerprint, +`ControlPlaneOperationRecord`, and store lookup pattern, with RUN-310's stronger +scope. The effective lookup scope is: + +```text +(target, authenticated principal, operation kind, + participant, episode, client idempotency key) +``` + +The semantic fingerprint covers the canonical parsed request plus the bound +declaration, controller, authority/scope, policy revision, expected state +revision, typed target revision, and order coordinates. Raw JSON byte equality +alone is insufficient, and the authenticated identity must not be omitted. + +An exact retry of an already committed occurrence returns the original receipt +and never appends or applies again. Reusing a key, client correlation, proposal +identity, decision identity, or event identity with different semantics is an +explicit conflict. A request whose policy, target, proposal, or expected state +revision no longer matches is stale and performs no state change. Exact +payload equality never collapses distinct occurrence identities. + +### Admission, execution, cancellation, and observation remain separate + +- A proposal is not selected, approved, admitted, attempted, or executed. +- Approval or external direction targets one proposal/revision but does not + call a backend. A mixed-control action must carry a stable reference to the + accepted control occurrence into the existing action-admission binding; the + admission validator must reject a missing, stale, cross-participant, or + policy-mismatched control basis. +- Denial records a decision and leaves action admission closed. +- Intervention and override append a new fact. They never edit a proposal, + approval, admission event, attempt, result, or observation. +- Cancellation resolves the typed target's actual stage. Before admission it + may prevent work; after admission or attempt it records partial limitation + or too-late effect. It cannot manufacture retroactive non-occurrence. +- Handoff completion advances controller state by exactly one declared + revision and preserves participant identity and all prior provenance. +- Execution remains in participant lifecycle/behavior history; observation + remains in participant observation/exposure carriers. Stable references and + predecessor/order relations connect them to control history. + +## Canonical Incumbents To Reuse + +| Concern | Canonical incumbent and required use | +| --- | --- | +| Authored/compiled policy | `MixedControlParticipantOperation`, `MixedControlControllerState`, `MixedControlTransition`, `ParticipantBehaviorSpecificationRuntime.controller_states` and `.control_transitions` | Treat these as trusted permitted-policy declarations; do not recreate or accept them from the caller. | +| Portable occurrence | `ParticipantControlOccurrenceModel`, its closed variants/vocabularies, `ParticipantRuntimeBaseEnvelopeModel`, and `validate_participant_control_occurrence_context()` | Emit one validated immutable fact per bound attempt; do not add a generic event/details bag or duplicate occurrence schema. | +| Runtime owner | `RuntimeControlPlane`, `ParticipantControlMixin`, `OperationReceipt`, `OperationStatus`, and `ControlPlaneOperationRecord` | Add mediation at the existing participant control-plane boundary and retain the common operation lifecycle. | +| Action admission | `ParticipantActionAdmissionRequest`, `participant_action_admission_request_violations()`, decision-surface binding, and `admit_participant_action()` | Link accepted control to admission; never dispatch directly from approval/direction. | +| Lifecycle/observation | `ParticipantLifecycleEventModel`, `ParticipantBehaviorHistoryEventModel`, `ParticipantActionResultModel`, `ParticipantObservationEnvelopeModel`, and API-408 projections | Link by stable refs and order; do not copy their state machines into control history. | +| State and validation | `RuntimeSnapshot`, `RuntimeSnapshotEnvelopeModel`, participant snapshot/transition invariant helpers, and backend contract diagnostics | Add a first-class typed append-only history and validate it on load, transition, backend result, serialization, and replay. | +| Persistence | `ControlPlaneStore`, `InMemoryControlPlaneStore`, `LocalControlPlaneStore`, atomic replacement, and existing record serialization | Extend this boundary with atomic expected-head occurrence/receipt commit; add no participant-control store. | +| Authentication | `create_control_plane_app()`, `ControlPlaneSecurityConfig.strict_defaults()`, `_ControlPlaneApiAuth`, `ControlPlaneIdentity`, `ControlPlaneRole`, bearer/verified-proxy identity, and target binding | Reuse auth and request guards, then perform a separate principal-to-participant/controller binding. | +| Request protection | `request_size_guard_response()`, closed Pydantic DTOs, `_request_fingerprint()` pattern, and `Idempotency-Key` | Bound size/shape before semantic work; use a canonical, identity- and state-scoped semantic fingerprint. | +| Diagnostics/errors | `Diagnostic`, `Severity`, operation receipts/statuses, bounded `HTTPException` details, and the redacted FastAPI exception handler | Return stable codes and safe identifiers only; add no RUN-310 exception hierarchy. | +| Audit/observability | `AuditEvent`, `operational_apparatus_summary()`, participant histories, evidence/provenance/marking refs | Correlate safe identities and outcomes without logging bodies, secrets, policies, or evidence content. | +| Contract governance | `ContractModel(extra="forbid")`, `schema_bundle()`, `contracts/schema-publication-manifest.json`, publication entries/fixtures, and schema compatibility checks | Keep model/schema/serializer parity and classify every published change. | +| Workflow | `.ground-control.yaml`, `.gc/plan-rules.md`, canonical `nox` sessions, repo policy, requirement governance, schema/publication, JSON, semantic coverage, and full verification checks | Extend the existing workflow only. | + +## Cross-Cutting Layers And Security Posture + +The intended design must pass these layers in order: + +1. **HTTP size and closed-shape gate.** The existing middleware bounds content + length and actual body bytes before parsing. A closed request DTO rejects + unknown keys, inline credentials, raw policy bodies, hidden payloads, + free-form metadata, and caller-owned result fields. +2. **Caller authentication and target gate.** Bearer or verified trusted-proxy + identity passes `_ControlPlaneApiAuth`, role authorization, and exact + `ControlPlaneIdentity.target_name` binding. Strict defaults remain empty and + fail closed. +3. **Participant/controller subject gate.** Closed subject-binding fields + supplied with the trusted `ControlPlaneIdentity` configuration map the + authenticated principal to permitted participant/controller subject refs. + The mediator checks those fields as a separate gate after role + authorization. This is operational authorization, not controller state; + role membership alone is insufficient. +4. **Compiled policy gate.** The mediator resolves the request against the + trusted compiled ACT-617 declaration and verifies participant/episode, + controller, authority basis, non-widening scope, policy revision, validity, + expected state revision, order, typed target, evidence, and provenance. +5. **Admission and visibility gates.** Actionable outcomes still pass SEM-211 + action admission. Returned or participant-facing facts separately pass + audience, visibility, marking, redaction, and completeness projection. +6. **Persistence and replay gate.** Snapshot models, API-409 contextual + validation, append-only transition validators, expected-head atomic commit, + restart load validation, and deterministic replay must agree before state is + exposed. +7. **Diagnostic/error gate.** Expected denials use bounded status/disposition + codes and value-safe diagnostics. Do not expose `ValidationError` input, + `str(exc)` from untrusted shape/authorization failures, stack traces, + controller inventories, policy content, or cross-participant existence. + Unexpected failures retain exactly `{"detail":"internal server error"}`. +8. **Audit and secret gate.** Audit stores safe ids, decisions, reason codes, + and references—not bearer tokens, headers, action bodies, rejected input, + policy material, prompts, evidence bodies, or backend objects. +9. **Configuration/OS/process gate.** RUN-310 needs no environment variable, + secret loader, CLI flag, subprocess, shell, socket, filesystem path, or + process-argument surface. Security and subject bindings remain injected + configuration. Do not put a token, policy, proposal, or participant payload + in environment variables, argv, filenames, stdout, or stderr. Any later + deployment adapter must use its existing secret/config mechanism and must + not add `shell=True` or caller-derived argv. + +## Extensibility Seam + +Keep three explicit seams: + +- the order strategy plus effective order, predecessor refs, and expected + state revision, so a later causal/partial-order implementation can be added + without changing existing occurrence meaning; +- the authenticated-principal-to-controller/participant binding carried + alongside `ControlPlaneIdentity`, so a future non-participant controller is + introduced as a closed subject-binding variant rather than an arbitrary + string or role; and +- the store's expected-head atomic append/receipt operation, so a later + multi-writer store can provide compare-and-swap without replacing the + lifecycle engine or history contract. + +These are parameters and injected dependencies of the existing runtime +boundary, not new policy engines or portable schema families. + +## Required Assurance Guardrails + +Evidence must exercise existing test families and invariant paths, including: + +- approval, denial, direction, intervention, handoff, override, cancellation, + exact retry, conflicting reuse, stale proposal/policy/state, revoked/late + authority, cross-participant targets, and unordered/unsupported order; +- restart and replay from the same append-only history, truncated/corrupt + store rejection, atomic-commit failure, duplicate recovery, and history + prefix preservation; +- unauthenticated, wrong-role, wrong-target, unbound subject, authority + widening, hidden-target probing, oversized body, and error-leakage cases; +- the separation of approval from admission, cancellation before/after + admission and attempt, and observation/visibility from audit retention; and +- published schema/model/fixture parity, snapshot/store round trips, API-408 + projection scoping, contract compatibility, repo policy, requirement + governance, and full verification. + +Finite behavioral tests are implementation evidence for RUN-310. They are not +a backend support claim, distributed-order proof, noninterference proof, +refinement, simulation, or bisimulation result. + +## Gotchas And Anti-Patterns + +Avoid: + +- treating `behavior_mode`, authenticated caller, operator/auditor/backend + role, token, OS account, backend process, participant implementation, or + actor/producer field as controller state or participant authority; +- accepting a caller-completed API-409 fact, trusting a requested disposition, + or using an authored transition identity as an occurrence identity; +- a generic supervisory event, nullable command/result union, free-form + `details`/`metadata` map, duplicate control-kind enum, or second validation + stack; +- reading declarations, authority, policy revision, or conflict rules from the + request, environment, snapshot metadata, backend response, or current wall + clock; +- last-writer-wins, arrival-time/list-order semantics, silently linearizing a + partial order, or returning a cached idempotent result across a different + identity/target/policy/controller/state binding; +- snapshot plus receipt writes with a crash window, relying on file rename as + multi-process locking, or mutating/replacing an earlier occurrence; +- calling a backend directly from approval/direction, carrying approval across + proposal transformation, or equating admission with execution; +- retroactive cancellation, deletion on revocation/concealment, or rewriting + prior controller, action, observation, evidence, or provenance history; +- returning control history without visibility/marking projection or treating + audit retention as participant disclosure; +- raw credentials, headers, tokens, prompts, action payloads, policy bodies, + hidden content, rejected records, evidence bodies, backend objects, or + exception text in contracts, snapshots, diagnostics, logs, audits, fixtures, + environment, or argv; +- adding a new gateway, workflow engine, store, audit channel, logger, + exception hierarchy, schema registry, compatibility process, or verification + script; and +- updating the lineage ledger/source audit merely because RUN-310 delivery + status changes. + +## Non-Goals And Implementation Boundaries + +- No new SDL syntax, authored controller semantics, API-409 occurrence family, + information-flow relation, participant gateway, human-control UI, identity + provider, event bus, workflow engine, or persistence product. +- No backend-specific supervisory method, capability declaration, universal + backend support claim, or automatic weakening when support is absent. +- No replacement of SEM-211 admission, participant lifecycle/action results, + SEM-226 exposure, API-408 retrieval, or evidence/provenance ownership. +- No participant-internal reasoning, prompt/answer capture, chain-of-thought, + hidden world state, or policy body in the supervisory record. +- No distributed ordering or multi-writer guarantee unless the selected store + proves the expected-head atomic commit contract. +- No retroactive erasure and no claim that approval proves admission, + admission proves execution, delivery proves observation, or audit proves + participant visibility. +- The implementation must update the participant section of + `docs/explain/sdl/lineage.md` with actual delivery evidence and explicit + nonclaims. `contracts/provenance/sdl-lineage-ledger-v1.json` and the source + audit change only if normative derivation or compatibility claims change. diff --git a/docs/decisions/issue-903-experiment-binding-contracts-preflight.md b/docs/decisions/issue-903-experiment-binding-contracts-preflight.md new file mode 100644 index 000000000..a5163906b --- /dev/null +++ b/docs/decisions/issue-903-experiment-binding-contracts-preflight.md @@ -0,0 +1,468 @@ +# Issue 903 Cross-Plane Experiment Binding Contracts Preflight + +Date: 2026-07-26 + +Issue: #903. + +Requirement: none. The GitHub issue title, body, acceptance criteria, and +non-goals are the authoritative contract. + +This note records architecture guardrails for publishing experiment binding +descriptors and typed participant-implementation configuration targets. It is +guidance only: it does not add or change contract models, schemas, validators, +runtime behavior, trial plans, fixtures, or an implementation plan. + +## Binding Authorities And Existing Gaps + +- ADR-009 makes `contracts/schemas/` the hand-governed machine-readable + authority and the Python `schema_bundle()` output its parity proof. ADR-061 + and `contracts/schema-publication/` govern schema lineage, compatibility, + content hashes, and removal. +- ADR-055, ADR-065, and ADR-074 make experiment authoring input, apparatus + context, archival runs, studies, and participant-implementation provenance + distinct artifacts. An authored parameter is not automatically a scenario + mutation, apparatus setting, or run fact. +- `ExperimentParameterModel` currently has only `name`, scalar `value`, + `value_kind`, and `redaction`. + `ExperimentConditionAssignmentParameterModel` narrows that shape, while + `_parameter_satisfies_requirement()` and + `_condition_assignment_run_criteria_signature()` still compare parameters by + name, kind, type name, and serialized value. None of those fields identifies + a binding plane, owner, canonical target, source factor/condition, or + validator. They are legacy descriptive/audit parameters, not an authority + boundary. +- ADR-084, `raes.variation`, `SemanticValidator._verify_variation_points()`, + `instantiate_scenario()`, and instantiation provenance already own scenario + family selection and scalar SDL binding. Issue #903 must connect experiment + intent to that path; it must not add another SDL target registry, + substitution engine, or instantiation path. +- ADR-076, `DeclarationIndex`, and SDL composition already preserve canonical + declaration collisions before alias projection. Aliases are lookup + conveniences, not identities. Cross-plane binding needs the same + collision-preserving rule without importing private SDL implementation code + into `raes_contracts` or treating processor runtime addresses as authoring + addresses. +- ADR-041 and + `ParticipantImplementationManifestModel` / + `ParticipantImplementationSelectionModel` own participant implementation + declaration and selection. `configuration_ref` and + `configuration_digest` preserve opaque configuration identity, but the + manifest currently declares no typed configuration targets and no portable + complete-configuration validation result. +- `ParticipantImplementationManifestModel.constraints` and + `ParticipantExposurePolicyModel.constraints` are disclosure text. They are + not schemas, validators, target registries, defaults, or permission to inject + arbitrary configuration. +- `ExperimentApparatusContextModel.configuration_parameters` records apparatus + parameters but does not make their names authoritative. Processor, backend, + participant-implementation, and other apparatus components remain separate + manifest owners. +- Runtime fact contracts and `RuntimeFactBindingPlane` already demonstrate + strict scalar unions, value-or-secret-reference separation, typed sink + policy, explicit failure dispositions, and value-free portable events. + Runtime facts are nevertheless run-local late-bound action inputs. They must + not be reused as pre-run experiment factors, scenario selections, + participant configuration, or apparatus configuration. +- `canonical_contract_digest()` already implements RFC 8785/JCS plus SHA-256 + for closed contracts. Ad hoc `json.dumps(sort_keys=True)` digests are not a + second canonicalization profile. + +## Architecture Decisions And Guardrails + +### One descriptor family, three closed authority planes + +Publish one versioned, closed binding-descriptor contract family with a closed +plane vocabulary: + +- `scenario`; +- `participant-implementation`; and +- `apparatus`. + +Unknown planes are invalid. A plane is declared data, never inferred from +`value_kind`, a parameter name or prefix, a target spelling, a matching field, +the selected component kind, or fallback order. + +The descriptor records the source factor id, factor level id, and condition id +explicitly. Those ids must resolve against the owning +`ExperimentSpecModel` / `ExperimentStudyModel` factor and allocation maps. +Collection position and equality between a parameter name and a factor name +carry no provenance meaning. + +The descriptor also records: + +- the exact plane-specific canonical target; +- the exact JSON scalar type (`string`, `integer`, `number`, `boolean`, or + `null`); +- a discriminated literal-value or secret-reference disposition; +- the owning contract id and version; and +- a governed validator/profile id and version. + +The validator identity is a portable governed identifier, not a Python import +path, callback, command, entry point, template, plugin name, or experiment- +selected dispatch string. Trusted code maps the governed identity to an +installed validator behind the owning package/backend boundary. + +Use strict Pydantic scalar types and explicit type predicates, following +`RuntimeFactScalar` and `_value_matches_type()`. Boolean is not integer, integer +and number remain distinguishable where declared, strings are not parsed into +numbers or booleans, and non-finite numbers fail before canonicalization. +`ContractModel` closure alone is insufficient because default Pydantic scalar +coercion and float acceptance do not prove this invariant. + +### Canonical targets are typed owner references, not generic paths + +The target is a discriminated plane-specific reference, not one universal +string path: + +| Plane | Canonical owner and target | Admission rule | +| --- | --- | --- | +| Scenario | The exact scenario-family identity plus canonical variation-point address and its owner-declared target | Resolve through the existing composed `Scenario` variation registry, declaration index, variation semantic validator, selection application, and public instantiation/admission path. Do not bind directly to arbitrary SDL fields or `${...}` occurrences. | +| Participant implementation | Selected implementation identity and manifest version plus one manifest-declared configuration-target id | Resolve only in the selected `ParticipantImplementationManifestModel` target registry and validate through that manifest's governed configuration contract/validator. | +| Apparatus | Selected apparatus component identity/manifest plus one configuration-target id declared by that public owning contract | Processor, backend, host, or other component configuration is admissible only when its selected portable manifest/contract publishes the target. Component presence, `configuration_parameters`, private backend schema fields, and free-text constraints do not create targets. | + +This preserves the difference between SDL declaration addresses, processor +compiled addresses (`raes_contracts.addressing.CompiledAddress`), participant +configuration ids, and apparatus component configuration ids. Common dotted +rendering does not give those concepts common authority. + +Each owner may publish bounded input aliases for migration or ergonomics. +Resolution produces exactly one canonical target before any value validation or +mutation. Preserve every supplied spelling until collision analysis is +complete. Two bindings that resolve to the same `(plane, owner, canonical +target)` fail even when values, source factors, or secret-reference identities +are equal. An alias colliding with another canonical id or alias also fails. +Never trim, lowercase, case-fold, choose first/last writer, deduplicate through +a set/map, or use source order to resolve ambiguity. + +Reuse the collision-preserving semantics of `DeclarationIndex`; do not import +the private SDL class across the package boundary. If a dependency-neutral +helper is extracted, it may own only canonical-key/alias collision mechanics. +It must not become a global registry that owns SDL, participant, and apparatus +target meaning. + +### Target declarations and complete participant configuration + +A configuration-target declaration is a reusable closed scalar-target value +model, but its registry and validation remain owner-specific. A declaration +needs a stable target id, exact scalar type, optional same-type default, allowed +literal or secret-reference dispositions, sensitivity posture, aliases, owning +contract/validator identity, and any bounded declarative constraints that are +portable. It must not contain arbitrary JSON Schema fragments, Python +callables, commands, paths, environment lookups, backend option maps, or +free-text constraints interpreted as executable validation. + +Extend the participant implementation manifest capability surface with a keyed +typed target registry. Map keys must equal embedded target ids, aliases must be +unique across the complete registry, defaults must validate at manifest +admission, and the manifest's supported contract ids must declare the +configuration contract/result versions it claims. + +Participant configuration validation is a complete, atomic owner operation: + +1. resolve all aliases and reject unknown, ambiguous, or duplicate canonical + targets; +2. apply declared defaults and explicit overrides without type coercion; +3. require every target whose declaration has neither a default nor an + admitted override; +4. validate the complete configuration with the selected manifest's governed + validator; +5. return normalized same-type realized values, default/override origin, + per-target provenance, and one authoritative configuration digest; and +6. publish no mutation or success result if any target fails. + +Normalization may canonicalize an admitted value within its declared type; it +must not convert between JSON scalar types, silently drop inputs, substitute a +different target, clamp values, or turn a failure into a default. + +The portable validation/result carrier belongs with neutral participant +contracts in `raes_contracts`; the callable/protocol that realizes owner +validation belongs behind the participant/backend protocol boundary. It must +compose with `ParticipantImplementationSelectionModel` and the existing +participant action-admission path, not create a second participant identity, +action binder, control plane, or backend plugin mechanism. + +### Secret references are structurally distinct and never resolved here + +Literal values and secret references are a discriminated union. A secret +reference cannot be represented as a string literal plus `redaction`, and a +redacted legacy parameter is not automatically a secret reference. + +Only a bounded, explicitly non-sensitive reference identity may enter an +authoring descriptor, trial plan, validation result, digest, or provenance +record. Provider credentials, secret locator details that themselves expose +private data, resolved values, hashes of resolved values, environment-variable +names, file paths, command fragments, and backend-native objects remain +outside portable contracts. + +Secret dereference is a separate deny-first runtime authorization at a +protected sink. Successful descriptor/configuration validation does not grant +dereference authority. Borrow the value-free event and protected-sink posture +from runtime fact binding where applicable; do not reuse a runtime fact as the +experiment binding itself. + +ADR-056/057 distinguish authored scenario fixture values from operator secrets. +That does not authorize operator secrets in experiment binding artifacts. +Scenario fixture values remain governed by their SDL owner; issue #903's secret +reference form exists specifically so resolved external secret material never +enters the portable experiment/configuration lifecycle. + +### Canonicalization, ordering, identity, and provenance + +Canonicalization happens only after plane, owner, target, type, source +factor/level/condition, disposition, and value/reference validation succeeds. +Normalize semantically unordered bindings by `(plane, owner identity, +canonical target)` before RFC 8785/JCS serialization. Input list order, aliases, +map insertion order, source paths, private validator objects, and resolved +secret values never affect identity. + +Reuse `canonical_contract_digest()` and its RFC 8785 semantics, or move that +dependency-neutral implementation behind a shared public contract helper if +its current satisfiability module placement would create a conceptually wrong +import. Do not create a binding-only canonical JSON implementation. + +An authoritative realized-configuration digest commits to: + +- configuration contract/profile and owner identity/version; +- each canonical target and exact declared type; +- each default/override origin; +- normalized non-secret realized values; and +- admitted non-sensitive secret-reference identities. + +It never commits to resolved secret material. If an external configuration +artifact also has a byte checksum, keep that artifact checksum distinct from +the authoritative normalized configuration digest. Existing +`ParticipantImplementationSelectionModel.configuration_digest` must equal or +unambiguously reference the authoritative result digest; do not publish two +fields with overlapping meanings. + +Define one reusable realized-binding provenance value model, distinct from the +authoring descriptor. It preserves source factor, level and condition, plane, +canonical target, exact type, default/override origin, normalized non-secret +value or non-sensitive reference identity, owning contract/validator version, +and authoritative configuration digest. + +Each lifecycle owner embeds that value rather than copying its fields: + +- admitted trial intent records the binding to be realized; +- scenario instantiation provenance records the scenario binding actually + applied through the existing instantiation path; +- participant configuration validation records the complete normalized + configuration result; and +- `ExperimentRunModel` / participant implementation provenance archive the + realized binding and digest used by the run. + +Do not place binding provenance in `RuntimeSnapshot.metadata`, generic +`metadata`/`details`, audit text, logs, or backend-private state. Repeated +representations must be joined by exact ids/digests and validated for equality, +not treated as independent authorities. + +### Compatibility is explicit and fail-closed + +The affected published schemas are currently `draft`, so ADR-061 permits +reviewed in-line structural changes, but every change still needs its +per-contract publication record, content hash/change summary, generated-schema +parity, reader tests, and explicit compatibility statement. A `v1` suffix does +not by itself prove stability or old-reader compatibility. + +When a workflow/profile requires explicit binding semantics, legacy +`ExperimentParameterModel` or +`ExperimentConditionAssignmentParameterModel` inputs without a descriptor are +invalid before trial compilation or runtime mutation. No adapter may infer a +plane, target, factor, condition, type, sensitivity, default, owner, or +validator from `name`, `value_kind`, prefixes, free text, matching fields, +collection order, or a selected backend. + +Legacy descriptive parameters may remain accepted only on paths that make no +binding/mutation claim. Any deterministic migration must be version-pair +specific, preserve the original input, and fail on ambiguity; a best-effort +upgrade is prohibited. + +## Required Incumbents + +- Experiment contracts and joins: + `ExperimentParameterModel`, + `ExperimentConditionAssignmentParameterModel`, + `ExperimentStudyFactorModel`, + `ExperimentConditionAssignmentModel`, + `ExperimentRunAllocationPlanModel`, `ExperimentSpecModel`, + `ExperimentApparatusContextModel`, `ExperimentRunModel`, + `_run_satisfies_condition_assignment()`, and the existing task/run/study + semantic validators and `x-aces-invariants`. +- Scenario-family authority: + `Variable`, `VariableTarget`, `ParameterVariationPoint`, the other closed + variation target types, `DeclarationIndex`, + `SemanticValidator._verify_variation_points()`, module composition, + `instantiate_scenario()`, `admit_instantiated_scenario()`, + `InstantiationProvenance`, and canonical instantiated snapshots. +- Participant and apparatus authority: + `ParticipantImplementationManifestModel`, + `ParticipantImplementationCapabilitiesModel`, + `ParticipantImplementationSelectionModel`, + `ParticipantImplementationProvenanceModel`, + `ProcessorManifestV2Model`, `BackendManifestV2Model`, + manifest authority allowlists, controlled vocabulary validation, + `ParticipantActionAdmissionRequest`, and participant/backend protocols. +- Shared contract mechanics: + `ContractModel(extra="forbid")`, strict Pydantic scalar types, + `Diagnostic` / `DiagnosticModel`, `canonical_contract_digest()` RFC 8785 + semantics, `schema_bundle()`, `x-aces-invariants`, and the existing + reference-model/concept-authority machinery where a new public concept + binding is actually required. +- Schema and conformance: + `contracts/schemas/`, `contracts/fixtures/`, + `contracts/schema-publication/entries/`, + `tools/generate_contract_schemas.py`, + `tools/check_generated_schemas.py`, + `tools/check_schema_publication.py`, `tools/check_json_artifacts.py`, + `raes_conformance.conformance.validators`, + `raes_conformance.conformance.semantics`, and backend profile contract sets + only when a backend is expected to claim the new contracts. +- Security, persistence, and public delivery: + `ControlPlaneSecurityConfig.strict_defaults()`, read/mutating role + dependencies, request-size guards, request fingerprints, idempotency keys, + `AuditEvent`, `ControlPlaneStore`, bounded `HTTPException` details, and the + redacted `{"detail": "internal server error"}` handler. +- Repository workflow: + `.ground-control.yaml`, `.gc/plan-rules.md`, `noxfile.py`, + `tools/check_repo_policy.py`, `tools/check_requirement_governance.py`, + `tools/check_authority_boundary.py`, `tools/check_sdl_catalog_parity.py`, + `tools/check_semantic_coverage.py`, `tools/check_specification_coverage.py`, + and `tools/verify_all.py`. + +## Cross-Cutting Layers The Implementation Must Pass + +- **Authoring/config shape:** experiment input enters closed experiment models; + scenario targets additionally pass safe SDL parsing, phase-specific schema, + composition/trust, portable-id, declaration collision, variation semantic, + instantiation, and post-instantiation admission gates. Participant/apparatus + targets pass their selected manifest and owner registry. Raw mappings never + become configuration merely because Pydantic can parse them. +- **Factor/condition join:** every source id resolves against the exact + experiment spec/study allocation artifact. Factor level and condition + membership are validated before target resolution, and target resolution is + completed for the whole binding set before any mutation. +- **Type/default/normalization:** strict scalar and finite-number validation + runs for explicit values, manifest defaults, owner-normalized results, and + reconstructed provenance. Default application is owner-declared behavior, + not missing-field inference. +- **Alias/collision:** preserve canonical declarations and all aliases until + an injective resolution is proven. Duplicate canonical targets are rejected + before constructing dictionaries or digests, including identical-value + duplicates. +- **Manifest/owner validation:** selected participant/apparatus identity, + manifest digest/version, supported contracts, target registry, validator + identity, complete realized configuration, and result digest agree. Manifest + constraints remain disclosure only. +- **Authentication/authorization:** publishing and validating offline + contracts adds no auth surface. Any later HTTP mutation reuses control-plane + strict defaults, target-bound principals, mutating roles, request limits, + idempotency/fingerprints, and audit. Experiment binding authority, caller + authorization, apparatus support, and secret dereference authorization are + independent gates. +- **Secret handling:** only the non-sensitive reference identity crosses the + portable boundary. Resolved values and sensitive locator/provider details + are excluded from models, digests, fixtures, diagnostics, provenance, audit, + persistence, and logs. Redaction is structural, not a promise to scrub later. +- **Environment and OS exposure:** environment binding is a non-goal. Do not + resolve target/value/secret data through environment names, arbitrary files, + filenames, process argv, shell interpolation, stdout/stderr, or plugin + dispatch. If a trusted owner adapter later needs a process boundary, it uses + fixed invocation shapes, controlled working directories, bounded input via + an appropriate protected channel, bounded timeouts, no `shell=True`, and + redacted output handling. +- **Error envelope and observability:** expected failures use bounded + `Diagnostic` codes, domains, JSON-pointer addresses, and safe messages or the + existing contract/SDL error envelopes. Do not include rejected values, + secret refs, raw Pydantic `input_value`, backend exception strings, full + payloads, or tracebacks. HTTP 500 remains redacted; logs/audit may carry safe + ids, contract/profile versions, digests, counts, dispositions, stages, and + durations only. +- **Persistence and archival joins:** this issue needs no new repository, + controller, database, cache, or mutable parameter store. Git-tracked schemas, + fixtures, publication records, and specs are the publication audit surface. + Live state continues through existing control-plane/runtime carriers; + admitted intent and archival evidence continue through trial, + instantiation, participant provenance, apparatus context, and experiment-run + contracts. +- **Schema/conformance:** structure, cross-object semantics, canonicalization, + and runtime owner validation are separate gates. Every new root contract is + routed explicitly, exported publicly, registered with conformance, published + with positive/negative fixtures, and included in applicable support + allowlists/profiles. JSON Schema acceptance alone is not owner validation. + +## Extensibility Seam + +The extension seam is an owner-published configuration-target registry plus a +governed validator profile. It is parameterized by plane, owner +identity/manifest, target id and aliases, exact scalar type, default and allowed +value disposition, sensitivity, validator contract/profile version, and +normalization/digest profile. + +The next reasonable change is another participant implementation target, +another portable apparatus component target, or another version of an owning +validator. It should add a declaration/validator version and fixtures behind +that seam. It must not require editing the cross-plane resolver, adding a +backend-specific field to experiment input, creating a second SDL binder, or +allowing experiment input to select executable code. + +Adding an entirely new authoritative plane is a contract-lineage and +architecture change: extend the closed union, provenance, compatibility rules, +validators, schemas, and negative fixtures together. Do not admit +`x-` planes as an escape hatch. + +## Gotchas And Anti-Patterns + +Avoid: + +- enriching `ExperimentParameterModel` while retaining name equality as the + binding authority; +- inferring plane or target from `value_kind`, prefixes, field names, free-text + constraints, component order, selected backend, or fallback; +- treating SDL variable names, variation-point ids, compiled resource + addresses, participant target ids, backend option names, and environment + names as interchangeable strings; +- binding scenario values directly to arbitrary JSON/YAML pointers, templates, + overlays, patches, object attributes, or private compiler fields; +- using participant manifest `constraints`, apparatus + `configuration_parameters`, backend driver config, or a private provider + schema as a portable target registry; +- accepting duplicate canonical targets because their values match, or losing + alias collisions through dict/set construction; +- relying on permissive union parsing, bool/int equivalence, numeric/string + coercion, `NaN`/infinity, insertion order, or non-JCS JSON serialization; +- making defaults, normalization, validation, and digest computation separate + mutable passes that can observe different configuration; +- hashing a resolved secret, treating a redacted scalar as a secret reference, + putting a sensitive locator in the reference identity, or leaking rejected + input through validation errors; +- letting an experiment choose a Python entry point, validator callable, + provider plugin, command, environment variable, file path, or backend option + map; +- using runtime facts to select pre-run factors/conditions, scenario variation, + participant implementation configuration, or apparatus; +- creating duplicate target schemas, reference resolvers, canonicalizers, + validator registries, exception hierarchies, diagnostic envelopes, + provenance roots, configuration stores, audit streams, or conformance + runners; and +- hand-editing generated/reference schemas without the authoritative schema, + publication record, compatibility, fixture, and parity changes moving + together. + +## Non-Goals And Implementation Boundary + +- This preflight does not implement issue #903 or downstream issues #787, + #788, #789, #790, or #345. +- Issue #903 does not select or construct participant providers, execute or + schedule trials, resolve secrets, mutate runtime state, or add HTTP/CLI/MCP + execution surfaces. +- It does not define an APTL-specific allowlist, expose arbitrary backend or + provider configuration, or make free-text manifest constraints executable. +- It does not add environment-variable binding, arbitrary filesystem paths, + command fragments, Python entry points, templates, generic overlays, + experiment-selected plugins, or backend-private schema injection. +- It does not persist or hash resolved secret values, and it does not turn SDL + scenario fixture credentials into operator-secret references. +- It does not replace SDL variation/instantiation, participant action + admission, runtime fact binding, apparatus manifests, control-plane + security/persistence, experiment run/study provenance, or schema publication + governance. diff --git a/docs/explain/reference/README.md b/docs/explain/reference/README.md index 08723cddf..2707b63f2 100644 --- a/docs/explain/reference/README.md +++ b/docs/explain/reference/README.md @@ -30,3 +30,6 @@ themselves normative specifications or ADRs. - [scenario-variation-and-trial-realization.md](scenario-variation-and-trial-realization.md) - Complete SCE-002 phase, ownership, identity, random-stream, trial-plan, runtime-fact, migration, and follow-on architecture +- [experiment-binding-contracts.md](experiment-binding-contracts.md) + - Authoritative experiment-to-scenario, participant, and apparatus binding + contracts and configuration realization diff --git a/docs/explain/reference/canonical-reference-map.md b/docs/explain/reference/canonical-reference-map.md index 12ef43274..5cf73f68e 100644 --- a/docs/explain/reference/canonical-reference-map.md +++ b/docs/explain/reference/canonical-reference-map.md @@ -29,6 +29,7 @@ page is an index, not a replacement for the linked artifacts. | Instantiation, planning, and realization path | [`docs/explain/sdl/runtime-architecture.md`](../sdl/runtime-architecture.md) | | Explicitness and realized-form semantics | [`explicitness-realization-semantics.md`](explicitness-realization-semantics.md), [`realization-envelopes.md`](realization-envelopes.md) | | Variation and trial realization | [`scenario-variation-and-trial-realization.md`](scenario-variation-and-trial-realization.md) | +| Experiment binding contracts | [`experiment-binding-contracts.md`](experiment-binding-contracts.md), [ADR-094](../../decisions/adrs/adr-094-authoritative-cross-plane-experiment-bindings.md) | | Backend conformance | [`backend-conformance.md`](backend-conformance.md) | | Evidence-bounded claim guidance | [`docs/explain/sdl/agent-guidance.md`](../sdl/agent-guidance.md), [`docs/explain/sdl/scientific-scenario-completeness.md`](../sdl/scientific-scenario-completeness.md) | @@ -59,6 +60,7 @@ page is an index, not a replacement for the linked artifacts. | Design precedents | [`docs/explain/sdl/precedents.md`](../sdl/precedents.md) | | Academic lineage | [`docs/explain/sdl/lineage.md`](../sdl/lineage.md) | | Scenario variation and trial realization design | [`scenario-variation-and-trial-realization.md`](scenario-variation-and-trial-realization.md), [ADR-084](../../decisions/adrs/adr-084-scenario-variation-and-deterministic-trial-realization.md) | +| Cross-plane experiment binding and configuration | [`experiment-binding-contracts.md`](experiment-binding-contracts.md), [ADR-094](../../decisions/adrs/adr-094-authoritative-cross-plane-experiment-bindings.md) | ## Contracts And Processing diff --git a/docs/explain/reference/experiment-binding-contracts.md b/docs/explain/reference/experiment-binding-contracts.md new file mode 100644 index 000000000..6b1328563 --- /dev/null +++ b/docs/explain/reference/experiment-binding-contracts.md @@ -0,0 +1,81 @@ +# Experiment Binding Contracts + +Issue #903 publishes the portable contract surfaces that connect experiment +factors and conditions to scenario variation, participant implementation +configuration, and apparatus configuration. The contracts state intent and +validation results; they do not mutate a runtime. + +## Authority planes + +| Plane | Canonical owner | Target | +|-------|-----------------|--------| +| `scenario` | Composed SDL scenario family | Declared variation point and its owner target | +| `participant-implementation` | Selected participant implementation manifest | Manifest `configuration_registry` target | +| `apparatus` | Selected processor or backend manifest | Manifest `configuration_registry` target | + +A descriptor carries its source factor, factor level, and condition. These +joins are validated by `ExperimentSpecModel` when +`binding_semantics: explicit-required` is selected. Legacy +`required_parameters` remain descriptive data and are rejected in that mode. + +Targets never fall back across planes. A participant target that is absent +from the selected participant manifest does not become a scenario or backend +target because the same spelling exists elsewhere. + +## Target registries + +An owner registry declares canonical target ids, bounded aliases, exact scalar +types, admitted literal/secret-reference forms, sensitivity, optional defaults, +and the governing contract and validator version. + +Aliases are input spellings only. Admission resolves aliases to canonical ids +before constructing a configuration or digest. Alias/canonical collisions and +two inputs that resolve to one target fail, including equal-value duplicates. + +The extension seam is the owner registry: a manifest can add a target or a new +validator version without changing the cross-plane resolver. A new authority +plane is not an extension id; it requires a versioned contract change. + +## Participant configuration realization + +`realize_participant_configuration()` accepts a selected participant manifest +and an override list. It resolves the whole list, applies defaults, requires +missing values, enforces strict types, and optionally invokes a trusted +`ParticipantConfigurationValidator` over the complete configuration. It +returns no partial result. + +The normalized entries are sorted by canonical target id and preserve whether +each value came from a default or override. +`participant-configuration-result-v1` records the selected manifest identity, +governed validator, normalized values, and a `canonical_contract_digest()` +RFC 8785/JCS digest. Alias spelling and input ordering do not affect that +digest. + +## Scalar and secret posture + +The scalar vocabulary is closed: string, integer, number, Boolean, and null. +Validation is non-coercing. Boolean does not satisfy integer, numeric strings +remain strings, and NaN or infinity is invalid. + +A secret reference is structurally different from a string literal. Portable +artifacts carry only its non-sensitive reference identity. They contain no +resolved-secret field and do not resolve environment variables, files, +commands, provider objects, or private locators. The digest commits to the +reference identity, never to resolved secret material. + +## Lifecycle integration + +- `experiment-authoring-input-v1` carries explicit descriptors and validates + their factor/condition provenance. +- Scenario admission delegates target resolution to the public SDL + variation/instantiation authority. +- Participant, processor, and backend manifests publish their own target + registries. +- `participant-configuration-result-v1` is the authoritative normalized + participant configuration result. +- `experiment-run-v1` may archive the realized descriptor, its origin, and the + configuration digest. + +Downstream admitted trial-plan and compiler work consumes these surfaces. It +must not add another target registry, SDL binder, canonicalizer, or secret +resolver. diff --git a/docs/explain/sdl/lineage.md b/docs/explain/sdl/lineage.md index 4f6139b39..fde9c64dc 100644 --- a/docs/explain/sdl/lineage.md +++ b/docs/explain/sdl/lineage.md @@ -1039,6 +1039,35 @@ which dynamic queue/log/config details remain evidence or bounded settings. information-flow proof, and API-423 crossing-policy realization are explicit nonclaims. The lineage ledger and source audit remain unchanged because this delivery adds no normative external derivation or compatibility claim. +- RUN-310 composes the same participant-interface, append-only event-history, + mixed-control, information-flow, and access-control lineage into live + supervisory mediation; it introduces no new external semantic source. The + exact RAES/ACES SDL mapping is trusted + `ParticipantBehaviorSpecificationRuntime.controller_states` and + `control_transitions` for policy authority, closed + `Participant*ControlIntent` models for caller-owned intent, + `ControlPlaneIdentity.participant_control_subjects` for the separate + principal-to-participant/controller binding, `RuntimeControlPlane` and + `ParticipantControlMixin` for mediation, API-409 + `ParticipantControlOccurrenceModel` for immutable outcomes, + `RuntimeSnapshot.participant_control_history` for append-only state, and + `ControlPlaneStore.commit_control_transition()` for the expected-head atomic + occurrence/receipt/idempotency/audit commit. The existing HTTP request-size, + authentication, role/target, redacted-error, SEM-211 admission, and API-408 + visibility boundaries remain separate gates. +- RUN-310 delivery evidence is the `runtime-snapshot-v1` schema and publication + entry, the in-memory and local-store restart/replay implementation, and + `implementations/python/tests/test_run_310_supervisory_lifecycle.py`, which + covers every control kind, negative subject binding, stale state, scoped + idempotency conflict, append-only integrity, atomic failure, restart, closed + HTTP input, and denial without participant occurrence. This does not claim + that approval proves admission, execution, delivery, observation, or + participant visibility; it does not rewrite prior action or controller + history; and it makes no backend-support, multi-process CAS, distributed or + partial-order, noninterference, refinement, simulation, bisimulation, UI, or + participant-internal-reasoning claim. The lineage ledger and source audit + remain unchanged because RUN-310 adds no normative derivation or + compatibility claim. - CALDERA adversary-emulation research informs the action semantics: cyber actions can change foothold, knowledge, observations, detection surface, and downstream outcomes under uncertainty. diff --git a/docs/index.md b/docs/index.md index c1fd163e6..0df62efd3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -143,6 +143,7 @@ explain/reference/objective-semantics explain/reference/explicitness-realization-semantics explain/reference/realization-envelopes explain/reference/scenario-variation-and-trial-realization +explain/reference/experiment-binding-contracts ``` ```{toctree} diff --git a/implementations/python/packages/raes_backend_stubs/manifest.py b/implementations/python/packages/raes_backend_stubs/manifest.py index 9c328706d..6dd2f9258 100644 --- a/implementations/python/packages/raes_backend_stubs/manifest.py +++ b/implementations/python/packages/raes_backend_stubs/manifest.py @@ -40,7 +40,10 @@ from raes_contracts.manifest_authority import BACKEND_SUPPORTED_CONTRACT_IDS from raes_contracts.vocabulary import RealizationSupportMode -REFERENCE_BACKEND_SUPPORTED_CONTRACT_VERSIONS = frozenset(BACKEND_SUPPORTED_CONTRACT_IDS) - {"realization-envelope-v1"} +REFERENCE_BACKEND_SUPPORTED_CONTRACT_VERSIONS = frozenset(BACKEND_SUPPORTED_CONTRACT_IDS) - { + "experiment-binding-descriptors-v1", + "realization-envelope-v1", +} REFERENCE_PARTICIPANT_ROLES = frozenset( PARTICIPANT_RUNTIME_CAPABILITY_REQUIRED_CONTRACTS[PARTICIPANT_RUNTIME_ROLE_SCOPE] ) diff --git a/implementations/python/packages/raes_conformance/conformance/validators.py b/implementations/python/packages/raes_conformance/conformance/validators.py index 3477dfcc2..175026f2d 100644 --- a/implementations/python/packages/raes_conformance/conformance/validators.py +++ b/implementations/python/packages/raes_conformance/conformance/validators.py @@ -10,6 +10,7 @@ EvaluationPlanModel, EvaluationResultStateModel, ExperimentApparatusContextModel, + ExperimentBindingDescriptorSetModel, ExperimentCaptureSpecModel, ExperimentDerivedMeasureModel, ExperimentEvidenceRecordModel, @@ -20,6 +21,7 @@ OperationStatusModel, OrchestrationPlanModel, ParticipantBehaviorHistoryEventModel, + ParticipantConfigurationResultModel, ParticipantEpisodeHistoryEventModel, ParticipantEpisodeStateModel, ParticipantImplementationManifestModel, @@ -67,6 +69,8 @@ "experiment-evidence-record-v1": ExperimentEvidenceRecordModel.model_validate, "experiment-derived-measure-v1": ExperimentDerivedMeasureModel.model_validate, "experiment-run-v1": ExperimentRunModel.model_validate, + "experiment-binding-descriptors-v1": ExperimentBindingDescriptorSetModel.model_validate, + "participant-configuration-result-v1": ParticipantConfigurationResultModel.model_validate, } diff --git a/implementations/python/packages/raes_contracts/contracts/__init__.py b/implementations/python/packages/raes_contracts/contracts/__init__.py index f3ddb889d..35e9638cc 100644 --- a/implementations/python/packages/raes_contracts/contracts/__init__.py +++ b/implementations/python/packages/raes_contracts/contracts/__init__.py @@ -115,19 +115,9 @@ ExperimentDerivedMeasureReferenceModel, ExperimentMeasurementChannelReferenceModel, ) -from .experiment_capture import ( - ExperimentCaptureRequirementModel, - ExperimentCaptureSpecModel, - ExperimentCaptureWindowModel, - ExperimentValidityNoteModel, -) -from .experiment_disclosure import ( - ExperimentApparatusConstraintModel, - ExperimentAugmentationDisclosureModel, - ExperimentEvaluationProtocolModel, - ExperimentMetricDefinitionModel, - ExperimentSplitAndLeakageControlsModel, -) +from .experiment_bindings import * +from .experiment_capture import * +from .experiment_disclosure import * from .experiment_evidence import ( ExperimentDerivedMeasureMethodModel, ExperimentDerivedMeasureModel, @@ -400,10 +390,13 @@ "ControlledVocabularyTermModel", "ContractModel", "ExperimentAnalysisPlanModel", "NIST_CSF_DEFENSIVE_CATEGORIES_SOURCE_SCHEMA_VERSION", "NistCsfDefensiveCategorySourceModel", "NistCsfDefensiveCategorySourceTermModel", + "ApparatusBindingTargetModel", "BindingOwnerModel", "BindingScalarType", + "ConfigurationTargetDeclarationModel", "ConfigurationTargetRegistryModel", "ExperimentApparatusCompatibilityReferenceModel", "ExperimentApparatusComponentModel", "ExperimentApparatusConstraintModel", "ExperimentApparatusContextModel", "ExperimentArtifactRefModel", "ExperimentAugmentationDisclosureModel", "ExperimentBackendReferenceModel", "ExperimentCaptureRequirementModel", "ExperimentCaptureSpecModel", "ExperimentCaptureSpecReferenceModel", + "ExperimentBindingDescriptorModel", "ExperimentBindingDescriptorSetModel", "ExperimentCaptureWindowModel", "ExperimentChecksumModel", "ExperimentClockContextModel", "ExperimentConditionAssignmentParameterModel", "ExperimentConditionAssignmentReferenceModel", "ExperimentDerivedMeasureMethodModel", "ExperimentDerivedMeasureModel", @@ -420,7 +413,11 @@ "ExperimentStatisticalMethodModel", "ExperimentStochasticControlModel", "ExperimentStudyFactorModel", "ExperimentStudyMembershipModel", "ExperimentStudyModel", "ExperimentTaskReferenceModel", "ExperimentTaskModel", "ExperimentUncertaintyMethodModel", "ExperimentValidityNoteModel", + "LiteralBindingValueModel", "ParticipantConfigurationModel", "ParticipantConfigurationResultModel", + "ParticipantImplementationBindingTargetModel", + "RealizedBindingProvenanceModel", "ScenarioBindingTargetModel", "SecretReferenceBindingValueModel", "EXPERIMENT_APPARATUS_CONTEXT_SCHEMA_VERSION", "EXPERIMENT_AUTHORING_INPUT_SCHEMA_VERSION", + "EXPERIMENT_BINDING_DESCRIPTORS_V1_SCHEMA_VERSION", "EXPERIMENT_CAPTURE_SPEC_SCHEMA_VERSION", "EXPERIMENT_DERIVED_MEASURE_SCHEMA_VERSION", "EXPERIMENT_EVIDENCE_RECORD_SCHEMA_VERSION", "EXPERIMENT_RUN_SCHEMA_VERSION", "EXPERIMENT_STUDY_SCHEMA_VERSION", "EXPERIMENT_TASK_SCHEMA_VERSION", "EvaluationHistoryEventModel", @@ -432,7 +429,8 @@ "OPERATION_SCHEMA_VERSION", "OperationReceiptModel", "OperationStatusModel", "ObservationCapabilitiesModel", "OrchestrationPlanModel", "OrchestratorCapabilitiesModel", "PARTICIPANT_EPISODE_STATE_SCHEMA_VERSION", "PARTICIPANT_IMPLEMENTATION_MANIFEST_V1_SCHEMA_VERSION", - "PARTICIPANT_IMPLEMENTATION_PROVENANCE_V1_SCHEMA_VERSION", "ParticipantActionEffectResultModel", + "PARTICIPANT_IMPLEMENTATION_PROVENANCE_V1_SCHEMA_VERSION", + "PARTICIPANT_CONFIGURATION_RESULT_V1_SCHEMA_VERSION", "ParticipantActionEffectResultModel", "ParticipantActionPreconditionResultModel", "ParticipantActionResultModel", "ParticipantAttributionCandidateModel", "ParticipantAttributionEdgeModel", "ParticipantAttributionEvidenceBasisModel", "ParticipantAttributionOrderingBasisModel", diff --git a/implementations/python/packages/raes_contracts/contracts/bundle.py b/implementations/python/packages/raes_contracts/contracts/bundle.py index a7e062e8f..12c367a5d 100644 --- a/implementations/python/packages/raes_contracts/contracts/bundle.py +++ b/implementations/python/packages/raes_contracts/contracts/bundle.py @@ -25,6 +25,10 @@ WorkflowHistoryEventModel, ) from .experiment_apparatus import ExperimentApparatusContextModel, ExperimentTaskModel +from .experiment_bindings import ( + ExperimentBindingDescriptorSetModel, + ParticipantConfigurationResultModel, +) from .experiment_capture import ExperimentCaptureSpecModel from .experiment_evidence import ExperimentDerivedMeasureModel, ExperimentEvidenceRecordModel from .experiment_run import ExperimentRunModel @@ -138,6 +142,7 @@ def _raw_schema_bundle() -> dict[str, dict[str, Any]]: "random-stream-vector-v1": RandomStreamVectorModel.model_json_schema(), "experiment-apparatus-context-v1": ExperimentApparatusContextModel.model_json_schema(), "experiment-authoring-input-v1": ExperimentSpecModel.model_json_schema(), + "experiment-binding-descriptors-v1": ExperimentBindingDescriptorSetModel.model_json_schema(), "experiment-capture-spec-v1": ExperimentCaptureSpecModel.model_json_schema(), "experiment-derived-measure-v1": ExperimentDerivedMeasureModel.model_json_schema(), "experiment-evidence-record-v1": ExperimentEvidenceRecordModel.model_json_schema(), @@ -192,6 +197,7 @@ def _raw_schema_bundle() -> dict[str, dict[str, Any]]: "participant-context-view-v1": ParticipantContextViewModel.model_json_schema(), "runtime-fact-binding-plane-v1": RuntimeFactBindingPlaneModel.model_json_schema(), "participant-decision-surface-v1": ParticipantDecisionSurfaceModel.model_json_schema(), + "participant-configuration-result-v1": ParticipantConfigurationResultModel.model_json_schema(), "operation-receipt-v1": OperationReceiptModel.model_json_schema(), "operation-status-v1": OperationStatusModel.model_json_schema(), "associated-artifact-manifest-v1": AssociatedArtifactManifestModel.model_json_schema(), diff --git a/implementations/python/packages/raes_contracts/contracts/experiment_bindings.py b/implementations/python/packages/raes_contracts/contracts/experiment_bindings.py new file mode 100644 index 000000000..dbce5f081 --- /dev/null +++ b/implementations/python/packages/raes_contracts/contracts/experiment_bindings.py @@ -0,0 +1,428 @@ +"""Portable contracts for authoritative cross-plane experiment bindings.""" + +from __future__ import annotations + +import math +from enum import Enum +from typing import Annotated, Literal + +from pydantic import ( + Field, + GetJsonSchemaHandler, + SerializerFunctionWrapHandler, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, + model_serializer, + model_validator, +) +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import CoreSchema + +from ..versions import ( + EXPERIMENT_BINDING_DESCRIPTORS_V1_SCHEMA_VERSION, + PARTICIPANT_CONFIGURATION_RESULT_V1_SCHEMA_VERSION, +) +from .base import ContractModel, NonEmptyString, PrefixedDigestString +from .capabilities import ApparatusIdentityModel +from .schema_invariants import _add_aces_invariant + + +class BindingScalarType(str, Enum): + """Exact JSON scalar type declared by a binding owner.""" + + STRING = "string" + INTEGER = "integer" + NUMBER = "number" + BOOLEAN = "boolean" + NULL = "null" + + +BindingScalar = StrictBool | StrictInt | StrictFloat | StrictStr | None + + +class LiteralBindingValueModel(ContractModel): + """Portable literal value; strict type validation occurs at its descriptor.""" + + kind: Literal["literal"] + value: BindingScalar + + +class SecretReferenceBindingValueModel(ContractModel): + """Non-sensitive identity of a secret resolved only at an authorized sink.""" + + kind: Literal["secret-reference"] + reference_id: Annotated[ + str, + Field( + min_length=1, + max_length=256, + pattern=r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$", + ), + ] + + +BindingValue = Annotated[ + LiteralBindingValueModel | SecretReferenceBindingValueModel, + Field(discriminator="kind"), +] + + +class ScenarioBindingTargetModel(ContractModel): + """Canonical target owned by a composed SDL scenario family.""" + + plane: Literal["scenario"] + scenario_family_id: NonEmptyString + variation_point_id: NonEmptyString + target_id: NonEmptyString + + def canonical_key(self) -> tuple[str, ...]: + return ( + self.plane, + self.scenario_family_id, + self.variation_point_id, + self.target_id, + ) + + +class ParticipantImplementationBindingTargetModel(ContractModel): + """Canonical target declared by one selected participant implementation.""" + + plane: Literal["participant-implementation"] + participant_address: NonEmptyString + implementation_name: NonEmptyString + implementation_version: NonEmptyString + manifest_version: NonEmptyString + target_id: NonEmptyString + + def canonical_key(self) -> tuple[str, ...]: + return ( + self.plane, + self.participant_address, + self.implementation_name, + self.implementation_version, + self.manifest_version, + self.target_id, + ) + + +class ApparatusBindingTargetModel(ContractModel): + """Canonical target declared by a selected portable apparatus manifest.""" + + plane: Literal["apparatus"] + component_kind: Literal["processor", "backend", "participant-runtime", "other"] + component_name: NonEmptyString + component_version: NonEmptyString + manifest_version: NonEmptyString + target_id: NonEmptyString + + def canonical_key(self) -> tuple[str, ...]: + return ( + self.plane, + self.component_kind, + self.component_name, + self.component_version, + self.manifest_version, + self.target_id, + ) + + +BindingTarget = Annotated[ + ScenarioBindingTargetModel | ParticipantImplementationBindingTargetModel | ApparatusBindingTargetModel, + Field(discriminator="plane"), +] + + +class BindingOwnerModel(ContractModel): + """Governed contract and validator profile that owns one binding.""" + + contract_id: NonEmptyString + contract_version: NonEmptyString + validator_id: NonEmptyString + validator_version: NonEmptyString + + +class ConfigurationTargetDeclarationModel(ContractModel): + """One scalar target admitted by an owning portable manifest.""" + + target_id: NonEmptyString + value_type: BindingScalarType + aliases: list[NonEmptyString] = Field(default_factory=list) + allowed_value_kinds: list[Literal["literal", "secret-reference"]] = Field(min_length=1) + sensitivity: Literal["public", "internal", "restricted", "secret"] + default: LiteralBindingValueModel | None = None + + @model_validator(mode="after") + def _validate_target_declaration(self) -> ConfigurationTargetDeclarationModel: + if len(self.aliases) != len(set(self.aliases)): + raise ValueError("configuration target aliases must be unique") + if self.target_id in self.aliases: + raise ValueError("configuration target aliases must not repeat the canonical target id") + if len(self.allowed_value_kinds) != len(set(self.allowed_value_kinds)): + raise ValueError("allowed_value_kinds must be unique") + if self.sensitivity == "secret" and self.allowed_value_kinds != ["secret-reference"]: + raise ValueError("secret configuration targets admit only secret-reference values") + if self.default is not None: + if self.sensitivity == "secret": + raise ValueError("secret configuration targets must not declare portable defaults") + if "literal" not in self.allowed_value_kinds: + raise ValueError("a literal default requires literal values to be allowed") + self.validate_value(self.default) + return self + + @model_serializer(mode="wrap") + def _serialize_optional_default( + self, + handler: SerializerFunctionWrapHandler, + ) -> dict[str, object]: + payload = handler(self) + if self.default is None: + payload.pop("default", None) + return payload + + def validate_value(self, value: BindingValue) -> None: + if value.kind not in self.allowed_value_kinds: + raise ValueError(f"configuration target {self.target_id!r} rejects value kind {value.kind!r}") + if isinstance(value, LiteralBindingValueModel): + if not _binding_value_matches_type(value.value, self.value_type): + raise ValueError(f"configuration target {self.target_id!r} value does not match value_type") + if isinstance(value.value, float) and not math.isfinite(value.value): + raise ValueError(f"configuration target {self.target_id!r} number must be finite") + elif self.value_type == BindingScalarType.NULL: + raise ValueError(f"configuration target {self.target_id!r} null type cannot admit a secret reference") + + +class ConfigurationTargetRegistryModel(ContractModel): + """Collision-free target registry published by one manifest owner.""" + + owner: BindingOwnerModel + targets: dict[NonEmptyString, ConfigurationTargetDeclarationModel] = Field(min_length=1) + + @model_validator(mode="after") + def _validate_registry(self) -> ConfigurationTargetRegistryModel: + claimed_names: dict[str, str] = {} + for key, declaration in self.targets.items(): + if key != declaration.target_id: + raise ValueError("configuration target map key must match embedded target_id") + for name in (declaration.target_id, *declaration.aliases): + prior = claimed_names.get(name) + if prior is not None: + raise ValueError(f"configuration target alias or id {name!r} collides with target {prior!r}") + claimed_names[name] = declaration.target_id + return self + + def resolve(self, supplied_id: str) -> ConfigurationTargetDeclarationModel: + direct = self.targets.get(supplied_id) + if direct is not None: + return direct + matches = [target for target in self.targets.values() if supplied_id in target.aliases] + if len(matches) != 1: + raise ValueError(f"unknown configuration target {supplied_id!r}") + return matches[0] + + +class ExperimentBindingDescriptorModel(ContractModel): + """One explicit factor/condition value bound to one authoritative target.""" + + binding_id: NonEmptyString + source_factor_id: NonEmptyString + source_factor_level_id: NonEmptyString + source_condition_id: NonEmptyString + target: BindingTarget + value_type: BindingScalarType + value: BindingValue + owner: BindingOwnerModel + + @model_validator(mode="after") + def _validate_declared_value_type(self) -> ExperimentBindingDescriptorModel: + if isinstance(self.value, SecretReferenceBindingValueModel): + if self.value_type == BindingScalarType.NULL: + raise ValueError("secret references cannot use value_type null") + return self + if not _binding_value_matches_type(self.value.value, self.value_type): + raise ValueError("literal binding value does not match declared value_type") + if isinstance(self.value.value, float) and not math.isfinite(self.value.value): + raise ValueError("literal number must be finite") + return self + + +class ExperimentBindingDescriptorSetModel(ContractModel): + """Versioned, collision-free set of authoritative experiment bindings.""" + + schema_version: Literal[EXPERIMENT_BINDING_DESCRIPTORS_V1_SCHEMA_VERSION] = ( + EXPERIMENT_BINDING_DESCRIPTORS_V1_SCHEMA_VERSION + ) + descriptors: list[ExperimentBindingDescriptorModel] = Field(min_length=1) + + @model_validator(mode="after") + def _validate_unique_bindings_and_targets(self) -> ExperimentBindingDescriptorSetModel: + binding_ids = [descriptor.binding_id for descriptor in self.descriptors] + if len(binding_ids) != len(set(binding_ids)): + raise ValueError("binding_id values must be unique") + canonical_targets = [ + (descriptor.source_condition_id, *descriptor.target.canonical_key()) for descriptor in self.descriptors + ] + if len(canonical_targets) != len(set(canonical_targets)): + raise ValueError("binding descriptors must not contain a duplicate canonical target") + 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) + _add_aces_invariant( + json_schema, + "binding-descriptors-canonical-targets-injective", + "Binding ids must be unique and target resolution must be injective within each source condition.", + validator=( + "raes_contracts.contracts.ExperimentBindingDescriptorSetModel._validate_unique_bindings_and_targets" + ), + inputs=[{"contract_id": "experiment-binding-descriptors-v1", "instance_path": "#"}], + ) + return json_schema + + +class RealizedBindingProvenanceModel(ContractModel): + """Portable provenance for one binding actually realized by its owner.""" + + descriptor: ExperimentBindingDescriptorModel + origin: Literal["selection", "default", "override"] + configuration_digest: PrefixedDigestString | None = None + + @model_validator(mode="after") + def _validate_configuration_digest_scope(self) -> RealizedBindingProvenanceModel: + if self.descriptor.target.plane != "scenario" and self.configuration_digest is None: + raise ValueError("participant and apparatus realized bindings require configuration_digest") + return self + + +class RealizedConfigurationValueModel(ContractModel): + """One normalized value in a complete participant configuration.""" + + target_id: NonEmptyString + value_type: BindingScalarType + origin: Literal["default", "override"] + value: BindingValue + + @model_validator(mode="after") + def _validate_value_type(self) -> RealizedConfigurationValueModel: + if isinstance(self.value, LiteralBindingValueModel): + if not _binding_value_matches_type(self.value.value, self.value_type): + raise ValueError("realized configuration value does not match value_type") + if isinstance(self.value.value, float) and not math.isfinite(self.value.value): + raise ValueError("realized configuration number must be finite") + elif self.value_type == BindingScalarType.NULL: + raise ValueError("null configuration values cannot be secret references") + return self + + +class ParticipantConfigurationModel(ContractModel): + """Canonical digest payload for one complete participant configuration.""" + + implementation_identity: ApparatusIdentityModel + manifest_version: NonEmptyString + owner: BindingOwnerModel + values: list[RealizedConfigurationValueModel] = Field(min_length=1) + + @model_validator(mode="after") + def _validate_canonical_values(self) -> ParticipantConfigurationModel: + target_ids = [entry.target_id for entry in self.values] + if target_ids != sorted(target_ids): + raise ValueError("participant configuration values must be ordered by canonical target id") + if len(target_ids) != len(set(target_ids)): + raise ValueError("participant configuration values must have unique canonical target ids") + return self + + +class ParticipantConfigurationResultModel(ContractModel): + """Portable result of one complete, atomic participant configuration validation.""" + + schema_version: Literal[PARTICIPANT_CONFIGURATION_RESULT_V1_SCHEMA_VERSION] = ( + PARTICIPANT_CONFIGURATION_RESULT_V1_SCHEMA_VERSION + ) + participant_address: NonEmptyString + manifest_ref: NonEmptyString + manifest_digest: PrefixedDigestString + configuration: ParticipantConfigurationModel + configuration_digest: PrefixedDigestString + + @model_validator(mode="after") + def _validate_configuration_digest(self) -> ParticipantConfigurationResultModel: + from ..satisfiability import canonical_contract_digest + + if self.configuration_digest != canonical_contract_digest(self.configuration): + raise ValueError("configuration_digest must match the canonical normalized configuration") + 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) + _add_aces_invariant( + json_schema, + "participant-configuration-digest-valid", + "The configuration digest must be the RFC 8785/JCS digest of the complete normalized configuration.", + validator=("raes_contracts.contracts.ParticipantConfigurationResultModel._validate_configuration_digest"), + inputs=[{"contract_id": "participant-configuration-result-v1", "instance_path": "#"}], + ) + return json_schema + + +def _binding_value_matches_type(value: BindingScalar, value_type: BindingScalarType) -> bool: + if value_type == BindingScalarType.NULL: + matches = value is None + elif value_type == BindingScalarType.BOOLEAN: + matches = isinstance(value, bool) + elif value_type == BindingScalarType.INTEGER: + matches = isinstance(value, int) and not isinstance(value, bool) + elif value_type == BindingScalarType.NUMBER: + matches = isinstance(value, (int, float)) and not isinstance(value, bool) + elif value_type == BindingScalarType.STRING: + matches = isinstance(value, str) + else: + matches = False + return matches + + +def _validate_realized_bindings(bindings: list[RealizedBindingProvenanceModel]) -> None: + binding_ids = [binding.descriptor.binding_id for binding in bindings] + if len(binding_ids) != len(set(binding_ids)): + raise ValueError("realized binding ids must be unique") + canonical_targets = [ + (binding.descriptor.source_condition_id, *binding.descriptor.target.canonical_key()) for binding in bindings + ] + if len(canonical_targets) != len(set(canonical_targets)): + raise ValueError("realized bindings must not contain duplicate canonical targets") + + +__all__ = [ + "ApparatusBindingTargetModel", + "BindingOwnerModel", + "BindingScalar", + "BindingScalarType", + "BindingTarget", + "BindingValue", + "ConfigurationTargetDeclarationModel", + "ConfigurationTargetRegistryModel", + "EXPERIMENT_BINDING_DESCRIPTORS_V1_SCHEMA_VERSION", + "ExperimentBindingDescriptorModel", + "ExperimentBindingDescriptorSetModel", + "LiteralBindingValueModel", + "PARTICIPANT_CONFIGURATION_RESULT_V1_SCHEMA_VERSION", + "ParticipantConfigurationModel", + "ParticipantConfigurationResultModel", + "ParticipantImplementationBindingTargetModel", + "RealizedBindingProvenanceModel", + "RealizedConfigurationValueModel", + "ScenarioBindingTargetModel", + "SecretReferenceBindingValueModel", +] diff --git a/implementations/python/packages/raes_contracts/contracts/experiment_capture.py b/implementations/python/packages/raes_contracts/contracts/experiment_capture.py index 3287e3b2a..428878667 100644 --- a/implementations/python/packages/raes_contracts/contracts/experiment_capture.py +++ b/implementations/python/packages/raes_contracts/contracts/experiment_capture.py @@ -19,6 +19,13 @@ from .schema_invariants import _add_aces_invariant, _add_aces_plane from .validators import _validate_unique_string_values +__all__ = [ + "ExperimentCaptureRequirementModel", + "ExperimentCaptureSpecModel", + "ExperimentCaptureWindowModel", + "ExperimentValidityNoteModel", +] + class ExperimentValidityNoteModel(ContractModel): """Validity threat, limitation, or mitigation note for experiment interpretation.""" diff --git a/implementations/python/packages/raes_contracts/contracts/experiment_disclosure.py b/implementations/python/packages/raes_contracts/contracts/experiment_disclosure.py index d6c1c0ee3..c1439fd05 100644 --- a/implementations/python/packages/raes_contracts/contracts/experiment_disclosure.py +++ b/implementations/python/packages/raes_contracts/contracts/experiment_disclosure.py @@ -22,6 +22,14 @@ from .schema_invariants import _add_aces_invariant from .validators import _validate_unique_string_values +__all__ = [ + "ExperimentApparatusConstraintModel", + "ExperimentAugmentationDisclosureModel", + "ExperimentEvaluationProtocolModel", + "ExperimentMetricDefinitionModel", + "ExperimentSplitAndLeakageControlsModel", +] + _SEM_225_PORTABLE_CARRIER_KINDS = frozenset( { "apparatus-context", diff --git a/implementations/python/packages/raes_contracts/contracts/experiment_run.py b/implementations/python/packages/raes_contracts/contracts/experiment_run.py index 60facbc1f..974415650 100644 --- a/implementations/python/packages/raes_contracts/contracts/experiment_run.py +++ b/implementations/python/packages/raes_contracts/contracts/experiment_run.py @@ -31,6 +31,7 @@ _reference_identity_satisfies_requirement, _reference_satisfies_requirement, ) +from .experiment_bindings import RealizedBindingProvenanceModel, _validate_realized_bindings from .experiment_disclosure import ExperimentAugmentationDisclosureModel from .experiment_evidence import ( ExperimentRealizedFormDisclosureModel, @@ -122,6 +123,7 @@ class ExperimentRunModel(ContractModel): apparatus_context: ExperimentApparatusContextModel participant_implementation_provenance: ParticipantImplementationProvenanceModel | None = None parameter_set: list[ExperimentParameterModel] = Field(min_length=1) + realized_bindings: list[RealizedBindingProvenanceModel] = Field(default_factory=list) stochastic_controls: list[ExperimentStochasticControlModel] = Field(min_length=1) stochastic_draws: list[RandomStreamDrawRecordModel] = Field(default_factory=list) started_at: Rfc3339DateTimeString @@ -152,6 +154,7 @@ def _validate_archival_run(self) -> ExperimentRunModel: _validate_run_evidence_artifact_refs(self) _validate_run_realized_form_disclosures(self) _validate_run_augmentation_disclosures(self) + _validate_realized_bindings(self.realized_bindings) validate_carrier_validation_basis_disclosures(self, subject_kind="experiment_run") if self.realized_time_model is not None: validate_realized_time_model( diff --git a/implementations/python/packages/raes_contracts/contracts/experiment_spec.py b/implementations/python/packages/raes_contracts/contracts/experiment_spec.py index c3b9a52b2..ba8d45d38 100644 --- a/implementations/python/packages/raes_contracts/contracts/experiment_spec.py +++ b/implementations/python/packages/raes_contracts/contracts/experiment_spec.py @@ -12,6 +12,7 @@ from .base import BehavioralClaimBindingModel, ContractModel, NonEmptyString, PositiveInteger from .experiment_apparatus import ExperimentClockContextModel, ExperimentStochasticControlModel from .experiment_artifacts import ExperimentArtifactRefModel +from .experiment_bindings import ExperimentBindingDescriptorModel, ExperimentBindingDescriptorSetModel from .experiment_capture import ExperimentValidityNoteModel from .experiment_disclosure import ExperimentApparatusConstraintModel from .experiment_manifest_references import ExperimentCaptureSpecReferenceModel @@ -317,6 +318,30 @@ def __get_pydantic_json_schema__( return json_schema +def _validate_binding_descriptor_source( + descriptor: ExperimentBindingDescriptorModel, + factors: dict[NonEmptyString, ExperimentStudyFactorModel], + allocation: ExperimentRunAllocationPlanModel, +) -> str: + factor = factors.get(descriptor.source_factor_id) + if factor is None: + raise ValueError(f"binding source factor {descriptor.source_factor_id!r} must reference a declared factor") + if descriptor.source_factor_level_id not in factor.levels: + raise ValueError( + f"binding source factor level {descriptor.source_factor_level_id!r} must be declared " + f"by factor {descriptor.source_factor_id!r}" + ) + assignment = allocation.condition_assignments.get(descriptor.source_condition_id) + if assignment is None: + raise ValueError( + f"binding source condition {descriptor.source_condition_id!r} must reference an allocation condition" + ) + assigned_level = assignment.factor_levels.get(descriptor.source_factor_id) + if assigned_level != descriptor.source_factor_level_id: + raise ValueError("binding source factor level must match its condition assignment") + return descriptor.source_condition_id + + class ExperimentSpecModel(ContractModel): """Pre-run experiment authoring input: a design that binds a task to a run plan. @@ -338,6 +363,8 @@ class ExperimentSpecModel(ContractModel): intended_scenario_ref: ExperimentScenarioReferenceModel | None = None apparatus_intent: ExperimentApparatusConstraintModel | None = None factors: dict[NonEmptyString, ExperimentStudyFactorModel] = Field(default_factory=dict) + binding_semantics: Literal["descriptive", "explicit-required"] = "descriptive" + binding_descriptors: ExperimentBindingDescriptorSetModel | None = None capture_spec_refs: list[ExperimentCaptureSpecReferenceModel] = Field(default_factory=list) validity_notes: list[ExperimentValidityNoteModel] = Field(default_factory=list) artifact_refs: list[ExperimentArtifactRefModel] = Field(default_factory=list) @@ -352,8 +379,38 @@ def _validate_experiment_spec(self) -> ExperimentSpecModel: raise ValueError( f"run_plan allocation blocking factor '{blocking_factor}' must be a declared factor" ) + self._validate_binding_descriptors() return self + def _validate_binding_descriptors(self) -> None: + if self.binding_semantics == "explicit-required" and self.binding_descriptors is None: + raise ValueError("binding_semantics explicit-required requires binding_descriptors") + if self.binding_descriptors is None: + return + if self.binding_semantics != "explicit-required": + raise ValueError("binding_descriptors require binding_semantics explicit-required") + allocation = self.run_plan.allocation + if allocation is None: + raise ValueError("explicit binding descriptors require condition-based run allocation") + legacy_conditions = sorted( + condition_id + for condition_id, assignment in allocation.condition_assignments.items() + if assignment.required_parameters + ) + if legacy_conditions: + raise ValueError( + "explicit binding semantics reject legacy required_parameters: " + ", ".join(legacy_conditions) + ) + covered_conditions = { + _validate_binding_descriptor_source(descriptor, self.factors, allocation) + for descriptor in self.binding_descriptors.descriptors + } + missing_conditions = sorted(set(allocation.compared_conditions) - covered_conditions) + if missing_conditions: + raise ValueError( + "explicit binding descriptors must cover every compared condition: " + ", ".join(missing_conditions) + ) + @classmethod def __get_pydantic_json_schema__( cls, @@ -362,6 +419,29 @@ def __get_pydantic_json_schema__( ) -> JsonSchemaValue: json_schema = handler(core_schema) json_schema = handler.resolve_ref_schema(json_schema) + json_schema.setdefault("allOf", []).extend( + [ + { + "if": { + "properties": {"binding_semantics": {"const": "explicit-required"}}, + "required": ["binding_semantics"], + }, + "then": { + "required": ["binding_descriptors"], + "properties": {"binding_descriptors": {"not": {"type": "null"}}}, + }, + }, + { + "if": { + "properties": {"binding_descriptors": {"not": {"type": "null"}}}, + "required": ["binding_descriptors"], + }, + "then": { + "properties": {"binding_semantics": {"const": "explicit-required"}}, + }, + }, + ] + ) _add_aces_invariant( json_schema, "experiment-spec-blocking-factors-declared", @@ -370,4 +450,11 @@ def __get_pydantic_json_schema__( validator="raes_contracts.contracts.ExperimentSpecModel._validate_experiment_spec", inputs=[{"contract_id": "experiment-authoring-input-v1", "instance_path": "#"}], ) + _add_aces_invariant( + json_schema, + "experiment-binding-source-joins-valid", + "Explicit bindings must cover every compared condition and resolve exact declared factor levels.", + validator="raes_contracts.contracts.ExperimentSpecModel._validate_binding_descriptors", + inputs=[{"contract_id": "experiment-authoring-input-v1", "instance_path": "#"}], + ) return json_schema diff --git a/implementations/python/packages/raes_contracts/contracts/manifests.py b/implementations/python/packages/raes_contracts/contracts/manifests.py index 9e7654409..7edf4add3 100644 --- a/implementations/python/packages/raes_contracts/contracts/manifests.py +++ b/implementations/python/packages/raes_contracts/contracts/manifests.py @@ -5,7 +5,7 @@ import re from typing import Literal -from pydantic import Field, GetJsonSchemaHandler, model_validator +from pydantic import Field, GetJsonSchemaHandler, SerializerFunctionWrapHandler, model_serializer, model_validator from pydantic.json_schema import JsonSchemaValue from pydantic_core import CoreSchema @@ -27,6 +27,7 @@ ProcessorCompatibilityModel, ProvisionerCapabilitiesModel, ) +from .experiment_bindings import ConfigurationTargetRegistryModel from .trial_cleanup import CleanupActionKind from .validators import ( _validate_canonical_concept_bindings, @@ -424,16 +425,32 @@ class ProcessorManifestV2Model(ContractModel): concept_bindings: list[ConceptBindingEntryModel] = Field(min_length=1) constraints: dict[str, str] = Field(default_factory=dict) capabilities: ProcessorCapabilitiesV2Model + configuration_registry: ConfigurationTargetRegistryModel | None = None @model_validator(mode="after") def _validate_unique_binding_scopes(self) -> ProcessorManifestV2Model: validate_processor_supported_contract_versions(self.supported_contract_versions) + if ( + self.configuration_registry is not None + and "experiment-binding-descriptors-v1" not in self.supported_contract_versions + ): + raise ValueError("configuration_registry requires experiment-binding-descriptors-v1 support") scopes = [binding.scope for binding in self.concept_bindings] if len(scopes) != len(set(scopes)): raise ValueError("concept_bindings must not contain duplicate scopes") _validate_canonical_concept_bindings(self, allowed_scopes=_PROCESSOR_CONCEPT_BINDING_SCOPES) return self + @model_serializer(mode="wrap") + def _serialize_optional_configuration_registry( + self, + handler: SerializerFunctionWrapHandler, + ) -> dict[str, object]: + payload = handler(self) + if self.configuration_registry is None: + payload.pop("configuration_registry", None) + return payload + @classmethod def __get_pydantic_json_schema__( cls, diff --git a/implementations/python/packages/raes_contracts/contracts/participant_control_validation.py b/implementations/python/packages/raes_contracts/contracts/participant_control_validation.py index a6ec9ea8a..bacca141a 100644 --- a/implementations/python/packages/raes_contracts/contracts/participant_control_validation.py +++ b/implementations/python/packages/raes_contracts/contracts/participant_control_validation.py @@ -8,6 +8,7 @@ ParticipantApprovalOccurrenceModel, ParticipantCancellationOccurrenceModel, ParticipantControlDeclarationModel, + ParticipantControlDisposition, ParticipantControlOccurrenceModel, ParticipantControlTargetContextModel, ParticipantControlTargetKind, @@ -20,6 +21,7 @@ ) TargetIndex = dict[tuple[ParticipantControlTargetKind, str], ParticipantControlTargetContextModel] +DeclarationIndex = dict[tuple[str, str, str], ParticipantControlDeclarationModel] def validate_participant_control_occurrence_context( @@ -43,10 +45,15 @@ def validate_participant_control_occurrence_context( def _index_declarations( declarations: Sequence[ParticipantControlDeclarationModel], -) -> dict[str, ParticipantControlDeclarationModel]: - declarations_by_ref: dict[str, ParticipantControlDeclarationModel] = {} +) -> DeclarationIndex: + declarations_by_ref: DeclarationIndex = {} for declaration in declarations: - existing = declarations_by_ref.setdefault(declaration.declaration_ref, declaration) + key = ( + declaration.declaration_ref, + declaration.participant_address, + declaration.episode_id, + ) + existing = declarations_by_ref.setdefault(key, declaration) if existing != declaration: raise ValueError("declaration identity was reused with different semantics") return declarations_by_ref @@ -86,6 +93,8 @@ def _register_record_targets( target_contexts: TargetIndex, ) -> None: occurrence = record.occurrence + if occurrence.disposition is ParticipantControlDisposition.REJECTED: + return _register_target( target_contexts, ParticipantControlTargetContextModel( @@ -125,16 +134,24 @@ def _register_record_targets( def _validate_record( record: ParticipantControlOccurrenceModel, *, - declarations_by_ref: dict[str, ParticipantControlDeclarationModel], + declarations_by_ref: DeclarationIndex, proposal_records: dict[str, ParticipantControlOccurrenceModel], target_contexts: TargetIndex, ) -> None: occurrence = record.occurrence - declaration = declarations_by_ref.get(occurrence.declaration_ref) + declaration = declarations_by_ref.get( + ( + occurrence.declaration_ref, + record.participant_address, + record.episode_id, + ) + ) if declaration is None: raise ValueError("declaration reference must resolve") if not _declaration_agrees(record, declaration): raise ValueError("occurrence and declaration coordinates disagree") + if occurrence.disposition is ParticipantControlDisposition.REJECTED: + return if isinstance(occurrence, ParticipantProposalOccurrenceModel): _validate_transformed_proposal(record, proposal_records) elif isinstance(occurrence, (ParticipantApprovalOccurrenceModel, ParticipantDenialOccurrenceModel)): diff --git a/implementations/python/packages/raes_contracts/contracts/participant_manifests.py b/implementations/python/packages/raes_contracts/contracts/participant_manifests.py index 2f5d04b96..404e8e59e 100644 --- a/implementations/python/packages/raes_contracts/contracts/participant_manifests.py +++ b/implementations/python/packages/raes_contracts/contracts/participant_manifests.py @@ -4,7 +4,7 @@ from typing import Annotated, Any, Literal -from pydantic import Field, GetJsonSchemaHandler, model_validator +from pydantic import Field, GetJsonSchemaHandler, SerializerFunctionWrapHandler, model_serializer, model_validator from pydantic.json_schema import JsonSchemaValue from pydantic_core import CoreSchema @@ -31,6 +31,7 @@ BackendCompatibilityModel, RealizationSupportDeclarationModel, ) +from .experiment_bindings import ConfigurationTargetRegistryModel from .manifests import BackendCapabilitiesV2Model, ConceptBindingEntryModel from .realization_plans import RealizationEnvelopeIdentityModel from .validators import ( @@ -50,10 +51,16 @@ class BackendManifestV2Model(ContractModel): concept_bindings: list[ConceptBindingEntryModel] = Field(min_length=1) constraints: dict[str, str] = Field(default_factory=dict) capabilities: BackendCapabilitiesV2Model + configuration_registry: ConfigurationTargetRegistryModel | None = None @model_validator(mode="after") def _validate_unique_binding_scopes(self) -> BackendManifestV2Model: validate_backend_supported_contract_versions(self.supported_contract_versions) + if ( + self.configuration_registry is not None + and "experiment-binding-descriptors-v1" not in self.supported_contract_versions + ): + raise ValueError("configuration_registry requires experiment-binding-descriptors-v1 support") self._validate_realization_envelope_contract() self._validate_cleanup_contracts() self._validate_time_contracts() @@ -104,6 +111,16 @@ def _validate_concept_bindings(self) -> None: raise ValueError("concept_bindings must not contain duplicate scopes") _validate_canonical_concept_bindings(self, allowed_scopes=_BACKEND_CONCEPT_BINDING_SCOPES) + @model_serializer(mode="wrap") + def _serialize_optional_configuration_registry( + self, + handler: SerializerFunctionWrapHandler, + ) -> dict[str, object]: + payload = handler(self) + if self.configuration_registry is None: + payload.pop("configuration_registry", None) + return payload + @classmethod def __get_pydantic_json_schema__( cls, @@ -230,6 +247,7 @@ class ParticipantImplementationManifestModel(ContractModel): concept_bindings: list[ConceptBindingEntryModel] = Field(min_length=1) constraints: dict[str, str] = Field(default_factory=dict) capabilities: ParticipantImplementationCapabilitiesModel + configuration_registry: ConfigurationTargetRegistryModel | None = None @model_validator(mode="after") def _validate_participant_implementation_manifest(self) -> ParticipantImplementationManifestModel: @@ -244,6 +262,14 @@ def _validate_participant_implementation_manifest(self) -> ParticipantImplementa raise ValueError( "supported_participant_contracts must be declared in supported_contract_versions: " + joined ) + if self.configuration_registry is not None: + required_configuration_contracts = { + "experiment-binding-descriptors-v1", + "participant-configuration-result-v1", + } + missing = sorted(required_configuration_contracts - set(self.supported_contract_versions)) + if missing: + raise ValueError("configuration_registry requires supported_contract_versions: " + ", ".join(missing)) scopes = [binding.scope for binding in self.concept_bindings] if len(scopes) != len(set(scopes)): raise ValueError("concept_bindings must not contain duplicate scopes") @@ -253,6 +279,16 @@ def _validate_participant_implementation_manifest(self) -> ParticipantImplementa ) return self + @model_serializer(mode="wrap") + def _serialize_optional_configuration_registry( + self, + handler: SerializerFunctionWrapHandler, + ) -> dict[str, object]: + payload = handler(self) + if self.configuration_registry is None: + payload.pop("configuration_registry", None) + return payload + @classmethod def __get_pydantic_json_schema__( cls, @@ -316,6 +352,8 @@ class ParticipantImplementationSelectionModel(ContractModel): @model_validator(mode="after") def _validate_participant_implementation_selection(self) -> ParticipantImplementationSelectionModel: + if (self.configuration_ref is None) != (self.configuration_digest is None): + raise ValueError("configuration_ref and configuration_digest must be supplied together") _validate_unique_string_values("participant_contract_versions", self.participant_contract_versions) validate_participant_supported_contract_versions(self.participant_contract_versions) _validate_controlled_vocabulary_terms( @@ -324,6 +362,33 @@ def _validate_participant_implementation_selection(self) -> ParticipantImplement ) 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("oneOf", []).extend( + [ + { + "required": ["configuration_ref", "configuration_digest"], + "properties": { + "configuration_ref": {"not": {"type": "null"}}, + "configuration_digest": {"not": {"type": "null"}}, + }, + }, + { + "properties": { + "configuration_ref": {"type": "null"}, + "configuration_digest": {"type": "null"}, + } + }, + ] + ) + return json_schema + class ParticipantImplementationProvenanceModel(ContractModel): schema_version: Literal[PARTICIPANT_IMPLEMENTATION_PROVENANCE_V1_SCHEMA_VERSION] = ( diff --git a/implementations/python/packages/raes_contracts/contracts/realization_plans.py b/implementations/python/packages/raes_contracts/contracts/realization_plans.py index ebf6a1676..3ffe03584 100644 --- a/implementations/python/packages/raes_contracts/contracts/realization_plans.py +++ b/implementations/python/packages/raes_contracts/contracts/realization_plans.py @@ -18,6 +18,7 @@ WorkflowExecutionStateModel, WorkflowHistoryEventModel, ) +from .participant_control import ParticipantControlOccurrenceModel from .participant_envelopes import ( ParticipantJointActionRecordModel, ParticipantSharedStateRecordModel, @@ -165,6 +166,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) + participant_control_history: dict[str, list[ParticipantControlOccurrenceModel]] = Field(default_factory=dict) participant_autonomous_execution_states: dict[str, ParticipantAutonomousExecutionStateModel] = Field( default_factory=dict ) diff --git a/implementations/python/packages/raes_contracts/experiment_bindings.py b/implementations/python/packages/raes_contracts/experiment_bindings.py new file mode 100644 index 000000000..3cdc2b901 --- /dev/null +++ b/implementations/python/packages/raes_contracts/experiment_bindings.py @@ -0,0 +1,177 @@ +"""Cross-artifact admission for authoritative experiment binding targets.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Literal, Protocol + +from .contracts.base import ContractModel, NonEmptyString +from .contracts.experiment_bindings import ( + ApparatusBindingTargetModel, + BindingOwnerModel, + BindingScalarType, + ConfigurationTargetDeclarationModel, + ExperimentBindingDescriptorModel, + ExperimentBindingDescriptorSetModel, + ParticipantImplementationBindingTargetModel, + ScenarioBindingTargetModel, +) +from .contracts.manifests import ProcessorManifestV2Model +from .contracts.participant_manifests import ( + BackendManifestV2Model, + ParticipantImplementationManifestModel, +) + +ParticipantManifestKey = tuple[str, str, str, str] +ApparatusManifestKey = tuple[str, str, str, str] +ApparatusManifest = ProcessorManifestV2Model | BackendManifestV2Model + + +class ScenarioBindingResolution(ContractModel): + """Canonical SDL target result returned by the public variation authority.""" + + canonical_target_id: NonEmptyString + value_type: BindingScalarType + allowed_value_kinds: list[Literal["literal", "secret-reference"]] + sensitivity: Literal["public", "internal", "restricted", "secret"] + owner: BindingOwnerModel + + +class ScenarioBindingTargetResolver(Protocol): + """Public SDL variation-target resolution seam used during admission.""" + + def resolve( + self, + scenario_family_id: str, + variation_point_id: str, + supplied_target_id: str, + ) -> ScenarioBindingResolution: ... + + +def validate_experiment_binding_targets( + descriptors: ExperimentBindingDescriptorSetModel, + *, + scenario_resolver: ScenarioBindingTargetResolver, + participant_manifests: Mapping[ParticipantManifestKey, ParticipantImplementationManifestModel], + apparatus_manifests: Mapping[ApparatusManifestKey, ApparatusManifest], +) -> ExperimentBindingDescriptorSetModel: + """Resolve every descriptor through exactly one plane owner before admission.""" + + admitted: list[ExperimentBindingDescriptorModel] = [] + for descriptor in descriptors.descriptors: + target = descriptor.target + if isinstance(target, ScenarioBindingTargetModel): + canonical_target = _resolve_scenario_target(descriptor, target, scenario_resolver) + elif isinstance(target, ParticipantImplementationBindingTargetModel): + canonical_target = _resolve_participant_target(descriptor, target, participant_manifests) + elif isinstance(target, ApparatusBindingTargetModel): + canonical_target = _resolve_apparatus_target(descriptor, target, apparatus_manifests) + # The discriminated union is closed before dispatch. + else: + raise ValueError("unknown binding plane") + admitted.append(descriptor.model_copy(update={"target": canonical_target})) + return ExperimentBindingDescriptorSetModel( + schema_version=descriptors.schema_version, + descriptors=admitted, + ) + + +def _resolve_scenario_target( + descriptor: ExperimentBindingDescriptorModel, + target: ScenarioBindingTargetModel, + resolver: ScenarioBindingTargetResolver, +) -> ScenarioBindingTargetModel: + resolution = resolver.resolve( + target.scenario_family_id, + target.variation_point_id, + target.target_id, + ) + if resolution.value_type != descriptor.value_type: + raise ValueError("scenario target value_type does not match binding descriptor") + if resolution.owner != descriptor.owner: + raise ValueError("scenario target owner does not match binding descriptor") + if descriptor.value.kind not in resolution.allowed_value_kinds: + raise ValueError("scenario target rejects the binding value kind") + if resolution.sensitivity == "secret" and descriptor.value.kind != "secret-reference": + raise ValueError("secret scenario targets admit only secret-reference values") + return target.model_copy(update={"target_id": resolution.canonical_target_id}) + + +def _resolve_participant_target( + descriptor: ExperimentBindingDescriptorModel, + target: ParticipantImplementationBindingTargetModel, + manifests: Mapping[ParticipantManifestKey, ParticipantImplementationManifestModel], +) -> ParticipantImplementationBindingTargetModel: + key = ( + target.participant_address, + target.implementation_name, + target.implementation_version, + target.manifest_version, + ) + manifest = manifests.get(key) + if manifest is None: + raise ValueError("participant binding target owner must resolve to the selected manifest") + if ( + manifest.identity.name != target.implementation_name + or manifest.identity.version != target.implementation_version + or manifest.schema_version != target.manifest_version + ): + raise ValueError("participant binding target identity must match the resolved manifest") + registry = manifest.configuration_registry + if registry is None: + raise ValueError("selected participant manifest has no configuration target registry") + declaration = registry.resolve(target.target_id) + _validate_declared_target(descriptor, declaration, registry.owner) + return target.model_copy(update={"target_id": declaration.target_id}) + + +def _resolve_apparatus_target( + descriptor: ExperimentBindingDescriptorModel, + target: ApparatusBindingTargetModel, + manifests: Mapping[ApparatusManifestKey, ApparatusManifest], +) -> ApparatusBindingTargetModel: + key = ( + target.component_kind, + target.component_name, + target.component_version, + target.manifest_version, + ) + manifest = manifests.get(key) + if manifest is None: + raise ValueError("apparatus binding target owner must resolve to the selected manifest") + manifest_kind = "processor" if isinstance(manifest, ProcessorManifestV2Model) else "backend" + if ( + target.component_kind != manifest_kind + or manifest.identity.name != target.component_name + or manifest.identity.version != target.component_version + or manifest.schema_version != target.manifest_version + ): + raise ValueError("apparatus binding target identity and kind must match the resolved manifest") + registry = manifest.configuration_registry + if registry is None: + raise ValueError("selected apparatus manifest has no configuration target registry") + declaration = registry.resolve(target.target_id) + _validate_declared_target(descriptor, declaration, registry.owner) + return target.model_copy(update={"target_id": declaration.target_id}) + + +def _validate_declared_target( + descriptor: ExperimentBindingDescriptorModel, + declaration: ConfigurationTargetDeclarationModel, + owner: BindingOwnerModel, +) -> None: + if descriptor.owner != owner: + raise ValueError("configuration target owner does not match binding descriptor") + if descriptor.value_type != declaration.value_type: + raise ValueError("configuration target value_type does not match binding descriptor") + declaration.validate_value(descriptor.value) + + +__all__ = [ + "ApparatusManifest", + "ApparatusManifestKey", + "ParticipantManifestKey", + "ScenarioBindingResolution", + "ScenarioBindingTargetResolver", + "validate_experiment_binding_targets", +] diff --git a/implementations/python/packages/raes_contracts/manifest_authority.py b/implementations/python/packages/raes_contracts/manifest_authority.py index b4f4cd631..ac265c7d8 100644 --- a/implementations/python/packages/raes_contracts/manifest_authority.py +++ b/implementations/python/packages/raes_contracts/manifest_authority.py @@ -12,6 +12,7 @@ # separate authority surfaces and do not belong in this declaration field. PROCESSOR_SUPPORTED_CONTRACT_IDS = ( "processor-manifest-v2", + "experiment-binding-descriptors-v1", "provisioning-plan-v1", "orchestration-plan-v1", "evaluation-plan-v1", @@ -36,6 +37,7 @@ # separate authority surfaces and do not belong in this declaration field. BACKEND_SUPPORTED_CONTRACT_IDS = ( "backend-manifest-v2", + "experiment-binding-descriptors-v1", "realization-envelope-v1", "provisioning-plan-v1", "orchestration-plan-v1", @@ -71,6 +73,8 @@ PARTICIPANT_IMPLEMENTATION_SUPPORTED_CONTRACT_IDS = ( "participant-implementation-manifest-v1", "participant-implementation-provenance-v1", + "experiment-binding-descriptors-v1", + "participant-configuration-result-v1", "participant-episode-state-envelope-v1", "participant-episode-history-event-stream-v1", "participant-behavior-history-event-stream-v1", diff --git a/implementations/python/packages/raes_contracts/participant_configuration.py b/implementations/python/packages/raes_contracts/participant_configuration.py new file mode 100644 index 000000000..589cfa9c9 --- /dev/null +++ b/implementations/python/packages/raes_contracts/participant_configuration.py @@ -0,0 +1,166 @@ +"""Atomic realization of manifest-declared participant configuration.""" + +from __future__ import annotations + +from typing import Protocol + +from pydantic import Field + +from .contracts.base import ContractModel, NonEmptyString, PrefixedDigestString +from .contracts.experiment_bindings import ( + BindingValue, + ConfigurationTargetDeclarationModel, + ParticipantConfigurationModel, + ParticipantConfigurationResultModel, + RealizedConfigurationValueModel, +) +from .contracts.participant_manifests import ( + ParticipantImplementationManifestModel, + ParticipantImplementationSelectionModel, +) +from .satisfiability import canonical_contract_digest + + +class ConfigurationOverrideModel(ContractModel): + """One author-supplied override using a canonical target id or declared alias.""" + + target_id: NonEmptyString + value: BindingValue = Field(discriminator="kind") + + +class ParticipantConfigurationValidator(Protocol): + """Trusted owner hook for complete same-type normalization and validation.""" + + def validate_and_normalize( + self, + configuration: ParticipantConfigurationModel, + ) -> ParticipantConfigurationModel: ... + + +def realize_participant_configuration( + *, + participant_address: str, + manifest: ParticipantImplementationManifestModel, + manifest_ref: str, + manifest_digest: PrefixedDigestString, + overrides: list[ConfigurationOverrideModel], + validator: ParticipantConfigurationValidator | None = None, +) -> ParticipantConfigurationResultModel: + """Validate a complete configuration and return one atomic normalized result.""" + + registry = manifest.configuration_registry + if registry is None: + raise ValueError("participant implementation manifest has no configuration target registry") + + overrides_by_target: dict[str, ConfigurationOverrideModel] = {} + for override in overrides: + declaration = registry.resolve(override.target_id) + canonical_target = declaration.target_id + if canonical_target in overrides_by_target: + raise ValueError(f"duplicate canonical target {canonical_target!r} in configuration overrides") + declaration.validate_value(override.value) + overrides_by_target[canonical_target] = override + + realized_values: list[RealizedConfigurationValueModel] = [] + for target_id in sorted(registry.targets): + declaration = registry.targets[target_id] + override = overrides_by_target.get(target_id) + if override is not None: + value = override.value + origin = "override" + elif declaration.default is not None: + value = declaration.default + origin = "default" + else: + raise ValueError(f"required configuration target {target_id!r} has no override") + declaration.validate_value(value) + realized_values.append( + RealizedConfigurationValueModel( + target_id=target_id, + value_type=declaration.value_type, + origin=origin, + value=value, + ) + ) + + configuration = ParticipantConfigurationModel( + implementation_identity=manifest.identity, + manifest_version=manifest.schema_version, + owner=registry.owner, + values=realized_values, + ) + if validator is not None: + normalized = validator.validate_and_normalize(configuration) + configuration = _validate_owner_normalization(configuration, normalized, registry.targets) + + digest = canonical_contract_digest(configuration) + return ParticipantConfigurationResultModel( + participant_address=participant_address, + manifest_ref=manifest_ref, + manifest_digest=manifest_digest, + configuration=configuration, + configuration_digest=digest, + ) + + +def _validate_owner_normalization( + original: ParticipantConfigurationModel, + normalized: ParticipantConfigurationModel, + declarations: dict[str, ConfigurationTargetDeclarationModel], +) -> ParticipantConfigurationModel: + if ( + normalized.implementation_identity != original.implementation_identity + or normalized.manifest_version != original.manifest_version + or normalized.owner != original.owner + ): + raise ValueError("participant configuration validator must preserve owner and manifest identity") + if [entry.target_id for entry in normalized.values] != [entry.target_id for entry in original.values]: + raise ValueError("participant configuration validator must preserve the complete canonical target set") + original_by_target = {entry.target_id: entry for entry in original.values} + for entry in normalized.values: + _validate_normalized_entry( + entry, + original_by_target[entry.target_id], + declarations[entry.target_id], + ) + return normalized + + +def _validate_normalized_entry( + entry: RealizedConfigurationValueModel, + original_entry: RealizedConfigurationValueModel, + declaration: ConfigurationTargetDeclarationModel, +) -> None: + if entry.origin != original_entry.origin: + raise ValueError("participant configuration validator must preserve default/override origin") + if entry.value_type != declaration.value_type: + raise ValueError("participant configuration validator must preserve declared value types") + if entry.value.kind != original_entry.value.kind: + raise ValueError("participant configuration validator must preserve literal/secret-reference disposition") + if entry.value.kind == "secret-reference" and entry.value != original_entry.value: + raise ValueError("participant configuration validator must preserve secret-reference identity") + declaration.validate_value(entry.value) + + +def validate_participant_configuration_selection( + selection: ParticipantImplementationSelectionModel, + result: ParticipantConfigurationResultModel, +) -> None: + """Verify that a participant selection names one authoritative configuration result.""" + + if selection.participant_address != result.participant_address: + raise ValueError("participant selection address does not match configuration result") + if selection.implementation_identity != result.configuration.implementation_identity: + raise ValueError("participant selection implementation identity does not match configuration result") + if selection.manifest_ref != result.manifest_ref or selection.manifest_digest != result.manifest_digest: + raise ValueError("participant selection manifest identity does not match configuration result") + if selection.configuration_digest != result.configuration_digest: + raise ValueError("participant selection configuration digest does not match authoritative result") + + +__all__ = [ + "ConfigurationOverrideModel", + "ParticipantConfigurationValidator", + "realize_participant_configuration", + "validate_participant_configuration_selection", +] diff --git a/implementations/python/packages/raes_contracts/participant_control_history.py b/implementations/python/packages/raes_contracts/participant_control_history.py new file mode 100644 index 000000000..85d5203bc --- /dev/null +++ b/implementations/python/packages/raes_contracts/participant_control_history.py @@ -0,0 +1,70 @@ +"""RUN-310 append-only participant control-history invariants.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +from pydantic import ValidationError + +from .contracts import ParticipantControlOccurrenceModel + + +def iter_participant_control_history_snapshot_violations( + history: Mapping[str, Sequence[dict[str, object]]], +) -> list[tuple[str, str]]: + """Return value-safe violations for one complete control-history snapshot.""" + + violations: list[tuple[str, str]] = [] + seen_event_ids: set[str] = set() + for participant_address, events in history.items(): + address = f"runtime.snapshot.participant-control-history.{participant_address}" + for index, payload in enumerate(events): + try: + event = ParticipantControlOccurrenceModel.model_validate(payload) + except (TypeError, ValidationError): + violations.append((address, "participant control history event is not a valid API-409 occurrence")) + continue + if event.participant_address != participant_address: + violations.append( + ( + address, + "participant control history map key must equal the embedded participant_address", + ) + ) + expected_revision = index + 1 + if event.occurrence.occurrence_revision != expected_revision: + violations.append( + ( + address, + "participant control occurrence_revision must be contiguous and match append order", + ) + ) + if event.event_id in seen_event_ids: + violations.append((address, "participant control event identity must be globally unique")) + seen_event_ids.add(event.event_id) + return violations + + +def iter_participant_control_history_transition_violations( + previous: Mapping[str, Sequence[dict[str, object]]], + next_history: Mapping[str, Sequence[dict[str, object]]], +) -> list[tuple[str, str]]: + """Return violations when a control-history transition rewrites prior facts.""" + + violations: list[tuple[str, str]] = [] + for participant_address, previous_events in previous.items(): + next_events = next_history.get(participant_address, ()) + if list(next_events[: len(previous_events)]) != list(previous_events): + violations.append( + ( + f"runtime.snapshot.participant-control-history.{participant_address}", + "participant control history must preserve its append-only prefix", + ) + ) + return violations + + +__all__ = ( + "iter_participant_control_history_snapshot_violations", + "iter_participant_control_history_transition_violations", +) diff --git a/implementations/python/packages/raes_contracts/runtime_state.py b/implementations/python/packages/raes_contracts/runtime_state.py index 63b930289..40f3b396a 100644 --- a/implementations/python/packages/raes_contracts/runtime_state.py +++ b/implementations/python/packages/raes_contracts/runtime_state.py @@ -84,6 +84,7 @@ 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) + participant_control_history: dict[str, list[dict[str, Any]]] = field(default_factory=dict) participant_autonomous_execution_states: dict[str, dict[str, Any]] = field(default_factory=dict) shared_state_records: dict[str, dict[str, Any]] = field(default_factory=dict) shared_state_history: dict[str, list[dict[str, Any]]] = field(default_factory=dict) @@ -149,6 +150,11 @@ def with_entries( "participant_behavior_history", self.participant_behavior_history, ), + participant_control_history=_history_update( + updates, + "participant_control_history", + self.participant_control_history, + ), participant_autonomous_execution_states=_mapping_update( updates, "participant_autonomous_execution_states", @@ -202,6 +208,7 @@ def with_entries( "participant_episode_results", "participant_episode_history", "participant_behavior_history", + "participant_control_history", "participant_autonomous_execution_states", "shared_state_records", "shared_state_history", diff --git a/implementations/python/packages/raes_contracts/versions.py b/implementations/python/packages/raes_contracts/versions.py index 65b3abe37..8ca7db44d 100644 --- a/implementations/python/packages/raes_contracts/versions.py +++ b/implementations/python/packages/raes_contracts/versions.py @@ -44,6 +44,8 @@ EXPERIMENT_EVIDENCE_RECORD_SCHEMA_VERSION = "experiment-evidence-record/v1" EXPERIMENT_DERIVED_MEASURE_SCHEMA_VERSION = "experiment-derived-measure/v1" EXPERIMENT_AUTHORING_INPUT_SCHEMA_VERSION = "experiment-authoring-input/v1" +EXPERIMENT_BINDING_DESCRIPTORS_V1_SCHEMA_VERSION = "experiment-binding-descriptors/v1" +PARTICIPANT_CONFIGURATION_RESULT_V1_SCHEMA_VERSION = "participant-configuration-result/v1" REUSABLE_ASSET_TRUST_POLICY_SCHEMA_VERSION = "reusable-asset-trust-policy/v1" ASSOCIATED_ARTIFACT_MANIFEST_SCHEMA_VERSION = "associated-artifact-manifest/v1" RANDOM_STREAM_PROFILE_SCHEMA_VERSION = "random-stream-profile/v1" diff --git a/implementations/python/packages/raes_reference_backend/manifest.py b/implementations/python/packages/raes_reference_backend/manifest.py index 5c597db3b..27d05e091 100644 --- a/implementations/python/packages/raes_reference_backend/manifest.py +++ b/implementations/python/packages/raes_reference_backend/manifest.py @@ -39,7 +39,9 @@ REFERENCE_BACKEND_NAME = "reference-emulation" REFERENCE_BACKEND_SUPPORTED_CONTRACT_VERSIONS = frozenset( - contract_id for contract_id in BACKEND_SUPPORTED_CONTRACT_IDS if contract_id != "realization-envelope-v1" + contract_id + for contract_id in BACKEND_SUPPORTED_CONTRACT_IDS + if contract_id not in {"experiment-binding-descriptors-v1", "realization-envelope-v1"} ) _TIME_DEDICATED_CONTRACT_VERSIONS = frozenset({"time-model-v1", "time-runtime-state-v1", "realized-time-model-v1"}) diff --git a/implementations/python/packages/raes_runtime/control_plane.py b/implementations/python/packages/raes_runtime/control_plane.py index 691a287b3..d4070c514 100644 --- a/implementations/python/packages/raes_runtime/control_plane.py +++ b/implementations/python/packages/raes_runtime/control_plane.py @@ -8,7 +8,9 @@ from __future__ import annotations +from collections.abc import Mapping from datetime import UTC, datetime +from threading import RLock from uuid import uuid4 from raes_backend_protocols.backend_manifest import BackendManifest @@ -41,6 +43,7 @@ WorkflowHistoryEventType, WorkflowStatus, ) +from raes_processor.models import ParticipantBehaviorSpecificationRuntime from .backend_calls import _call_backend_diagnostics from .control_plane_execution import ( @@ -209,11 +212,14 @@ def __init__( *, initial_snapshot: RuntimeSnapshot | None = None, store: ControlPlaneStore | None = None, + behavior_specifications: Mapping[str, ParticipantBehaviorSpecificationRuntime] | None = None, ) -> None: self._target = target self._store = store or InMemoryControlPlaneStore(initial_snapshot) self._snapshot = initial_snapshot if initial_snapshot is not None else self._store.load_snapshot() self._operations: dict[str, ControlPlaneOperationRecord] = self._store.load_records() + self._behavior_specifications = dict(behavior_specifications or {}) + self._participant_control_lock = RLock() @property def snapshot(self) -> RuntimeSnapshot: diff --git a/implementations/python/packages/raes_runtime/control_plane_api.py b/implementations/python/packages/raes_runtime/control_plane_api.py index a064fbcff..9102bd53d 100644 --- a/implementations/python/packages/raes_runtime/control_plane_api.py +++ b/implementations/python/packages/raes_runtime/control_plane_api.py @@ -42,6 +42,7 @@ ControlPlaneRole, ControlPlaneSecurityConfig, ) +from .participant_control_intents import ParticipantControlIntent _CONFLICT_RESPONSES = {409: {"description": "Conflict"}} _NOT_FOUND_RESPONSES = {404: {"description": "Not found"}} @@ -181,6 +182,7 @@ def create_control_plane_app( _register_operation_routes(app, control_plane) _register_workflow_routes(app, control_plane) _register_participant_episode_routes(app, control_plane) + _register_participant_control_routes(app, control_plane) register_participant_retrieval_routes(app, control_plane) return app @@ -423,6 +425,44 @@ def _register_participant_episode_routes( _register_participant_episode_end_routes(app, control_plane) +def _register_participant_control_routes( + app: FastAPI, + control_plane: RuntimeControlPlane, +) -> None: + @app.post( + "/participants/{participant_address}/control-occurrences", + responses=_BAD_REQUEST_CONFLICT_RESPONSES, + ) + async def record_participant_control( + participant_address: str, + request: Request, + body: ParticipantControlIntent, + identity: _MutatingIdentity, + ) -> OperationReceiptModel: + try: + receipt = control_plane.record_participant_control( + participant_address, + body, + identity=identity, + idempotency_key=request.headers.get("idempotency-key", ""), + ) + except PermissionError as exc: + control_plane.record_audit( + action="record_participant_control", + identity=identity.identity, + allowed=False, + target=participant_address, + reason="forbidden-subject", + ) + raise HTTPException(status_code=403, detail="forbidden") from exc + except ValueError as exc: + raise HTTPException( + status_code=409, + detail="control intent conflicts with runtime state", + ) from exc + return _receipt_response(receipt) + + def _register_participant_episode_start_routes( app: FastAPI, control_plane: RuntimeControlPlane, diff --git a/implementations/python/packages/raes_runtime/control_plane_api_models.py b/implementations/python/packages/raes_runtime/control_plane_api_models.py index 1d1de0a81..036c60703 100644 --- a/implementations/python/packages/raes_runtime/control_plane_api_models.py +++ b/implementations/python/packages/raes_runtime/control_plane_api_models.py @@ -156,6 +156,7 @@ def _snapshot_model(envelope: RuntimeSnapshotEnvelope) -> RuntimeSnapshotEnvelop "participant_episode_results": dict(snapshot.participant_episode_results), "participant_episode_history": dict(snapshot.participant_episode_history), "participant_behavior_history": dict(snapshot.participant_behavior_history), + "participant_control_history": dict(snapshot.participant_control_history), "participant_autonomous_execution_states": dict(snapshot.participant_autonomous_execution_states), "shared_state_records": dict(snapshot.shared_state_records), "shared_state_history": dict(snapshot.shared_state_history), diff --git a/implementations/python/packages/raes_runtime/control_plane_security.py b/implementations/python/packages/raes_runtime/control_plane_security.py index ca5cfe195..fea491caa 100644 --- a/implementations/python/packages/raes_runtime/control_plane_security.py +++ b/implementations/python/packages/raes_runtime/control_plane_security.py @@ -14,6 +14,18 @@ class ControlPlaneRole(str, Enum): AUDITOR = "auditor" +@dataclass(frozen=True) +class ParticipantControlSubjectBinding: + """One authenticated principal-to-participant/controller binding.""" + + participant_address: str + controller_ref: str + + def __post_init__(self) -> None: + if not self.participant_address or not self.controller_ref: + raise ValueError("participant control subject binding fields must be non-empty") + + @dataclass(frozen=True) class ControlPlaneIdentity: """Authenticated control-plane principal.""" @@ -21,6 +33,7 @@ class ControlPlaneIdentity: identity: str roles: frozenset[ControlPlaneRole] = field(default_factory=frozenset) target_name: str | None = None + participant_control_subjects: tuple[ParticipantControlSubjectBinding, ...] = () @dataclass(frozen=True) diff --git a/implementations/python/packages/raes_runtime/control_plane_store.py b/implementations/python/packages/raes_runtime/control_plane_store.py index 9385836c0..599cf56ce 100644 --- a/implementations/python/packages/raes_runtime/control_plane_store.py +++ b/implementations/python/packages/raes_runtime/control_plane_store.py @@ -2,13 +2,9 @@ from __future__ import annotations -import json import os -import tempfile -from contextlib import suppress -from dataclasses import asdict, dataclass, field -from pathlib import Path -from typing import Any, Protocol +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Protocol from raes_contracts.contracts import RealizationEnvelopeIdentityModel from raes_contracts.contracts.time_model import TimeRuntimeStateModel @@ -27,6 +23,9 @@ SnapshotEntry, ) +if TYPE_CHECKING: + from .control_plane_store_local import LocalControlPlaneStore + @dataclass(frozen=True) class AuditEvent: @@ -72,6 +71,33 @@ def append_audit(self, event: AuditEvent) -> None: ... def read_audit(self) -> list[AuditEvent]: ... + def commit_control_transition( + self, + *, + participant_address: str, + expected_head: str | None, + snapshot: RuntimeSnapshot, + record: ControlPlaneOperationRecord, + audit_event: AuditEvent, + ) -> None: ... + + +def _control_history_head(snapshot: RuntimeSnapshot, participant_address: str) -> str | None: + events = snapshot.participant_control_history.get(participant_address, ()) + if not events: + return None + event_id = events[-1].get("event_id") + return event_id if isinstance(event_id, str) and event_id else None + + +def _require_expected_control_head( + snapshot: RuntimeSnapshot, + participant_address: str, + expected_head: str | None, +) -> None: + if _control_history_head(snapshot, participant_address) != expected_head: + raise ValueError("expected control history head does not match durable state") + def _snapshot_payload(snapshot: RuntimeSnapshot) -> dict[str, Any]: require_participant_autonomous_runtime_snapshot(snapshot) @@ -103,6 +129,10 @@ def _snapshot_payload(snapshot: RuntimeSnapshot) -> dict[str, Any]: participant_address: list(events) for participant_address, events in snapshot.participant_behavior_history.items() }, + "participant_control_history": { + participant_address: list(events) + for participant_address, events in snapshot.participant_control_history.items() + }, "participant_autonomous_execution_states": dict(snapshot.participant_autonomous_execution_states), "shared_state_records": dict(snapshot.shared_state_records), "shared_state_history": { @@ -165,6 +195,10 @@ def _snapshot_from_payload(payload: dict[str, Any]) -> RuntimeSnapshot: participant_address: list(events) for participant_address, events in payload.get("participant_behavior_history", {}).items() }, + participant_control_history={ + participant_address: list(events) + for participant_address, events in payload.get("participant_control_history", {}).items() + }, participant_autonomous_execution_states=dict(payload.get("participant_autonomous_execution_states", {})), shared_state_records=dict(payload.get("shared_state_records", {})), shared_state_history={ @@ -283,6 +317,19 @@ def _record_from_payload(payload: dict[str, Any]) -> ControlPlaneOperationRecord ) +def _audit_event_from_payload(payload: dict[str, Any]) -> AuditEvent: + return AuditEvent( + timestamp=str(payload.get("timestamp", "")), + action=str(payload.get("action", "")), + identity=str(payload.get("identity", "")), + allowed=bool(payload.get("allowed", False)), + target=str(payload.get("target", "")), + operation_id=str(payload.get("operation_id", "")), + reason=str(payload.get("reason", "")), + details=dict(payload.get("details", {})), + ) + + class InMemoryControlPlaneStore: """Simple in-memory store.""" @@ -322,91 +369,42 @@ def append_audit(self, event: AuditEvent) -> None: def read_audit(self) -> list[AuditEvent]: return list(self._audit) + def commit_control_transition( + self, + *, + participant_address: str, + expected_head: str | None, + snapshot: RuntimeSnapshot, + record: ControlPlaneOperationRecord, + audit_event: AuditEvent, + ) -> None: + _require_expected_control_head(self._snapshot, participant_address, expected_head) + require_participant_autonomous_runtime_snapshot(snapshot) + records = {**self._records, record.receipt.operation_id: record} + idempotency = dict(self._idempotency) + if record.idempotency_key: + idempotency[record.idempotency_key] = record.receipt.operation_id + self._snapshot = snapshot + self._records = records + self._idempotency = idempotency + self._audit = [*self._audit, audit_event] -class LocalControlPlaneStore: - """Filesystem-backed control-plane durability.""" - - def __init__(self, base_dir: Path) -> None: - self._base_dir = base_dir - self._base_dir.mkdir(parents=True, exist_ok=True) - self._snapshot_path = self._base_dir / "snapshot.json" - self._operations_path = self._base_dir / "operations.json" - self._audit_path = self._base_dir / "audit.jsonl" - - @staticmethod - def _atomic_write(path: Path, content: str) -> None: - """Write content atomically via a temporary file and os.replace.""" - fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") - try: - with os.fdopen(fd, "w", encoding="utf-8") as handle: - handle.write(content) - os.replace(tmp, path) - except BaseException: - with suppress(OSError): - os.unlink(tmp) - raise - - def load_snapshot(self) -> RuntimeSnapshot: - if not self._snapshot_path.exists(): - return RuntimeSnapshot() - payload = json.loads(self._snapshot_path.read_text(encoding="utf-8")) - return _snapshot_from_payload(payload) - - def save_snapshot(self, snapshot: RuntimeSnapshot) -> None: - content = json.dumps(_snapshot_payload(snapshot), indent=2, sort_keys=True) + "\n" - self._atomic_write(self._snapshot_path, content) - def load_records(self) -> dict[str, ControlPlaneOperationRecord]: - if not self._operations_path.exists(): - return {} - payload = json.loads(self._operations_path.read_text(encoding="utf-8")) - return { - operation_id: _record_from_payload(record_payload) - for operation_id, record_payload in payload.items() - if isinstance(record_payload, dict) - } +def __getattr__(name: str) -> object: + """Lazily expose the local store without creating an import cycle.""" - def save_record(self, record: ControlPlaneOperationRecord) -> None: - records = self.load_records() - records[record.receipt.operation_id] = record - payload = { - operation_id: _record_payload(operation_record) for operation_id, operation_record in records.items() - } - content = json.dumps(payload, indent=2, sort_keys=True) + "\n" - self._atomic_write(self._operations_path, content) + if name == "LocalControlPlaneStore": + from .control_plane_store_local import LocalControlPlaneStore - def find_by_idempotency( - self, - key: str, - ) -> ControlPlaneOperationRecord | None: - for record in self.load_records().values(): - if record.idempotency_key == key: - return record - return None + return LocalControlPlaneStore + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - def append_audit(self, event: AuditEvent) -> None: - self._audit_path.parent.mkdir(parents=True, exist_ok=True) - with self._audit_path.open("a", encoding="utf-8") as handle: - handle.write(json.dumps(asdict(event), sort_keys=True) + "\n") - def read_audit(self) -> list[AuditEvent]: - if not self._audit_path.exists(): - return [] - events: list[AuditEvent] = [] - for line in self._audit_path.read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - payload = json.loads(line) - events.append( - AuditEvent( - timestamp=str(payload.get("timestamp", "")), - action=str(payload.get("action", "")), - identity=str(payload.get("identity", "")), - allowed=bool(payload.get("allowed", False)), - target=str(payload.get("target", "")), - operation_id=str(payload.get("operation_id", "")), - reason=str(payload.get("reason", "")), - details=dict(payload.get("details", {})), - ) - ) - return events +__all__ = ( + "AuditEvent", + "ControlPlaneOperationRecord", + "ControlPlaneStore", + "InMemoryControlPlaneStore", + "LocalControlPlaneStore", + "os", +) diff --git a/implementations/python/packages/raes_runtime/control_plane_store_local.py b/implementations/python/packages/raes_runtime/control_plane_store_local.py new file mode 100644 index 000000000..1e24ee7da --- /dev/null +++ b/implementations/python/packages/raes_runtime/control_plane_store_local.py @@ -0,0 +1,167 @@ +"""Filesystem-backed runtime control-plane persistence.""" + +from __future__ import annotations + +import json +import tempfile +from contextlib import suppress +from dataclasses import asdict +from pathlib import Path +from typing import Any + +from raes_contracts.participant_autonomous_state import require_participant_autonomous_runtime_snapshot +from raes_contracts.runtime_state import RuntimeSnapshot + +from .control_plane_store import ( + AuditEvent, + ControlPlaneOperationRecord, + _audit_event_from_payload, + _record_from_payload, + _record_payload, + _require_expected_control_head, + _snapshot_from_payload, + _snapshot_payload, + os, +) + + +class LocalControlPlaneStore: + """Filesystem-backed control-plane durability.""" + + def __init__(self, base_dir: Path) -> None: + self._base_dir = base_dir + self._base_dir.mkdir(parents=True, exist_ok=True) + self._snapshot_path = self._base_dir / "snapshot.json" + self._operations_path = self._base_dir / "operations.json" + self._audit_path = self._base_dir / "audit.jsonl" + self._control_state_path = self._base_dir / "control-transition-state.json" + + @staticmethod + def _atomic_write(path: Path, content: str) -> None: + """Write content atomically via a temporary file and os.replace.""" + + fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(content) + os.replace(tmp, path) + except BaseException: + with suppress(OSError): + os.unlink(tmp) + raise + + def load_snapshot(self) -> RuntimeSnapshot: + legacy_snapshot = RuntimeSnapshot() + if self._snapshot_path.exists(): + payload = json.loads(self._snapshot_path.read_text(encoding="utf-8")) + legacy_snapshot = _snapshot_from_payload(payload) + control_state = self._load_control_state() + if control_state is None: + return legacy_snapshot + committed_snapshot = _snapshot_from_payload(dict(control_state.get("snapshot", {}))) + legacy_count = sum(len(events) for events in legacy_snapshot.participant_control_history.values()) + committed_count = sum(len(events) for events in committed_snapshot.participant_control_history.values()) + return committed_snapshot if committed_count > legacy_count else legacy_snapshot + + def save_snapshot(self, snapshot: RuntimeSnapshot) -> None: + content = json.dumps(_snapshot_payload(snapshot), indent=2, sort_keys=True) + "\n" + self._atomic_write(self._snapshot_path, content) + + def load_records(self) -> dict[str, ControlPlaneOperationRecord]: + records: dict[str, ControlPlaneOperationRecord] = {} + control_state = self._load_control_state() + if control_state is not None: + records.update( + { + operation_id: _record_from_payload(record_payload) + for operation_id, record_payload in dict(control_state.get("records", {})).items() + if isinstance(record_payload, dict) + } + ) + if not self._operations_path.exists(): + return records + payload = json.loads(self._operations_path.read_text(encoding="utf-8")) + records.update( + { + operation_id: _record_from_payload(record_payload) + for operation_id, record_payload in payload.items() + if isinstance(record_payload, dict) + } + ) + return records + + def save_record(self, record: ControlPlaneOperationRecord) -> None: + records = self.load_records() + records[record.receipt.operation_id] = record + payload = { + operation_id: _record_payload(operation_record) for operation_id, operation_record in records.items() + } + content = json.dumps(payload, indent=2, sort_keys=True) + "\n" + self._atomic_write(self._operations_path, content) + + def find_by_idempotency( + self, + key: str, + ) -> ControlPlaneOperationRecord | None: + for record in self.load_records().values(): + if record.idempotency_key == key: + return record + return None + + def append_audit(self, event: AuditEvent) -> None: + self._audit_path.parent.mkdir(parents=True, exist_ok=True) + with self._audit_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(asdict(event), sort_keys=True) + "\n") + + def read_audit(self) -> list[AuditEvent]: + events: list[AuditEvent] = [] + control_state = self._load_control_state() + if control_state is not None: + events.extend( + _audit_event_from_payload(payload) + for payload in control_state.get("audit", []) + if isinstance(payload, dict) + ) + if not self._audit_path.exists(): + return events + for line in self._audit_path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + event = _audit_event_from_payload(json.loads(line)) + if event not in events: + events.append(event) + return events + + def _load_control_state(self) -> dict[str, Any] | None: + if not self._control_state_path.exists(): + return None + payload = json.loads(self._control_state_path.read_text(encoding="utf-8")) + return payload if isinstance(payload, dict) else None + + def commit_control_transition( + self, + *, + participant_address: str, + expected_head: str | None, + snapshot: RuntimeSnapshot, + record: ControlPlaneOperationRecord, + audit_event: AuditEvent, + ) -> None: + current_snapshot = self.load_snapshot() + _require_expected_control_head(current_snapshot, participant_address, expected_head) + require_participant_autonomous_runtime_snapshot(snapshot) + records = self.load_records() + records[record.receipt.operation_id] = record + audits = [*self.read_audit(), audit_event] + payload = { + "snapshot": _snapshot_payload(snapshot), + "records": { + operation_id: _record_payload(operation_record) for operation_id, operation_record in records.items() + }, + "audit": [asdict(event) for event in audits], + } + content = json.dumps(payload, indent=2, sort_keys=True) + "\n" + self._atomic_write(self._control_state_path, content) + + +__all__ = ("LocalControlPlaneStore",) diff --git a/implementations/python/packages/raes_runtime/participant_control.py b/implementations/python/packages/raes_runtime/participant_control.py index 9c8de66eb..9da7a1938 100644 --- a/implementations/python/packages/raes_runtime/participant_control.py +++ b/implementations/python/packages/raes_runtime/participant_control.py @@ -21,6 +21,18 @@ from raes_processor.models import ParticipantBehaviorRuntime from .control_plane_execution import execute_participant_action +from .participant_control_intents import ( + ParticipantApprovalControlIntent, + ParticipantCancellationControlIntent, + ParticipantControlIntent, + ParticipantDenialControlIntent, + ParticipantExternalDirectionControlIntent, + ParticipantHandoffControlIntent, + ParticipantInterventionControlIntent, + ParticipantOverrideControlIntent, + ParticipantProposalControlIntent, +) +from .participant_control_mediation import record_participant_control _NO_PARTICIPANT_RUNTIME_MESSAGE = "Target does not provide a participant runtime." _PARTICIPANT_BINDING_REJECTED = "runtime.participant-binding.rejected" @@ -205,6 +217,24 @@ def _participant_binding_request_diagnostics( class ParticipantControlMixin: """Participant runtime methods for the shared runtime control plane.""" + def record_participant_control( + self, + participant_address: str, + intent: ParticipantControlIntent, + *, + identity: object, + idempotency_key: str = "", + ) -> OperationReceipt: + """Mediate and durably append one supervisory control occurrence.""" + + return record_participant_control( + self, + participant_address=participant_address, + intent=intent, + identity=identity, + idempotency_key=idempotency_key, + ) + def initialize_participant_episode( self, participant_address: str, @@ -404,3 +434,17 @@ def admit_participant_decision_surface_selection( idempotency_key=idempotency_key, request_fingerprint=request_fingerprint, ) + + +__all__ = ( + "ParticipantApprovalControlIntent", + "ParticipantCancellationControlIntent", + "ParticipantControlIntent", + "ParticipantControlMixin", + "ParticipantDenialControlIntent", + "ParticipantExternalDirectionControlIntent", + "ParticipantHandoffControlIntent", + "ParticipantInterventionControlIntent", + "ParticipantOverrideControlIntent", + "ParticipantProposalControlIntent", +) diff --git a/implementations/python/packages/raes_runtime/participant_control_intents.py b/implementations/python/packages/raes_runtime/participant_control_intents.py new file mode 100644 index 000000000..3f38fef20 --- /dev/null +++ b/implementations/python/packages/raes_runtime/participant_control_intents.py @@ -0,0 +1,138 @@ +"""Closed caller-intent models for RUN-310 supervisory mediation.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator +from raes_contracts.contracts.participant_control import ParticipantControlTargetKind + + +class ParticipantControlIntentBase(BaseModel): + """Caller-owned coordinates shared by all supervisory intents.""" + + model_config = ConfigDict(extra="forbid") + + declaration_ref: str = Field(min_length=1) + episode_id: str = Field(min_length=1) + client_correlation_id: str = Field(min_length=1) + policy_revision: str = Field(min_length=1) + expected_state_revision: int = Field(ge=0) + provenance_refs: list[str] = Field(min_length=1) + evidence_refs: list[str] = Field(min_length=1) + object_marking_refs: list[str] = Field(min_length=1) + limitation_refs: list[str] = Field(min_length=1) + + +class ParticipantProposalControlIntent(ParticipantControlIntentBase): + kind: Literal["proposal"] = "proposal" + proposal_id: str = Field(min_length=1) + proposal_revision: int = Field(gt=0) + action_contract_ref: str = Field(min_length=1) + decision_surface_ref: str | None = None + proposal_binding_ref: str | None = None + payload_ref: str | None = None + payload_digest: str | None = None + source_proposal_ref: str | None = None + source_proposal_revision: int | None = Field(default=None, gt=0) + transformation_ref: str | None = None + + @model_validator(mode="after") + def _require_one_payload_source(self) -> ParticipantProposalControlIntent: + if (self.payload_ref is None) == (self.payload_digest is None): + raise ValueError("proposal intent requires exactly one payload reference or digest") + return self + + +class ParticipantApprovalControlIntent(ParticipantControlIntentBase): + kind: Literal["approval"] = "approval" + proposal_ref: str = Field(min_length=1) + proposal_revision: int = Field(gt=0) + decision_ref: str = Field(min_length=1) + decision_revision: int = Field(gt=0) + + +class ParticipantDenialControlIntent(ParticipantControlIntentBase): + kind: Literal["denial"] = "denial" + proposal_ref: str = Field(min_length=1) + proposal_revision: int = Field(gt=0) + decision_ref: str = Field(min_length=1) + decision_revision: int = Field(gt=0) + + +class ParticipantExternalDirectionControlIntent(ParticipantControlIntentBase): + kind: Literal["external-direction"] = "external-direction" + target_kind: Literal[ + ParticipantControlTargetKind.PROPOSAL, + ParticipantControlTargetKind.ACTION, + ParticipantControlTargetKind.CONTROL, + ] + target_ref: str = Field(min_length=1) + target_revision: int = Field(gt=0) + + +class ParticipantInterventionControlIntent(ParticipantControlIntentBase): + kind: Literal["intervention"] = "intervention" + affected_target_kind: Literal[ + ParticipantControlTargetKind.ACTION, + ParticipantControlTargetKind.CONTROL, + ParticipantControlTargetKind.ATTEMPT, + ] + affected_occurrence_ref: str = Field(min_length=1) + affected_revision: int = Field(gt=0) + intervention_ref: str = Field(min_length=1) + + +class ParticipantHandoffControlIntent(ParticipantControlIntentBase): + kind: Literal["handoff"] = "handoff" + completion_evidence_ref: str = Field(min_length=1) + + +class ParticipantOverrideControlIntent(ParticipantControlIntentBase): + kind: Literal["override"] = "override" + superseded_target_kind: Literal[ + ParticipantControlTargetKind.CONTROL, + ParticipantControlTargetKind.DECISION, + ] + superseded_occurrence_ref: str = Field(min_length=1) + superseded_revision: int = Field(gt=0) + replacement_ref: str = Field(min_length=1) + + +class ParticipantCancellationControlIntent(ParticipantControlIntentBase): + kind: Literal["cancellation"] = "cancellation" + target_kind: Literal[ + ParticipantControlTargetKind.PROPOSAL, + ParticipantControlTargetKind.DECISION, + ParticipantControlTargetKind.ADMITTED_ACTION, + ParticipantControlTargetKind.ATTEMPT, + ] + target_ref: str = Field(min_length=1) + target_revision: int = Field(gt=0) + + +ParticipantControlIntent = Annotated[ + ParticipantProposalControlIntent + | ParticipantApprovalControlIntent + | ParticipantDenialControlIntent + | ParticipantExternalDirectionControlIntent + | ParticipantInterventionControlIntent + | ParticipantHandoffControlIntent + | ParticipantOverrideControlIntent + | ParticipantCancellationControlIntent, + Field(discriminator="kind"), +] + + +__all__ = ( + "ParticipantApprovalControlIntent", + "ParticipantCancellationControlIntent", + "ParticipantControlIntent", + "ParticipantControlIntentBase", + "ParticipantDenialControlIntent", + "ParticipantExternalDirectionControlIntent", + "ParticipantHandoffControlIntent", + "ParticipantInterventionControlIntent", + "ParticipantOverrideControlIntent", + "ParticipantProposalControlIntent", +) diff --git a/implementations/python/packages/raes_runtime/participant_control_mediation.py b/implementations/python/packages/raes_runtime/participant_control_mediation.py new file mode 100644 index 000000000..660f69f79 --- /dev/null +++ b/implementations/python/packages/raes_runtime/participant_control_mediation.py @@ -0,0 +1,449 @@ +"""RUN-310 live supervisory mediation over compiled ACT-617 policy.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass +from uuid import uuid4 + +from raes_contracts.contracts import ( + ParticipantControlDeclarationModel, + ParticipantControlOccurrenceModel, + validate_participant_control_occurrence_context, +) +from raes_contracts.contracts.participant_control import ( + ParticipantControlDisposition, + ParticipantControlTargetContextModel, +) +from raes_contracts.diagnostics import Diagnostic +from raes_contracts.planning import RuntimeDomain +from raes_contracts.runtime_state import OperationReceipt, OperationState, OperationStatus +from raes_processor.models import ( + MixedControlControllerStateRuntime, + MixedControlTransitionRuntime, + ParticipantBehaviorSpecificationRuntime, +) + +from .control_plane_security import ControlPlaneIdentity +from .control_plane_store import AuditEvent, ControlPlaneOperationRecord +from .participant_control_intents import ParticipantControlIntent +from .participant_control_occurrences import ( + ParticipantControlOccurrenceContext, + build_participant_control_occurrence, +) +from .participant_control_rejections import participant_control_rejection_reason +from .participant_control_targets import ( + participant_control_target_contexts, + resolve_participant_control_target, +) + + +@dataclass(frozen=True) +class _BoundControlRequest: + specification: ParticipantBehaviorSpecificationRuntime + transition: MixedControlTransitionRuntime + state: MixedControlControllerStateRuntime + semantic_fingerprint: str + scoped_key: str + + +def record_participant_control( + control_plane: object, + *, + participant_address: str, + intent: ParticipantControlIntent, + identity: object, + idempotency_key: str, +) -> OperationReceipt: + """Bind, validate, and atomically append one supervisory occurrence.""" + + if not isinstance(identity, ControlPlaneIdentity): + raise PermissionError("participant control requires an authenticated identity") + if identity.target_name is not None and identity.target_name != control_plane.target_name: + raise PermissionError("participant control identity is not authorized for this target") + _require_participant_binding(identity, participant_address) + bound = _bind_control_request( + control_plane, + participant_address, + intent, + identity, + idempotency_key, + ) + + with control_plane._participant_control_lock: + existing = control_plane._store.find_by_idempotency(bound.scoped_key) if bound.scoped_key else None + if existing is not None: + if existing.request_fingerprint != bound.semantic_fingerprint: + raise ValueError("Idempotency-Key was reused with different semantics.") + control_plane._operations[existing.receipt.operation_id] = existing + return existing.receipt + return _record_new_participant_control( + control_plane, + participant_address, + intent, + identity, + bound, + ) + + +def _bind_control_request( + control_plane: object, + participant_address: str, + intent: ParticipantControlIntent, + identity: ControlPlaneIdentity, + idempotency_key: str, +) -> _BoundControlRequest: + specification = _specification_for_participant(control_plane, participant_address, identity) + transition = _transition_for_intent(specification, intent, identity) + state = _state_by_address(specification, transition.from_state_address) + return _BoundControlRequest( + specification=specification, + transition=transition, + state=state, + semantic_fingerprint=_semantic_fingerprint( + control_plane, + participant_address, + intent, + identity, + specification, + transition, + ), + scoped_key=_scoped_idempotency_key( + control_plane, + participant_address, + intent, + identity, + idempotency_key, + ), + ) + + +def _record_new_participant_control( + control_plane: object, + participant_address: str, + intent: ParticipantControlIntent, + identity: ControlPlaneIdentity, + bound: _BoundControlRequest, +) -> OperationReceipt: + history = list(control_plane._snapshot.participant_control_history.get(participant_address, ())) + current_state, current_revision = _fold_controller_state( + bound.specification, + history, + episode_id=intent.episode_id, + ) + resolved_target, target_rejection_reason = resolve_participant_control_target( + control_plane._snapshot, + intent, + participant_address=participant_address, + ) + rejection_reason = participant_control_rejection_reason( + bound.specification, + bound.transition, + bound.state, + intent, + current_state=current_state, + current_revision=current_revision, + target_rejection_reason=target_rejection_reason, + ) + accepted = rejection_reason is None + occurrence = build_participant_control_occurrence( + ParticipantControlOccurrenceContext( + control_plane=control_plane, + participant_address=participant_address, + specification=bound.specification, + transition=bound.transition, + state=bound.state, + history=history, + ), + intent, + resolved_target=resolved_target, + accepted=accepted, + rejection_reason=rejection_reason, + ) + candidate_history = [*history, occurrence.model_dump(mode="json")] + _validate_candidate_history( + bound.specification, + candidate_history, + known_targets=participant_control_target_contexts(control_plane._snapshot), + ) + next_snapshot = control_plane._snapshot.with_entries( + dict(control_plane._snapshot.entries), + participant_control_history={ + **control_plane._snapshot.participant_control_history, + participant_address: candidate_history, + }, + ) + record, audit_event = _operation_artifacts( + participant_address, + intent, + identity, + bound, + occurrence, + accepted, + rejection_reason, + ) + control_plane._store.commit_control_transition( + participant_address=participant_address, + expected_head=_history_head(history), + snapshot=next_snapshot, + record=record, + audit_event=audit_event, + ) + control_plane._snapshot = next_snapshot + control_plane._operations[record.receipt.operation_id] = record + return record.receipt + + +def _operation_artifacts( + participant_address: str, + intent: ParticipantControlIntent, + identity: ControlPlaneIdentity, + bound: _BoundControlRequest, + occurrence: ParticipantControlOccurrenceModel, + accepted: bool, + rejection_reason: str | None, +) -> tuple[ControlPlaneOperationRecord, AuditEvent]: + operation_id = str(uuid4()) + submitted_at = occurrence.recorded_at + diagnostics = [] if accepted else [_rejection_diagnostic(participant_address, rejection_reason)] + receipt = OperationReceipt( + operation_id=operation_id, + domain=RuntimeDomain.PARTICIPANT, + submitted_at=submitted_at, + accepted=accepted, + diagnostics=diagnostics, + ) + status = OperationStatus( + operation_id=operation_id, + domain=RuntimeDomain.PARTICIPANT, + state=OperationState.SUCCEEDED if accepted else OperationState.FAILED, + submitted_at=submitted_at, + updated_at=submitted_at, + diagnostics=diagnostics, + changed_addresses=[participant_address], + ) + record = ControlPlaneOperationRecord( + receipt=receipt, + status=status, + request_fingerprint=bound.semantic_fingerprint, + idempotency_key=bound.scoped_key, + ) + audit_event = AuditEvent( + timestamp=submitted_at, + action="record_participant_control", + identity=identity.identity, + allowed=accepted, + target=participant_address, + operation_id=operation_id, + reason=rejection_reason or "accepted", + details={ + "episode_id": intent.episode_id, + "kind": intent.kind, + "event_id": occurrence.event_id, + }, + ) + return record, audit_event + + +def _specification_for_participant( + control_plane: object, + participant_address: str, + identity: ControlPlaneIdentity, +) -> ParticipantBehaviorSpecificationRuntime: + authorized_controllers = _authorized_controller_refs(identity, participant_address) + candidates = [ + specification + for specification in control_plane._behavior_specifications.values() + if specification.mixed_control_participant_address == participant_address + and any(state.controller_address in authorized_controllers for state in specification.controller_states) + ] + if len(candidates) != 1: + raise ValueError("participant must resolve exactly one trusted mixed-control specification") + return candidates[0] + + +def _transition_for_intent( + specification: ParticipantBehaviorSpecificationRuntime, + intent: ParticipantControlIntent, + identity: ControlPlaneIdentity, +) -> MixedControlTransitionRuntime: + authorized_controllers = _authorized_controller_refs( + identity, + specification.mixed_control_participant_address, + ) + states = {state.address: state for state in specification.controller_states} + candidates = [ + transition + for transition in specification.control_transitions + if transition.address == intent.declaration_ref + and transition.transition_kind == intent.kind + and transition.from_state_address in states + and states[transition.from_state_address].controller_address in authorized_controllers + ] + if len(candidates) != 1: + raise ValueError("control intent must resolve exactly one compiled transition") + return candidates[0] + + +def _state_by_address( + specification: ParticipantBehaviorSpecificationRuntime, + address: str, +) -> MixedControlControllerStateRuntime: + candidates = [state for state in specification.controller_states if state.address == address] + if len(candidates) != 1: + raise ValueError("compiled controller state must resolve exactly once") + return candidates[0] + + +def _require_participant_binding( + identity: ControlPlaneIdentity, + participant_address: str, +) -> None: + if not _authorized_controller_refs(identity, participant_address): + raise PermissionError("participant control subject is not authorized") + + +def _authorized_controller_refs( + identity: ControlPlaneIdentity, + participant_address: str, +) -> frozenset[str]: + return frozenset( + binding.controller_ref + for binding in identity.participant_control_subjects + if binding.participant_address == participant_address + ) + + +def _fold_controller_state( + specification: ParticipantBehaviorSpecificationRuntime, + history: list[dict[str, object]], + *, + episode_id: str, +) -> tuple[str, int]: + state_address = specification.mixed_control_initial_state_address + revision = 0 + transitions = {transition.address: transition for transition in specification.control_transitions} + for payload in history: + event = ParticipantControlOccurrenceModel.model_validate(payload) + if event.episode_id != episode_id: + continue + if event.occurrence.disposition is not ParticipantControlDisposition.ACCEPTED: + continue + transition = transitions.get(event.occurrence.declaration_ref) + if transition is None: + raise ValueError("control history references an unknown compiled transition") + if transition.from_state_address != state_address or transition.expected_state_revision != revision: + raise ValueError("control history does not replay from compiled controller state") + state_address = transition.to_state_address + revision = transition.resulting_state_revision + return state_address, revision + + +def _history_head(history: list[dict[str, object]]) -> str | None: + if not history: + return None + value = history[-1].get("event_id") + return value if isinstance(value, str) and value else None + + +def _declaration( + specification: ParticipantBehaviorSpecificationRuntime, + transition: MixedControlTransitionRuntime, + episode_id: str, +) -> ParticipantControlDeclarationModel: + state = _state_by_address(specification, transition.from_state_address) + return ParticipantControlDeclarationModel.model_validate( + { + "declaration_ref": transition.address, + "kind": transition.transition_kind, + "participant_address": specification.mixed_control_participant_address, + "episode_id": episode_id, + "controller_ref": state.controller_address, + "controller_state_ref": state.address, + "authority_basis_refs": list(state.authority_basis_addresses or state.authority_basis_refs), + "controlled_scope_refs": list(state.scope_addresses or state.scope_refs), + "behavior_specification_ref": specification.address, + "mixed_control_policy_ref": specification.address, + "policy_revision": transition.policy_revision, + "expected_state_revision": transition.expected_state_revision, + "effective_order": transition.effective_order, + "valid_from_order": transition.valid_from_order, + "valid_until_order": transition.valid_until_order, + } + ) + + +def _validate_candidate_history( + specification: ParticipantBehaviorSpecificationRuntime, + history: list[dict[str, object]], + *, + known_targets: tuple[ParticipantControlTargetContextModel, ...], +) -> None: + records = [ParticipantControlOccurrenceModel.model_validate(payload) for payload in history] + transitions = {transition.address: transition for transition in specification.control_transitions} + declarations = [ + _declaration( + specification, + transitions[record.occurrence.declaration_ref], + record.episode_id, + ) + for record in records + ] + validate_participant_control_occurrence_context( + records, + declarations=declarations, + known_targets=known_targets, + ) + + +def _semantic_fingerprint( + control_plane: object, + participant_address: str, + intent: ParticipantControlIntent, + identity: ControlPlaneIdentity, + specification: ParticipantBehaviorSpecificationRuntime, + transition: MixedControlTransitionRuntime, +) -> str: + payload = { + "target": control_plane.target_name, + "identity": identity.identity, + "participant": participant_address, + "intent": intent.model_dump(mode="json"), + "specification": specification.address, + "transition": asdict(transition), + } + return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def _scoped_idempotency_key( + control_plane: object, + participant_address: str, + intent: ParticipantControlIntent, + identity: ControlPlaneIdentity, + idempotency_key: str, +) -> str: + if not idempotency_key: + return "" + scope = ( + control_plane.target_name, + identity.identity, + intent.kind, + participant_address, + intent.episode_id, + idempotency_key, + ) + digest = hashlib.sha256("\x1f".join(scope).encode()).hexdigest() + return f"participant-control:{digest}" + + +def _rejection_diagnostic(participant_address: str, reason: str | None) -> Diagnostic: + return Diagnostic( + code=f"runtime.participant-control.{reason or 'rejected'}", + domain="runtime", + address=participant_address, + message="Participant supervisory control intent was rejected by the bound runtime policy.", + ) + + +__all__ = ("record_participant_control",) diff --git a/implementations/python/packages/raes_runtime/participant_control_occurrences.py b/implementations/python/packages/raes_runtime/participant_control_occurrences.py new file mode 100644 index 000000000..41424e744 --- /dev/null +++ b/implementations/python/packages/raes_runtime/participant_control_occurrences.py @@ -0,0 +1,184 @@ +"""API-409 occurrence construction for RUN-310 runtime mediation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from uuid import uuid4 + +from raes_contracts.contracts import ParticipantControlOccurrenceModel +from raes_contracts.contracts.participant_control import ( + ParticipantCancellationEffect, + ParticipantControlTargetKind, +) +from raes_processor.models import ( + MixedControlControllerStateRuntime, + MixedControlTransitionRuntime, + ParticipantBehaviorSpecificationRuntime, +) + +from .participant_control_intents import ( + ParticipantApprovalControlIntent, + ParticipantCancellationControlIntent, + ParticipantControlIntent, + ParticipantDenialControlIntent, + ParticipantExternalDirectionControlIntent, + ParticipantHandoffControlIntent, + ParticipantInterventionControlIntent, + ParticipantOverrideControlIntent, + ParticipantProposalControlIntent, +) +from .participant_control_targets import ResolvedParticipantControlTarget + + +@dataclass(frozen=True) +class ParticipantControlOccurrenceContext: + """Trusted runtime-owned inputs used to construct one occurrence.""" + + control_plane: object + participant_address: str + specification: ParticipantBehaviorSpecificationRuntime + transition: MixedControlTransitionRuntime + state: MixedControlControllerStateRuntime + history: list[dict[str, object]] + + +def build_participant_control_occurrence( + context: ParticipantControlOccurrenceContext, + intent: ParticipantControlIntent, + *, + resolved_target: ResolvedParticipantControlTarget | None, + accepted: bool, + rejection_reason: str | None, +) -> ParticipantControlOccurrenceModel: + """Build one immutable runtime-owned occurrence from a caller intent.""" + + control_plane = context.control_plane + participant_address = context.participant_address + specification = context.specification + transition = context.transition + state = context.state + history = context.history + now = datetime.now(UTC).isoformat().replace("+00:00", "Z") + detail: dict[str, object] = { + "kind": intent.kind, + "declaration_ref": transition.address, + "controller_ref": state.controller_address, + "controller_state_ref": state.address, + "authority_basis_refs": list(state.authority_basis_addresses or state.authority_basis_refs), + "controlled_scope_refs": list(state.scope_addresses or state.scope_refs), + "behavior_specification_ref": specification.address, + "mixed_control_policy_ref": specification.address, + "policy_revision": transition.policy_revision, + "expected_state_revision": transition.expected_state_revision, + "effective_order": transition.effective_order, + "valid_from_order": transition.valid_from_order, + "valid_until_order": transition.valid_until_order, + "occurrence_revision": len(history) + 1, + "disposition": "accepted" if accepted else "rejected", + "reason_code": rejection_reason, + "limitation_refs": list(intent.limitation_refs), + **_kind_detail(intent, transition), + } + return ParticipantControlOccurrenceModel.model_validate( + { + "event_id": f"participant-control.{uuid4()}", + "schema_name": "participant-control-occurrence", + "schema_version": "1.0.0", + "event_type": "participant-control-occurrence", + "extension_policy": "closed", + "participant_address": participant_address, + "episode_id": intent.episode_id, + "occurred_at": now, + "recorded_at": now, + "ingested_at": now, + "clock_authority": "runtime.control-plane.clock", + "ordering_basis": "logical_clock", + "logical_order_ref": f"effective-order:{transition.effective_order}", + "predecessor_event_refs": [resolved_target.predecessor_ref] if resolved_target is not None else [], + "actor_ref": state.controller_address, + "producer_ref": f"runtime.control-plane.{control_plane.target_name}", + "provenance_refs": list(intent.provenance_refs), + "evidence_refs": list(intent.evidence_refs), + "object_marking_refs": list(intent.object_marking_refs), + "authorization_scope": (state.scope_addresses or state.scope_refs)[0], + "occurrence": detail, + } + ) + + +def _kind_detail( + intent: ParticipantControlIntent, + transition: MixedControlTransitionRuntime, +) -> dict[str, object]: + if isinstance(intent, ParticipantProposalControlIntent): + detail = { + "proposal_id": intent.proposal_id, + "proposal_revision": intent.proposal_revision, + "admission_status": "not-admitted", + "action_contract_ref": intent.action_contract_ref, + "decision_surface_ref": intent.decision_surface_ref, + "proposal_binding_ref": intent.proposal_binding_ref, + "payload_ref": intent.payload_ref, + "payload_digest": intent.payload_digest, + "source_proposal_ref": intent.source_proposal_ref, + "source_proposal_revision": intent.source_proposal_revision, + "transformation_ref": intent.transformation_ref, + } + elif isinstance(intent, (ParticipantApprovalControlIntent, ParticipantDenialControlIntent)): + detail = { + "proposal_ref": intent.proposal_ref, + "proposal_revision": intent.proposal_revision, + "decision_ref": intent.decision_ref, + "decision_revision": intent.decision_revision, + } + elif isinstance(intent, ParticipantExternalDirectionControlIntent): + detail = { + "target_kind": intent.target_kind.value, + "target_ref": intent.target_ref, + "target_revision": intent.target_revision, + } + elif isinstance(intent, ParticipantInterventionControlIntent): + detail = { + "affected_target_kind": intent.affected_target_kind.value, + "affected_occurrence_ref": intent.affected_occurrence_ref, + "affected_revision": intent.affected_revision, + "intervention_ref": intent.intervention_ref, + } + elif isinstance(intent, ParticipantHandoffControlIntent): + detail = { + "prior_controller_state_ref": transition.from_state_address, + "resulting_controller_state_ref": transition.to_state_address, + "resulting_state_revision": transition.resulting_state_revision, + "completion_evidence_ref": intent.completion_evidence_ref, + } + elif isinstance(intent, ParticipantOverrideControlIntent): + detail = { + "superseded_target_kind": intent.superseded_target_kind.value, + "superseded_occurrence_ref": intent.superseded_occurrence_ref, + "superseded_revision": intent.superseded_revision, + "replacement_ref": intent.replacement_ref, + } + else: + assert isinstance(intent, ParticipantCancellationControlIntent) + detail = { + "target_kind": intent.target_kind.value, + "target_ref": intent.target_ref, + "target_revision": intent.target_revision, + "cancellation_effect": _cancellation_effect(intent.target_kind).value, + } + return detail + + +def _cancellation_effect(target_kind: ParticipantControlTargetKind) -> ParticipantCancellationEffect: + if target_kind in {ParticipantControlTargetKind.PROPOSAL, ParticipantControlTargetKind.DECISION}: + return ParticipantCancellationEffect.PREVENTED + if target_kind is ParticipantControlTargetKind.ADMITTED_ACTION: + return ParticipantCancellationEffect.PARTIAL_LIMITATION + return ParticipantCancellationEffect.TOO_LATE + + +__all__ = ( + "ParticipantControlOccurrenceContext", + "build_participant_control_occurrence", +) diff --git a/implementations/python/packages/raes_runtime/participant_control_rejections.py b/implementations/python/packages/raes_runtime/participant_control_rejections.py new file mode 100644 index 000000000..1a6dfb5b6 --- /dev/null +++ b/implementations/python/packages/raes_runtime/participant_control_rejections.py @@ -0,0 +1,87 @@ +"""Ordered rejection rules for RUN-310 supervisory mediation.""" + +from __future__ import annotations + +from raes_processor.models import ( + MixedControlControllerStateRuntime, + MixedControlTransitionRuntime, + ParticipantBehaviorSpecificationRuntime, +) + +from .participant_control_intents import ParticipantControlIntent + +_ORDER_STRATEGY = "total-effective-order" + + +def participant_control_rejection_reason( + specification: ParticipantBehaviorSpecificationRuntime, + transition: MixedControlTransitionRuntime, + state: MixedControlControllerStateRuntime, + intent: ParticipantControlIntent, + *, + current_state: str, + current_revision: int, + target_rejection_reason: str | None, +) -> str | None: + """Return the first rejection in the lifecycle's binding order.""" + + checks = ( + _order_strategy_rejection(specification), + _intent_policy_rejection(specification, intent), + _state_revision_rejection(transition, intent, current_state, current_revision), + _transition_policy_rejection(specification, transition), + target_rejection_reason, + _authority_status_rejection(state), + _authority_window_rejection(state, transition), + ) + return next((reason for reason in checks if reason is not None), None) + + +def _order_strategy_rejection( + specification: ParticipantBehaviorSpecificationRuntime, +) -> str | None: + return None if specification.mixed_control_order_strategy == _ORDER_STRATEGY else "unsupported-order-strategy" + + +def _intent_policy_rejection( + specification: ParticipantBehaviorSpecificationRuntime, + intent: ParticipantControlIntent, +) -> str | None: + return None if intent.policy_revision == specification.mixed_control_policy_revision else "stale-policy" + + +def _state_revision_rejection( + transition: MixedControlTransitionRuntime, + intent: ParticipantControlIntent, + current_state: str, + current_revision: int, +) -> str | None: + matches_current_state = ( + intent.expected_state_revision == current_revision + and transition.expected_state_revision == current_revision + and transition.from_state_address == current_state + ) + return None if matches_current_state else "stale-state" + + +def _transition_policy_rejection( + specification: ParticipantBehaviorSpecificationRuntime, + transition: MixedControlTransitionRuntime, +) -> str | None: + return None if transition.policy_revision == specification.mixed_control_policy_revision else "stale-policy" + + +def _authority_status_rejection(state: MixedControlControllerStateRuntime) -> str | None: + return None if state.authority_status == "active" else "revoked-authority" + + +def _authority_window_rejection( + state: MixedControlControllerStateRuntime, + transition: MixedControlTransitionRuntime, +) -> str | None: + state_is_current = state.valid_from_order <= transition.effective_order <= state.valid_until_order + transition_is_current = transition.valid_from_order <= transition.effective_order <= transition.valid_until_order + return None if state_is_current and transition_is_current else "late-authority" + + +__all__ = ("participant_control_rejection_reason",) diff --git a/implementations/python/packages/raes_runtime/participant_control_targets.py b/implementations/python/packages/raes_runtime/participant_control_targets.py new file mode 100644 index 000000000..12b5d7126 --- /dev/null +++ b/implementations/python/packages/raes_runtime/participant_control_targets.py @@ -0,0 +1,243 @@ +"""Authoritative target resolution for RUN-310 supervisory occurrences.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from raes_contracts.contracts import ( + ParticipantBehaviorHistoryEventModel, + ParticipantControlOccurrenceModel, +) +from raes_contracts.contracts.participant_control import ( + ParticipantApprovalOccurrenceModel, + ParticipantControlDisposition, + ParticipantControlTargetContextModel, + ParticipantControlTargetKind, + ParticipantDenialOccurrenceModel, + ParticipantProposalOccurrenceModel, +) +from raes_contracts.runtime_state import RuntimeSnapshot + +from .participant_control_intents import ( + ParticipantApprovalControlIntent, + ParticipantCancellationControlIntent, + ParticipantControlIntent, + ParticipantDenialControlIntent, + ParticipantExternalDirectionControlIntent, + ParticipantInterventionControlIntent, + ParticipantOverrideControlIntent, + ParticipantProposalControlIntent, +) + + +@dataclass(frozen=True) +class ResolvedParticipantControlTarget: + """One target whose revision and runtime scope resolved exactly.""" + + context: ParticipantControlTargetContextModel + predecessor_ref: str + + +def resolve_participant_control_target( + snapshot: RuntimeSnapshot, + intent: ParticipantControlIntent, + *, + participant_address: str, +) -> tuple[ResolvedParticipantControlTarget | None, str | None]: + """Resolve an intent target without accepting caller-supplied coordinates.""" + + requested = _intent_target(intent) + if requested is None: + return None, None + kind, reference, revision = requested + matches = [ + candidate + for candidate in participant_control_targets(snapshot) + if candidate.context.target_kind is kind + and candidate.context.target_ref == reference + and candidate.context.target_revision == revision + and candidate.context.participant_address == participant_address + and candidate.context.episode_id == intent.episode_id + ] + if len(matches) != 1: + return None, "invalid-target" + return matches[0], None + + +def participant_control_target_contexts( + snapshot: RuntimeSnapshot, +) -> tuple[ParticipantControlTargetContextModel, ...]: + """Return all lifecycle targets used by API-409 contextual validation.""" + + return tuple(candidate.context for candidate in participant_control_targets(snapshot)) + + +def participant_control_targets( + snapshot: RuntimeSnapshot, +) -> tuple[ResolvedParticipantControlTarget, ...]: + """Project control and behavior histories into typed target coordinates.""" + + targets: dict[ + tuple[ParticipantControlTargetKind, str, int, str, str], + ResolvedParticipantControlTarget, + ] = {} + for events in snapshot.participant_control_history.values(): + for payload in events: + _register_control_targets( + targets, + ParticipantControlOccurrenceModel.model_validate(payload), + ) + for events in snapshot.participant_behavior_history.values(): + for payload in events: + _register_behavior_targets( + targets, + ParticipantBehaviorHistoryEventModel.model_validate(payload), + ) + return tuple(targets.values()) + + +def _register_control_targets( + targets: dict[ + tuple[ParticipantControlTargetKind, str, int, str, str], + ResolvedParticipantControlTarget, + ], + event: ParticipantControlOccurrenceModel, +) -> None: + occurrence = event.occurrence + if occurrence.disposition is not ParticipantControlDisposition.ACCEPTED: + return + _register( + targets, + kind=ParticipantControlTargetKind.CONTROL, + reference=event.event_id, + revision=occurrence.occurrence_revision, + participant_address=event.participant_address, + episode_id=event.episode_id, + predecessor_ref=event.event_id, + ) + if isinstance(occurrence, ParticipantProposalOccurrenceModel): + _register( + targets, + kind=ParticipantControlTargetKind.PROPOSAL, + reference=occurrence.proposal_id, + revision=occurrence.proposal_revision, + participant_address=event.participant_address, + episode_id=event.episode_id, + predecessor_ref=event.event_id, + ) + elif isinstance(occurrence, (ParticipantApprovalOccurrenceModel, ParticipantDenialOccurrenceModel)): + _register( + targets, + kind=ParticipantControlTargetKind.DECISION, + reference=occurrence.decision_ref, + revision=occurrence.decision_revision, + participant_address=event.participant_address, + episode_id=event.episode_id, + predecessor_ref=event.event_id, + ) + + +def _register_behavior_targets( + targets: dict[ + tuple[ParticipantControlTargetKind, str, int, str, str], + ResolvedParticipantControlTarget, + ], + event: ParticipantBehaviorHistoryEventModel, +) -> None: + if event.event_type.value != "action_attempted": + return + phase = event.lifecycle_phase.value if event.lifecycle_phase is not None else None + _register( + targets, + kind=ParticipantControlTargetKind.ACTION, + reference=event.action_instance_id, + revision=1, + participant_address=event.participant_address, + episode_id=event.episode_id, + predecessor_ref=event.action_instance_id, + ) + admission = event.admission_disposition.value if event.admission_disposition is not None else None + if phase == "selection_or_admission" and admission == "admitted": + _register( + targets, + kind=ParticipantControlTargetKind.ADMITTED_ACTION, + reference=event.action_instance_id, + revision=1, + participant_address=event.participant_address, + episode_id=event.episode_id, + predecessor_ref=event.action_instance_id, + ) + if phase == "execution_attempt": + attempt_ref = event.operation_ref or event.action_instance_id + _register( + targets, + kind=ParticipantControlTargetKind.ATTEMPT, + reference=attempt_ref, + revision=1, + participant_address=event.participant_address, + episode_id=event.episode_id, + predecessor_ref=attempt_ref, + ) + + +def _register( + targets: dict[ + tuple[ParticipantControlTargetKind, str, int, str, str], + ResolvedParticipantControlTarget, + ], + *, + kind: ParticipantControlTargetKind, + reference: str, + revision: int, + participant_address: str, + episode_id: str, + predecessor_ref: str, +) -> None: + context = ParticipantControlTargetContextModel( + target_kind=kind, + target_ref=reference, + target_revision=revision, + participant_address=participant_address, + episode_id=episode_id, + ) + key = (kind, reference, revision, participant_address, episode_id) + candidate = ResolvedParticipantControlTarget(context=context, predecessor_ref=predecessor_ref) + existing = targets.setdefault(key, candidate) + if existing != candidate: + raise ValueError("runtime target identity is ambiguous") + + +def _intent_target( + intent: ParticipantControlIntent, +) -> tuple[ParticipantControlTargetKind, str, int] | None: + target: tuple[ParticipantControlTargetKind, str, int] | None + if isinstance(intent, ParticipantProposalControlIntent): + if intent.source_proposal_ref is None: + target = None + else: + assert intent.source_proposal_revision is not None + target = ( + ParticipantControlTargetKind.PROPOSAL, + intent.source_proposal_ref, + intent.source_proposal_revision, + ) + elif isinstance(intent, (ParticipantApprovalControlIntent, ParticipantDenialControlIntent)): + target = (ParticipantControlTargetKind.PROPOSAL, intent.proposal_ref, intent.proposal_revision) + elif isinstance(intent, ParticipantExternalDirectionControlIntent): + target = (intent.target_kind, intent.target_ref, intent.target_revision) + elif isinstance(intent, ParticipantInterventionControlIntent): + target = (intent.affected_target_kind, intent.affected_occurrence_ref, intent.affected_revision) + elif isinstance(intent, ParticipantOverrideControlIntent): + target = (intent.superseded_target_kind, intent.superseded_occurrence_ref, intent.superseded_revision) + elif isinstance(intent, ParticipantCancellationControlIntent): + target = (intent.target_kind, intent.target_ref, intent.target_revision) + else: + target = None + return target + + +__all__ = ( + "ResolvedParticipantControlTarget", + "participant_control_target_contexts", + "resolve_participant_control_target", +) diff --git a/implementations/python/packages/raes_runtime/participant_result_contracts.py b/implementations/python/packages/raes_runtime/participant_result_contracts.py index fbc6e1bd7..62a7f7978 100644 --- a/implementations/python/packages/raes_runtime/participant_result_contracts.py +++ b/implementations/python/packages/raes_runtime/participant_result_contracts.py @@ -11,6 +11,10 @@ iter_participant_concurrency_snapshot_violations, iter_participant_concurrency_transition_violations, ) +from raes_contracts.participant_control_history import ( + iter_participant_control_history_snapshot_violations, + iter_participant_control_history_transition_violations, +) from raes_contracts.participant_episode import iter_participant_episode_snapshot_violations from raes_contracts.participant_shared_state import ( iter_participant_shared_state_history_transition_violations, @@ -76,6 +80,9 @@ def participant_runtime_state_contract_diagnostics( shared_state_records=snapshot.shared_state_records, shared_state_history=snapshot.shared_state_history, ), + *iter_participant_control_history_snapshot_violations( + snapshot.participant_control_history, + ), ] return [ _failure_diagnostic("runtime.backend-contract-invalid", address, message) for address, message in violations @@ -114,4 +121,11 @@ def participant_runtime_history_transition_diagnostics( next_snapshot.time_management_contexts, ) ] + + [ + _failure_diagnostic("runtime.backend-contract-invalid", address, message) + for address, message in iter_participant_control_history_transition_violations( + previous_snapshot.participant_control_history, + next_snapshot.participant_control_history, + ) + ] ) diff --git a/implementations/python/pyproject.toml b/implementations/python/pyproject.toml index f12402bda..6ed8c3e94 100644 --- a/implementations/python/pyproject.toml +++ b/implementations/python/pyproject.toml @@ -166,6 +166,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/**" = ["S101", "S105", "S106", "S108", "E402", "SIM105", "F841"] +"packages/raes_contracts/contracts/__init__.py" = ["F403", "F405"] # intentional package re-exports "packages/raes_cli/**" = ["B008"] # typer requires function calls in defaults "packages/raes_runtime/control_plane_api.py" = ["B008"] # fastapi Depends() pattern "packages/raes_runtime/control_plane.py" = ["S112"] # intentional exception suppression diff --git a/implementations/python/tests/test_api_409_participant_control_occurrences.py b/implementations/python/tests/test_api_409_participant_control_occurrences.py index 85dc8de88..214b9ab22 100644 --- a/implementations/python/tests/test_api_409_participant_control_occurrences.py +++ b/implementations/python/tests/test_api_409_participant_control_occurrences.py @@ -389,6 +389,47 @@ def test_typed_target_context_rejects_an_unknown_kind_and_reference_pair() -> No ) +def test_rejected_typed_target_preserves_the_invalid_attempt_without_becoming_a_target() -> None: + value = _envelope( + { + "kind": "external-direction", + "target_kind": "action", + "target_ref": "action:unknown", + "target_revision": 1, + } + ) + value["occurrence"]["disposition"] = "rejected" + value["occurrence"]["reason_code"] = "invalid-target" + direction = ParticipantControlOccurrenceModel.model_validate(value) + + validate_participant_control_occurrence_context( + [direction], + declarations=[_declaration("external-direction")], + ) + intervention = ParticipantControlOccurrenceModel.model_validate( + _envelope( + { + "kind": "intervention", + "affected_target_kind": "control", + "affected_occurrence_ref": direction.event_id, + "affected_revision": direction.occurrence.occurrence_revision, + "intervention_ref": "intervention:rejected-direction", + } + ) + ) + + records = [direction, intervention] + declarations = [ + _declaration("external-direction"), + _declaration("intervention"), + ] + with pytest.raises(ValueError, match="typed target reference and kind must resolve"): + validate_participant_control_occurrence_context( + records, + declarations=declarations, + ) + + def test_transformed_proposal_requires_source_provenance_and_marking_inheritance() -> None: source, transformed = _transformed_proposal() assert transformed.occurrence.admission_status == "not-admitted" diff --git a/implementations/python/tests/test_backend_manifest.py b/implementations/python/tests/test_backend_manifest.py index 723a5b4bb..836d8ad84 100644 --- a/implementations/python/tests/test_backend_manifest.py +++ b/implementations/python/tests/test_backend_manifest.py @@ -39,7 +39,9 @@ V2_VALID_DIR = FIXTURES_ROOT / "backend-manifest" / "backend-manifest-v2" / "valid" V2_INVALID_DIR = FIXTURES_ROOT / "backend-manifest" / "backend-manifest-v2" / "invalid" EXPECTED_SUPPORTED_CONTRACT_VERSIONS_V2 = [ - contract_id for contract_id in BACKEND_SUPPORTED_CONTRACT_IDS if contract_id != "realization-envelope-v1" + contract_id + for contract_id in BACKEND_SUPPORTED_CONTRACT_IDS + if contract_id not in {"experiment-binding-descriptors-v1", "realization-envelope-v1"} ] diff --git a/implementations/python/tests/test_experiment_bindings.py b/implementations/python/tests/test_experiment_bindings.py new file mode 100644 index 000000000..44f793ebd --- /dev/null +++ b/implementations/python/tests/test_experiment_bindings.py @@ -0,0 +1,1281 @@ +"""Authoritative cross-plane experiment binding contract tests.""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path + +import pytest +from jsonschema import Draft202012Validator +from pydantic import ValidationError +from raes_conformance.conformance import _validate_payload +from raes_contracts.contracts import ( + BackendManifestV2Model, + ConfigurationTargetDeclarationModel, + ExperimentBindingDescriptorSetModel, + ExperimentRunModel, + ExperimentSpecModel, + LiteralBindingValueModel, + ParticipantConfigurationResultModel, + ParticipantImplementationManifestModel, + ParticipantImplementationProvenanceModel, + ParticipantImplementationSelectionModel, + ProcessorManifestV2Model, + RealizedBindingProvenanceModel, + schema_bundle, +) +from raes_contracts.experiment_bindings import ( + ScenarioBindingResolution, + validate_experiment_binding_targets, +) +from raes_contracts.participant_configuration import ( + ConfigurationOverrideModel, + realize_participant_configuration, + validate_participant_configuration_selection, +) +from raes_contracts.satisfiability import canonical_contract_digest + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_PARTICIPANT_MANIFEST_FIXTURE = ( + _REPO_ROOT + / "contracts" + / "fixtures" + / "participant-implementation-manifest" + / "participant-implementation-manifest-v1" + / "valid" + / "reference.json" +) + + +def _scenario_binding_payload() -> dict[str, object]: + return { + "schema_version": "experiment-binding-descriptors/v1", + "descriptors": [ + { + "binding_id": "binding.worker-count", + "source_factor_id": "factor.worker-count", + "source_factor_level_id": "four", + "source_condition_id": "condition.four-workers", + "target": { + "plane": "scenario", + "scenario_family_id": "family.techvault", + "variation_point_id": "variation.worker-count", + "target_id": "variables.worker_count", + }, + "value_type": "integer", + "value": {"kind": "literal", "value": 4}, + "owner": { + "contract_id": "sdl-authoring-input-v1", + "contract_version": "1", + "validator_id": "raes-sdl-instantiation", + "validator_version": "1", + }, + } + ], + } + + +def test_binding_descriptor_preserves_explicit_source_and_plane() -> None: + model = ExperimentBindingDescriptorSetModel.model_validate(_scenario_binding_payload()) + + descriptor = model.descriptors[0] + assert descriptor.source_factor_id == "factor.worker-count" + assert descriptor.source_factor_level_id == "four" + assert descriptor.source_condition_id == "condition.four-workers" + assert descriptor.target.plane == "scenario" + assert descriptor.target.target_id == "variables.worker_count" + + +@pytest.mark.parametrize("value", [True, "4", 4.0]) +def test_binding_descriptor_rejects_integer_coercion(value: object) -> None: + payload = _scenario_binding_payload() + payload["descriptors"][0]["value"]["value"] = value # type: ignore[index] + + with pytest.raises(ValidationError, match="value_type"): + ExperimentBindingDescriptorSetModel.model_validate(payload) + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) +def test_binding_descriptor_rejects_non_finite_numbers(value: float) -> None: + payload = _scenario_binding_payload() + payload["descriptors"][0]["value_type"] = "number" # type: ignore[index] + payload["descriptors"][0]["value"]["value"] = value # type: ignore[index] + + with pytest.raises(ValidationError, match="finite"): + ExperimentBindingDescriptorSetModel.model_validate(payload) + + +def test_binding_descriptor_rejects_unknown_plane() -> None: + payload = _scenario_binding_payload() + payload["descriptors"][0]["target"]["plane"] = "backend-private" # type: ignore[index] + + with pytest.raises(ValidationError): + ExperimentBindingDescriptorSetModel.model_validate(payload) + + +def test_binding_descriptor_rejects_duplicate_canonical_target_even_when_values_match() -> None: + payload = _scenario_binding_payload() + duplicate = deepcopy(payload["descriptors"][0]) # type: ignore[index] + duplicate["binding_id"] = "binding.worker-count-duplicate" + payload["descriptors"].append(duplicate) # type: ignore[union-attr] + + with pytest.raises(ValidationError, match="duplicate canonical target"): + ExperimentBindingDescriptorSetModel.model_validate(payload) + + +def test_binding_descriptor_allows_same_target_in_mutually_exclusive_conditions() -> None: + payload = _scenario_binding_payload() + second = deepcopy(payload["descriptors"][0]) # type: ignore[index] + second["binding_id"] = "binding.worker-count.two" + second["source_factor_level_id"] = "two" + second["source_condition_id"] = "condition.two-workers" + second["value"]["value"] = 2 + payload["descriptors"].append(second) # type: ignore[union-attr] + + model = ExperimentBindingDescriptorSetModel.model_validate(payload) + + assert len(model.descriptors) == 2 + + +def test_secret_reference_is_structurally_distinct_and_has_no_resolved_value_field() -> None: + payload = _scenario_binding_payload() + descriptor = payload["descriptors"][0] # type: ignore[index] + descriptor["value_type"] = "string" + descriptor["value"] = { + "kind": "secret-reference", + "reference_id": "operator-secret.techvault-password", + } + + model = ExperimentBindingDescriptorSetModel.model_validate(payload) + dumped = model.model_dump(mode="json") + + assert dumped["descriptors"][0]["value"] == { + "kind": "secret-reference", + "reference_id": "operator-secret.techvault-password", + } + assert "resolved_value" not in str(dumped) + + +def test_secret_reference_rejects_literal_or_locator_smuggling() -> None: + payload = _scenario_binding_payload() + descriptor = payload["descriptors"][0] # type: ignore[index] + descriptor["value_type"] = "string" + descriptor["value"] = { + "kind": "secret-reference", + "reference_id": "operator-secret.techvault-password", + "resolved_value": "do-not-record", + } + + with pytest.raises(ValidationError): + ExperimentBindingDescriptorSetModel.model_validate(payload) + + +@pytest.mark.parametrize("reference_id", ["/run/secrets/api-key", "../../secret", "ENV:API_KEY"]) +def test_secret_reference_rejects_host_locator_shapes(reference_id: str) -> None: + payload = _scenario_binding_payload() + descriptor = payload["descriptors"][0] # type: ignore[index] + descriptor["value_type"] = "string" + descriptor["value"] = { + "kind": "secret-reference", + "reference_id": reference_id, + } + + with pytest.raises(ValidationError): + ExperimentBindingDescriptorSetModel.model_validate(payload) + + +def _participant_manifest_payload() -> dict[str, object]: + payload = json.loads(_PARTICIPANT_MANIFEST_FIXTURE.read_text(encoding="utf-8")) + for contract_id in ["experiment-binding-descriptors-v1", "participant-configuration-result-v1"]: + if contract_id not in payload["supported_contract_versions"]: + payload["supported_contract_versions"].append(contract_id) + payload["configuration_registry"] = { + "owner": { + "contract_id": "participant-configuration-result-v1", + "contract_version": "1", + "validator_id": "reference-participant-configuration", + "validator_version": "1", + }, + "targets": { + "policy.temperature": { + "target_id": "policy.temperature", + "value_type": "number", + "aliases": ["temperature"], + "allowed_value_kinds": ["literal"], + "sensitivity": "public", + "default": {"kind": "literal", "value": 0.25}, + }, + "policy.mode": { + "target_id": "policy.mode", + "value_type": "string", + "aliases": ["mode"], + "allowed_value_kinds": ["literal"], + "sensitivity": "internal", + }, + "credentials.api": { + "target_id": "credentials.api", + "value_type": "string", + "aliases": [], + "allowed_value_kinds": ["secret-reference"], + "sensitivity": "secret", + }, + }, + } + return payload + + +def test_participant_manifest_publishes_typed_configuration_targets() -> None: + manifest = ParticipantImplementationManifestModel.model_validate(_participant_manifest_payload()) + + assert manifest.configuration_registry is not None + assert manifest.configuration_registry.targets["policy.temperature"].value_type == "number" + assert manifest.configuration_registry.targets["policy.temperature"].aliases == ["temperature"] + + +def test_configuration_registry_rejects_alias_collision_with_canonical_target() -> None: + payload = _participant_manifest_payload() + payload["configuration_registry"]["targets"]["policy.temperature"]["aliases"] = ["policy.mode"] # type: ignore[index] + + with pytest.raises(ValidationError, match="alias"): + ParticipantImplementationManifestModel.model_validate(payload) + + +@pytest.mark.parametrize("aliases", [["mode", "mode"], ["policy.mode"]]) +def test_configuration_target_rejects_duplicate_or_self_alias(aliases: list[str]) -> None: + with pytest.raises(ValidationError, match="alias"): + ConfigurationTargetDeclarationModel( + target_id="policy.mode", + value_type="string", + aliases=aliases, + allowed_value_kinds=["literal"], + sensitivity="internal", + ) + + +def test_secret_configuration_target_rejects_literal_value_kind() -> None: + with pytest.raises(ValidationError, match="admit only secret-reference"): + ConfigurationTargetDeclarationModel( + target_id="credentials.api", + value_type="string", + aliases=[], + allowed_value_kinds=["literal", "secret-reference"], + sensitivity="secret", + ) + + +def test_secret_configuration_target_rejects_portable_default() -> None: + with pytest.raises(ValidationError, match="must not declare portable defaults"): + ConfigurationTargetDeclarationModel( + target_id="credentials.api", + value_type="string", + aliases=[], + allowed_value_kinds=["secret-reference"], + sensitivity="secret", + default={"kind": "literal", "value": "plaintext"}, + ) + + +@pytest.mark.parametrize( + "missing_contract_id", + ["experiment-binding-descriptors-v1", "participant-configuration-result-v1"], +) +def test_participant_configuration_registry_requires_supported_contract_ids( + missing_contract_id: str, +) -> None: + payload = _participant_manifest_payload() + payload["supported_contract_versions"].remove(missing_contract_id) # type: ignore[union-attr] + + with pytest.raises(ValidationError, match="configuration_registry requires supported_contract_versions"): + ParticipantImplementationManifestModel.model_validate(payload) + + +def test_complete_participant_configuration_applies_defaults_and_alias_overrides_atomically() -> None: + manifest = ParticipantImplementationManifestModel.model_validate(_participant_manifest_payload()) + + result = realize_participant_configuration( + participant_address="participants.red", + manifest=manifest, + manifest_ref="manifests/reference-red-agent.json", + manifest_digest="sha256:" + "1" * 64, + overrides=[ + ConfigurationOverrideModel( + target_id="mode", + value={"kind": "literal", "value": "deterministic"}, + ), + ConfigurationOverrideModel( + target_id="credentials.api", + value={"kind": "secret-reference", "reference_id": "operator-secret.reference-red-api"}, + ), + ], + ) + + entries = {entry.target_id: entry for entry in result.configuration.values} + assert entries["policy.temperature"].origin == "default" + assert entries["policy.mode"].origin == "override" + assert entries["credentials.api"].value.kind == "secret-reference" + assert result.configuration_digest == canonical_contract_digest(result.configuration) + assert "operator-secret.reference-red-api" in result.model_dump_json() + assert "resolved" not in result.model_dump_json() + + +def test_participant_configuration_digest_is_independent_of_override_order_and_alias_spelling() -> None: + manifest = ParticipantImplementationManifestModel.model_validate(_participant_manifest_payload()) + common = { + "participant_address": "participants.red", + "manifest": manifest, + "manifest_ref": "manifests/reference-red-agent.json", + "manifest_digest": "sha256:" + "1" * 64, + } + canonical = realize_participant_configuration( + **common, + overrides=[ + ConfigurationOverrideModel( + target_id="policy.mode", + value={"kind": "literal", "value": "deterministic"}, + ), + ConfigurationOverrideModel( + target_id="credentials.api", + value={"kind": "secret-reference", "reference_id": "operator-secret.reference-red-api"}, + ), + ], + ) + reordered_alias = realize_participant_configuration( + **common, + overrides=[ + ConfigurationOverrideModel( + target_id="credentials.api", + value={"kind": "secret-reference", "reference_id": "operator-secret.reference-red-api"}, + ), + ConfigurationOverrideModel( + target_id="mode", + value={"kind": "literal", "value": "deterministic"}, + ), + ], + ) + + assert canonical.configuration_digest == reordered_alias.configuration_digest + + +def test_participant_selection_joins_to_authoritative_configuration_result_digest() -> None: + manifest = ParticipantImplementationManifestModel.model_validate(_participant_manifest_payload()) + result = realize_participant_configuration( + participant_address="participants.red", + manifest=manifest, + manifest_ref="manifests/reference-red-agent.json", + manifest_digest="sha256:" + "1" * 64, + overrides=[ + ConfigurationOverrideModel( + target_id="mode", + value={"kind": "literal", "value": "deterministic"}, + ), + ConfigurationOverrideModel( + target_id="credentials.api", + value={"kind": "secret-reference", "reference_id": "operator-secret.reference-red-api"}, + ), + ], + ) + selection_payload = json.loads( + ( + _REPO_ROOT + / "contracts" + / "fixtures" + / "participant-implementation-provenance" + / "participant-implementation-provenance-v1" + / "valid" + / "reference.json" + ).read_text(encoding="utf-8") + )["participant_implementations"][0] + selection_payload.update( + { + "manifest_ref": result.manifest_ref, + "manifest_digest": result.manifest_digest, + "configuration_ref": "participant-configurations/red/result.json", + "configuration_digest": result.configuration_digest, + } + ) + selection = ParticipantImplementationSelectionModel.model_validate(selection_payload) + + validate_participant_configuration_selection(selection, result) + + mismatched = selection.model_copy(update={"configuration_digest": "sha256:" + "f" * 64}) + with pytest.raises(ValueError, match="configuration digest"): + validate_participant_configuration_selection(mismatched, result) + + +def test_participant_configuration_rejects_duplicate_canonical_override_via_alias() -> None: + manifest = ParticipantImplementationManifestModel.model_validate(_participant_manifest_payload()) + overrides = [ + ConfigurationOverrideModel( + target_id="policy.mode", + value={"kind": "literal", "value": "deterministic"}, + ), + ConfigurationOverrideModel( + target_id="mode", + value={"kind": "literal", "value": "deterministic"}, + ), + ] + + with pytest.raises(ValueError, match="duplicate canonical target"): + realize_participant_configuration( + participant_address="participants.red", + manifest=manifest, + manifest_ref="manifests/reference-red-agent.json", + manifest_digest="sha256:" + "1" * 64, + overrides=overrides, + ) + + +def test_participant_configuration_rejects_missing_required_target_without_partial_result() -> None: + manifest = ParticipantImplementationManifestModel.model_validate(_participant_manifest_payload()) + + with pytest.raises(ValueError, match="required configuration target"): + realize_participant_configuration( + participant_address="participants.red", + manifest=manifest, + manifest_ref="manifests/reference-red-agent.json", + manifest_digest="sha256:" + "1" * 64, + overrides=[], + ) + + +def test_participant_configuration_rejects_type_coercion() -> None: + manifest = ParticipantImplementationManifestModel.model_validate(_participant_manifest_payload()) + overrides = [ + ConfigurationOverrideModel( + target_id="policy.mode", + value={"kind": "literal", "value": "deterministic"}, + ), + ConfigurationOverrideModel( + target_id="temperature", + value={"kind": "literal", "value": "0.5"}, + ), + ConfigurationOverrideModel( + target_id="credentials.api", + value={"kind": "secret-reference", "reference_id": "operator-secret.reference-red-api"}, + ), + ] + + with pytest.raises(ValueError, match="value_type"): + realize_participant_configuration( + participant_address="participants.red", + manifest=manifest, + manifest_ref="manifests/reference-red-agent.json", + manifest_digest="sha256:" + "1" * 64, + overrides=overrides, + ) + + +class _OriginChangingValidator: + def validate_and_normalize(self, configuration): + values = [ + value.model_copy(update={"origin": "default"}) if value.origin == "override" else value + for value in configuration.values + ] + return configuration.model_copy(update={"values": values}) + + +class _SecretDispositionChangingValidator: + def validate_and_normalize(self, configuration): + values = [ + value.model_copy(update={"value": LiteralBindingValueModel(kind="literal", value="resolved-secret")}) + if value.target_id == "policy.mode" + else value + for value in configuration.values + ] + return configuration.model_copy(update={"values": values}) + + +def test_participant_owner_normalization_must_preserve_default_override_provenance() -> None: + manifest = ParticipantImplementationManifestModel.model_validate(_participant_manifest_payload()) + overrides = [ + ConfigurationOverrideModel( + target_id="mode", + value={"kind": "literal", "value": "deterministic"}, + ), + ConfigurationOverrideModel( + target_id="credentials.api", + value={"kind": "secret-reference", "reference_id": "operator-secret.reference-red-api"}, + ), + ] + validator = _OriginChangingValidator() + + with pytest.raises(ValueError, match="origin"): + realize_participant_configuration( + participant_address="participants.red", + manifest=manifest, + manifest_ref="manifests/reference-red-agent.json", + manifest_digest="sha256:" + "1" * 64, + overrides=overrides, + validator=validator, + ) + + +def test_participant_owner_normalization_must_not_replace_secret_reference_with_literal() -> None: + payload = _participant_manifest_payload() + payload["configuration_registry"]["targets"]["policy.mode"]["allowed_value_kinds"] = [ # type: ignore[index] + "literal", + "secret-reference", + ] + manifest = ParticipantImplementationManifestModel.model_validate(payload) + overrides = [ + ConfigurationOverrideModel( + target_id="mode", + value={"kind": "secret-reference", "reference_id": "operator-secret.mode"}, + ), + ConfigurationOverrideModel( + target_id="credentials.api", + value={"kind": "secret-reference", "reference_id": "operator-secret.reference-red-api"}, + ), + ] + validator = _SecretDispositionChangingValidator() + + with pytest.raises(ValueError, match="literal/secret-reference disposition"): + realize_participant_configuration( + participant_address="participants.red", + manifest=manifest, + manifest_ref="manifests/reference-red-agent.json", + manifest_digest="sha256:" + "1" * 64, + overrides=overrides, + validator=validator, + ) + + +def _experiment_spec_payload_with_bindings() -> dict[str, object]: + payload = json.loads( + ( + _REPO_ROOT + / "contracts" + / "fixtures" + / "experiment-core" + / "experiment-authoring-input-v1" + / "valid" + / "reference.json" + ).read_text(encoding="utf-8") + ) + for condition_id, assignment in payload["run_plan"]["allocation"]["condition_assignments"].items(): + assignment.pop("required_parameters") + assignment["required_refs"] = [ + { + "ref_kind": "profile", + "ref_id": f"protocol.reference-red-tactic.{condition_id}", + } + ] + payload["binding_semantics"] = "explicit-required" + payload["binding_descriptors"] = { + "schema_version": "experiment-binding-descriptors/v1", + "descriptors": [ + { + **_scenario_binding_payload()["descriptors"][0], + "binding_id": "binding.red-tactic.aggressive", + "source_factor_id": "red-tactic", + "source_factor_level_id": "aggressive", + "source_condition_id": "cond-aggressive", + "value_type": "string", + "value": {"kind": "literal", "value": "aggressive"}, + }, + { + **_scenario_binding_payload()["descriptors"][0], + "binding_id": "binding.red-tactic.stealthy", + "source_factor_id": "red-tactic", + "source_factor_level_id": "stealthy", + "source_condition_id": "cond-stealthy", + "value_type": "string", + "value": {"kind": "literal", "value": "stealthy"}, + }, + ], + } + return payload + + +def test_experiment_spec_joins_binding_sources_to_declared_factor_levels_and_conditions() -> None: + spec = ExperimentSpecModel.model_validate(_experiment_spec_payload_with_bindings()) + + assert spec.binding_semantics == "explicit-required" + assert spec.binding_descriptors is not None + assert {item.source_condition_id for item in spec.binding_descriptors.descriptors} == { + "cond-aggressive", + "cond-stealthy", + } + + +def test_experiment_spec_rejects_binding_factor_level_mismatch() -> None: + payload = _experiment_spec_payload_with_bindings() + payload["binding_descriptors"]["descriptors"][0]["source_factor_level_id"] = "stealthy" # type: ignore[index] + + with pytest.raises(ValidationError, match="factor level"): + ExperimentSpecModel.model_validate(payload) + + +def test_experiment_spec_fails_closed_when_explicit_bindings_are_required_but_absent() -> None: + payload = _experiment_spec_payload_with_bindings() + del payload["binding_descriptors"] + + with pytest.raises(ValidationError, match="explicit-required"): + ExperimentSpecModel.model_validate(payload) + + +def test_experiment_spec_rejects_ambiguous_legacy_parameters_in_explicit_binding_mode() -> None: + payload = _experiment_spec_payload_with_bindings() + assignment = payload["run_plan"]["allocation"]["condition_assignments"]["cond-aggressive"] # type: ignore[index] + assignment["required_parameters"] = [ + { + "name": "red_tactic", + "value": "aggressive", + "value_kind": "protocol", + } + ] + + with pytest.raises(ValidationError, match="legacy required_parameters"): + ExperimentSpecModel.model_validate(payload) + + +def test_realized_binding_provenance_preserves_non_secret_value_and_configuration_digest() -> None: + descriptor = ExperimentBindingDescriptorSetModel.model_validate(_scenario_binding_payload()).descriptors[0] + + provenance = RealizedBindingProvenanceModel( + descriptor=descriptor, + origin="override", + configuration_digest="sha256:" + "2" * 64, + ) + + assert provenance.descriptor.binding_id == "binding.worker-count" + assert provenance.origin == "override" + assert provenance.configuration_digest == "sha256:" + "2" * 64 + + +@pytest.mark.parametrize( + ("model_type", "fixture_path"), + [ + ( + ProcessorManifestV2Model, + _REPO_ROOT / "contracts/fixtures/processor-manifest/processor-manifest-v2/valid/reference.json", + ), + ( + BackendManifestV2Model, + _REPO_ROOT / "contracts/fixtures/backend-manifest/backend-manifest-v2/valid/stub.json", + ), + ], +) +def test_apparatus_manifests_publish_typed_configuration_targets( + model_type: type[ProcessorManifestV2Model] | type[BackendManifestV2Model], + fixture_path: Path, +) -> None: + payload = json.loads(fixture_path.read_text(encoding="utf-8")) + payload["supported_contract_versions"].append("experiment-binding-descriptors-v1") + payload["configuration_registry"] = { + "owner": { + "contract_id": payload["schema_version"], + "contract_version": "1", + "validator_id": f"{payload['identity']['name']}-configuration", + "validator_version": "1", + }, + "targets": { + "execution.parallelism": { + "target_id": "execution.parallelism", + "value_type": "integer", + "aliases": ["parallelism"], + "allowed_value_kinds": ["literal"], + "sensitivity": "internal", + "default": {"kind": "literal", "value": 1}, + } + }, + } + + manifest = model_type.model_validate(payload) + + assert manifest.configuration_registry is not None + assert manifest.configuration_registry.targets["execution.parallelism"].value_type == "integer" + + +def test_experiment_run_archives_realized_binding_provenance() -> None: + payload = json.loads( + ( + _REPO_ROOT / "contracts" / "fixtures" / "experiment-core" / "experiment-run-v1" / "valid" / "reference.json" + ).read_text(encoding="utf-8") + ) + payload["realized_bindings"] = [ + { + "descriptor": _scenario_binding_payload()["descriptors"][0], + "origin": "selection", + } + ] + + run = ExperimentRunModel.model_validate(payload) + + assert run.realized_bindings[0].descriptor.source_condition_id == "condition.four-workers" + + +def test_participant_selection_requires_configuration_ref_and_digest_together() -> None: + payload = json.loads( + ( + _REPO_ROOT + / "contracts" + / "fixtures" + / "participant-implementation-provenance" + / "participant-implementation-provenance-v1" + / "valid" + / "reference.json" + ).read_text(encoding="utf-8") + ) + del payload["participant_implementations"][0]["configuration_digest"] + + with pytest.raises(ValidationError, match="configuration_ref and configuration_digest"): + ParticipantImplementationProvenanceModel.model_validate(payload) + + +class _ScenarioResolver: + def resolve( + self, + scenario_family_id: str, + variation_point_id: str, + supplied_target_id: str, + ) -> ScenarioBindingResolution: + if ( + scenario_family_id, + variation_point_id, + supplied_target_id, + ) != ("family.techvault", "variation.worker-count", "variables.worker_count"): + raise ValueError("unknown scenario variation target") + return ScenarioBindingResolution( + canonical_target_id="variables.worker_count", + value_type="integer", + allowed_value_kinds=["literal"], + sensitivity="public", + owner={ + "contract_id": "sdl-authoring-input-v1", + "contract_version": "1", + "validator_id": "raes-sdl-instantiation", + "validator_version": "1", + }, + ) + + +class _ScenarioTypeMismatchResolver(_ScenarioResolver): + def resolve(self, scenario_family_id, variation_point_id, supplied_target_id): + resolution = super().resolve(scenario_family_id, variation_point_id, supplied_target_id) + return resolution.model_copy(update={"value_type": "string"}) + + +class _ScenarioOwnerMismatchResolver(_ScenarioResolver): + def resolve(self, scenario_family_id, variation_point_id, supplied_target_id): + resolution = super().resolve(scenario_family_id, variation_point_id, supplied_target_id) + owner = resolution.owner.model_copy(update={"validator_version": "2"}) + return resolution.model_copy(update={"owner": owner}) + + +class _SecretLiteralScenarioResolver(_ScenarioResolver): + def resolve(self, scenario_family_id, variation_point_id, supplied_target_id): + resolution = super().resolve(scenario_family_id, variation_point_id, supplied_target_id) + return resolution.model_copy( + update={ + "allowed_value_kinds": ["literal", "secret-reference"], + "sensitivity": "secret", + } + ) + + +def _participant_binding_payload(target_id: str = "mode") -> dict[str, object]: + return { + "schema_version": "experiment-binding-descriptors/v1", + "descriptors": [ + { + "binding_id": "binding.participant-mode", + "source_factor_id": "participant-mode", + "source_factor_level_id": "deterministic", + "source_condition_id": "condition.deterministic", + "target": { + "plane": "participant-implementation", + "participant_address": "participants.red", + "implementation_name": "reference-red-agent", + "implementation_version": "1.0.0", + "manifest_version": "participant-implementation-manifest/v1", + "target_id": target_id, + }, + "value_type": "string", + "value": {"kind": "literal", "value": "deterministic"}, + "owner": { + "contract_id": "participant-configuration-result-v1", + "contract_version": "1", + "validator_id": "reference-participant-configuration", + "validator_version": "1", + }, + } + ], + } + + +def _apparatus_binding_payload( + *, + component_kind: str = "processor", + component_name: str = "aces-reference-processor", + component_version: str = "0.2.0", + manifest_version: str = "processor-manifest/v2", +) -> dict[str, object]: + return { + "schema_version": "experiment-binding-descriptors/v1", + "descriptors": [ + { + "binding_id": "binding.apparatus-parallelism", + "source_factor_id": "parallelism", + "source_factor_level_id": "four", + "source_condition_id": "condition.four-workers", + "target": { + "plane": "apparatus", + "component_kind": component_kind, + "component_name": component_name, + "component_version": component_version, + "manifest_version": manifest_version, + "target_id": "execution.parallelism", + }, + "value_type": "integer", + "value": {"kind": "literal", "value": 4}, + "owner": { + "contract_id": "processor-manifest/v2", + "contract_version": "1", + "validator_id": "aces-reference-processor-configuration", + "validator_version": "1", + }, + } + ], + } + + +def _processor_manifest_payload_with_registry() -> dict[str, object]: + fixture_path = ( + _REPO_ROOT + / "contracts" + / "fixtures" + / "processor-manifest" + / "processor-manifest-v2" + / "valid" + / "reference.json" + ) + payload = json.loads(fixture_path.read_text(encoding="utf-8")) + payload["supported_contract_versions"].append("experiment-binding-descriptors-v1") + payload["configuration_registry"] = { + "owner": { + "contract_id": "processor-manifest/v2", + "contract_version": "1", + "validator_id": "aces-reference-processor-configuration", + "validator_version": "1", + }, + "targets": { + "execution.parallelism": { + "target_id": "execution.parallelism", + "value_type": "integer", + "aliases": ["parallelism"], + "allowed_value_kinds": ["literal"], + "sensitivity": "internal", + "default": {"kind": "literal", "value": 1}, + } + }, + } + return payload + + +def _participant_manifest_map( + manifest: ParticipantImplementationManifestModel, +) -> dict[tuple[str, str, str, str], ParticipantImplementationManifestModel]: + return { + ( + "participants.red", + "reference-red-agent", + "1.0.0", + "participant-implementation-manifest/v1", + ): manifest + } + + +def _apparatus_manifest_map( + manifest: ProcessorManifestV2Model, + *, + component_kind: str = "processor", +) -> dict[tuple[str, str, str, str], ProcessorManifestV2Model]: + return { + ( + component_kind, + "aces-reference-processor", + "0.2.0", + "processor-manifest/v2", + ): manifest + } + + +def test_canonical_target_identity_preserves_scenario_variation_point() -> None: + payload = _scenario_binding_payload() + second = deepcopy(payload["descriptors"][0]) # type: ignore[index] + second["binding_id"] = "binding.worker-count.alternate-variation" + second["target"]["variation_point_id"] = "variation.alternate-worker-count" + payload["descriptors"].append(second) # type: ignore[union-attr] + + model = ExperimentBindingDescriptorSetModel.model_validate(payload) + + assert len(model.descriptors) == 2 + + +def test_canonical_target_identity_preserves_participant_manifest_version() -> None: + payload = _participant_binding_payload() + second = deepcopy(payload["descriptors"][0]) # type: ignore[index] + second["binding_id"] = "binding.participant-mode.v2" + second["target"]["manifest_version"] = "participant-implementation-manifest/v2" + payload["descriptors"].append(second) # type: ignore[union-attr] + + model = ExperimentBindingDescriptorSetModel.model_validate(payload) + + assert len(model.descriptors) == 2 + + +def test_canonical_target_identity_preserves_apparatus_manifest_version() -> None: + payload = _apparatus_binding_payload() + second = deepcopy(payload["descriptors"][0]) # type: ignore[index] + second["binding_id"] = "binding.apparatus-parallelism.v3" + second["target"]["manifest_version"] = "processor-manifest/v3" + payload["descriptors"].append(second) # type: ignore[union-attr] + + model = ExperimentBindingDescriptorSetModel.model_validate(payload) + + assert len(model.descriptors) == 2 + + +def test_canonical_target_identity_keeps_apparatus_owner_coordinates_structured() -> None: + payload = _apparatus_binding_payload(component_name="alpha:beta", component_version="gamma") + second = deepcopy(payload["descriptors"][0]) # type: ignore[index] + second["binding_id"] = "binding.apparatus-parallelism.distinct-owner" + second["target"]["component_name"] = "alpha" + second["target"]["component_version"] = "beta@gamma" + payload["descriptors"].append(second) # type: ignore[union-attr] + + model = ExperimentBindingDescriptorSetModel.model_validate(payload) + + assert len(model.descriptors) == 2 + + +def test_binding_admission_resolves_alias_only_through_declared_participant_owner() -> None: + manifest = ParticipantImplementationManifestModel.model_validate(_participant_manifest_payload()) + descriptors = ExperimentBindingDescriptorSetModel.model_validate(_participant_binding_payload()) + + admitted = validate_experiment_binding_targets( + descriptors, + scenario_resolver=_ScenarioResolver(), + participant_manifests={ + ( + "participants.red", + "reference-red-agent", + "1.0.0", + "participant-implementation-manifest/v1", + ): manifest + }, + apparatus_manifests={}, + ) + + assert admitted.descriptors[0].target.target_id == "policy.mode" + + +@pytest.mark.parametrize( + ("resolver", "message"), + [ + (_ScenarioTypeMismatchResolver(), "value_type"), + (_ScenarioOwnerMismatchResolver(), "owner"), + ], +) +def test_scenario_binding_admission_rejects_resolver_contract_mismatch( + resolver: _ScenarioResolver, + message: str, +) -> None: + descriptors = ExperimentBindingDescriptorSetModel.model_validate(_scenario_binding_payload()) + + with pytest.raises(ValueError, match=message): + validate_experiment_binding_targets( + descriptors, + scenario_resolver=resolver, + participant_manifests={}, + apparatus_manifests={}, + ) + + +def test_scenario_binding_admission_rejects_literal_for_secret_target() -> None: + descriptors = ExperimentBindingDescriptorSetModel.model_validate(_scenario_binding_payload()) + scenario_resolver = _SecretLiteralScenarioResolver() + + with pytest.raises(ValueError, match="secret scenario targets"): + validate_experiment_binding_targets( + descriptors, + scenario_resolver=scenario_resolver, + participant_manifests={}, + apparatus_manifests={}, + ) + + +def test_binding_admission_rejects_miskeyed_participant_manifest_identity() -> None: + manifest_payload = _participant_manifest_payload() + manifest_payload["identity"]["name"] = "different-red-agent" # type: ignore[index] + manifest = ParticipantImplementationManifestModel.model_validate(manifest_payload) + descriptors = ExperimentBindingDescriptorSetModel.model_validate(_participant_binding_payload()) + scenario_resolver = _ScenarioResolver() + participant_manifests = _participant_manifest_map(manifest) + + with pytest.raises(ValueError, match="identity must match"): + validate_experiment_binding_targets( + descriptors, + scenario_resolver=scenario_resolver, + participant_manifests=participant_manifests, + apparatus_manifests={}, + ) + + +def test_binding_admission_rejects_participant_manifest_without_registry() -> None: + manifest_payload = _participant_manifest_payload() + del manifest_payload["configuration_registry"] + manifest = ParticipantImplementationManifestModel.model_validate(manifest_payload) + descriptors = ExperimentBindingDescriptorSetModel.model_validate(_participant_binding_payload()) + scenario_resolver = _ScenarioResolver() + participant_manifests = _participant_manifest_map(manifest) + + with pytest.raises(ValueError, match="no configuration target registry"): + validate_experiment_binding_targets( + descriptors, + scenario_resolver=scenario_resolver, + participant_manifests=participant_manifests, + apparatus_manifests={}, + ) + + +def test_binding_admission_rejects_participant_target_owner_mismatch() -> None: + manifest = ParticipantImplementationManifestModel.model_validate(_participant_manifest_payload()) + payload = _participant_binding_payload() + payload["descriptors"][0]["owner"]["validator_version"] = "2" # type: ignore[index] + descriptors = ExperimentBindingDescriptorSetModel.model_validate(payload) + scenario_resolver = _ScenarioResolver() + participant_manifests = _participant_manifest_map(manifest) + + with pytest.raises(ValueError, match="owner"): + validate_experiment_binding_targets( + descriptors, + scenario_resolver=scenario_resolver, + participant_manifests=participant_manifests, + apparatus_manifests={}, + ) + + +def test_binding_admission_rejects_participant_target_value_type_mismatch() -> None: + manifest = ParticipantImplementationManifestModel.model_validate(_participant_manifest_payload()) + payload = _participant_binding_payload() + payload["descriptors"][0]["value_type"] = "integer" # type: ignore[index] + payload["descriptors"][0]["value"]["value"] = 1 # type: ignore[index] + descriptors = ExperimentBindingDescriptorSetModel.model_validate(payload) + scenario_resolver = _ScenarioResolver() + participant_manifests = _participant_manifest_map(manifest) + + with pytest.raises(ValueError, match="value_type"): + validate_experiment_binding_targets( + descriptors, + scenario_resolver=scenario_resolver, + participant_manifests=participant_manifests, + apparatus_manifests={}, + ) + + +def test_binding_admission_rejects_miskeyed_apparatus_manifest_identity() -> None: + manifest_payload = _processor_manifest_payload_with_registry() + manifest_payload["identity"]["name"] = "different-processor" # type: ignore[index] + manifest = ProcessorManifestV2Model.model_validate(manifest_payload) + descriptors = ExperimentBindingDescriptorSetModel.model_validate(_apparatus_binding_payload()) + scenario_resolver = _ScenarioResolver() + apparatus_manifests = _apparatus_manifest_map(manifest) + + with pytest.raises(ValueError, match="identity and kind must match"): + validate_experiment_binding_targets( + descriptors, + scenario_resolver=scenario_resolver, + participant_manifests={}, + apparatus_manifests=apparatus_manifests, + ) + + +def test_binding_admission_rejects_apparatus_manifest_without_registry() -> None: + fixture_path = ( + _REPO_ROOT + / "contracts" + / "fixtures" + / "processor-manifest" + / "processor-manifest-v2" + / "valid" + / "reference.json" + ) + manifest = ProcessorManifestV2Model.model_validate_json(fixture_path.read_text(encoding="utf-8")) + descriptors = ExperimentBindingDescriptorSetModel.model_validate(_apparatus_binding_payload()) + scenario_resolver = _ScenarioResolver() + apparatus_manifests = _apparatus_manifest_map(manifest) + + with pytest.raises(ValueError, match="no configuration target registry"): + validate_experiment_binding_targets( + descriptors, + scenario_resolver=scenario_resolver, + participant_manifests={}, + apparatus_manifests=apparatus_manifests, + ) + + +def test_binding_admission_rejects_apparatus_kind_mismatch() -> None: + manifest = ProcessorManifestV2Model.model_validate(_processor_manifest_payload_with_registry()) + descriptors = ExperimentBindingDescriptorSetModel.model_validate( + _apparatus_binding_payload(component_kind="backend") + ) + scenario_resolver = _ScenarioResolver() + apparatus_manifests = _apparatus_manifest_map(manifest, component_kind="backend") + + with pytest.raises(ValueError, match="identity and kind must match"): + validate_experiment_binding_targets( + descriptors, + scenario_resolver=scenario_resolver, + participant_manifests={}, + apparatus_manifests=apparatus_manifests, + ) + + +def test_binding_admission_rejects_unknown_participant_target_without_cross_plane_fallback() -> None: + manifest = ParticipantImplementationManifestModel.model_validate(_participant_manifest_payload()) + descriptors = ExperimentBindingDescriptorSetModel.model_validate( + _participant_binding_payload("variables.worker_count") + ) + scenario_resolver = _ScenarioResolver() + participant_manifests = _participant_manifest_map(manifest) + + with pytest.raises(ValueError, match="unknown configuration target"): + validate_experiment_binding_targets( + descriptors, + scenario_resolver=scenario_resolver, + participant_manifests=participant_manifests, + apparatus_manifests={}, + ) + + +def test_binding_admission_rejects_alias_and_canonical_duplicate_after_resolution() -> None: + manifest = ParticipantImplementationManifestModel.model_validate(_participant_manifest_payload()) + payload = _participant_binding_payload() + duplicate = deepcopy(payload["descriptors"][0]) # type: ignore[index] + duplicate["binding_id"] = "binding.participant-mode-duplicate" + duplicate["target"]["target_id"] = "policy.mode" + payload["descriptors"].append(duplicate) # type: ignore[union-attr] + descriptors = ExperimentBindingDescriptorSetModel.model_validate(payload) + scenario_resolver = _ScenarioResolver() + participant_manifests = _participant_manifest_map(manifest) + + with pytest.raises(ValidationError, match="duplicate canonical target"): + validate_experiment_binding_targets( + descriptors, + scenario_resolver=scenario_resolver, + participant_manifests=participant_manifests, + apparatus_manifests={}, + ) + + +def test_scenario_binding_admission_enforces_owner_declared_value_disposition() -> None: + payload = _scenario_binding_payload() + payload["descriptors"][0]["value_type"] = "integer" # type: ignore[index] + payload["descriptors"][0]["value"] = { # type: ignore[index] + "kind": "secret-reference", + "reference_id": "operator-secret.worker-count", + } + descriptors = ExperimentBindingDescriptorSetModel.model_validate(payload) + scenario_resolver = _ScenarioResolver() + + with pytest.raises(ValueError, match="value kind"): + validate_experiment_binding_targets( + descriptors, + scenario_resolver=scenario_resolver, + participant_manifests={}, + apparatus_manifests={}, + ) + + +def _participant_configuration_result_payload() -> dict[str, object]: + manifest = ParticipantImplementationManifestModel.model_validate(_participant_manifest_payload()) + result = realize_participant_configuration( + participant_address="participants.red", + manifest=manifest, + manifest_ref="manifests/reference-red-agent.json", + manifest_digest="sha256:" + "1" * 64, + overrides=[ + ConfigurationOverrideModel( + target_id="mode", + value={"kind": "literal", "value": "deterministic"}, + ), + ConfigurationOverrideModel( + target_id="credentials.api", + value={"kind": "secret-reference", "reference_id": "operator-secret.reference-red-api"}, + ), + ], + ) + return result.model_dump(mode="json") + + +@pytest.mark.parametrize( + ("contract_id", "model_type", "payload"), + [ + ( + "experiment-binding-descriptors-v1", + ExperimentBindingDescriptorSetModel, + _scenario_binding_payload(), + ), + ( + "participant-configuration-result-v1", + ParticipantConfigurationResultModel, + _participant_configuration_result_payload(), + ), + ], +) +def test_binding_contract_roots_are_published_and_registered( + contract_id: str, + model_type: type[ExperimentBindingDescriptorSetModel] | type[ParticipantConfigurationResultModel], + payload: dict[str, object], +) -> None: + assert contract_id in schema_bundle() + assert schema_bundle()[contract_id]["additionalProperties"] is False + assert not _validate_payload(contract_id, payload) + assert model_type.model_validate(payload) + + schema_family = ( + "experiment-core" + if contract_id == "experiment-binding-descriptors-v1" + else "participant-implementation-configuration" + ) + schema_path = _REPO_ROOT / "contracts" / "schemas" / schema_family / f"{contract_id}.json" + schema = json.loads(schema_path.read_text(encoding="utf-8")) + assert not list(Draft202012Validator(schema).iter_errors(payload)) + + +@pytest.mark.parametrize( + "contract_id", + ["experiment-binding-descriptors-v1", "participant-configuration-result-v1"], +) +def test_binding_contract_fixture_corpora_are_nonempty_and_enforced(contract_id: str) -> None: + fixture_family = ( + "experiment-core" + if contract_id == "experiment-binding-descriptors-v1" + else "participant-implementation-configuration" + ) + root = _REPO_ROOT / "contracts" / "fixtures" / fixture_family / contract_id + valid_paths = sorted((root / "valid").glob("*.json")) + invalid_paths = sorted((root / "invalid").glob("*.json")) + assert valid_paths + assert invalid_paths + for path in valid_paths: + assert not _validate_payload(contract_id, json.loads(path.read_text(encoding="utf-8"))) + for path in invalid_paths: + assert _validate_payload(contract_id, json.loads(path.read_text(encoding="utf-8"))) + + +def test_binding_schemas_disclose_semantic_invariants_and_explicit_mode_conditionals() -> None: + bundle = schema_bundle() + descriptor_invariants = {item["id"] for item in bundle["experiment-binding-descriptors-v1"]["x-aces-invariants"]} + result_invariants = {item["id"] for item in bundle["participant-configuration-result-v1"]["x-aces-invariants"]} + assert "binding-descriptors-canonical-targets-injective" in descriptor_invariants + assert "participant-configuration-digest-valid" in result_invariants + assert bundle["experiment-authoring-input-v1"]["allOf"] + + selection_schema = bundle["participant-implementation-provenance-v1"]["$defs"][ + "ParticipantImplementationSelectionModel" + ] + assert selection_schema["oneOf"] diff --git a/implementations/python/tests/test_participant_implementation_manifest.py b/implementations/python/tests/test_participant_implementation_manifest.py index 416531cae..e3f1da161 100644 --- a/implementations/python/tests/test_participant_implementation_manifest.py +++ b/implementations/python/tests/test_participant_implementation_manifest.py @@ -37,6 +37,8 @@ def _manifest_payload() -> dict[str, object]: "supported_contract_versions": [ "participant-implementation-manifest-v1", "participant-implementation-provenance-v1", + "experiment-binding-descriptors-v1", + "participant-configuration-result-v1", "participant-episode-state-envelope-v1", "participant-episode-history-event-stream-v1", "participant-behavior-history-event-stream-v1", @@ -64,6 +66,38 @@ def _manifest_payload() -> dict[str, object]: "tool_affordance_expectations": ["shell", "http-api"], "exposure_policy_kinds": ["task-statement", "observation-stream"], }, + "configuration_registry": { + "owner": { + "contract_id": "participant-configuration-result-v1", + "contract_version": "1", + "validator_id": "reference-participant-configuration", + "validator_version": "1", + }, + "targets": { + "policy.temperature": { + "target_id": "policy.temperature", + "value_type": "number", + "aliases": ["temperature"], + "allowed_value_kinds": ["literal"], + "sensitivity": "public", + "default": {"kind": "literal", "value": 0.25}, + }, + "policy.mode": { + "target_id": "policy.mode", + "value_type": "string", + "aliases": ["mode"], + "allowed_value_kinds": ["literal"], + "sensitivity": "internal", + }, + "credentials.api": { + "target_id": "credentials.api", + "value_type": "string", + "aliases": [], + "allowed_value_kinds": ["secret-reference"], + "sensitivity": "secret", + }, + }, + }, } @@ -110,7 +144,7 @@ def test_participant_implementation_manifest_roundtrip(): assert model.identity.name == "reference-red-agent" assert model.implementation_kind == "agent" - assert model.supported_contract_versions == list(PARTICIPANT_IMPLEMENTATION_SUPPORTED_CONTRACT_IDS) + assert set(model.supported_contract_versions).issubset(PARTICIPANT_IMPLEMENTATION_SUPPORTED_CONTRACT_IDS) assert model.model_dump(mode="json") == payload diff --git a/implementations/python/tests/test_run_310_supervisory_lifecycle.py b/implementations/python/tests/test_run_310_supervisory_lifecycle.py new file mode 100644 index 000000000..d05226f12 --- /dev/null +++ b/implementations/python/tests/test_run_310_supervisory_lifecycle.py @@ -0,0 +1,928 @@ +"""RUN-310 observable supervisory lifecycle tests.""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path + +import pytest +from raes.participant_behavior_specification import MixedControlTransitionKind +from raes_backend_stubs.stubs import create_stub_target +from raes_contracts.planning import RuntimeDomain +from raes_contracts.runtime_state import OperationReceipt, OperationState, OperationStatus, RuntimeSnapshot +from raes_processor.models import ( + MixedControlControllerStateRuntime, + MixedControlDispositionRulesRuntime, + MixedControlTransitionRuntime, + ParticipantBehaviorSpecificationRuntime, +) +from raes_runtime.control_plane import RuntimeControlPlane +from raes_runtime.control_plane_api import create_control_plane_app +from raes_runtime.control_plane_security import ( + ControlPlaneIdentity, + ControlPlaneRole, + ControlPlaneSecurityConfig, + ParticipantControlSubjectBinding, +) +from raes_runtime.control_plane_store import ( + AuditEvent, + ControlPlaneOperationRecord, + InMemoryControlPlaneStore, + LocalControlPlaneStore, +) +from raes_runtime.participant_control import ( + ParticipantApprovalControlIntent, + ParticipantCancellationControlIntent, + ParticipantDenialControlIntent, + ParticipantExternalDirectionControlIntent, + ParticipantHandoffControlIntent, + ParticipantInterventionControlIntent, + ParticipantOverrideControlIntent, + ParticipantProposalControlIntent, +) +from raes_runtime.participant_result_contracts import ( + participant_runtime_history_transition_diagnostics, + participant_runtime_state_contract_diagnostics, +) +from starlette.testclient import TestClient + + +def _control_event(event_id: str, *, revision: int = 1) -> dict[str, object]: + return { + "event_id": event_id, + "schema_name": "participant-control-occurrence", + "schema_version": "1.0.0", + "event_type": "participant-control-occurrence", + "extension_policy": "closed", + "participant_address": "participant.behavior.red-agent", + "episode_id": "episode-1", + "occurred_at": "2026-07-26T10:00:00Z", + "recorded_at": "2026-07-26T10:00:00Z", + "ingested_at": "2026-07-26T10:00:00Z", + "clock_authority": "runtime.control-plane.clock", + "ordering_basis": "logical_clock", + "logical_order_ref": f"order:{revision}", + "actor_ref": "participant.behavior.supervisor", + "producer_ref": "runtime.control-plane.test", + "provenance_refs": ["provenance:test"], + "evidence_refs": ["evidence:test"], + "object_marking_refs": ["marking:test"], + "authorization_scope": "nodes.web", + "occurrence": { + "kind": "handoff", + "declaration_ref": "participant.behavior-specification.controlled.control-transition.handoff", + "controller_ref": "participant.behavior.supervisor", + "controller_state_ref": "participant.behavior-specification.controlled.controller-state.autonomous", + "authority_basis_refs": ["entities.red-team"], + "controlled_scope_refs": ["nodes.web"], + "behavior_specification_ref": "participant.behavior-specification.controlled", + "mixed_control_policy_ref": "participant.behavior-specification.controlled", + "policy_revision": "1.0.0", + "expected_state_revision": 0, + "effective_order": 1, + "valid_from_order": 0, + "valid_until_order": 10, + "occurrence_revision": revision, + "disposition": "accepted", + "limitation_refs": ["limitation:none"], + "prior_controller_state_ref": ("participant.behavior-specification.controlled.controller-state.autonomous"), + "resulting_controller_state_ref": ( + "participant.behavior-specification.controlled.controller-state.supervised" + ), + "resulting_state_revision": 1, + "completion_evidence_ref": "evidence:handoff", + }, + } + + +def _operation_record(operation_id: str = "operation-1") -> ControlPlaneOperationRecord: + return ControlPlaneOperationRecord( + receipt=OperationReceipt( + operation_id=operation_id, + domain=RuntimeDomain.PARTICIPANT, + submitted_at="2026-07-26T10:00:00Z", + ), + status=OperationStatus( + operation_id=operation_id, + domain=RuntimeDomain.PARTICIPANT, + state=OperationState.SUCCEEDED, + submitted_at="2026-07-26T10:00:00Z", + updated_at="2026-07-26T10:00:00Z", + changed_addresses=["participant.behavior.red-agent"], + ), + request_fingerprint="fingerprint-1", + idempotency_key="scope-key-1", + ) + + +def _audit_event(operation_id: str = "operation-1") -> AuditEvent: + return AuditEvent( + timestamp="2026-07-26T10:00:00Z", + action="record_participant_control", + identity="operator", + allowed=True, + target="participant.behavior.red-agent", + operation_id=operation_id, + reason="accepted", + ) + + +_PARTICIPANT = "participant.behavior.red-agent" +_SPEC_ADDRESS = "participant.behavior-specification.controlled" +_AUTONOMOUS = f"{_SPEC_ADDRESS}.controller-state.autonomous" +_SUPERVISED = f"{_SPEC_ADDRESS}.controller-state.supervised" +_CONTROLLER = "participant.behavior.supervisor" + + +def _compiled_specification() -> ParticipantBehaviorSpecificationRuntime: + autonomous = MixedControlControllerStateRuntime( + address=_AUTONOMOUS, + name="autonomous", + spec={}, + state_id="autonomous", + controller_ref="supervisor", + controller_address=_CONTROLLER, + authority_basis_refs=("red-team",), + authority_basis_addresses=("entities.red-team",), + scope_refs=("web",), + scope_addresses=("nodes.web",), + policy_revision="1.0.0", + valid_from_order=0, + valid_until_order=20, + authority_status="active", + evidence_refs=("authority-evidence",), + evidence_addresses=("evidence.authority",), + ) + supervised = replace( + autonomous, + address=_SUPERVISED, + name="supervised", + state_id="supervised", + ) + kinds = [ + MixedControlTransitionKind.PROPOSAL, + MixedControlTransitionKind.APPROVAL, + MixedControlTransitionKind.DENIAL, + MixedControlTransitionKind.EXTERNAL_DIRECTION, + MixedControlTransitionKind.INTERVENTION, + MixedControlTransitionKind.HANDOFF, + MixedControlTransitionKind.OVERRIDE, + MixedControlTransitionKind.CANCELLATION, + ] + transitions = tuple( + MixedControlTransitionRuntime( + address=f"{_SPEC_ADDRESS}.control-transition.{kind.value}", + name=kind.value, + spec={}, + transition_id=kind.value, + transition_kind=kind.value, + from_state_address=_SUPERVISED if index > 5 else _AUTONOMOUS, + to_state_address=_SUPERVISED if index >= 5 else _AUTONOMOUS, + policy_revision="1.0.0", + expected_state_revision=index, + resulting_state_revision=index + 1, + effective_order=index + 1, + valid_from_order=0, + valid_until_order=20, + proposal_address=f"{_SPEC_ADDRESS}.control-transition.proposal" if index else "", + proposal_revision=1 if index else None, + evidence_refs=("transition-evidence",), + evidence_addresses=("evidence.transition",), + completion_evidence_refs=("handoff-evidence",) if kind is MixedControlTransitionKind.HANDOFF else (), + completion_evidence_addresses=("evidence.handoff",) if kind is MixedControlTransitionKind.HANDOFF else (), + ) + for index, kind in enumerate(kinds) + ) + return ParticipantBehaviorSpecificationRuntime( + address=_SPEC_ADDRESS, + name="controlled", + spec={}, + spec_name="controlled", + participant_addresses=(_PARTICIPANT,), + behavior_mode="mixed-control", + mixed_control_participant_address=_PARTICIPANT, + mixed_control_policy_revision="1.0.0", + mixed_control_order_strategy="total-effective-order", + mixed_control_initial_state_address=_AUTONOMOUS, + mixed_control_dispositions=MixedControlDispositionRulesRuntime( + duplicate="idempotent", + stale="reject", + revoked="reject", + late="reject", + concurrent="reject", + conflict="reject", + ), + controller_states=(autonomous, supervised), + control_transitions=transitions, + ) + + +def _single_transition_specification(kind: MixedControlTransitionKind) -> ParticipantBehaviorSpecificationRuntime: + specification = _compiled_specification() + transition = next( + candidate for candidate in specification.control_transitions if candidate.transition_kind == kind.value + ) + return replace( + specification, + control_transitions=( + replace( + transition, + from_state_address=_AUTONOMOUS, + to_state_address=_AUTONOMOUS, + expected_state_revision=0, + resulting_state_revision=1, + effective_order=1, + ), + ), + ) + + +def _identity(*, bound: bool = True) -> ControlPlaneIdentity: + return ControlPlaneIdentity( + identity="operator", + roles=frozenset({ControlPlaneRole.OPERATOR}), + target_name="stub", + participant_control_subjects=( + ParticipantControlSubjectBinding( + participant_address=_PARTICIPANT, + controller_ref=_CONTROLLER, + ), + ) + if bound + else (), + ) + + +def _base_intent_fields(kind: str, expected_revision: int) -> dict[str, object]: + return { + "declaration_ref": f"{_SPEC_ADDRESS}.control-transition.{kind}", + "episode_id": "episode-1", + "client_correlation_id": f"correlation-{kind}", + "policy_revision": "1.0.0", + "expected_state_revision": expected_revision, + "provenance_refs": ["provenance:test"], + "evidence_refs": ["evidence:test"], + "object_marking_refs": ["marking:test"], + "limitation_refs": ["limitation:none"], + } + + +def test_runtime_snapshot_preserves_first_class_control_history() -> None: + event = _control_event("control-event-1") + snapshot = RuntimeSnapshot(participant_control_history={"participant.behavior.red-agent": [event]}) + + updated = snapshot.with_entries(dict(snapshot.entries)) + + assert updated.participant_control_history == {"participant.behavior.red-agent": [event]} + + +@pytest.mark.parametrize("store_kind", ["memory", "local"]) +def test_control_history_round_trips_through_control_plane_store( + store_kind: str, + tmp_path: Path, +) -> None: + store = ( + InMemoryControlPlaneStore() if store_kind == "memory" else LocalControlPlaneStore(tmp_path / "control-plane") + ) + snapshot = RuntimeSnapshot( + participant_control_history={"participant.behavior.red-agent": [_control_event("control-event-1")]} + ) + + store.save_snapshot(snapshot) + + assert store.load_snapshot().participant_control_history == snapshot.participant_control_history + + +def test_control_history_snapshot_rejects_cross_participant_and_revision_gaps() -> None: + event = _control_event("control-event-1", revision=2) + snapshot = RuntimeSnapshot(participant_control_history={"participant.behavior.other-agent": [event]}) + + diagnostics = participant_runtime_state_contract_diagnostics(snapshot) + + assert diagnostics + assert all(diagnostic.code == "runtime.backend-contract-invalid" for diagnostic in diagnostics) + assert any("map key" in diagnostic.message for diagnostic in diagnostics) + assert any("occurrence_revision" in diagnostic.message for diagnostic in diagnostics) + + +def test_control_history_transition_rejects_rewrite_of_prior_occurrence() -> None: + original = _control_event("control-event-1") + rewritten = _control_event("control-event-rewritten") + previous = RuntimeSnapshot(participant_control_history={"participant.behavior.red-agent": [original]}) + next_snapshot = RuntimeSnapshot(participant_control_history={"participant.behavior.red-agent": [rewritten]}) + + diagnostics = participant_runtime_history_transition_diagnostics(previous, next_snapshot) + + assert diagnostics + assert any("append-only prefix" in diagnostic.message for diagnostic in diagnostics) + + +@pytest.mark.parametrize("store_kind", ["memory", "local"]) +def test_atomic_control_transition_commit_checks_head_and_persists_all_outputs( + store_kind: str, + tmp_path: Path, +) -> None: + store = ( + InMemoryControlPlaneStore() if store_kind == "memory" else LocalControlPlaneStore(tmp_path / "control-plane") + ) + event = _control_event("control-event-1") + snapshot = RuntimeSnapshot(participant_control_history={"participant.behavior.red-agent": [event]}) + record = _operation_record() + audit = _audit_event() + + store.commit_control_transition( + participant_address="participant.behavior.red-agent", + expected_head=None, + snapshot=snapshot, + record=record, + audit_event=audit, + ) + + restarted = store if store_kind == "memory" else LocalControlPlaneStore(tmp_path / "control-plane") + assert restarted.load_snapshot().participant_control_history == snapshot.participant_control_history + assert restarted.load_records()[record.receipt.operation_id] == record + assert restarted.find_by_idempotency(record.idempotency_key) == record + assert restarted.read_audit() == [audit] + + conflicting = RuntimeSnapshot( + participant_control_history={ + "participant.behavior.red-agent": [ + event, + _control_event("control-event-2", revision=2), + ] + } + ) + conflicting_record = replace(record, idempotency_key="scope-key-2") + conflicting_audit = replace(audit, operation_id="operation-2") + with pytest.raises(ValueError, match="expected control history head"): + restarted.commit_control_transition( + participant_address="participant.behavior.red-agent", + expected_head=None, + snapshot=conflicting, + record=conflicting_record, + audit_event=conflicting_audit, + ) + + assert restarted.load_snapshot().participant_control_history == snapshot.participant_control_history + + +def test_supervisory_lifecycle_records_every_control_kind_without_dispatch() -> None: + control_plane = RuntimeControlPlane( + create_stub_target(), + behavior_specifications={_SPEC_ADDRESS: _compiled_specification()}, + ) + intents = [ + ParticipantProposalControlIntent( + **_base_intent_fields("proposal", 0), + proposal_id="proposal-1", + proposal_revision=1, + action_contract_ref="action-contract:contain-host", + payload_ref="payload:proposal-1", + ), + ParticipantApprovalControlIntent( + **_base_intent_fields("approval", 1), + proposal_ref="proposal-1", + proposal_revision=1, + decision_ref="decision-approval-1", + decision_revision=1, + ), + ParticipantDenialControlIntent( + **_base_intent_fields("denial", 2), + proposal_ref="proposal-1", + proposal_revision=1, + decision_ref="decision-denial-1", + decision_revision=1, + ), + ParticipantExternalDirectionControlIntent( + **_base_intent_fields("external-direction", 3), + target_kind="control", + target_ref="pending-control-target", + target_revision=1, + ), + ParticipantInterventionControlIntent( + **_base_intent_fields("intervention", 4), + affected_target_kind="control", + affected_occurrence_ref="pending-control-target", + affected_revision=1, + intervention_ref="intervention-1", + ), + ParticipantHandoffControlIntent( + **_base_intent_fields("handoff", 5), + completion_evidence_ref="evidence:handoff", + ), + ParticipantOverrideControlIntent( + **_base_intent_fields("override", 6), + superseded_target_kind="decision", + superseded_occurrence_ref="decision-approval-1", + superseded_revision=1, + replacement_ref="decision-override-1", + ), + ParticipantCancellationControlIntent( + **_base_intent_fields("cancellation", 7), + target_kind="decision", + target_ref="decision-denial-1", + target_revision=1, + ), + ] + + receipts = [] + for index, intent in enumerate(intents): + history = control_plane.snapshot.participant_control_history.get(_PARTICIPANT, []) + if isinstance(intent, ParticipantExternalDirectionControlIntent): + intent = intent.model_copy(update={"target_ref": history[0]["event_id"]}) + elif isinstance(intent, ParticipantInterventionControlIntent): + intent = intent.model_copy( + update={ + "affected_occurrence_ref": history[-1]["event_id"], + "affected_revision": history[-1]["occurrence"]["occurrence_revision"], + } + ) + receipts.append( + control_plane.record_participant_control( + _PARTICIPANT, + intent, + identity=_identity(), + idempotency_key=f"key-{index}", + ) + ) + + history = control_plane.snapshot.participant_control_history[_PARTICIPANT] + assert [receipt.accepted for receipt in receipts] == [True] * len(receipts) + assert [event["occurrence"]["kind"] for event in history] == [ + kind.value + for kind in ( + MixedControlTransitionKind.PROPOSAL, + MixedControlTransitionKind.APPROVAL, + MixedControlTransitionKind.DENIAL, + MixedControlTransitionKind.EXTERNAL_DIRECTION, + MixedControlTransitionKind.INTERVENTION, + MixedControlTransitionKind.HANDOFF, + MixedControlTransitionKind.OVERRIDE, + MixedControlTransitionKind.CANCELLATION, + ) + ] + assert all(event["occurrence"]["disposition"] == "accepted" for event in history) + + +def test_supervisory_control_is_subject_bound_idempotent_and_state_revision_bound() -> None: + control_plane = RuntimeControlPlane( + create_stub_target(), + behavior_specifications={_SPEC_ADDRESS: _compiled_specification()}, + ) + intent = ParticipantProposalControlIntent( + **_base_intent_fields("proposal", 0), + proposal_id="proposal-1", + proposal_revision=1, + action_contract_ref="action-contract:contain-host", + payload_ref="payload:proposal-1", + ) + + unbound_identity = _identity(bound=False) + with pytest.raises(PermissionError, match="subject"): + control_plane.record_participant_control( + _PARTICIPANT, + intent, + identity=unbound_identity, + idempotency_key="key-1", + ) + assert not control_plane.snapshot.participant_control_history + other_target_identity = replace(_identity(), target_name="other-target") + with pytest.raises(PermissionError, match="target"): + control_plane.record_participant_control( + _PARTICIPANT, + intent, + identity=other_target_identity, + idempotency_key="key-target", + ) + assert not control_plane.snapshot.participant_control_history + + first = control_plane.record_participant_control( + _PARTICIPANT, + intent, + identity=_identity(), + idempotency_key="key-1", + ) + retry = control_plane.record_participant_control( + _PARTICIPANT, + intent, + identity=_identity(), + idempotency_key="key-1", + ) + assert retry.operation_id == first.operation_id + assert len(control_plane.snapshot.participant_control_history[_PARTICIPANT]) == 1 + + changed = intent.model_copy(update={"proposal_id": "proposal-2"}) + changed_identity = _identity() + with pytest.raises(ValueError, match="different semantics"): + control_plane.record_participant_control( + _PARTICIPANT, + changed, + identity=changed_identity, + idempotency_key="key-1", + ) + + stale = ParticipantApprovalControlIntent( + **_base_intent_fields("approval", 0), + proposal_ref="proposal-1", + proposal_revision=1, + decision_ref="decision-stale-1", + decision_revision=1, + ) + rejected = control_plane.record_participant_control( + _PARTICIPANT, + stale, + identity=_identity(), + idempotency_key="key-stale", + ) + assert rejected.accepted is False + assert ( + control_plane.snapshot.participant_control_history[_PARTICIPANT][-1]["occurrence"]["reason_code"] + == "stale-state" + ) + + +@pytest.mark.parametrize( + ("failure", "reason_code"), + [ + ("stale-policy", "stale-policy"), + ("revoked-authority", "revoked-authority"), + ("late-authority", "late-authority"), + ("unsupported-order", "unsupported-order-strategy"), + ], +) +def test_supervisory_control_records_bounded_policy_and_authority_rejections( + failure: str, + reason_code: str, +) -> None: + specification = _compiled_specification() + intent_fields = _base_intent_fields("proposal", 0) + if failure == "stale-policy": + intent_fields["policy_revision"] = "2.0.0" + elif failure == "revoked-authority": + state = replace(specification.controller_states[0], authority_status="revoked") + specification = replace( + specification, + controller_states=(state, *specification.controller_states[1:]), + ) + elif failure == "late-authority": + state = replace(specification.controller_states[0], valid_until_order=0) + specification = replace( + specification, + controller_states=(state, *specification.controller_states[1:]), + ) + else: + specification = replace( + specification, + mixed_control_order_strategy="causal-partial-order", + ) + control_plane = RuntimeControlPlane( + create_stub_target(), + behavior_specifications={_SPEC_ADDRESS: specification}, + ) + intent = ParticipantProposalControlIntent( + **intent_fields, + proposal_id="proposal-1", + proposal_revision=1, + action_contract_ref="action-contract:contain-host", + payload_ref="payload:proposal-1", + ) + + receipt = control_plane.record_participant_control( + _PARTICIPANT, + intent, + identity=_identity(), + idempotency_key=f"key-{failure}", + ) + + assert receipt.accepted is False + event = control_plane.snapshot.participant_control_history[_PARTICIPANT][-1] + assert event["occurrence"]["reason_code"] == reason_code + assert "2.0.0" not in str(receipt.diagnostics) + + +def test_supervisory_control_restarts_and_replays_before_the_next_transition( + tmp_path: Path, +) -> None: + store = LocalControlPlaneStore(tmp_path / "control-plane") + specification = _compiled_specification() + first = RuntimeControlPlane( + create_stub_target(), + store=store, + behavior_specifications={_SPEC_ADDRESS: specification}, + ) + proposal = ParticipantProposalControlIntent( + **_base_intent_fields("proposal", 0), + proposal_id="proposal-1", + proposal_revision=1, + action_contract_ref="action-contract:contain-host", + payload_ref="payload:proposal-1", + ) + assert first.record_participant_control( + _PARTICIPANT, + proposal, + identity=_identity(), + idempotency_key="key-proposal", + ).accepted + + restarted = RuntimeControlPlane( + create_stub_target(), + store=LocalControlPlaneStore(tmp_path / "control-plane"), + behavior_specifications={_SPEC_ADDRESS: specification}, + ) + approval = ParticipantApprovalControlIntent( + **_base_intent_fields("approval", 1), + proposal_ref="proposal-1", + proposal_revision=1, + decision_ref="decision-approval-1", + decision_revision=1, + ) + + assert restarted.record_participant_control( + _PARTICIPANT, + approval, + identity=_identity(), + idempotency_key="key-approval", + ).accepted + assert len(restarted.snapshot.participant_control_history[_PARTICIPANT]) == 2 + + +def test_controller_state_replay_is_scoped_to_one_episode() -> None: + control_plane = RuntimeControlPlane( + create_stub_target(), + behavior_specifications={_SPEC_ADDRESS: _single_transition_specification(MixedControlTransitionKind.PROPOSAL)}, + ) + first = ParticipantProposalControlIntent( + **_base_intent_fields("proposal", 0), + proposal_id="proposal-episode-1", + proposal_revision=1, + action_contract_ref="action-contract:contain-host", + payload_ref="payload:proposal-episode-1", + ) + second = first.model_copy( + update={ + "episode_id": "episode-2", + "proposal_id": "proposal-episode-2", + "payload_ref": "payload:proposal-episode-2", + } + ) + + assert control_plane.record_participant_control( + _PARTICIPANT, + first, + identity=_identity(), + idempotency_key="key-episode-1", + ).accepted + assert control_plane.record_participant_control( + _PARTICIPANT, + second, + identity=_identity(), + idempotency_key="key-episode-2", + ).accepted + + +def _behavior_target_snapshot() -> RuntimeSnapshot: + common = { + "event_type": "action_attempted", + "timestamp": "2026-07-26T10:00:00Z", + "participant_address": _PARTICIPANT, + "episode_id": "episode-1", + "action_instance_id": "action-1", + "action_contract_address": "participant.action-contract.contain-host", + "actor_provenance": "participant:red-agent", + "details": {}, + } + return RuntimeSnapshot( + participant_behavior_history={ + _PARTICIPANT: [ + { + **common, + "lifecycle_phase": "intent_or_proposal", + "phase_realization": "runtime_mediated", + }, + { + **common, + "lifecycle_phase": "selection_or_admission", + "phase_realization": "runtime_mediated", + "admission_disposition": "admitted", + }, + { + **common, + "lifecycle_phase": "execution_attempt", + "phase_realization": "runtime_mediated", + "operation_ref": "attempt-1", + "operation_state": "running", + }, + ] + } + ) + + +@pytest.mark.parametrize( + ("kind", "target_kind", "target_ref"), + [ + (MixedControlTransitionKind.EXTERNAL_DIRECTION, "action", "action-1"), + (MixedControlTransitionKind.INTERVENTION, "attempt", "attempt-1"), + (MixedControlTransitionKind.CANCELLATION, "admitted-action", "action-1"), + ], +) +def test_typed_targets_resolve_authoritative_behavior_lifecycle_stages( + kind: MixedControlTransitionKind, + target_kind: str, + target_ref: str, +) -> None: + control_plane = RuntimeControlPlane( + create_stub_target(), + initial_snapshot=_behavior_target_snapshot(), + behavior_specifications={_SPEC_ADDRESS: _single_transition_specification(kind)}, + ) + fields = _base_intent_fields(kind.value, 0) + if kind is MixedControlTransitionKind.EXTERNAL_DIRECTION: + intent = ParticipantExternalDirectionControlIntent( + **fields, + target_kind=target_kind, + target_ref=target_ref, + target_revision=1, + ) + elif kind is MixedControlTransitionKind.INTERVENTION: + intent = ParticipantInterventionControlIntent( + **fields, + affected_target_kind=target_kind, + affected_occurrence_ref=target_ref, + affected_revision=1, + intervention_ref="intervention-1", + ) + else: + intent = ParticipantCancellationControlIntent( + **fields, + target_kind=target_kind, + target_ref=target_ref, + target_revision=1, + ) + + receipt = control_plane.record_participant_control( + _PARTICIPANT, + intent, + identity=_identity(), + idempotency_key=f"key-{kind.value}", + ) + + assert receipt.accepted + event = control_plane.snapshot.participant_control_history[_PARTICIPANT][-1] + assert event["predecessor_event_refs"] == [target_ref] + + +def test_unresolved_typed_target_appends_a_bounded_rejection_without_fallback() -> None: + control_plane = RuntimeControlPlane( + create_stub_target(), + behavior_specifications={ + _SPEC_ADDRESS: _single_transition_specification(MixedControlTransitionKind.EXTERNAL_DIRECTION) + }, + ) + intent = ParticipantExternalDirectionControlIntent( + **_base_intent_fields("external-direction", 0), + target_kind="action", + target_ref="action-missing", + target_revision=1, + ) + + receipt = control_plane.record_participant_control( + _PARTICIPANT, + intent, + identity=_identity(), + idempotency_key="key-missing-target", + ) + + assert receipt.accepted is False + event = control_plane.snapshot.participant_control_history[_PARTICIPANT][-1] + assert event["occurrence"]["reason_code"] == "invalid-target" + assert event["predecessor_event_refs"] == [] + + +def test_failed_atomic_control_commit_exposes_no_partial_transition( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = LocalControlPlaneStore(tmp_path / "control-plane") + control_plane = RuntimeControlPlane( + create_stub_target(), + store=store, + behavior_specifications={_SPEC_ADDRESS: _compiled_specification()}, + ) + intent = ParticipantProposalControlIntent( + **_base_intent_fields("proposal", 0), + proposal_id="proposal-1", + proposal_revision=1, + action_contract_ref="action-contract:contain-host", + payload_ref="payload:proposal-1", + ) + + def fail_atomic_write(path: Path, content: str) -> None: + del path, content + raise OSError("commit failed") + + monkeypatch.setattr(store, "_atomic_write", fail_atomic_write) + identity = _identity() + with pytest.raises(OSError, match="commit failed"): + control_plane.record_participant_control( + _PARTICIPANT, + intent, + identity=identity, + idempotency_key="key-1", + ) + + assert not control_plane.snapshot.participant_control_history + restarted = LocalControlPlaneStore(tmp_path / "control-plane") + assert not restarted.load_snapshot().participant_control_history + assert not restarted.load_records() + assert not restarted.read_audit() + + +def _api_security(*, bound: bool = True) -> ControlPlaneSecurityConfig: + return ControlPlaneSecurityConfig( + bearer_tokens={ + "operator-token": _identity(bound=bound), + } + ) + + +def _proposal_body() -> dict[str, object]: + return { + "kind": "proposal", + **_base_intent_fields("proposal", 0), + "proposal_id": "proposal-1", + "proposal_revision": 1, + "action_contract_ref": "action-contract:contain-host", + "payload_ref": "payload:proposal-1", + } + + +def test_supervisory_http_route_is_closed_subject_bound_and_idempotent() -> None: + control_plane = RuntimeControlPlane( + create_stub_target(), + behavior_specifications={_SPEC_ADDRESS: _compiled_specification()}, + ) + app = create_control_plane_app(control_plane, security=_api_security()) + headers = { + "authorization": "Bearer operator-token", + "idempotency-key": "key-1", + } + + with TestClient(app) as client: + first = client.post( + f"/participants/{_PARTICIPANT}/control-occurrences", + json=_proposal_body(), + headers=headers, + ) + retry = client.post( + f"/participants/{_PARTICIPANT}/control-occurrences", + json=_proposal_body(), + headers=headers, + ) + smuggled = client.post( + f"/participants/{_PARTICIPANT}/control-occurrences", + json={**_proposal_body(), "disposition": "accepted"}, + headers={**headers, "idempotency-key": "key-2"}, + ) + invalid_target = client.post( + f"/participants/{_PARTICIPANT}/control-occurrences", + json={ + "kind": "external-direction", + **_base_intent_fields("external-direction", 1), + "target_kind": "decision", + "target_ref": "decision-1", + "target_revision": 1, + }, + headers={**headers, "idempotency-key": "key-3"}, + ) + + assert first.status_code == 200 + assert retry.status_code == 200 + assert retry.json()["operation_id"] == first.json()["operation_id"] + assert smuggled.status_code == 422 + assert invalid_target.status_code == 422 + assert len(control_plane.snapshot.participant_control_history[_PARTICIPANT]) == 1 + + +def test_supervisory_http_route_rejects_unbound_subject_without_occurrence() -> None: + control_plane = RuntimeControlPlane( + create_stub_target(), + behavior_specifications={_SPEC_ADDRESS: _compiled_specification()}, + ) + app = create_control_plane_app(control_plane, security=_api_security(bound=False)) + + with TestClient(app) as client: + known = client.post( + f"/participants/{_PARTICIPANT}/control-occurrences", + json=_proposal_body(), + headers={"authorization": "Bearer operator-token"}, + ) + unknown = client.post( + "/participants/participant.behavior.unknown/control-occurrences", + json=_proposal_body(), + headers={"authorization": "Bearer operator-token"}, + ) + + assert known.status_code == 403 + assert unknown.status_code == 403 + assert known.json() == unknown.json() == {"detail": "forbidden"} + assert not control_plane.snapshot.participant_control_history diff --git a/implementations/python/tests/test_sem_227_shared_time_model.py b/implementations/python/tests/test_sem_227_shared_time_model.py index dfac8a611..2b63b0a1b 100644 --- a/implementations/python/tests/test_sem_227_shared_time_model.py +++ b/implementations/python/tests/test_sem_227_shared_time_model.py @@ -7,7 +7,7 @@ from pathlib import Path import pytest -from hypothesis import given +from hypothesis import given, settings from hypothesis import strategies as st from raes._errors import SDLParseError, SDLValidationError from raes.parser import parse_sdl, parse_sdl_file @@ -237,6 +237,7 @@ def test_shared_time_model_references_follow_module_namespacing(tmp_path: Path) assert constraint.subject_refs == ["nodes.shared.workstation"] +@settings(deadline=None) @given(st.lists(st.sampled_from(("advance", "reset")), min_size=1, max_size=30)) def test_clock_transition_history_is_append_only_across_generated_lifecycles( operations: list[str], diff --git a/tools/generate_contract_schemas.py b/tools/generate_contract_schemas.py index 21ce7b448..a7270e602 100644 --- a/tools/generate_contract_schemas.py +++ b/tools/generate_contract_schemas.py @@ -32,6 +32,8 @@ def _schema_output_path(schemas_dir: Path, name: str) -> Path: return schemas_dir / "participant-implementation-manifest" / f"{name}.json" if name.startswith("participant-implementation-provenance-v"): return schemas_dir / "participant-implementation-provenance" / f"{name}.json" + if name.startswith("participant-configuration-result-v"): + return schemas_dir / "participant-implementation-configuration" / f"{name}.json" if name in {"concept-families-v1", "behavioral-relations-v1"}: return schemas_dir / "concept-authority" / f"{name}.json" if name == "reference-models-v1":