diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f683cd0..73faba39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ versions still track specification maturity rather than a released product. ### Changed +- Replaced sentinel external-request schema and reconciliation identities + with generator-owned canonical artifacts. External-action application builds + now require an exact `externalActionResources` closure, independently validate + each closed resource meta-contract and domain-framed digest, and fail before + publication on missing, duplicate, disconnected, substituted, opaque, + non-canonical, sentinel, mutated, or over-budget resources. - Hardened result-projection admission with immutable emitted artifacts, bounded recursive decoding, shared compiler input identity, reuse of the compiler-computed semantic closure, and provider-schema parity for positive diff --git a/crates/edict-cli/src/application_build.rs b/crates/edict-cli/src/application_build.rs index de374b41..e880d1b0 100644 --- a/crates/edict-cli/src/application_build.rs +++ b/crates/edict-cli/src/application_build.rs @@ -25,9 +25,10 @@ use edict_syntax::{ ProviderVerificationInvocationContract, ProviderVerificationOutputKind, ProviderVerificationOutputRequest, ProviderVerificationRequest, ResultProjectionArtifact, TargetIrArtifact, TargetLoweringStatus, TargetProviderManifest, ValidatedLawpackBundle, - ValidatedTargetProviderManifest, CORE_MODULE_DIGEST_DOMAIN, PROVIDER_LAWPACK_ARTIFACT_DOMAIN, - RESULT_PROJECTION_DIGEST_DOMAIN, TARGET_IR_ARTIFACT_DIGEST_DOMAIN, TARGET_PROFILE_API_VERSION, - TARGET_PROVIDER_PROTOCOL_VERSION, + ValidatedTargetProviderManifest, CORE_MODULE_DIGEST_DOMAIN, + EXTERNAL_ACTION_RESOURCE_API_VERSION, EXTERNAL_ACTION_RESOURCE_DIGEST_DOMAIN, + PROVIDER_LAWPACK_ARTIFACT_DOMAIN, RESULT_PROJECTION_DIGEST_DOMAIN, + TARGET_IR_ARTIFACT_DIGEST_DOMAIN, TARGET_PROFILE_API_VERSION, TARGET_PROVIDER_PROTOCOL_VERSION, }; use serde::Deserialize; @@ -41,6 +42,7 @@ const VERIFICATION_REPORT_ROLE: &str = "verifier-report.echo-operation"; const VERIFICATION_REPORT_DOMAIN: &str = "echo.operation-package-verifier-report/v1"; const RESULT_PROJECTION_ROLE: &str = "07-result-projection"; const MAX_APPLICATION_ARTIFACT_BYTES: u64 = 1024 * 1024; +const MAX_EXTERNAL_ACTION_RESOURCES: usize = 192; #[derive(Debug)] pub(crate) struct ApplicationBuildFailure { @@ -65,10 +67,18 @@ struct ApplicationManifest { coordinate: String, sources: Vec, lawpacks: Vec, + #[serde(default)] + external_action_resources: Vec, target: ApplicationTarget, output_directory: PathBuf, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ApplicationExternalActionResource { + artifact: PathBuf, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct ApplicationLawpack { @@ -93,6 +103,38 @@ struct LoadedLawpack { bundle: ValidatedLawpackBundle, } +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum ExternalActionResourceKind { + InputSchema, + SettlementSchema, + ReconciliationLaw, +} + +impl ExternalActionResourceKind { + const fn as_str(self) -> &'static str { + match self { + Self::InputSchema => "inputSchema", + Self::SettlementSchema => "settlementSchema", + Self::ReconciliationLaw => "reconciliationLaw", + } + } + + fn parse(value: &str) -> Option { + match value { + "inputSchema" => Some(Self::InputSchema), + "settlementSchema" => Some(Self::SettlementSchema), + "reconciliationLaw" => Some(Self::ReconciliationLaw), + _ => None, + } + } +} + +struct LoadedExternalActionResource { + coordinate: String, + kind: ExternalActionResourceKind, + digest: String, +} + struct ProviderInvocationContext<'a> { coordinate: &'a str, core_bytes: &'a [u8], @@ -125,6 +167,8 @@ pub(crate) fn build_application(config_path: &Path) -> Result<(), ApplicationBui })?; validate_application_manifest(&config)?; let root = canonical_application_root(config_path)?; + let external_action_resources = + load_external_action_resources(&root, &config.external_action_resources)?; let source = config.sources.first().ok_or_else(|| { failure( @@ -293,7 +337,11 @@ pub(crate) fn build_application(config_path: &Path) -> Result<(), ApplicationBui })?; if config.build_kind == ApplicationBuildKind::ExternalAction { - validate_external_action_artifacts(&target_ir, &loaded_lawpacks)?; + validate_external_action_artifacts( + &target_ir, + &loaded_lawpacks, + &external_action_resources, + )?; let output_directory = prepare_output_directory(&root, &config.output_directory)?; return write_external_action_outputs(&output_directory, &core_bytes, &target_ir_bytes); } @@ -408,9 +456,288 @@ pub(crate) fn build_application(config_path: &Path) -> Result<(), ApplicationBui write_outputs(&output_directory, &package_bytes, &report_bytes) } +fn load_external_action_resources( + root: &Path, + configured: &[ApplicationExternalActionResource], +) -> Result, ApplicationBuildFailure> { + let mut loaded = Vec::with_capacity(configured.len()); + let mut coordinates = BTreeSet::new(); + for resource in configured { + let resource = load_external_action_resource(root, resource)?; + if !coordinates.insert(resource.coordinate.clone()) { + return Err(failure( + "ExternalActionResourceClosureMismatch", + format!( + "external-action resource coordinate `{}` is configured more than once", + resource.coordinate + ), + )); + } + loaded.push(resource); + } + Ok(loaded) +} + +fn load_external_action_resource( + root: &Path, + configured: &ApplicationExternalActionResource, +) -> Result { + let path = confined_existing_path( + root, + &configured.artifact, + "externalActionResources.artifact", + "ExternalActionResourceReadFailed", + "external-action resource", + )?; + let bytes = read( + &path, + "ExternalActionResourceReadFailed", + "external-action resource", + )?; + let value = decode_canonical_cbor(&bytes).map_err(|error| { + invalid_external_action_resource(&path, format!("is not canonical CBOR: {error}")) + })?; + let fields = external_action_resource_object( + &value, + &["apiVersion", "coordinate", "definition", "kind"], + &path, + "artifact", + )?; + require_external_action_resource_text( + &fields, + "apiVersion", + Some(EXTERNAL_ACTION_RESOURCE_API_VERSION), + &path, + )?; + let coordinate = + require_external_action_resource_text(&fields, "coordinate", None, &path)?.to_owned(); + let kind_text = require_external_action_resource_text(&fields, "kind", None, &path)?; + let kind = ExternalActionResourceKind::parse(kind_text).ok_or_else(|| { + invalid_external_action_resource(&path, format!("kind `{kind_text}` is unsupported")) + })?; + let definition = fields + .get("definition") + .ok_or_else(|| invalid_external_action_resource(&path, "is missing its `definition`"))?; + validate_external_action_resource_definition(kind, definition, &path)?; + let digest = digest_canonical_artifact(EXTERNAL_ACTION_RESOURCE_DIGEST_DOMAIN, &bytes) + .map_err(|error| { + invalid_external_action_resource(&path, format!("cannot be identified: {error}")) + })? + .to_review_string(); + Ok(LoadedExternalActionResource { + coordinate, + kind, + digest, + }) +} + +fn validate_external_action_resource_definition( + kind: ExternalActionResourceKind, + definition: &CanonicalValue, + path: &Path, +) -> Result<(), ApplicationBuildFailure> { + match kind { + ExternalActionResourceKind::InputSchema | ExternalActionResourceKind::SettlementSchema => { + let fields = external_action_resource_object( + definition, + &["closed", "encoding", "fields", "root"], + path, + "schema definition", + )?; + require_external_action_resource_text( + &fields, + "encoding", + Some("canonical-cbor"), + path, + )?; + require_external_action_resource_text(&fields, "root", None, path)?; + if !matches!(fields.get("closed"), Some(CanonicalValue::Bool(true))) { + return Err(invalid_external_action_resource( + path, + "schema definition field `closed` must be true", + )); + } + let Some(CanonicalValue::Array(schema_fields)) = fields.get("fields") else { + return Err(invalid_external_action_resource( + path, + "schema definition field `fields` must be a non-empty array", + )); + }; + if schema_fields.is_empty() { + return Err(invalid_external_action_resource( + path, + "schema definition field `fields` must be a non-empty array", + )); + } + let mut names = BTreeSet::new(); + for field in schema_fields { + let field = external_action_resource_object( + field, + &["authority", "name", "required", "type"], + path, + "schema field", + )?; + let name = require_external_action_resource_text(&field, "name", None, path)?; + require_external_action_resource_text(&field, "type", None, path)?; + require_external_action_resource_text(&field, "authority", None, path)?; + if !matches!(field.get("required"), Some(CanonicalValue::Bool(_))) { + return Err(invalid_external_action_resource( + path, + "schema field `required` must be boolean", + )); + } + if !names.insert(name) { + return Err(invalid_external_action_resource( + path, + "schema field names must be unique", + )); + } + } + } + ExternalActionResourceKind::ReconciliationLaw => { + let fields = external_action_resource_object( + definition, + &[ + "replayRule", + "requestKind", + "requiredBindings", + "settlementKind", + "terminalPostures", + ], + path, + "reconciliation definition", + )?; + require_external_action_resource_text(&fields, "requestKind", None, path)?; + require_external_action_resource_text(&fields, "settlementKind", None, path)?; + require_external_action_resource_text(&fields, "replayRule", None, path)?; + let bindings = + require_external_action_resource_text_array(&fields, "requiredBindings", path)?; + if bindings.is_empty() + || bindings.iter().copied().collect::>().len() != bindings.len() + { + return Err(invalid_external_action_resource( + path, + "reconciliation requiredBindings must be non-empty and unique", + )); + } + let postures = + require_external_action_resource_text_array(&fields, "terminalPostures", path)?; + if postures != ["succeeded", "obstructed", "outcomeUnknown"] { + return Err(invalid_external_action_resource( + path, + "reconciliation terminalPostures must be succeeded, obstructed, outcomeUnknown", + )); + } + } + } + Ok(()) +} + +fn external_action_resource_object<'a>( + value: &'a CanonicalValue, + expected: &[&str], + path: &Path, + label: &str, +) -> Result, ApplicationBuildFailure> { + let CanonicalValue::Map(entries) = value else { + return Err(invalid_external_action_resource( + path, + format!("{label} must be a canonical map"), + )); + }; + let mut fields = BTreeMap::new(); + for (key, value) in entries { + let CanonicalValue::Text(key) = key else { + return Err(invalid_external_action_resource( + path, + format!("{label} has a non-text field name"), + )); + }; + if fields.insert(key.as_str(), value).is_some() { + return Err(invalid_external_action_resource( + path, + format!("{label} repeats field `{key}`"), + )); + } + } + if fields.keys().copied().collect::>() + != expected.iter().copied().collect::>() + { + return Err(invalid_external_action_resource( + path, + format!("{label} has an unsupported field set"), + )); + } + Ok(fields) +} + +fn require_external_action_resource_text<'a>( + fields: &BTreeMap<&str, &'a CanonicalValue>, + field: &str, + exact: Option<&str>, + path: &Path, +) -> Result<&'a str, ApplicationBuildFailure> { + let Some(CanonicalValue::Text(value)) = fields.get(field) else { + return Err(invalid_external_action_resource( + path, + format!("resource field `{field}` must be text"), + )); + }; + if value.is_empty() || exact.is_some_and(|expected| value != expected) { + return Err(invalid_external_action_resource( + path, + format!("resource field `{field}` has an invalid value"), + )); + } + Ok(value) +} + +fn require_external_action_resource_text_array<'a>( + fields: &BTreeMap<&str, &'a CanonicalValue>, + field: &str, + path: &Path, +) -> Result, ApplicationBuildFailure> { + let Some(CanonicalValue::Array(values)) = fields.get(field) else { + return Err(invalid_external_action_resource( + path, + format!("resource field `{field}` must be an array"), + )); + }; + values + .iter() + .map(|value| match value { + CanonicalValue::Text(value) if !value.is_empty() => Ok(value.as_str()), + _ => Err(invalid_external_action_resource( + path, + format!("resource field `{field}` must contain non-empty text"), + )), + }) + .collect() +} + +fn invalid_external_action_resource( + path: &Path, + message: impl std::fmt::Display, +) -> ApplicationBuildFailure { + failure( + "InvalidExternalActionResource", + format!("external-action resource `{}` {message}", path.display()), + ) +} + fn validate_external_action_artifacts( target_ir: &TargetIrArtifact, loaded_lawpacks: &[LoadedLawpack], + loaded_resources: &[LoadedExternalActionResource], +) -> Result<(), ApplicationBuildFailure> { + validate_external_action_execution_class(target_ir)?; + validate_external_action_capability_closure(target_ir, loaded_lawpacks)?; + let required_resources = required_external_action_resources(target_ir)?; + validate_external_action_resource_closure(&required_resources, loaded_resources) +} + +fn validate_external_action_execution_class( + target_ir: &TargetIrArtifact, ) -> Result<(), ApplicationBuildFailure> { let request_count = target_ir .intents @@ -433,6 +760,13 @@ fn validate_external_action_artifacts( "external-action application build cannot mix requests with callable target steps", )); } + Ok(()) +} + +fn validate_external_action_capability_closure( + target_ir: &TargetIrArtifact, + loaded_lawpacks: &[LoadedLawpack], +) -> Result<(), ApplicationBuildFailure> { let capability_manifest_digests = loaded_lawpacks .iter() .map(|loaded| loaded.bundle.manifest_digest_review_string()) @@ -461,6 +795,103 @@ fn validate_external_action_artifacts( Ok(()) } +fn required_external_action_resources( + target_ir: &TargetIrArtifact, +) -> Result, ApplicationBuildFailure> { + let mut required_resources = BTreeMap::new(); + for request in target_ir + .intents + .values() + .flat_map(|intent| &intent.external_action_requests) + { + for (kind, resource) in [ + ( + ExternalActionResourceKind::InputSchema, + &request.input_schema, + ), + ( + ExternalActionResourceKind::SettlementSchema, + &request.settlement_schema, + ), + ( + ExternalActionResourceKind::ReconciliationLaw, + &request.reconciliation_law, + ), + ] { + let Some(digest) = resource.digest.as_deref() else { + return Err(failure( + "ExternalActionResourceClosureMismatch", + format!( + "request resource `{}` is not digest locked", + resource.coordinate + ), + )); + }; + let key = (resource.coordinate.clone(), kind); + if required_resources + .insert(key, digest.to_owned()) + .is_some_and(|existing| existing != digest) + { + return Err(failure( + "ExternalActionResourceClosureMismatch", + format!( + "request resource `{}` has conflicting digests", + resource.coordinate + ), + )); + } + } + } + Ok(required_resources) +} + +fn validate_external_action_resource_closure( + required_resources: &BTreeMap<(String, ExternalActionResourceKind), String>, + loaded_resources: &[LoadedExternalActionResource], +) -> Result<(), ApplicationBuildFailure> { + let supplied = loaded_resources + .iter() + .map(|resource| (resource.coordinate.as_str(), resource)) + .collect::>(); + for ((coordinate, kind), digest) in required_resources { + let Some(resource) = supplied.get(coordinate.as_str()) else { + return Err(failure( + "ExternalActionResourceClosureMismatch", + format!( + "request {kind} `{coordinate}` has no configured canonical artifact", + kind = kind.as_str() + ), + )); + }; + if resource.kind != *kind || resource.digest != *digest { + return Err(failure( + "ExternalActionResourceClosureMismatch", + format!( + "request {kind} `{coordinate}` does not match its configured canonical artifact", + kind = kind.as_str() + ), + )); + } + } + let required_coordinates = required_resources + .keys() + .map(|(coordinate, _)| coordinate.as_str()) + .collect::>(); + if let Some(disconnected) = loaded_resources + .iter() + .find(|resource| !required_coordinates.contains(resource.coordinate.as_str())) + { + return Err(failure( + "ExternalActionResourceClosureMismatch", + format!( + "configured external-action resource `{}` is not referenced by the Target request closure", + disconnected.coordinate + ), + )); + } + Ok(()) +} + fn validate_application_lawpack_closure( loaded_lawpacks: &[LoadedLawpack], ) -> Result<(), ApplicationBuildFailure> { @@ -570,6 +1001,22 @@ fn validate_application_manifest( "the application build requires one root lawpack followed by its complete dependency closure", )); } + if config.external_action_resources.len() > MAX_EXTERNAL_ACTION_RESOURCES { + return Err(failure( + "InvalidApplicationConfig", + format!( + "externalActionResources exceeds the bounded maximum of {MAX_EXTERNAL_ACTION_RESOURCES}" + ), + )); + } + if config.build_kind == ApplicationBuildKind::ExecutableOperation + && !config.external_action_resources.is_empty() + { + return Err(failure( + "InvalidApplicationConfig", + "executable-operation builds cannot declare external-action resources", + )); + } let paths = config .sources .iter() @@ -585,6 +1032,12 @@ fn validate_application_manifest( ), ] })) + .chain(config.external_action_resources.iter().map(|resource| { + ( + "externalActionResources.artifact", + resource.artifact.as_path(), + ) + })) .chain([ ( "target.providerPackage", @@ -1938,11 +2391,12 @@ mod tests { use std::sync::atomic::{AtomicU64, Ordering}; use edict_syntax::{ - compile_to_core, decode_lawpack_adapter, decode_lawpack_bundle, decode_result_projection, - lower_to_target_ir, parse_module, prepare_lawpack_compilation, LawpackResourceRef, - LawpackTargetAdapter, ProviderArtifactKind, ProviderArtifactRef, ProviderArtifactSource, - ProviderSchemaBinding, ProviderSchemaFormat, ResourceRef, ResultProjectionArtifact, - TargetIrArtifact, TargetLoweringReport, TargetProviderManifest, + compile_to_core, decode_canonical_cbor, decode_lawpack_adapter, decode_lawpack_bundle, + decode_result_projection, digest_canonical_artifact, encode_canonical_cbor, + lower_to_target_ir, parse_module, prepare_lawpack_compilation, CanonicalValue, + LawpackResourceRef, LawpackTargetAdapter, ProviderArtifactKind, ProviderArtifactRef, + ProviderArtifactSource, ProviderSchemaBinding, ProviderSchemaFormat, ResourceRef, + ResultProjectionArtifact, TargetIrArtifact, TargetLoweringReport, TargetProviderManifest, RESULT_PROJECTION_DIGEST_DOMAIN, TARGET_PROVIDER_ABI, TARGET_PROVIDER_MANIFEST_API_VERSION, }; @@ -1951,8 +2405,10 @@ mod tests { read, selected_adapter_reference, single_result_projection, single_unique_configuration, validate_application_manifest, validate_external_action_artifacts, with_result_projection_input, write_external_action_outputs, write_outputs, - ApplicationBuildKind, ApplicationLawpack, ApplicationManifest, ApplicationTarget, - LoadedLawpack, RESULT_PROJECTION_ROLE, + ApplicationBuildKind, ApplicationExternalActionResource, ApplicationLawpack, + ApplicationManifest, ApplicationTarget, ExternalActionResourceKind, + LoadedExternalActionResource, LoadedLawpack, EXTERNAL_ACTION_RESOURCE_DIGEST_DOMAIN, + MAX_EXTERNAL_ACTION_RESOURCES, RESULT_PROJECTION_ROLE, }; const STRESS_SEED: u64 = 0x5eed_1a77_c105_0a11; @@ -1999,8 +2455,9 @@ mod tests { #[test] fn external_action_build_accepts_request_only_target_ir() { let closure = [external_action_loaded_lawpack()]; + let resources = external_action_loaded_resources(); test_ok( - validate_external_action_artifacts(&external_action_target_ir(), &closure), + validate_external_action_artifacts(&external_action_target_ir(), &closure, &resources), "request-only Target IR is publishable", ); } @@ -2008,6 +2465,7 @@ mod tests { #[test] fn external_action_build_binds_operation_authority_by_manifest_digest() { let closure = [external_action_loaded_lawpack()]; + let resources = external_action_loaded_resources(); let mut external = external_action_target_ir(); let Some(observe) = external.intents.get_mut("observe") else { panic!("workspace observer intent exists"); @@ -2016,7 +2474,7 @@ mod tests { "workspace.snapshot.observe@2".to_owned(); test_ok( - validate_external_action_artifacts(&external, &closure), + validate_external_action_artifacts(&external, &closure, &resources), "operation identity is independent of its authority manifest version", ); } @@ -2024,7 +2482,7 @@ mod tests { #[test] fn external_action_build_requires_a_typed_request() { let failure = test_err( - validate_external_action_artifacts(&hello_echo_target_ir(), &[]), + validate_external_action_artifacts(&hello_echo_target_ir(), &[], &[]), "callable-only Target IR must not publish as external-action artifacts", ); @@ -2034,6 +2492,7 @@ mod tests { #[test] fn external_action_build_rejects_mixed_callable_execution() { let closure = [external_action_loaded_lawpack()]; + let resources = external_action_loaded_resources(); let mut external = external_action_target_ir(); let Some(callable) = hello_echo_target_ir() .intents @@ -2049,7 +2508,7 @@ mod tests { observe.steps.push(callable); let failure = test_err( - validate_external_action_artifacts(&external, &closure), + validate_external_action_artifacts(&external, &closure, &resources), "mixed callable/request execution must reject", ); @@ -2059,6 +2518,7 @@ mod tests { #[test] fn external_action_build_rejects_a_substituted_capability_manifest() { let closure = [external_action_loaded_lawpack()]; + let resources = external_action_loaded_resources(); let mut external = external_action_target_ir(); let Some(observe) = external.intents.get_mut("observe") else { panic!("workspace observer intent exists"); @@ -2067,7 +2527,7 @@ mod tests { operation.digest = Some(format!("sha256:{}", "0".repeat(64))); let failure = test_err( - validate_external_action_artifacts(&external, &closure), + validate_external_action_artifacts(&external, &closure, &resources), "substituted capability manifest must reject", ); @@ -2135,6 +2595,435 @@ mod tests { test_ok(fs::remove_dir_all(root), "remove public build tree"); } + #[test] + fn public_external_action_build_rejects_sentinel_resource_identities() { + let root = temp_tree("public-external-action-sentinel-resources"); + let config_path = write_external_action_application(&root); + let source_path = root.join("src/observe-workspace.edict"); + let mut source = test_ok( + fs::read_to_string(&source_path), + "read resource-bound source", + ); + for (fixture_digest, sentinel) in [ + ( + include_str!("../../../fixtures/lawpack/workspace-snapshot/input-schema.sha256") + .trim(), + '9', + ), + ( + include_str!( + "../../../fixtures/lawpack/workspace-snapshot/settlement-schema.sha256" + ) + .trim(), + '8', + ), + ( + include_str!( + "../../../fixtures/lawpack/workspace-snapshot/reconciliation-law.sha256" + ) + .trim(), + '7', + ), + ] { + let substituted = source.replacen( + fixture_digest, + &format!("sha256:{}", sentinel.to_string().repeat(64)), + 1, + ); + assert_ne!(substituted, source, "resource fixture digest must mutate"); + source = substituted; + } + test_ok( + fs::write(&source_path, source), + "write sentinel-bound application source", + ); + + let failure = test_err( + build_application(&config_path), + "syntactically valid placeholder resource digests must not publish", + ); + + assert_eq!(failure.kind, "ExternalActionResourceClosureMismatch"); + assert!(!root.join(".build/application/core.cbor").exists()); + assert!(!root.join(".build/application/target-ir.cbor").exists()); + test_ok(fs::remove_dir_all(root), "remove sentinel resource tree"); + } + + #[test] + fn public_external_action_build_rejects_opaque_resource_definitions() { + let root = temp_tree("public-external-action-opaque-resource"); + let config_path = write_external_action_application(&root); + let artifact_path = root.join("vendor/workspace-snapshot/input-schema.cbor"); + let source_path = root.join("src/observe-workspace.edict"); + let artifact_bytes = test_ok(fs::read(&artifact_path), "read input schema artifact"); + let CanonicalValue::Map(mut artifact) = test_ok( + decode_canonical_cbor(&artifact_bytes), + "decode input schema artifact", + ) else { + panic!("input schema artifact is a canonical map"); + }; + let Some((_, definition)) = artifact + .iter_mut() + .find(|(key, _)| key == &CanonicalValue::Text("definition".to_owned())) + else { + panic!("input schema artifact carries a definition"); + }; + *definition = CanonicalValue::Map(vec![( + CanonicalValue::Text("opaque".to_owned()), + CanonicalValue::Text("not-a-schema".to_owned()), + )]); + let substituted_bytes = test_ok( + encode_canonical_cbor(&CanonicalValue::Map(artifact)), + "encode opaque input schema artifact", + ); + let substituted_digest = test_ok( + digest_canonical_artifact(EXTERNAL_ACTION_RESOURCE_DIGEST_DOMAIN, &substituted_bytes), + "digest opaque input schema artifact", + ) + .to_review_string(); + test_ok( + fs::write(&artifact_path, substituted_bytes), + "write opaque input schema artifact", + ); + let source = test_ok( + fs::read_to_string(&source_path), + "read resource-bound source", + ); + let original_digest = + include_str!("../../../fixtures/lawpack/workspace-snapshot/input-schema.sha256").trim(); + let substituted = source.replacen(original_digest, &substituted_digest, 1); + assert_ne!(substituted, source, "input schema digest must mutate"); + test_ok( + fs::write(&source_path, substituted), + "pin opaque input schema artifact", + ); + + let failure = test_err( + build_application(&config_path), + "opaque resource definition must not publish", + ); + + assert_eq!(failure.kind, "InvalidExternalActionResource"); + assert!(!root.join(".build/application/core.cbor").exists()); + assert!(!root.join(".build/application/target-ir.cbor").exists()); + test_ok(fs::remove_dir_all(root), "remove opaque resource tree"); + } + + #[test] + fn public_external_action_build_rejects_invalid_request_resource_closure() { + for (case, expected_kind) in [ + ("missing", "ExternalActionResourceClosureMismatch"), + ("duplicate", "ExternalActionResourceClosureMismatch"), + ("disconnected", "ExternalActionResourceClosureMismatch"), + ("noncanonical", "InvalidExternalActionResource"), + ] { + let root = temp_tree(&format!("public-external-action-resource-{case}")); + let config_path = write_external_action_application(&root); + let mut config = test_ok( + serde_json::from_slice::(&test_ok( + fs::read(&config_path), + "read application config", + )), + "decode application config", + ); + let Some(resources) = config + .get_mut("externalActionResources") + .and_then(serde_json::Value::as_array_mut) + else { + panic!("application config has an externalActionResources array"); + }; + match case { + "missing" => { + let removed = resources.pop(); + assert!(removed.is_some(), "resource fixture must be removable"); + } + "duplicate" => { + let Some(first) = resources.first().cloned() else { + panic!("resource fixture is non-empty"); + }; + resources.push(first); + } + "disconnected" => { + let disconnected = + root.join("vendor/workspace-snapshot/disconnected-resource.cbor"); + test_ok( + fs::write( + &disconnected, + include_bytes!( + "../../../fixtures/lawpack/workspace-patch/input-schema.cbor" + ), + ), + "write disconnected resource", + ); + resources.push(serde_json::json!({ + "artifact": "vendor/workspace-snapshot/disconnected-resource.cbor" + })); + } + "noncanonical" => { + test_ok( + fs::write( + root.join("vendor/workspace-snapshot/input-schema.cbor"), + [0x18, 0x00], + ), + "write non-canonical resource", + ); + } + _ => unreachable!("the case table is closed"), + } + test_ok( + fs::write( + &config_path, + test_ok( + serde_json::to_vec_pretty(&config), + "encode application config", + ), + ), + "write mutated application config", + ); + + let failure = test_err( + build_application(&config_path), + "invalid request resource closure must reject", + ); + + assert_eq!( + failure.kind, expected_kind, + "case {case} returned unexpected failure kind" + ); + assert!(!root.join(".build/application/core.cbor").exists()); + assert!(!root.join(".build/application/target-ir.cbor").exists()); + test_ok(fs::remove_dir_all(root), "remove invalid closure tree"); + } + } + + fn canonical_map_field_mut<'a>( + fields: &'a mut [(CanonicalValue, CanonicalValue)], + key: &str, + ) -> &'a mut CanonicalValue { + fields + .iter_mut() + .find_map(|(candidate, value)| { + matches!(candidate, CanonicalValue::Text(text) if text == key).then_some(value) + }) + .unwrap_or_else(|| panic!("canonical map contains `{key}`")) + } + + fn mutate_external_action_resource( + artifact: &mut [(CanonicalValue, CanonicalValue)], + ordinal: usize, + seed: u64, + ) { + match ordinal { + 0 => { + *canonical_map_field_mut(artifact, "apiVersion") = + CanonicalValue::Text(format!("edict.external-action-resource/v1-{seed:016x}")); + } + 1 => { + *canonical_map_field_mut(artifact, "coordinate") = + CanonicalValue::Text(format!("workspace.snapshot.input.mutated-{seed:016x}@1")); + } + 2 => { + *canonical_map_field_mut(artifact, "kind") = + CanonicalValue::Text("settlementSchema".to_owned()); + } + _ => { + let CanonicalValue::Map(definition) = + canonical_map_field_mut(artifact, "definition") + else { + panic!("property resource has a definition map"); + }; + match ordinal { + 3 => { + *canonical_map_field_mut(definition, "encoding") = + CanonicalValue::Text("noncanonical-cbor".to_owned()); + } + 4 => { + *canonical_map_field_mut(definition, "root") = CanonicalValue::Text( + format!("boundedWorkspaceObservationInput-{seed:016x}"), + ); + } + 5 => { + *canonical_map_field_mut(definition, "closed") = + CanonicalValue::Bool(false); + } + 6 => { + *canonical_map_field_mut(definition, "fields") = + CanonicalValue::Array(Vec::new()); + } + 7..=14 => { + let CanonicalValue::Array(fields) = + canonical_map_field_mut(definition, "fields") + else { + panic!("property schema has a fields array"); + }; + let field_ordinal = (ordinal - 7) / 4; + let Some(CanonicalValue::Map(field)) = fields.get_mut(field_ordinal) else { + panic!("property schema has field {field_ordinal}"); + }; + let field_key = + ["name", "type", "required", "authority"][(ordinal - 7) % 4]; + match canonical_map_field_mut(field, field_key) { + CanonicalValue::Text(text) => { + test_ok( + std::fmt::Write::write_fmt( + text, + format_args!("-{seed:016x}-{ordinal:02x}"), + ), + "mutate property schema text", + ); + } + CanonicalValue::Bool(required) => *required = !*required, + _ => panic!("property schema field `{field_key}` is scalar"), + } + } + 15 => { + let CanonicalValue::Array(fields) = + canonical_map_field_mut(definition, "fields") + else { + panic!("property schema has a fields array"); + }; + fields.reverse(); + } + _ => unreachable!("the mutation table is closed"), + } + } + } + } + + #[test] + fn fixed_seed_request_resource_mutations_fail_closed() { + const RESOURCE_MUTATION_SEED: u64 = 0x4558_5452_4551_0180; + + let mutations = [ + ("api-version", "InvalidExternalActionResource"), + ("coordinate", "ExternalActionResourceClosureMismatch"), + ("kind", "ExternalActionResourceClosureMismatch"), + ("definition-encoding", "InvalidExternalActionResource"), + ("definition-root", "ExternalActionResourceClosureMismatch"), + ("definition-closed", "InvalidExternalActionResource"), + ("definition-fields-empty", "InvalidExternalActionResource"), + ("field-zero-name", "ExternalActionResourceClosureMismatch"), + ("field-zero-type", "ExternalActionResourceClosureMismatch"), + ( + "field-zero-required", + "ExternalActionResourceClosureMismatch", + ), + ( + "field-zero-authority", + "ExternalActionResourceClosureMismatch", + ), + ("field-one-name", "ExternalActionResourceClosureMismatch"), + ("field-one-type", "ExternalActionResourceClosureMismatch"), + ( + "field-one-required", + "ExternalActionResourceClosureMismatch", + ), + ( + "field-one-authority", + "ExternalActionResourceClosureMismatch", + ), + ("field-order", "ExternalActionResourceClosureMismatch"), + ]; + + for (ordinal, (case, expected_kind)) in mutations.into_iter().enumerate() { + let root = temp_tree(&format!("external-resource-property-{case}")); + let config_path = write_external_action_application(&root); + let artifact_path = root.join("vendor/workspace-snapshot/input-schema.cbor"); + let artifact_bytes = test_ok(fs::read(&artifact_path), "read property resource"); + let CanonicalValue::Map(mut artifact) = test_ok( + decode_canonical_cbor(&artifact_bytes), + "decode property resource", + ) else { + panic!("property resource is a canonical map"); + }; + + mutate_external_action_resource(&mut artifact, ordinal, RESOURCE_MUTATION_SEED); + let mutated = test_ok( + encode_canonical_cbor(&CanonicalValue::Map(artifact)), + "encode property resource mutation", + ); + test_ok( + fs::write(&artifact_path, mutated), + "write property resource mutation", + ); + + let failure = test_err( + build_application(&config_path), + "stale request digest must reject a canonical resource mutation", + ); + assert_eq!( + failure.kind, expected_kind, + "property case {case} ({ordinal}) returned the wrong failure kind" + ); + assert!(!root.join(".build/application/core.cbor").exists()); + assert!(!root.join(".build/application/target-ir.cbor").exists()); + test_ok(fs::remove_dir_all(root), "remove property resource tree"); + } + } + + #[test] + fn sixty_four_request_resources_resolve_as_one_bounded_closure() { + let closure = [external_action_loaded_lawpack()]; + let mut external = external_action_target_ir(); + let Some(intent) = external.intents.get_mut("observe") else { + panic!("workspace observer intent exists"); + }; + let Some(template) = intent.external_action_requests.first().cloned() else { + panic!("workspace observer request exists"); + }; + intent.external_action_requests.clear(); + let mut resources = external_action_loaded_resources() + .into_iter() + .filter(|resource| resource.kind != ExternalActionResourceKind::InputSchema) + .collect::>(); + for ordinal in 0_u8..64 { + let coordinate = format!("workspace.snapshot.input-{ordinal:02}@1"); + let digest = format!("sha256:{}", format!("{:02x}", ordinal + 1).repeat(32)); + let mut request = template.clone(); + request.id = format!("observe-{ordinal:02}"); + request.input_schema.coordinate = coordinate.clone(); + request.input_schema.digest = Some(digest.clone()); + intent.external_action_requests.push(request); + resources.push(LoadedExternalActionResource { + coordinate, + kind: ExternalActionResourceKind::InputSchema, + digest, + }); + } + assert_eq!(intent.external_action_requests.len(), 64); + assert_eq!(resources.len(), 66); + + test_ok( + validate_external_action_artifacts(&external, &closure, &resources), + "64 request resource identities form one bounded exact closure", + ); + } + + #[test] + fn external_action_resource_configuration_has_a_fixed_boundary() { + let mut config = application_manifest(1); + config.build_kind = ApplicationBuildKind::ExternalAction; + config.external_action_resources = (0..MAX_EXTERNAL_ACTION_RESOURCES) + .map(|index| ApplicationExternalActionResource { + artifact: PathBuf::from(format!("resources/{index:03}.cbor")), + }) + .collect(); + test_ok( + validate_application_manifest(&config), + "the exact resource configuration boundary is accepted", + ); + config + .external_action_resources + .push(ApplicationExternalActionResource { + artifact: PathBuf::from("resources/overflow.cbor"), + }); + let failure = test_err( + validate_application_manifest(&config), + "one resource beyond the boundary must reject", + ); + assert_eq!(failure.kind, "InvalidApplicationConfig"); + } + #[test] fn public_external_action_build_rejects_capability_substitution() { let root = temp_tree("public-external-action-substitution"); @@ -2497,6 +3386,44 @@ mod tests { } } + fn external_action_loaded_resources() -> Vec { + [ + ( + "workspace.snapshot.input@1", + ExternalActionResourceKind::InputSchema, + include_bytes!("../../../fixtures/lawpack/workspace-snapshot/input-schema.cbor") + .as_slice(), + ), + ( + "workspace.snapshot.settlement@1", + ExternalActionResourceKind::SettlementSchema, + include_bytes!( + "../../../fixtures/lawpack/workspace-snapshot/settlement-schema.cbor" + ) + .as_slice(), + ), + ( + "workspace.snapshot.reconcile@1", + ExternalActionResourceKind::ReconciliationLaw, + include_bytes!( + "../../../fixtures/lawpack/workspace-snapshot/reconciliation-law.cbor" + ) + .as_slice(), + ), + ] + .into_iter() + .map(|(coordinate, kind, bytes)| LoadedExternalActionResource { + coordinate: coordinate.to_owned(), + kind, + digest: test_ok( + digest_canonical_artifact(EXTERNAL_ACTION_RESOURCE_DIGEST_DOMAIN, bytes), + "digest external-action resource fixture", + ) + .to_review_string(), + }) + .collect() + } + #[allow( clippy::too_many_lines, reason = "the public-build fixture keeps its complete file closure visible" @@ -2543,6 +3470,25 @@ mod tests { ) .as_slice(), ), + ( + lawpack_directory.join("input-schema.cbor"), + include_bytes!("../../../fixtures/lawpack/workspace-snapshot/input-schema.cbor") + .as_slice(), + ), + ( + lawpack_directory.join("settlement-schema.cbor"), + include_bytes!( + "../../../fixtures/lawpack/workspace-snapshot/settlement-schema.cbor" + ) + .as_slice(), + ), + ( + lawpack_directory.join("reconciliation-law.cbor"), + include_bytes!( + "../../../fixtures/lawpack/workspace-snapshot/reconciliation-law.cbor" + ) + .as_slice(), + ), ( provider_generated.join("target-profile.echo-dpo.cbor"), include_bytes!( @@ -2612,6 +3558,11 @@ mod tests { "adapter": "vendor/workspace-snapshot/adapter.cbor", "targetConfiguration": "vendor/workspace-snapshot/request-profile-configuration.cbor" }], + "externalActionResources": [ + {"artifact": "vendor/workspace-snapshot/input-schema.cbor"}, + {"artifact": "vendor/workspace-snapshot/settlement-schema.cbor"}, + {"artifact": "vendor/workspace-snapshot/reconciliation-law.cbor"} + ], "target": { "profile": "echo.dpo@1", "providerPackage": "provider" @@ -2944,6 +3895,7 @@ mod tests { )), }) .collect(), + external_action_resources: Vec::new(), target: ApplicationTarget { profile: "target.test@1".to_owned(), provider_package: PathBuf::from("provider"), diff --git a/crates/edict-syntax/src/canonical.rs b/crates/edict-syntax/src/canonical.rs index b45e8d4c..493106ce 100644 --- a/crates/edict-syntax/src/canonical.rs +++ b/crates/edict-syntax/src/canonical.rs @@ -41,6 +41,12 @@ pub const CORE_MODULE_DIGEST_DOMAIN: &str = "edict.core.module/v1"; /// Artifact domain label for Target IR artifact digests. pub const TARGET_IR_ARTIFACT_DIGEST_DOMAIN: &str = "edict.target-ir.artifact/v1"; +/// Canonical envelope version for typed external-action resources. +pub const EXTERNAL_ACTION_RESOURCE_API_VERSION: &str = "edict.external-action-resource/v1"; + +/// Artifact domain label for typed external-action resource digests. +pub const EXTERNAL_ACTION_RESOURCE_DIGEST_DOMAIN: &str = "edict.external-action-resource/v1"; + /// Stable canonical encoding error categories. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CanonicalErrorKind { diff --git a/crates/edict-syntax/src/lib.rs b/crates/edict-syntax/src/lib.rs index 7267a67a..ddf0c47c 100644 --- a/crates/edict-syntax/src/lib.rs +++ b/crates/edict-syntax/src/lib.rs @@ -113,7 +113,9 @@ pub use canonical::{ encode_target_ir_artifact, BundleDigestDomain, BundlePreimageComponent, BundleSourceDescriptor, CanonicalError, CanonicalErrorKind, CanonicalValue, CoreDigest, BUNDLE_RELEASE_DIGEST_DOMAIN, BUNDLE_SEMANTIC_DIGEST_DOMAIN, CORE_CANONICAL_ENCODING, CORE_DIGEST_FRAME, - CORE_MODULE_DIGEST_DOMAIN, MAX_CANONICAL_NESTING_DEPTH, TARGET_IR_ARTIFACT_DIGEST_DOMAIN, + CORE_MODULE_DIGEST_DOMAIN, EXTERNAL_ACTION_RESOURCE_API_VERSION, + EXTERNAL_ACTION_RESOURCE_DIGEST_DOMAIN, MAX_CANONICAL_NESTING_DEPTH, + TARGET_IR_ARTIFACT_DIGEST_DOMAIN, }; pub use compiler::{ compile_to_core, lower_core, resolve_module, type_check, CompilerContext, CompilerError, diff --git a/docs/topics/cli/README.md b/docs/topics/cli/README.md index ae9bc40c..4a4a0b12 100644 --- a/docs/topics/cli/README.md +++ b/docs/topics/cli/README.md @@ -77,6 +77,11 @@ selects the request-only route explicitly: "adapter": "vendor/workspace-snapshot/adapter.cbor", "targetConfiguration": "vendor/workspace-snapshot/request-profile-configuration.cbor" }], + "externalActionResources": [ + {"artifact": "vendor/workspace-snapshot/input-schema.cbor"}, + {"artifact": "vendor/workspace-snapshot/settlement-schema.cbor"}, + {"artifact": "vendor/workspace-snapshot/reconciliation-law.cbor"} + ], "target": { "profile": "echo.dpo@1", "providerPackage": ".build/echo-provider" @@ -89,11 +94,17 @@ The request-only route requires at least one compiler-emitted external-action request, rejects any callable Target IR step, and requires every request operation digest to equal one exact root-reachable lawpack manifest digest; the operation coordinate remains its own independently versioned resource identity. -The source budget must equal the exact obligation declared by its selected -request-only profile. `providerPackage` remains required because the route -loads and verifies its provider manifest and selected target-profile artifact. -Omitting it is `InvalidApplicationConfig`, but no provider component is invoked. -The owning canonical encoders publish: +Every input schema, settlement schema, and reconciliation law must resolve +through `externalActionResources` to one canonical +`edict.external-action-resource/v1` artifact. The build validates the resource +meta-contract and exact domain-framed identity and rejects missing, duplicate, +disconnected, substituted, opaque, non-canonical, or sentinel resources. +The list is bounded to 192 artifacts. Executable-operation builds reject a +non-empty resource list. The source budget must equal the exact obligation +declared by its selected request-only profile. `providerPackage` remains +required because the route loads and verifies its provider manifest and +selected target-profile artifact. Omitting it is `InvalidApplicationConfig`, +but no provider component is invoked. The owning canonical encoders publish: - `core.cbor`; - `target-ir.cbor`. diff --git a/docs/topics/cli/test-plan.md b/docs/topics/cli/test-plan.md index 159cb7a5..7bab52db 100644 --- a/docs/topics/cli/test-plan.md +++ b/docs/topics/cli/test-plan.md @@ -43,7 +43,7 @@ Out of scope: | CLI-REQ-013 | implemented | The `project` operation accepts dirty editor source from `source` input records and emits structured syntax, diagnostics, Core, Target IR, digest, and status records without requiring a source file on disk. | crates/edict-cli/tests/jsonl_cli.rs | | CLI-REQ-014 | implemented | Compiler-level projection failures are emitted as structured projection records and diagnostics, not process-level CLI failures. | crates/edict-cli/tests/jsonl_cli.rs | | CLI-REQ-015 | planned | The executable-operation `build` route loads one `edict.application/v1` request, validates the exact Edict source and complete lawpack/adapter closure, invokes the selected target provider lowerer and independent verifier through the bounded provider host, and writes only the accepted canonical package and verification-report bytes. | crates/edict-cli/src/application_build.rs | -| CLI-REQ-016 | implemented | The explicit external-action `build` route validates a request-only source and complete root-reachable capability/adapter/target-profile closure, rejects profile-budget mismatches and disconnected lawpacks, invokes no provider component, and atomically publishes the owning encoders' exact Core and Target IR bytes while clearing stale executable outputs. | issue #176, crates/edict-cli/src/application_build.rs | +| CLI-REQ-016 | implemented | The explicit external-action `build` route validates a request-only source, complete root-reachable capability/adapter/target-profile closure, and exact canonical schema/reconciliation resource closure; rejects profile-budget mismatches, disconnected lawpacks, and missing, duplicate, disconnected, substituted, opaque, non-canonical, sentinel, or over-budget resources; invokes no provider component; and atomically publishes the owning encoders' exact Core and Target IR bytes while clearing stale executable outputs. | issue #180 | ## Fixtures @@ -103,8 +103,8 @@ Out of scope: | CLI-TP-027 | implemented | Build request dispatch | CLI-REQ-015 | A `build` settings record is accepted without compiler input records and reaches application-config loading; a missing application config fails as a structured `ApplicationConfigReadFailed` build diagnostic rather than as invalid settings or missing compiler input. | build_accepts_application_request_without_compiler_input_records | crates/edict-cli/tests/jsonl_cli.rs | Covers the public request shape and dispatch boundary only. | | CLI-TP-028 | planned | Verified application build | CLI-REQ-015 | A repository-owned provider-component fixture builds one exact source and complete lawpack closure, independently accepts the package, and publishes the canonical package/report pair. | - | - | The standalone external Hello Echo build is a green integration witness; this planned case makes the full crossing reproducible inside Edict's own automated suite. | | CLI-TP-029 | implemented | External request projection | CLI-REQ-013 | A `project` request carrying exact external-action source emits Core and Target IR review data with the capability resource, complete request payload, explicit awaiting-settlement posture, capability semantic closure, and zero callable target steps. | project_exposes_external_requests_as_non_callable_review_data | crates/edict-cli/tests/jsonl_cli.rs | Review JSON exposes compiler data only; the CLI performs no external action. | -| CLI-TP-030 | implemented | External-action build | CLI-REQ-016 | An explicit request-only application manifest loads exact source, lawpack, adapter, configuration, and Echo target-profile artifacts; emits exact checked Core and Target IR bytes; removes stale executable outputs; and reruns deterministically. | public_external_action_build_emits_exact_compiler_artifacts | crates/edict-cli/src/application_build.rs, fixtures/lawpack/workspace-snapshot/README.md, fixtures/providers/echo-target-profile/README.md | The provider component host is not invoked. | -| CLI-TP-031 | implemented | External-action refusal | CLI-REQ-016 | Missing requests, callable-step mixtures, substituted capability manifests, disconnected lawpacks, profile-budget mismatches, and failed pair replacement refuse before a passing publication witness. | external_action_build_requires_a_typed_request, external_action_build_rejects_mixed_callable_execution, public_external_action_build_rejects_capability_substitution, public_external_action_build_rejects_a_disconnected_lawpack, request_only_profile_rejects_another_profiles_budget, failed_external_action_pair_publication_preserves_previous_core | crates/edict-cli/src/application_build.rs, crates/edict-syntax/tests/lawpack.rs | Stable failure kinds distinguish closure, authority, execution-class, and output failures. | +| CLI-TP-030 | implemented | External-action build | CLI-REQ-016 | An explicit request-only application manifest loads exact source, lawpack, adapter, configuration, Echo target-profile, input-schema, settlement-schema, and reconciliation-law artifacts; independently recomputes the complete request resource closure; emits exact checked Core and Target IR bytes; removes stale executable outputs; and reruns deterministically. | public_external_action_build_emits_exact_compiler_artifacts | crates/edict-cli/src/application_build.rs, fixtures/lawpack/workspace-snapshot/README.md, fixtures/providers/echo-target-profile/README.md | The provider component host is not invoked. | +| CLI-TP-031 | implemented | External-action refusal | CLI-REQ-016 | Missing requests, callable-step mixtures, substituted capability manifests, disconnected lawpacks, profile-budget mismatches, invalid request-resource closures, opaque resource definitions, sentinel identities, fixed-seed resource mutations, boundary overflow, and failed pair replacement refuse before a passing publication witness. | external_action_build_requires_a_typed_request, external_action_build_rejects_mixed_callable_execution, public_external_action_build_rejects_capability_substitution, public_external_action_build_rejects_a_disconnected_lawpack, public_external_action_build_rejects_invalid_request_resource_closure, public_external_action_build_rejects_opaque_resource_definitions, public_external_action_build_rejects_sentinel_resource_identities, fixed_seed_request_resource_mutations_fail_closed, external_action_resource_configuration_has_a_fixed_boundary, request_only_profile_rejects_another_profiles_budget, failed_external_action_pair_publication_preserves_previous_core | crates/edict-cli/src/application_build.rs, crates/edict-syntax/tests/lawpack.rs | Stable failure kinds distinguish closure, authority, execution-class, resource-shape, and output failures. | ## Determinism Obligations diff --git a/docs/topics/external-action-requests/README.md b/docs/topics/external-action-requests/README.md index 6a44f3a6..691110d4 100644 --- a/docs/topics/external-action-requests/README.md +++ b/docs/topics/external-action-requests/README.md @@ -15,7 +15,7 @@ A requestable operation family enters source through a digest-locked ```edict use capability workspace.snapshot.observe@1 - digest "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + digest "sha256:7fcb985591d116a1624716334209dd5bf3948dfd756028dc32104621f9e90f71" as snapshot; ``` @@ -24,12 +24,12 @@ The alias is callable only in a `request` statement: ```edict request pending: ExternalActionRequest> = snapshot(input.payload) - input schema workspace.snapshot.input@1 digest "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - settlement schema workspace.snapshot.settlement@1 digest "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + input schema workspace.snapshot.input@1 digest "sha256:c390651d8975c49ff148a332feba4054a53fa9867e0412c1c303e0771cda1096" + settlement schema workspace.snapshot.settlement@1 digest "sha256:efff3be35fba0aeeadc59e73fe771cb42213d390a269bbc5b2e24a028bb84832" authority input.scope basis input.basis budget maxSettlementBytes input.maxSettlementBytes maxAttempts input.maxAttempts - reconcile workspace.snapshot.reconcile@1 digest "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + reconcile workspace.snapshot.reconcile@1 digest "sha256:352dab01263aa1a1f01a8ac213be0debef1bf23d7b522dce24527993124315c9"; ``` The operation takes one typed input. Scope, basis, and both budgets are ordinary @@ -67,9 +67,12 @@ participates in canonical identity. [EXTREQ-REQ-005] ## Public Application Build An `edict.application/v1` manifest selects the request-only route with -`"buildKind": "externalAction"`. The build loads the exact source, complete -lawpack dependency set, declarative adapter, request-profile configuration, and -provider-owned target profile. It then: +`"buildKind": "externalAction"`. Its `externalActionResources` array names the +canonical artifacts for every input schema, settlement schema, and +reconciliation law referenced by the source. The build loads those artifacts +alongside the exact source, complete lawpack dependency set, declarative +adapter, request-profile configuration, and provider-owned target profile. It +then: 1. compiles and lowers through the real lawpack closure; 2. requires at least one typed request and zero callable Target IR steps; @@ -79,20 +82,33 @@ provider-owned target profile. It then: 5. binds each request operation digest to an exact root-reachable lawpack manifest digest without inventing a namespace or version relationship between the two independent resource coordinates; -6. writes the owning encoders' exact `core.cbor` and `target-ir.cbor` bytes. +6. decodes each external-action resource as canonical + `edict.external-action-resource/v1`, validates its closed schema or + reconciliation meta-contract, and recomputes its + `edict.external-action-resource/v1` domain-framed identity; +7. requires the configured resource set to equal the Target request closure: + no missing, substituted, duplicate, disconnected, malformed, or placeholder + artifact survives; +8. writes the owning encoders' exact `core.cbor` and `target-ir.cbor` bytes. Publication is a locked pair replacement. A failure restores the previous pair, and a successful request build removes stale executable-operation package and verification-report outputs. The route does not invoke a provider component or perform an external action. [EXTREQ-REQ-009] +Resource resolution grants no performance authority. Schema artifacts describe +canonical request or settlement values. Reconciliation artifacts declare the +terminal postures, required bindings, and effect-free replay rule. They cannot +read, write, spawn, or call a provider component. [EXTREQ-REQ-011] + ## Authority Boundary Request authority is not performance authority: - a capability alias cannot be invoked as an ordinary semantic effect; - the current request-family allowlist contains only the domain-specific - `workspace` root used by `workspace.snapshot.observe@1`; + `workspace` root used by `workspace.snapshot.observe@1` and + `workspace.patch.applyValidated@1`; - raw `filesystem`, `process`, `network`, Git, GitHub, `model`, and `shell` operation families, case variants, abbreviations, and unregistered roots are rejected with `UnrequestableExternalOperation`; diff --git a/docs/topics/external-action-requests/test-plan.md b/docs/topics/external-action-requests/test-plan.md index 0318cc02..0e707cbe 100644 --- a/docs/topics/external-action-requests/test-plan.md +++ b/docs/topics/external-action-requests/test-plan.md @@ -38,6 +38,7 @@ Out of scope: | EXTREQ-REQ-008 | implemented | Request construction remains bounded under a fixed-seed mutation corpus and a 64-request stress module. | issue #172 | | EXTREQ-REQ-009 | implemented | The public application build explicitly selects request-only publication, validates the exact source/root-reachable-lawpack/adapter/target-profile closure, rejects zero requests, callable-step mixtures, disconnected lawpacks, profile-budget mismatches, and substituted capability manifests, and atomically publishes exact canonical Core and Target IR bytes without invoking a provider component. | issue #176 | | EXTREQ-REQ-010 | implemented | A real `workspace.patch.applyValidated@1` closure binds canonical patch input, exact workspace basis, writable-path policy authority, request budgets, settlement schema, and reconciliation law as non-callable request data; compiler-owned Core and Target IR remain independently derivable without granting write authority. | issue #178 | +| EXTREQ-REQ-011 | implemented | A public external-action build resolves every request input schema, settlement schema, and reconciliation law to one canonical compiler-owned resource artifact whose exact domain-framed digest matches Core and Target IR; missing, substituted, duplicate, disconnected, non-canonical, or sentinel identities fail before publication. | issue #180 | ## Fixtures @@ -49,6 +50,7 @@ Out of scope: | 64-request generated module | Bounded stress case. | All 64 requests survive Core and Target IR without becoming callable steps. | | `fixtures/lawpack/workspace-snapshot/` | Exact public-build capability closure. | The owning generator reproduces manifest, exports, request-only adapter, target configuration, source, Core, and Target IR; the public build reproduces the checked compiler bytes. | | `fixtures/lawpack/workspace-patch/` | Basis-bound validated patch request closure. | The owning generator reproduces manifest, exports, request-only adapter, target configuration, source, Core, and Target IR with one request and zero callable steps. | +| Generator-owned external-action resource artifacts | Exact request schema and reconciliation closure. | Each canonical artifact carries its coordinate, resource kind, and complete definition; its domain-framed digest is pinned by the source request and independently recomputed by the public build. | ## Cases @@ -77,6 +79,11 @@ Out of scope: | EXTREQ-TP-021 | implemented | Authority mutation | EXTREQ-REQ-005, EXTREQ-REQ-010 | Patch, basis, authority, budget, schema, operation, and reconciliation mutations move both Core and Target identity. | every_request_authority_field_moves_core_and_target_identity | crates/edict-syntax/tests/external_action_requests.rs | The generic mutation oracle applies to both domain-specific request families. | | EXTREQ-TP-022 | implemented | Property | EXTREQ-REQ-008, EXTREQ-REQ-010 | The fixed-seed request identity corpus remains deterministic and collision-free for request authority changes. | fixed_seed_request_identity_corpus_is_deterministic | crates/edict-syntax/tests/external_action_requests.rs | Seed `0x4558_5452_4551_0001` remains authoritative. | | EXTREQ-TP-023 | implemented | Stress | EXTREQ-REQ-008, EXTREQ-REQ-010 | Sixty-four request declarations remain bounded and non-callable. | sixty_four_requests_remain_bounded_non_callable_data | crates/edict-syntax/tests/external_action_requests.rs | Fixed CI bound; no adapter execution occurs. | +| EXTREQ-TP-024 | implemented | Resource golden path | EXTREQ-REQ-011 | The public build loads the exact generated input-schema, settlement-schema, and reconciliation-law artifacts referenced by every request and publishes only after their coordinates and digests reproduce the compiler-owned request closure. | public_external_action_build_emits_exact_compiler_artifacts | crates/edict-cli/src/application_build.rs, fixtures/lawpack/workspace-snapshot/README.md, fixtures/lawpack/workspace-patch/README.md | Resolution grants no execution authority. | +| EXTREQ-TP-025 | implemented | Resource refusal | EXTREQ-REQ-011 | A missing artifact, substituted artifact bytes, duplicate coordinate, disconnected artifact, invalid shape, opaque definition, or non-canonical encoding rejects before output publication. | public_external_action_build_rejects_invalid_request_resource_closure, public_external_action_build_rejects_opaque_resource_definitions | crates/edict-cli/src/application_build.rs | Configuration and artifact bytes must corroborate one complete closure. | +| EXTREQ-TP-026 | implemented | Sentinel refusal | EXTREQ-REQ-011 | Repeated-byte sentinel digests and other unresolved identities reject even when they are syntactically valid SHA-256 strings. | public_external_action_build_rejects_sentinel_resource_identities | crates/edict-cli/src/application_build.rs | Syntactic digest validity is not artifact identity. | +| EXTREQ-TP-027 | implemented | Property | EXTREQ-REQ-005, EXTREQ-REQ-011 | A fixed-seed 16-case mutation corpus changes one canonical resource artifact at a time and every stale request digest rejects. | fixed_seed_request_resource_mutations_fail_closed | crates/edict-cli/src/application_build.rs | Seed `0x4558_5452_4551_0180` is authoritative. | +| EXTREQ-TP-028 | implemented | Stress | EXTREQ-REQ-008, EXTREQ-REQ-011 | A 64-request resource closure resolves within the fixed CI bound, with no unreferenced artifact accepted, and application configuration rejects a 193rd artifact above the declared 192-resource ceiling. | sixty_four_request_resources_resolve_as_one_bounded_closure, external_action_resource_configuration_has_a_fixed_boundary | crates/edict-cli/src/application_build.rs | Cardinalities are fixed at 64 requests and 192 configured artifacts. | ## Determinism Obligations diff --git a/fixtures/lawpack/workspace-patch/README.md b/fixtures/lawpack/workspace-patch/README.md index 74be1186..67cd35f2 100644 --- a/fixtures/lawpack/workspace-patch/README.md +++ b/fixtures/lawpack/workspace-patch/README.md @@ -1,7 +1,8 @@ # Workspace Patch Lawpack Fixture This generator-owned closure defines the compiler side of one basis-bound -validated workspace patch request. It binds: +validated workspace patch request +[claim:workspace-patch-closure, confidence:1.00]. It binds: - `workspace.patch.applyValidated@1` as the requestable operation; - `workspace.patch.input@1` as the canonical patch-input schema; @@ -11,10 +12,20 @@ validated workspace patch request. It binds: - postcondition evidence as an exact resulting workspace root; and - bounded request construction and settlement budgets. -The closure grants no callable write effect. Its operation profile has empty -`semanticEffects`, its adapter has empty `effectImplementations`, and generated -Target IR contains one external-action request with zero callable steps. -Edict constructs request data; it does not open, validate, or mutate a +`input-schema.cbor`, `settlement-schema.cbor`, and +`reconciliation-law.cbor` are canonical +`edict.external-action-resource/v1` artifacts with generator-owned digest +sidecars [claim:workspace-patch-resources, confidence:1.00]. +`apply-validated-patch.edict` pins those identities rather than sentinel +strings. A public external-action application supplies the exact three artifact +paths through `externalActionResources`; Edict recomputes and validates the +complete closure before publishing Core or Target IR. + +The closure grants no callable write effect +[claim:workspace-patch-request-only, confidence:1.00]. Its operation profile +has empty `semanticEffects`, its adapter has empty `effectImplementations`, and +generated Target IR contains one external-action request with zero callable +steps. Edict constructs request data; it does not open, validate, or mutate a workspace. Artifacts are owned by: @@ -29,7 +40,19 @@ Checked bytes are verified by: cargo xtask lawpack-goldens ``` -Echo remains responsible for dynamic schema admission, exact basis and path -policy validation, request-before-write durability, bounded adapter authority, -settlement, ambiguous-outcome reconciliation, recovery, and effect-free -replay. +This fixture leaves dynamic schema admission, exact basis and path-policy +validation, durable execution, settlement, reconciliation, recovery, and +effect-free replay to the host boundary +[claim:workspace-patch-host-boundary, confidence:0.99]. + +
+Appendix: Citations + +| Claim | Evidence | Confidence | Notes | +| --- | --- | ---: | --- | +| `claim:workspace-patch-closure` | `xtask/src/lawpack_goldens.rs#493@67fe6682ee1b77d1c5dbdea15f45efcb311b5750`; `xtask/src/lawpack_goldens.rs#692@67fe6682ee1b77d1c5dbdea15f45efcb311b5750`; `xtask/src/lawpack_goldens.rs#746@67fe6682ee1b77d1c5dbdea15f45efcb311b5750` | 1.00 | Generator, target configuration, and emitted source bind the complete request closure. | +| `claim:workspace-patch-resources` | `xtask/src/lawpack_goldens.rs#1437@67fe6682ee1b77d1c5dbdea15f45efcb311b5750`; `crates/edict-cli/src/application_build.rs#481@67fe6682ee1b77d1c5dbdea15f45efcb311b5750`; `crates/edict-cli/src/application_build.rs#534@67fe6682ee1b77d1c5dbdea15f45efcb311b5750`; `crates/edict-cli/src/application_build.rs#848@67fe6682ee1b77d1c5dbdea15f45efcb311b5750`; `public_external_action_build_rejects_invalid_request_resource_closure` and `fixed_seed_request_resource_mutations_fail_closed` in `crates/edict-cli/src/application_build.rs` | 1.00 | The owner generates canonical identities; the public build decodes, validates, binds, and negatively exercises them. | +| `claim:workspace-patch-request-only` | `xtask/src/lawpack_goldens.rs#652@67fe6682ee1b77d1c5dbdea15f45efcb311b5750`; `xtask/src/lawpack_goldens.rs#1460@67fe6682ee1b77d1c5dbdea15f45efcb311b5750` | 1.00 | The adapter exposes no effect implementation and the golden compiler requires one request with zero callable steps. | +| `claim:workspace-patch-host-boundary` | `xtask/src/lawpack_goldens.rs#692@67fe6682ee1b77d1c5dbdea15f45efcb311b5750`; `xtask/src/lawpack_goldens.rs#746@67fe6682ee1b77d1c5dbdea15f45efcb311b5750` | 0.99 | The compiler-owned artifacts describe the boundary crossing without performing it. | + +
diff --git a/fixtures/lawpack/workspace-patch/apply-validated-patch.core.cbor b/fixtures/lawpack/workspace-patch/apply-validated-patch.core.cbor index b6024284..2b55e20a 100644 Binary files a/fixtures/lawpack/workspace-patch/apply-validated-patch.core.cbor and b/fixtures/lawpack/workspace-patch/apply-validated-patch.core.cbor differ diff --git a/fixtures/lawpack/workspace-patch/apply-validated-patch.core.sha256 b/fixtures/lawpack/workspace-patch/apply-validated-patch.core.sha256 index e2027eca..40dc48c4 100644 --- a/fixtures/lawpack/workspace-patch/apply-validated-patch.core.sha256 +++ b/fixtures/lawpack/workspace-patch/apply-validated-patch.core.sha256 @@ -1 +1 @@ -sha256:61ae6cb0319041d92681c06eedcbd28779d1aab4cbb289187fd57fc5065ece2f +sha256:21bd41a6b88ee0616367031a9da78cb8f8dcc4dcd132712ef54d9df9b8cf2333 diff --git a/fixtures/lawpack/workspace-patch/apply-validated-patch.edict b/fixtures/lawpack/workspace-patch/apply-validated-patch.edict index 43d0822e..6e11a3d0 100644 --- a/fixtures/lawpack/workspace-patch/apply-validated-patch.edict +++ b/fixtures/lawpack/workspace-patch/apply-validated-patch.edict @@ -20,15 +20,15 @@ intent applyValidated(input: ApplyPatchInput) request pending: ExternalActionRequest> = patch(input.patch) input schema workspace.patch.input@1 - digest "sha256:9999999999999999999999999999999999999999999999999999999999999999" + digest "sha256:a815f7baa77c260f9c84a73552b6cab244900fcf27db7d8384d473a59c7e8607" settlement schema workspace.patch.settlement@1 - digest "sha256:8888888888888888888888888888888888888888888888888888888888888888" + digest "sha256:b74398fa5a7a997ccf3af3ee225bb2ef6eb776182d32789d7f8252eadb983a4d" authority input.authority basis input.basis budget maxSettlementBytes input.maxSettlementBytes maxAttempts input.maxAttempts reconcile workspace.patch.reconcile@1 - digest "sha256:7777777777777777777777777777777777777777777777777777777777777777"; + digest "sha256:efa7abd9a5f485994aab71ca796c9762b0f7676262b847750d5310e435da3194"; return pending; } diff --git a/fixtures/lawpack/workspace-patch/apply-validated-patch.target-ir.cbor b/fixtures/lawpack/workspace-patch/apply-validated-patch.target-ir.cbor index 2b971f7a..290de415 100644 Binary files a/fixtures/lawpack/workspace-patch/apply-validated-patch.target-ir.cbor and b/fixtures/lawpack/workspace-patch/apply-validated-patch.target-ir.cbor differ diff --git a/fixtures/lawpack/workspace-patch/apply-validated-patch.target-ir.sha256 b/fixtures/lawpack/workspace-patch/apply-validated-patch.target-ir.sha256 index 362e37cc..cb450136 100644 --- a/fixtures/lawpack/workspace-patch/apply-validated-patch.target-ir.sha256 +++ b/fixtures/lawpack/workspace-patch/apply-validated-patch.target-ir.sha256 @@ -1 +1 @@ -sha256:7aa0f5e9f1091a9b73d36a8a90bd071fc605bfd457b4722f69331a0207911c17 +sha256:908a913c3009b4a32e262321acb79ae150a150860b4af4002faf52099e721c3a diff --git a/fixtures/lawpack/workspace-patch/input-schema.cbor b/fixtures/lawpack/workspace-patch/input-schema.cbor new file mode 100644 index 00000000..3045d9b6 --- /dev/null +++ b/fixtures/lawpack/workspace-patch/input-schema.cbor @@ -0,0 +1 @@ +¤dkindkinputSchemajapiVersionx!edict.external-action-resource/v1jcoordinatewworkspace.patch.input@1jdefinition¤drootxvalidatedWorkspacePatchInputfclosedõffields…¤dnamedkinddtypex$literal:validatedWorkspacePatchInputhrequiredõiauthorityxexact operation discriminator¤dnamedpathdtypewcanonical-relative-pathhrequiredõiauthorityxsingle writable aperture¤dnameuexpectedContentDigestdtypeobyteshrequiredõiauthorityxbasis-bound precondition¤dnamekreplacementdtypepbyteshrequiredõiauthorityxvalidated replacement bytes¤dnameqreplacementDigestdtypeobyteshrequiredõiauthoritytreplacement identityhencodingncanonical-cbor \ No newline at end of file diff --git a/fixtures/lawpack/workspace-patch/input-schema.sha256 b/fixtures/lawpack/workspace-patch/input-schema.sha256 new file mode 100644 index 00000000..9f1284b4 --- /dev/null +++ b/fixtures/lawpack/workspace-patch/input-schema.sha256 @@ -0,0 +1 @@ +sha256:a815f7baa77c260f9c84a73552b6cab244900fcf27db7d8384d473a59c7e8607 diff --git a/fixtures/lawpack/workspace-patch/reconciliation-law.cbor b/fixtures/lawpack/workspace-patch/reconciliation-law.cbor new file mode 100644 index 00000000..0736b75f --- /dev/null +++ b/fixtures/lawpack/workspace-patch/reconciliation-law.cbor @@ -0,0 +1 @@ +¤dkindqreconciliationLawjapiVersionx!edict.external-action-resource/v1jcoordinatexworkspace.patch.reconcile@1jdefinition¥jreplayRulexEreplay consumes the admitted settlement and never reapplies the patchkrequestKindxvalidatedWorkspacePatchInputnsettlementKindx!validatedWorkspacePatchSettlementprequiredBindings‡dpathlrequestBasishevidencesbeforeContentDigestrafterContentDigestnresultingBasiskobstructionpterminalPosturesƒisucceededjobstructednoutcomeUnknown \ No newline at end of file diff --git a/fixtures/lawpack/workspace-patch/reconciliation-law.sha256 b/fixtures/lawpack/workspace-patch/reconciliation-law.sha256 new file mode 100644 index 00000000..de31fc9f --- /dev/null +++ b/fixtures/lawpack/workspace-patch/reconciliation-law.sha256 @@ -0,0 +1 @@ +sha256:efa7abd9a5f485994aab71ca796c9762b0f7676262b847750d5310e435da3194 diff --git a/fixtures/lawpack/workspace-patch/settlement-schema.cbor b/fixtures/lawpack/workspace-patch/settlement-schema.cbor new file mode 100644 index 00000000..0a93ac19 --- /dev/null +++ b/fixtures/lawpack/workspace-patch/settlement-schema.cbor @@ -0,0 +1 @@ +¤dkindpsettlementSchemajapiVersionx!edict.external-action-resource/v1jcoordinatexworkspace.patch.settlement@1jdefinition¤drootx!validatedWorkspacePatchSettlementfclosedõffields‰¤dnamedkinddtypex)literal:validatedWorkspacePatchSettlementhrequiredõiauthorityxexact settlement discriminator¤dnamegposturedtypex(enum:succeeded|obstructed|outcomeUnknownhrequiredõiauthorityx terminal external-action posture¤dnamedpathdtypex!optionalhrequiredõiauthoritypsettled aperture¤dnamelrequestBasisdtypeobyteshrequiredõiauthorityxadmitted workspace basis¤dnamehevidencedtypeobyteshrequiredõiauthorityx$domain-separated settlement evidence¤dnamesbeforeContentDigestdtypexoptional>hrequiredõiauthorityxobserved pre-mutation content¤dnamerafterContentDigestdtypexoptional>hrequiredõiauthorityxobserved postcondition content¤dnamenresultingBasisdtypexoptional>hrequiredõiauthorityx%observed postcondition workspace root¤dnamekobstructiondtypenoptionalhrequiredõiauthorityx)typed obstruction or outcome-unknown codehencodingncanonical-cbor \ No newline at end of file diff --git a/fixtures/lawpack/workspace-patch/settlement-schema.sha256 b/fixtures/lawpack/workspace-patch/settlement-schema.sha256 new file mode 100644 index 00000000..f1bb3fb0 --- /dev/null +++ b/fixtures/lawpack/workspace-patch/settlement-schema.sha256 @@ -0,0 +1 @@ +sha256:b74398fa5a7a997ccf3af3ee225bb2ef6eb776182d32789d7f8252eadb983a4d diff --git a/fixtures/lawpack/workspace-snapshot/README.md b/fixtures/lawpack/workspace-snapshot/README.md index 63811953..996bd1d2 100644 --- a/fixtures/lawpack/workspace-snapshot/README.md +++ b/fixtures/lawpack/workspace-snapshot/README.md @@ -18,6 +18,14 @@ digest. The generated Core and Target IR therefore preserve the complete capability closure while Target IR contains one external-action request and zero callable steps. +The same generator emits `input-schema.cbor`, `settlement-schema.cbor`, and +`reconciliation-law.cbor` plus their domain-framed digest sidecars. The source +pins those exact identities. A public external-action application lists all +three paths in `externalActionResources`; the build independently decodes the +closed meta-contracts, recomputes their identities, and rejects an incomplete, +substituted, duplicate, disconnected, malformed, or placeholder closure before +publication. + This real-lawpack corpus is distinct from `fixtures/lang/external-actions/workspace-snapshot.edict`. That earlier compiler fixture uses synthetic context facts and a placeholder capability diff --git a/fixtures/lawpack/workspace-snapshot/input-schema.cbor b/fixtures/lawpack/workspace-snapshot/input-schema.cbor new file mode 100644 index 00000000..49c84916 --- /dev/null +++ b/fixtures/lawpack/workspace-snapshot/input-schema.cbor @@ -0,0 +1 @@ +¤dkindkinputSchemajapiVersionx!edict.external-action-resource/v1jcoordinatexworkspace.snapshot.input@1jdefinition¤drootx boundedWorkspaceObservationInputfclosedõffields‚¤dnamedkinddtypex(literal:boundedWorkspaceObservationInputhrequiredõiauthorityxexact operation discriminator¤dnameepathsdtypexarrayhrequiredõiauthorityxordered exact read aperturehencodingncanonical-cbor \ No newline at end of file diff --git a/fixtures/lawpack/workspace-snapshot/input-schema.sha256 b/fixtures/lawpack/workspace-snapshot/input-schema.sha256 new file mode 100644 index 00000000..9dd7bb6e --- /dev/null +++ b/fixtures/lawpack/workspace-snapshot/input-schema.sha256 @@ -0,0 +1 @@ +sha256:c390651d8975c49ff148a332feba4054a53fa9867e0412c1c303e0771cda1096 diff --git a/fixtures/lawpack/workspace-snapshot/observe-workspace.core.cbor b/fixtures/lawpack/workspace-snapshot/observe-workspace.core.cbor index 046e0832..7bd62647 100644 Binary files a/fixtures/lawpack/workspace-snapshot/observe-workspace.core.cbor and b/fixtures/lawpack/workspace-snapshot/observe-workspace.core.cbor differ diff --git a/fixtures/lawpack/workspace-snapshot/observe-workspace.core.sha256 b/fixtures/lawpack/workspace-snapshot/observe-workspace.core.sha256 index 0d9d3c7d..77921c45 100644 --- a/fixtures/lawpack/workspace-snapshot/observe-workspace.core.sha256 +++ b/fixtures/lawpack/workspace-snapshot/observe-workspace.core.sha256 @@ -1 +1 @@ -sha256:8c99118cefb996cda44a860fd8a04a136ac81bf98356c8b909a31b28da136598 +sha256:90fc32479227d6fa1fc87b95b4ea84337d9304443c8f32df9afa697a2b0fe6d9 diff --git a/fixtures/lawpack/workspace-snapshot/observe-workspace.edict b/fixtures/lawpack/workspace-snapshot/observe-workspace.edict index 9a1dd1b0..c2c10f1e 100644 --- a/fixtures/lawpack/workspace-snapshot/observe-workspace.edict +++ b/fixtures/lawpack/workspace-snapshot/observe-workspace.edict @@ -20,15 +20,15 @@ intent observe(input: ObserveInput) request pending: ExternalActionRequest> = snapshot(input.payload) input schema workspace.snapshot.input@1 - digest "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + digest "sha256:c390651d8975c49ff148a332feba4054a53fa9867e0412c1c303e0771cda1096" settlement schema workspace.snapshot.settlement@1 - digest "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + digest "sha256:efff3be35fba0aeeadc59e73fe771cb42213d390a269bbc5b2e24a028bb84832" authority input.scope basis input.basis budget maxSettlementBytes input.maxSettlementBytes maxAttempts input.maxAttempts reconcile workspace.snapshot.reconcile@1 - digest "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + digest "sha256:352dab01263aa1a1f01a8ac213be0debef1bf23d7b522dce24527993124315c9"; return pending; } diff --git a/fixtures/lawpack/workspace-snapshot/observe-workspace.target-ir.cbor b/fixtures/lawpack/workspace-snapshot/observe-workspace.target-ir.cbor index 103ff0d6..99180652 100644 Binary files a/fixtures/lawpack/workspace-snapshot/observe-workspace.target-ir.cbor and b/fixtures/lawpack/workspace-snapshot/observe-workspace.target-ir.cbor differ diff --git a/fixtures/lawpack/workspace-snapshot/observe-workspace.target-ir.sha256 b/fixtures/lawpack/workspace-snapshot/observe-workspace.target-ir.sha256 index cb22456c..84ccabac 100644 --- a/fixtures/lawpack/workspace-snapshot/observe-workspace.target-ir.sha256 +++ b/fixtures/lawpack/workspace-snapshot/observe-workspace.target-ir.sha256 @@ -1 +1 @@ -sha256:29cce7912efa7d2923b0c5631339bbac680c042b837ee1f4666aca2d746d3bfe +sha256:c6ea56f34e591c7f130124cc50ccf42c2c670b6bc4413740305e2734e28065bc diff --git a/fixtures/lawpack/workspace-snapshot/reconciliation-law.cbor b/fixtures/lawpack/workspace-snapshot/reconciliation-law.cbor new file mode 100644 index 00000000..df55572b --- /dev/null +++ b/fixtures/lawpack/workspace-snapshot/reconciliation-law.cbor @@ -0,0 +1 @@ +¤dkindqreconciliationLawjapiVersionx!edict.external-action-resource/v1jcoordinatexworkspace.snapshot.reconcile@1jdefinition¥jreplayRulexFreplay consumes the admitted settlement and performs no workspace readkrequestKindx boundedWorkspaceObservationInputnsettlementKindx%boundedWorkspaceObservationSettlementprequiredBindings„ebasishevidenceefileskobstructionpterminalPosturesƒisucceededjobstructednoutcomeUnknown \ No newline at end of file diff --git a/fixtures/lawpack/workspace-snapshot/reconciliation-law.sha256 b/fixtures/lawpack/workspace-snapshot/reconciliation-law.sha256 new file mode 100644 index 00000000..957cd04c --- /dev/null +++ b/fixtures/lawpack/workspace-snapshot/reconciliation-law.sha256 @@ -0,0 +1 @@ +sha256:352dab01263aa1a1f01a8ac213be0debef1bf23d7b522dce24527993124315c9 diff --git a/fixtures/lawpack/workspace-snapshot/settlement-schema.cbor b/fixtures/lawpack/workspace-snapshot/settlement-schema.cbor new file mode 100644 index 00000000..f01ad685 --- /dev/null +++ b/fixtures/lawpack/workspace-snapshot/settlement-schema.cbor @@ -0,0 +1 @@ +¤dkindpsettlementSchemajapiVersionx!edict.external-action-resource/v1jcoordinatexworkspace.snapshot.settlement@1jdefinition¤drootx%boundedWorkspaceObservationSettlementfclosedõffields†¤dnamedkinddtypex-literal:boundedWorkspaceObservationSettlementhrequiredõiauthorityxexact settlement discriminator¤dnamegposturedtypex(enum:succeeded|obstructed|outcomeUnknownhrequiredõiauthorityx terminal external-action posture¤dnameebasisdtypeobyteshrequiredõiauthoritywobserved workspace root¤dnamehevidencedtypeobyteshrequiredõiauthorityx%domain-separated observation evidence¤dnameefilesdtypex:arrayhrequiredõiauthorityx,strictly ordered requested file observations¤dnamekobstructiondtypenoptionalhrequiredõiauthorityx)typed obstruction or outcome-unknown codehencodingncanonical-cbor \ No newline at end of file diff --git a/fixtures/lawpack/workspace-snapshot/settlement-schema.sha256 b/fixtures/lawpack/workspace-snapshot/settlement-schema.sha256 new file mode 100644 index 00000000..06fcc0ee --- /dev/null +++ b/fixtures/lawpack/workspace-snapshot/settlement-schema.sha256 @@ -0,0 +1 @@ +sha256:efff3be35fba0aeeadc59e73fe771cb42213d390a269bbc5b2e24a028bb84832 diff --git a/xtask/src/lawpack_goldens.rs b/xtask/src/lawpack_goldens.rs index 890ee2f7..508a7b16 100644 --- a/xtask/src/lawpack_goldens.rs +++ b/xtask/src/lawpack_goldens.rs @@ -3,10 +3,11 @@ use std::fs; use std::path::Path; use edict_syntax::{ - compile_to_core, decode_lawpack_adapter, decode_lawpack_bundle, digest_core_module, - digest_target_ir_artifact, encode_canonical_cbor, encode_core_module, + compile_to_core, decode_lawpack_adapter, decode_lawpack_bundle, digest_canonical_artifact, + digest_core_module, digest_target_ir_artifact, encode_canonical_cbor, encode_core_module, encode_target_ir_artifact, lower_to_target_ir, parse_module, prepare_lawpack_compilation, - CanonicalValue, TargetLoweringStatus, + CanonicalValue, TargetLoweringStatus, ValidatedLawpackAdapter, ValidatedLawpackBundle, + EXTERNAL_ACTION_RESOURCE_API_VERSION, EXTERNAL_ACTION_RESOURCE_DIGEST_DOMAIN, }; use sha2::{Digest, Sha256}; @@ -75,6 +76,18 @@ const WORKSPACE_SNAPSHOT_CONFIGURATION_CBOR: &str = "fixtures/lawpack/workspace-snapshot/request-profile-configuration.cbor"; const WORKSPACE_SNAPSHOT_CONFIGURATION_DIGEST: &str = "fixtures/lawpack/workspace-snapshot/request-profile-configuration.sha256"; +const WORKSPACE_SNAPSHOT_INPUT_SCHEMA_CBOR: &str = + "fixtures/lawpack/workspace-snapshot/input-schema.cbor"; +const WORKSPACE_SNAPSHOT_INPUT_SCHEMA_DIGEST: &str = + "fixtures/lawpack/workspace-snapshot/input-schema.sha256"; +const WORKSPACE_SNAPSHOT_SETTLEMENT_SCHEMA_CBOR: &str = + "fixtures/lawpack/workspace-snapshot/settlement-schema.cbor"; +const WORKSPACE_SNAPSHOT_SETTLEMENT_SCHEMA_DIGEST: &str = + "fixtures/lawpack/workspace-snapshot/settlement-schema.sha256"; +const WORKSPACE_SNAPSHOT_RECONCILIATION_LAW_CBOR: &str = + "fixtures/lawpack/workspace-snapshot/reconciliation-law.cbor"; +const WORKSPACE_SNAPSHOT_RECONCILIATION_LAW_DIGEST: &str = + "fixtures/lawpack/workspace-snapshot/reconciliation-law.sha256"; const WORKSPACE_SNAPSHOT_SOURCE: &str = "fixtures/lawpack/workspace-snapshot/observe-workspace.edict"; const WORKSPACE_SNAPSHOT_CORE_CBOR: &str = @@ -99,6 +112,18 @@ const WORKSPACE_PATCH_CONFIGURATION_CBOR: &str = "fixtures/lawpack/workspace-patch/request-profile-configuration.cbor"; const WORKSPACE_PATCH_CONFIGURATION_DIGEST: &str = "fixtures/lawpack/workspace-patch/request-profile-configuration.sha256"; +const WORKSPACE_PATCH_INPUT_SCHEMA_CBOR: &str = + "fixtures/lawpack/workspace-patch/input-schema.cbor"; +const WORKSPACE_PATCH_INPUT_SCHEMA_DIGEST: &str = + "fixtures/lawpack/workspace-patch/input-schema.sha256"; +const WORKSPACE_PATCH_SETTLEMENT_SCHEMA_CBOR: &str = + "fixtures/lawpack/workspace-patch/settlement-schema.cbor"; +const WORKSPACE_PATCH_SETTLEMENT_SCHEMA_DIGEST: &str = + "fixtures/lawpack/workspace-patch/settlement-schema.sha256"; +const WORKSPACE_PATCH_RECONCILIATION_LAW_CBOR: &str = + "fixtures/lawpack/workspace-patch/reconciliation-law.cbor"; +const WORKSPACE_PATCH_RECONCILIATION_LAW_DIGEST: &str = + "fixtures/lawpack/workspace-patch/reconciliation-law.sha256"; const WORKSPACE_PATCH_SOURCE: &str = "fixtures/lawpack/workspace-patch/apply-validated-patch.edict"; const WORKSPACE_PATCH_CORE_CBOR: &str = "fixtures/lawpack/workspace-patch/apply-validated-patch.core.cbor"; @@ -118,6 +143,28 @@ pub(crate) enum LawpackGoldenMode { Write, } +struct GeneratedExternalActionResource { + coordinate: String, + bytes: Vec, + digest: String, +} + +#[derive(Clone, Copy)] +struct InputSchemaResource<'a>(&'a GeneratedExternalActionResource); + +#[derive(Clone, Copy)] +struct SettlementSchemaResource<'a>(&'a GeneratedExternalActionResource); + +#[derive(Clone, Copy)] +struct ReconciliationLawResource<'a>(&'a GeneratedExternalActionResource); + +struct CompiledExternalActionArtifacts { + core_bytes: Vec, + core_digest: String, + target_ir_bytes: Vec, + target_ir_digest: String, +} + pub(crate) fn lawpack_goldens(root: &Path, mode: LawpackGoldenMode) -> Result<(), String> { let artifacts = hello_echo_golden_artifacts(root)? .into_iter() @@ -144,6 +191,21 @@ pub(crate) fn lawpack_goldens(root: &Path, mode: LawpackGoldenMode) -> Result<() } fn workspace_snapshot_golden_artifacts() -> Result)>, String> { + let input_schema = external_action_resource( + "workspace.snapshot.input@1", + "inputSchema", + workspace_snapshot_input_schema(), + )?; + let settlement_schema = external_action_resource( + "workspace.snapshot.settlement@1", + "settlementSchema", + workspace_snapshot_settlement_schema(), + )?; + let reconciliation_law = external_action_resource( + "workspace.snapshot.reconcile@1", + "reconciliationLaw", + workspace_snapshot_reconciliation_law(), + )?; let exports_value = workspace_snapshot_exports(); let exports_bytes = encode_canonical_cbor(&exports_value) .map_err(|error| format!("encode workspace snapshot exports: {error}"))?; @@ -170,49 +232,14 @@ fn workspace_snapshot_golden_artifacts() -> Result)>, let adapter = decode_lawpack_adapter(&bundle, "echo.dpo@1", &adapter_bytes) .map_err(|failures| format!("validate workspace snapshot adapter: {failures:?}"))?; - let source = workspace_snapshot_application_source(&bundle.manifest_digest_review_string()); - let module = parse_module(&source) - .map_err(|error| format!("parse workspace snapshot application: {error:?}"))?; - let preparation = prepare_lawpack_compilation(&module, &bundle, &adapter) - .map_err(|failures| format!("prepare workspace snapshot application: {failures:?}"))?; - let core = compile_to_core(&module, preparation.compiler_context()) - .map_err(|error| format!("compile workspace snapshot application: {error:?}"))?; - let core_bytes = encode_core_module(&core) - .map_err(|error| format!("encode workspace snapshot Core: {error}"))?; - let core_digest = digest_core_module(&core) - .map_err(|error| format!("digest workspace snapshot Core: {error}"))? - .to_review_string(); - let target_ir_report = lower_to_target_ir(&core, preparation.target_ir_facts()); - if target_ir_report.status != TargetLoweringStatus::Lowered { - return Err(format!( - "lower workspace snapshot Target IR: expected lowered status, got {:?}", - target_ir_report.status - )); - } - let target_ir = target_ir_report.artifact.ok_or_else(|| { - "lower workspace snapshot Target IR: lowered report omitted artifact".to_owned() - })?; - let request_count = target_ir - .intents - .values() - .map(|intent| intent.external_action_requests.len()) - .sum::(); - if request_count != 1 - || target_ir - .intents - .values() - .any(|intent| !intent.steps.is_empty()) - { - return Err( - "workspace snapshot application must lower to one request and zero callable steps" - .to_owned(), - ); - } - let target_ir_bytes = encode_target_ir_artifact(&target_ir) - .map_err(|error| format!("encode workspace snapshot Target IR: {error}"))?; - let target_ir_digest = digest_target_ir_artifact(&target_ir) - .map_err(|error| format!("digest workspace snapshot Target IR: {error}"))? - .to_review_string(); + let source = workspace_snapshot_application_source( + &bundle.manifest_digest_review_string(), + InputSchemaResource(&input_schema), + SettlementSchemaResource(&settlement_schema), + ReconciliationLawResource(&reconciliation_law), + ); + let compiled = + compile_external_action_application(&source, &bundle, &adapter, "workspace snapshot")?; Ok(vec![ (WORKSPACE_SNAPSHOT_MANIFEST_CBOR, manifest_bytes), @@ -235,16 +262,37 @@ fn workspace_snapshot_golden_artifacts() -> Result)>, WORKSPACE_SNAPSHOT_CONFIGURATION_DIGEST, format!("{}\n", sha256_review_string(&configuration_digest)).into_bytes(), ), + (WORKSPACE_SNAPSHOT_INPUT_SCHEMA_CBOR, input_schema.bytes), + ( + WORKSPACE_SNAPSHOT_INPUT_SCHEMA_DIGEST, + format!("{}\n", input_schema.digest).into_bytes(), + ), + ( + WORKSPACE_SNAPSHOT_SETTLEMENT_SCHEMA_CBOR, + settlement_schema.bytes, + ), + ( + WORKSPACE_SNAPSHOT_SETTLEMENT_SCHEMA_DIGEST, + format!("{}\n", settlement_schema.digest).into_bytes(), + ), + ( + WORKSPACE_SNAPSHOT_RECONCILIATION_LAW_CBOR, + reconciliation_law.bytes, + ), + ( + WORKSPACE_SNAPSHOT_RECONCILIATION_LAW_DIGEST, + format!("{}\n", reconciliation_law.digest).into_bytes(), + ), (WORKSPACE_SNAPSHOT_SOURCE, source.into_bytes()), - (WORKSPACE_SNAPSHOT_CORE_CBOR, core_bytes), + (WORKSPACE_SNAPSHOT_CORE_CBOR, compiled.core_bytes), ( WORKSPACE_SNAPSHOT_CORE_DIGEST, - format!("{core_digest}\n").into_bytes(), + format!("{}\n", compiled.core_digest).into_bytes(), ), - (WORKSPACE_SNAPSHOT_TARGET_IR_CBOR, target_ir_bytes), + (WORKSPACE_SNAPSHOT_TARGET_IR_CBOR, compiled.target_ir_bytes), ( WORKSPACE_SNAPSHOT_TARGET_IR_DIGEST, - format!("{target_ir_digest}\n").into_bytes(), + format!("{}\n", compiled.target_ir_digest).into_bytes(), ), ]) } @@ -391,7 +439,18 @@ fn workspace_snapshot_exports() -> CanonicalValue { ]) } -fn workspace_snapshot_application_source(manifest_digest: &str) -> String { +fn workspace_snapshot_application_source( + manifest_digest: &str, + input_schema: InputSchemaResource<'_>, + settlement_schema: SettlementSchemaResource<'_>, + reconciliation_law: ReconciliationLawResource<'_>, +) -> String { + let input_schema_coordinate = &input_schema.0.coordinate; + let input_schema_digest = &input_schema.0.digest; + let settlement_schema_coordinate = &settlement_schema.0.coordinate; + let settlement_schema_digest = &settlement_schema.0.digest; + let reconciliation_law_coordinate = &reconciliation_law.0.coordinate; + let reconciliation_law_digest = &reconciliation_law.0.digest; format!( r#"package examples.workspace_observer@1; @@ -414,17 +473,17 @@ intent observe(input: ObserveInput) {{ request pending: ExternalActionRequest> = snapshot(input.payload) - input schema workspace.snapshot.input@1 - digest "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - settlement schema workspace.snapshot.settlement@1 - digest "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + input schema {input_schema_coordinate} + digest "{input_schema_digest}" + settlement schema {settlement_schema_coordinate} + digest "{settlement_schema_digest}" authority input.scope basis input.basis budget maxSettlementBytes input.maxSettlementBytes maxAttempts input.maxAttempts - reconcile workspace.snapshot.reconcile@1 - digest "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + reconcile {reconciliation_law_coordinate} + digest "{reconciliation_law_digest}"; return pending; }} "# @@ -432,6 +491,21 @@ intent observe(input: ObserveInput) } fn workspace_patch_golden_artifacts() -> Result)>, String> { + let input_schema = external_action_resource( + "workspace.patch.input@1", + "inputSchema", + workspace_patch_input_schema(), + )?; + let settlement_schema = external_action_resource( + "workspace.patch.settlement@1", + "settlementSchema", + workspace_patch_settlement_schema(), + )?; + let reconciliation_law = external_action_resource( + "workspace.patch.reconcile@1", + "reconciliationLaw", + workspace_patch_reconciliation_law(), + )?; let exports_value = workspace_patch_exports(); let exports_bytes = encode_canonical_cbor(&exports_value) .map_err(|error| format!("encode workspace patch exports: {error}"))?; @@ -458,49 +532,14 @@ fn workspace_patch_golden_artifacts() -> Result)>, St let adapter = decode_lawpack_adapter(&bundle, "echo.dpo@1", &adapter_bytes) .map_err(|failures| format!("validate workspace patch adapter: {failures:?}"))?; - let source = workspace_patch_application_source(&bundle.manifest_digest_review_string()); - let module = parse_module(&source) - .map_err(|error| format!("parse workspace patch application: {error:?}"))?; - let preparation = prepare_lawpack_compilation(&module, &bundle, &adapter) - .map_err(|failures| format!("prepare workspace patch application: {failures:?}"))?; - let core = compile_to_core(&module, preparation.compiler_context()) - .map_err(|error| format!("compile workspace patch application: {error:?}"))?; - let core_bytes = encode_core_module(&core) - .map_err(|error| format!("encode workspace patch Core: {error}"))?; - let core_digest = digest_core_module(&core) - .map_err(|error| format!("digest workspace patch Core: {error}"))? - .to_review_string(); - let target_ir_report = lower_to_target_ir(&core, preparation.target_ir_facts()); - if target_ir_report.status != TargetLoweringStatus::Lowered { - return Err(format!( - "lower workspace patch Target IR: expected lowered status, got {:?}", - target_ir_report.status - )); - } - let target_ir = target_ir_report.artifact.ok_or_else(|| { - "lower workspace patch Target IR: lowered report omitted artifact".to_owned() - })?; - let request_count = target_ir - .intents - .values() - .map(|intent| intent.external_action_requests.len()) - .sum::(); - if request_count != 1 - || target_ir - .intents - .values() - .any(|intent| !intent.steps.is_empty()) - { - return Err( - "workspace patch application must lower to one request and zero callable steps" - .to_owned(), - ); - } - let target_ir_bytes = encode_target_ir_artifact(&target_ir) - .map_err(|error| format!("encode workspace patch Target IR: {error}"))?; - let target_ir_digest = digest_target_ir_artifact(&target_ir) - .map_err(|error| format!("digest workspace patch Target IR: {error}"))? - .to_review_string(); + let source = workspace_patch_application_source( + &bundle.manifest_digest_review_string(), + InputSchemaResource(&input_schema), + SettlementSchemaResource(&settlement_schema), + ReconciliationLawResource(&reconciliation_law), + ); + let compiled = + compile_external_action_application(&source, &bundle, &adapter, "workspace patch")?; Ok(vec![ (WORKSPACE_PATCH_MANIFEST_CBOR, manifest_bytes), @@ -523,16 +562,37 @@ fn workspace_patch_golden_artifacts() -> Result)>, St WORKSPACE_PATCH_CONFIGURATION_DIGEST, format!("{}\n", sha256_review_string(&configuration_digest)).into_bytes(), ), + (WORKSPACE_PATCH_INPUT_SCHEMA_CBOR, input_schema.bytes), + ( + WORKSPACE_PATCH_INPUT_SCHEMA_DIGEST, + format!("{}\n", input_schema.digest).into_bytes(), + ), + ( + WORKSPACE_PATCH_SETTLEMENT_SCHEMA_CBOR, + settlement_schema.bytes, + ), + ( + WORKSPACE_PATCH_SETTLEMENT_SCHEMA_DIGEST, + format!("{}\n", settlement_schema.digest).into_bytes(), + ), + ( + WORKSPACE_PATCH_RECONCILIATION_LAW_CBOR, + reconciliation_law.bytes, + ), + ( + WORKSPACE_PATCH_RECONCILIATION_LAW_DIGEST, + format!("{}\n", reconciliation_law.digest).into_bytes(), + ), (WORKSPACE_PATCH_SOURCE, source.into_bytes()), - (WORKSPACE_PATCH_CORE_CBOR, core_bytes), + (WORKSPACE_PATCH_CORE_CBOR, compiled.core_bytes), ( WORKSPACE_PATCH_CORE_DIGEST, - format!("{core_digest}\n").into_bytes(), + format!("{}\n", compiled.core_digest).into_bytes(), ), - (WORKSPACE_PATCH_TARGET_IR_CBOR, target_ir_bytes), + (WORKSPACE_PATCH_TARGET_IR_CBOR, compiled.target_ir_bytes), ( WORKSPACE_PATCH_TARGET_IR_DIGEST, - format!("{target_ir_digest}\n").into_bytes(), + format!("{}\n", compiled.target_ir_digest).into_bytes(), ), ]) } @@ -683,7 +743,18 @@ fn workspace_patch_exports() -> CanonicalValue { ]) } -fn workspace_patch_application_source(manifest_digest: &str) -> String { +fn workspace_patch_application_source( + manifest_digest: &str, + input_schema: InputSchemaResource<'_>, + settlement_schema: SettlementSchemaResource<'_>, + reconciliation_law: ReconciliationLawResource<'_>, +) -> String { + let input_schema_coordinate = &input_schema.0.coordinate; + let input_schema_digest = &input_schema.0.digest; + let settlement_schema_coordinate = &settlement_schema.0.coordinate; + let settlement_schema_digest = &settlement_schema.0.digest; + let reconciliation_law_coordinate = &reconciliation_law.0.coordinate; + let reconciliation_law_digest = &reconciliation_law.0.digest; format!( r#"package examples.workspace_patcher@1; @@ -706,17 +777,17 @@ intent applyValidated(input: ApplyPatchInput) {{ request pending: ExternalActionRequest> = patch(input.patch) - input schema workspace.patch.input@1 - digest "sha256:9999999999999999999999999999999999999999999999999999999999999999" - settlement schema workspace.patch.settlement@1 - digest "sha256:8888888888888888888888888888888888888888888888888888888888888888" + input schema {input_schema_coordinate} + digest "{input_schema_digest}" + settlement schema {settlement_schema_coordinate} + digest "{settlement_schema_digest}" authority input.authority basis input.basis budget maxSettlementBytes input.maxSettlementBytes maxAttempts input.maxAttempts - reconcile workspace.patch.reconcile@1 - digest "sha256:7777777777777777777777777777777777777777777777777777777777777777"; + reconcile {reconciliation_law_coordinate} + digest "{reconciliation_law_digest}"; return pending; }} "# @@ -1363,6 +1434,291 @@ fn hello_echo_exports() -> CanonicalValue { ]) } +fn external_action_resource( + coordinate: &str, + kind: &str, + definition: CanonicalValue, +) -> Result { + let value = map([ + ("apiVersion", text(EXTERNAL_ACTION_RESOURCE_API_VERSION)), + ("coordinate", text(coordinate)), + ("kind", text(kind)), + ("definition", definition), + ]); + let bytes = encode_canonical_cbor(&value) + .map_err(|error| format!("encode external-action resource `{coordinate}`: {error}"))?; + let digest = digest_canonical_artifact(EXTERNAL_ACTION_RESOURCE_DIGEST_DOMAIN, &bytes) + .map_err(|error| format!("digest external-action resource `{coordinate}`: {error}"))? + .to_review_string(); + Ok(GeneratedExternalActionResource { + coordinate: coordinate.to_owned(), + bytes, + digest, + }) +} + +fn compile_external_action_application( + source: &str, + bundle: &ValidatedLawpackBundle, + adapter: &ValidatedLawpackAdapter, + label: &str, +) -> Result { + let module = + parse_module(source).map_err(|error| format!("parse {label} application: {error:?}"))?; + let preparation = prepare_lawpack_compilation(&module, bundle, adapter) + .map_err(|failures| format!("prepare {label} application: {failures:?}"))?; + let core = compile_to_core(&module, preparation.compiler_context()) + .map_err(|error| format!("compile {label} application: {error:?}"))?; + let core_bytes = + encode_core_module(&core).map_err(|error| format!("encode {label} Core: {error}"))?; + let core_digest = digest_core_module(&core) + .map_err(|error| format!("digest {label} Core: {error}"))? + .to_review_string(); + let target_ir_report = lower_to_target_ir(&core, preparation.target_ir_facts()); + if target_ir_report.status != TargetLoweringStatus::Lowered { + return Err(format!( + "lower {label} Target IR: expected lowered status, got {:?}: {:?}", + target_ir_report.status, target_ir_report.failures + )); + } + let target_ir = target_ir_report + .artifact + .ok_or_else(|| format!("lower {label} Target IR: lowered report omitted artifact"))?; + let request_count = target_ir + .intents + .values() + .map(|intent| intent.external_action_requests.len()) + .sum::(); + if request_count != 1 + || target_ir + .intents + .values() + .any(|intent| !intent.steps.is_empty()) + { + return Err(format!( + "{label} application must lower to one request and zero callable steps" + )); + } + let target_ir_bytes = encode_target_ir_artifact(&target_ir) + .map_err(|error| format!("encode {label} Target IR: {error}"))?; + let target_ir_digest = digest_target_ir_artifact(&target_ir) + .map_err(|error| format!("digest {label} Target IR: {error}"))? + .to_review_string(); + Ok(CompiledExternalActionArtifacts { + core_bytes, + core_digest, + target_ir_bytes, + target_ir_digest, + }) +} + +fn workspace_snapshot_input_schema() -> CanonicalValue { + schema_definition( + "boundedWorkspaceObservationInput", + vec![ + schema_field( + "kind", + "literal:boundedWorkspaceObservationInput", + "exact operation discriminator", + ), + schema_field( + "paths", + "array", + "ordered exact read aperture", + ), + ], + ) +} + +fn workspace_snapshot_settlement_schema() -> CanonicalValue { + schema_definition( + "boundedWorkspaceObservationSettlement", + vec![ + schema_field( + "kind", + "literal:boundedWorkspaceObservationSettlement", + "exact settlement discriminator", + ), + schema_field( + "posture", + "enum:succeeded|obstructed|outcomeUnknown", + "terminal external-action posture", + ), + schema_field("basis", "bytes", "observed workspace root"), + schema_field( + "evidence", + "bytes", + "domain-separated observation evidence", + ), + schema_field( + "files", + "array", + "strictly ordered requested file observations", + ), + schema_field( + "obstruction", + "optional", + "typed obstruction or outcome-unknown code", + ), + ], + ) +} + +fn workspace_snapshot_reconciliation_law() -> CanonicalValue { + reconciliation_definition( + "boundedWorkspaceObservationInput", + "boundedWorkspaceObservationSettlement", + &["basis", "evidence", "files", "obstruction"], + "replay consumes the admitted settlement and performs no workspace read", + ) +} + +fn workspace_patch_input_schema() -> CanonicalValue { + schema_definition( + "validatedWorkspacePatchInput", + vec![ + schema_field( + "kind", + "literal:validatedWorkspacePatchInput", + "exact operation discriminator", + ), + schema_field( + "path", + "canonical-relative-path", + "single writable aperture", + ), + schema_field( + "expectedContentDigest", + "bytes", + "basis-bound precondition", + ), + schema_field( + "replacement", + "bytes", + "validated replacement bytes", + ), + schema_field( + "replacementDigest", + "bytes", + "replacement identity", + ), + ], + ) +} + +fn workspace_patch_settlement_schema() -> CanonicalValue { + schema_definition( + "validatedWorkspacePatchSettlement", + vec![ + schema_field( + "kind", + "literal:validatedWorkspacePatchSettlement", + "exact settlement discriminator", + ), + schema_field( + "posture", + "enum:succeeded|obstructed|outcomeUnknown", + "terminal external-action posture", + ), + schema_field( + "path", + "optional", + "settled aperture", + ), + schema_field( + "requestBasis", + "bytes", + "admitted workspace basis", + ), + schema_field( + "evidence", + "bytes", + "domain-separated settlement evidence", + ), + schema_field( + "beforeContentDigest", + "optional>", + "observed pre-mutation content", + ), + schema_field( + "afterContentDigest", + "optional>", + "observed postcondition content", + ), + schema_field( + "resultingBasis", + "optional>", + "observed postcondition workspace root", + ), + schema_field( + "obstruction", + "optional", + "typed obstruction or outcome-unknown code", + ), + ], + ) +} + +fn workspace_patch_reconciliation_law() -> CanonicalValue { + reconciliation_definition( + "validatedWorkspacePatchInput", + "validatedWorkspacePatchSettlement", + &[ + "path", + "requestBasis", + "evidence", + "beforeContentDigest", + "afterContentDigest", + "resultingBasis", + "obstruction", + ], + "replay consumes the admitted settlement and never reapplies the patch", + ) +} + +fn schema_definition(root: &str, fields: Vec) -> CanonicalValue { + map([ + ("encoding", text("canonical-cbor")), + ("root", text(root)), + ("closed", CanonicalValue::Bool(true)), + ("fields", CanonicalValue::Array(fields)), + ]) +} + +fn schema_field(name: &str, field_type: &str, authority: &str) -> CanonicalValue { + map([ + ("name", text(name)), + ("type", text(field_type)), + ("required", CanonicalValue::Bool(true)), + ("authority", text(authority)), + ]) +} + +fn reconciliation_definition( + request_kind: &str, + settlement_kind: &str, + bindings: &[&str], + replay_rule: &str, +) -> CanonicalValue { + map([ + ("requestKind", text(request_kind)), + ("settlementKind", text(settlement_kind)), + ( + "terminalPostures", + CanonicalValue::Array(vec![ + text("succeeded"), + text("obstructed"), + text("outcomeUnknown"), + ]), + ), + ( + "requiredBindings", + CanonicalValue::Array(bindings.iter().map(|binding| text(binding)).collect()), + ), + ("replayRule", text(replay_rule)), + ]) +} + fn digest_value(domain: &str, value: &CanonicalValue) -> Result<[u8; 32], String> { let framed = CanonicalValue::Array(vec![text(DIGEST_FRAME), text(domain), value.clone()]); let bytes = encode_canonical_cbor(&framed)