diff --git a/crates/registry-evidence-client-node/__test__/discovery.test.js b/crates/registry-evidence-client-node/__test__/discovery.test.js index ada7bd5ff..01859c2e5 100644 --- a/crates/registry-evidence-client-node/__test__/discovery.test.js +++ b/crates/registry-evidence-client-node/__test__/discovery.test.js @@ -18,12 +18,12 @@ const GOLDEN_JWKS = JSON.parse( const DEFINITIONS_DOCUMENT = { schema: 'registry.evidence-definitions/v1', assuranceProfile: 'local', - configurationRevision: `sha256:${'0'.repeat(64)}`, issuedBy: 'urn:example:node-test:issuer', providedBy: 'urn:example:node-test:provider', definitions: [ { requirement: 'urn:example:node-test:requirement:status:v1', + configurationRevision: `sha256:${'0'.repeat(64)}`, kind: 'criterion', evidenceType: 'urn:example:node-test:evidence-type:status:v1', purpose: 'example-decision', @@ -71,6 +71,9 @@ test('discover reads a valid definitions document from a stub deployment', async assert.equal(document.schema, 'registry.evidence-definitions/v1'); assert.equal(document.definitions.length, 1); assert.equal(document.definitions[0].requirement, 'urn:example:node-test:requirement:status:v1'); + // The revision a relying party pins is published per definition, so it + // reaches the caller from the requirement it belongs to. + assert.equal(document.definitions[0].configurationRevision, `sha256:${'0'.repeat(64)}`); assert.equal(stub.requests.length, 1); } finally { await stub.close(); diff --git a/crates/registry-evidence-client-py/tests/python/test_concurrency.py b/crates/registry-evidence-client-py/tests/python/test_concurrency.py index 32bc876f6..7d10c501f 100644 --- a/crates/registry-evidence-client-py/tests/python/test_concurrency.py +++ b/crates/registry-evidence-client-py/tests/python/test_concurrency.py @@ -35,8 +35,7 @@ DEFINITIONS_DOCUMENT_BODY = ( b'{"schema": "registry.evidence-definitions/v1", "assuranceProfile": "local",' - b' "configurationRevision": "r", "issuedBy": "i", "providedBy": "p",' - b' "definitions": []}' + b' "issuedBy": "i", "providedBy": "p", "definitions": []}' ) diff --git a/crates/registry-evidence-client-py/tests/python/test_discovery.py b/crates/registry-evidence-client-py/tests/python/test_discovery.py index fc50f64f0..efde37c3b 100644 --- a/crates/registry-evidence-client-py/tests/python/test_discovery.py +++ b/crates/registry-evidence-client-py/tests/python/test_discovery.py @@ -33,10 +33,41 @@ DEFINITIONS_DOCUMENT = { "schema": "registry.evidence-definitions/v1", "assuranceProfile": "local", - "configurationRevision": "test-revision-1", "issuedBy": "https://issuer.example.test", "providedBy": "https://provider.example.test", - "definitions": [], + "definitions": [ + { + "requirement": "urn:example:py-test:requirement:status:v1", + # Published per definition, so a relying party pins one requirement + # without depending on the rest of the deployment. + "configurationRevision": "test-revision-1", + "kind": "criterion", + "evidenceType": "urn:example:py-test:evidence-type:status:v1", + "purpose": "example-decision", + "referenceFrameworks": ["urn:example:py-test:framework:status:v1"], + "subjects": [ + { + "role": "subject", + "cardinality": "one", + "selector": { + "profile": "record-lookup-v1", + "valueOrigin": "request", + "fields": [ + { + "type": "string", + "name": "record_reference", + "minimumBytes": 1, + "maximumBytes": 200, + } + ], + }, + } + ], + "concepts": [ + {"id": "urn:example:py-test:concept:status-holds", "form": "boolean"} + ], + } + ], } @@ -63,6 +94,12 @@ def test_discover_returns_the_definitions_document_as_a_dict(self): self._serve_definitions() document = self._client().discover() self.assertEqual(document, DEFINITIONS_DOCUMENT) + # The revision a relying party pins reaches the caller from the + # definition it belongs to, not from the document. + self.assertNotIn("configurationRevision", document) + self.assertEqual( + document["definitions"][0]["configurationRevision"], "test-revision-1" + ) def test_the_metadata_bound_governs_discovery_and_the_response_bound_does_not(self): """The two bounds answer different questions. diff --git a/crates/registry-evidence-client/src/client.rs b/crates/registry-evidence-client/src/client.rs index d2def6e42..0a453909e 100644 --- a/crates/registry-evidence-client/src/client.rs +++ b/crates/registry-evidence-client/src/client.rs @@ -1133,7 +1133,7 @@ mod tests { /// definitions contract permits. fn definitions_json(schema: &str) -> String { format!( - r#"{{"schema":"{schema}","assuranceProfile":"local","configurationRevision":"sha256:0000000000000000000000000000000000000000000000000000000000000000","issuedBy":"urn:example:client:issuer","providedBy":"urn:example:client:provider","definitions":[]}}"# + r#"{{"schema":"{schema}","assuranceProfile":"local","issuedBy":"urn:example:client:issuer","providedBy":"urn:example:client:provider","definitions":[]}}"# ) } diff --git a/crates/registry-evidence-client/src/definitions.rs b/crates/registry-evidence-client/src/definitions.rs index d84d3d7a5..ef4bad384 100644 --- a/crates/registry-evidence-client/src/definitions.rs +++ b/crates/registry-evidence-client/src/definitions.rs @@ -25,7 +25,6 @@ pub const EVIDENCE_DEFINITIONS_SCHEMA_V1: &str = "registry.evidence-definitions/ pub struct EvidenceDefinitionsDocument { pub schema: String, pub assurance_profile: AssuranceProfile, - pub configuration_revision: String, pub issued_by: String, pub provided_by: String, pub definitions: Vec, @@ -56,6 +55,10 @@ impl EvidenceDefinitionsDocument { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct EvidenceDefinition { pub requirement: String, + /// The revision an assertion for this requirement carries. It covers this + /// requirement's own configuration and artifact closure, so pinning it does + /// not couple a relying procedure to the rest of the deployment. + pub configuration_revision: String, pub kind: DefinitionKind, pub evidence_type: String, pub purpose: String, @@ -242,12 +245,12 @@ mod tests { const DOCUMENT: &str = r#"{ "schema": "registry.evidence-definitions/v1", "assuranceProfile": "local", - "configurationRevision": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "issuedBy": "urn:example:client:issuer", "providedBy": "urn:example:client:provider", "definitions": [ { "requirement": "urn:example:client:requirement:status:v1", + "configurationRevision": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "kind": "criterion", "evidenceType": "urn:example:client:evidence-type:status:v1", "purpose": "example-decision", @@ -345,6 +348,50 @@ mod tests { .any(|definition| definition.purpose == "other-decision")); } + #[test] + fn each_definition_carries_its_own_configuration_revision() { + // A deployment serves several requirements from one bundle and each + // publishes the revision its own assertions carry. A relying procedure + // that pinned a document-level value would break whenever an unrelated + // requirement's configuration changed, so the field lives here. + let mut document = document(); + let mut other_requirement = document.definitions[0].clone(); + other_requirement.requirement = "urn:example:client:requirement:other:v1".to_owned(); + other_requirement.configuration_revision = format!("sha256:{}", "1".repeat(64)); + document.definitions.push(other_requirement); + let round_tripped: EvidenceDefinitionsDocument = serde_json::from_str( + &serde_json::to_string(&document).expect("the two requirement document serializes"), + ) + .expect("the two requirement document parses"); + assert_eq!( + round_tripped + .definition("urn:example:client:requirement:status:v1") + .expect("the first requirement is present") + .configuration_revision, + format!("sha256:{}", "0".repeat(64)) + ); + assert_eq!( + round_tripped + .definition("urn:example:client:requirement:other:v1") + .expect("the second requirement is present") + .configuration_revision, + format!("sha256:{}", "1".repeat(64)) + ); + } + + #[test] + fn a_document_level_configuration_revision_is_refused() { + // The revision moved from the document to each definition. A deployment + // still publishing it at the document level would have a relying party + // pin a value no assertion carries, so the closed type refuses it + // rather than ignoring it. + let document_level = DOCUMENT.replace( + r#""assuranceProfile": "local","#, + r#""assuranceProfile": "local", "configurationRevision": "sha256:0000000000000000000000000000000000000000000000000000000000000000","#, + ); + assert!(serde_json::from_str::(&document_level).is_err()); + } + #[test] fn an_undeclared_member_is_refused() { let extended = DOCUMENT.replace( diff --git a/crates/registry-evidence-client/tests/against_a_real_deployment.rs b/crates/registry-evidence-client/tests/against_a_real_deployment.rs index 4c98a8fbe..1ffe7d27c 100644 --- a/crates/registry-evidence-client/tests/against_a_real_deployment.rs +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -232,12 +232,15 @@ async fn discovery_publishes_shapes_this_client_parses_exactly() { assert_eq!(definitions.schema, EVIDENCE_DEFINITIONS_SCHEMA_V1); assert_eq!(definitions.assurance_profile, AssuranceProfile::Local); - assert!(definitions.configuration_revision.starts_with("sha256:")); assert_eq!(definitions.definitions.len(), 1); let definition = definitions .definition(REQUIREMENT) .expect("the requester is entitled to the fixture requirement"); + // The revision is published per definition, because that is the scope an + // assertion for one requirement carries. + assert!(definition.configuration_revision.starts_with("sha256:")); + assert_eq!(definition.configuration_revision.len(), 71); assert_eq!(definition.kind, DefinitionKind::Criterion); assert_eq!(definition.purpose, "fixture-eligibility"); assert_eq!(definition.subjects.len(), 1); @@ -678,7 +681,7 @@ fn spec( evidence_type: definition.evidence_type.clone(), issued_by: definitions.issued_by.clone(), provided_by: definitions.provided_by.clone(), - configuration_revision: definitions.configuration_revision.clone(), + configuration_revision: definition.configuration_revision.clone(), expected_assurance_profile: definitions.assurance_profile, subjects: definition .subjects diff --git a/crates/registry-evidence/src/bundle.rs b/crates/registry-evidence/src/bundle.rs index 87af63226..56f6aa9ae 100644 --- a/crates/registry-evidence/src/bundle.rs +++ b/crates/registry-evidence/src/bundle.rs @@ -8,7 +8,9 @@ use std::path::{Path, PathBuf}; use base64::Engine as _; use jsonschema::{Draft, JSONSchema}; -use registry_platform_crypto::{PublicJwk, SigningAlgorithm as ProviderSigningAlgorithm}; +use registry_platform_crypto::{ + canonicalize_json, PublicJwk, SigningAlgorithm as ProviderSigningAlgorithm, +}; use rhai::{Engine, AST}; use serde::de::{self, MapAccess, Visitor}; use serde::{Deserialize, Deserializer}; @@ -19,8 +21,8 @@ use thiserror::Error; use url::Url; use crate::config::{ - ArtifactPath, ConceptForm, EvidenceConfig, OrderedMap, RuntimeConfig, SchemaFault, - SelectorField, + ArtifactPath, ConceptConfig, ConceptForm, EvidenceConfig, OrderedMap, RequirementConfig, + RuntimeConfig, SchemaFault, SelectorField, }; pub const MAX_BUNDLE_FILES: usize = 1_024; @@ -33,6 +35,11 @@ const CONFIG_FILE: &str = "evidence.yaml"; const RUNTIME_FILE: &str = "runtime.yaml"; const REVISION_DOMAIN: &[u8] = b"registry.evidence.bundle-revision/v1\0"; const RUNTIME_REVISION_DOMAIN: &[u8] = b"registry.evidence.runtime-revision/v1\0"; +const REQUIREMENT_REVISION_DOMAIN: &[u8] = b"registry.evidence.requirement-revision/v1\0"; +/// Path the canonical configuration projection takes inside a requirement's +/// closure. An artifact path can hold no `#`, so this can never collide with a +/// bundle file. +const PROJECTION_PATH: &str = "evidence.yaml#requirement"; const MAX_CA_BUNDLE_BYTES: u64 = 1024 * 1024; const ALLOWED_DIRECTORIES: [&str; 6] = [ "adapters", @@ -242,6 +249,7 @@ pub struct Bundle { root: PathBuf, pub config: EvidenceConfig, revision: String, + requirement_revisions: BTreeMap, files: BTreeMap>, pub scripts: BTreeMap, pub fact_schemas: BTreeMap, @@ -369,11 +377,13 @@ impl Bundle { let fixtures = load_fixtures(&config, &files)?; let (active_public_jwk, published_public_jwks) = load_public_jwks(&config, &files)?; let revision = compute_revision(&files)?; + let requirement_revisions = compute_requirement_revisions(&config, &files)?; Ok(Self { root: root.to_path_buf(), config, revision, + requirement_revisions, files, scripts, fact_schemas, @@ -388,12 +398,26 @@ impl Bundle { &self.root } - pub fn configuration_revision(&self) -> &str { - &self.revision - } - + /// The configuration revision an assertion for one requirement carries. + /// + /// It covers this requirement's own closure: the canonical projection of the + /// configuration it depends on, and the exact bytes of every artifact it + /// reaches. An edit that cannot change this requirement's assertions leaves + /// it alone, so a relying party pinning it is not broken by a deployment's + /// unrelated work. `None` names a requirement the bundle does not configure. + pub fn configuration_revision(&self, requirement_id: &str) -> Option<&str> { + self.requirement_revisions + .get(requirement_id) + .map(String::as_str) + } + + /// The digest of every file in the deployment bundle. + /// + /// This is the deployment's own identity, for audit, status, and operator + /// diagnostics. It is not what an assertion carries: see + /// [`Bundle::configuration_revision`]. pub fn revision(&self) -> &str { - self.configuration_revision() + &self.revision } pub fn artifact(&self, path: &str) -> Option<&[u8]> { @@ -726,8 +750,8 @@ fn validate_file_closure( for path in &config.signing.published_public_jwk_files { expected.insert(path.as_str().to_owned()); } - expected.extend(reviewed_schema_paths(config, files)?); - expected.extend(reviewed_bucket_codelist_paths(config, files)?); + expected.extend(reviewed_schema_paths(all_concepts(config), files)?); + expected.extend(reviewed_bucket_codelist_paths(all_concepts(config), files)?); let present: BTreeSet<&str> = files.keys().map(String::as_str).collect(); let referenced: BTreeSet<&str> = expected.iter().map(String::as_str).collect(); if let Some(missing) = referenced.difference(&present).next() { @@ -745,14 +769,19 @@ fn validate_file_closure( Ok(()) } -fn reviewed_bucket_codelist_paths( - config: &EvidenceConfig, - files: &BTreeMap>, -) -> Result, BundleError> { - let declarations = config +/// Every concept the configuration declares, in configuration order. +fn all_concepts(config: &EvidenceConfig) -> impl Iterator { + config .requirements .iter() .flat_map(|requirement| &requirement.concepts) +} + +fn reviewed_bucket_codelist_paths<'a>( + concepts: impl Iterator, + files: &BTreeMap>, +) -> Result, BundleError> { + let declarations = concepts .filter(|concept| { matches!( concept.form, @@ -791,14 +820,11 @@ fn reviewed_bucket_codelist_paths( Ok(paths) } -fn reviewed_schema_paths( - config: &EvidenceConfig, +fn reviewed_schema_paths<'a>( + concepts: impl Iterator, files: &BTreeMap>, ) -> Result, BundleError> { - let identifiers = config - .requirements - .iter() - .flat_map(|requirement| &requirement.concepts) + let identifiers = concepts .filter(|concept| concept.form == ConceptForm::ReviewedStructuredValue) .map(|concept| concept_constraint_string(&concept.constraints, "schema")) .collect::, _>>()?; @@ -1006,7 +1032,7 @@ fn load_fact_schemas( }) .map(ToOwned::to_owned) .collect::>(); - paths.extend(reviewed_schema_paths(config, files)?); + paths.extend(reviewed_schema_paths(all_concepts(config), files)?); let mut schemas = BTreeMap::new(); for path in paths { let role = if parameter_paths.contains(path.as_str()) { @@ -1410,7 +1436,7 @@ fn load_codelists( } } } - paths.extend(reviewed_bucket_codelist_paths(config, files)?); + paths.extend(reviewed_bucket_codelist_paths(all_concepts(config), files)?); let mut codelists = BTreeMap::new(); for path in paths { let codelist = load_codelist(&path, files).map_err(|error| error.in_artifact(&path))?; @@ -2006,6 +2032,257 @@ fn compute_revision(files: &BTreeMap>) -> Result>, +) -> Result, BundleError> { + let mut revisions = BTreeMap::new(); + for requirement in &config.requirements { + let mut closure = BTreeMap::from([( + PROJECTION_PATH.to_owned(), + canonical_projection(config, requirement)?, + )]); + for path in requirement_artifact_paths(config, requirement, files)? { + // The bundle-wide closure check ran first, so a referenced artifact + // is present. A miss here would silently shrink the digest, so it + // fails instead. + let bytes = files.get(&path).ok_or_else(|| { + unknown_file( + &path, + "the requirement references an artifact the bundle does not contain", + ) + })?; + closure.insert(path, bytes.clone()); + } + revisions.insert( + requirement.id.clone(), + compute_named_revision(REQUIREMENT_REVISION_DOMAIN, &closure)?, + ); + } + Ok(revisions) +} + +/// Every bundle artifact one requirement reaches. +/// +/// This is the bundle-wide closure of [`validate_file_closure`] restricted to +/// one requirement: its own derivation script, fixtures, concept codelists, +/// reviewed schemas and bucket codelists, the artifacts of the single source it +/// names, and the codelists of the selector profiles its subject roles and +/// grants use. The active and published public signing keys are deployment-wide +/// and stay in every requirement's closure, so this narrows nothing beyond +/// separating one requirement from another. +fn requirement_artifact_paths( + config: &EvidenceConfig, + requirement: &RequirementConfig, + files: &BTreeMap>, +) -> Result, BundleError> { + let mut paths = BTreeSet::new(); + let source = config.sources.get(&requirement.source).ok_or_else(|| { + invalid_artifact("the requirement names a source the configuration does not define") + })?; + paths.insert(source.request.prepare_script.as_str().to_owned()); + paths.insert(source.extract_script.as_str().to_owned()); + paths.insert(source.request.adapter_parameters_schema.as_str().to_owned()); + paths.insert(source.response_schema.as_str().to_owned()); + paths.insert(source.fact_schema.as_str().to_owned()); + paths.insert(requirement.derivation.script.as_str().to_owned()); + if let Some(fixtures) = &requirement.fixtures { + paths.insert(fixtures.as_str().to_owned()); + } + for concept in &requirement.concepts { + if matches!( + concept.form, + ConceptForm::ControlledCode + | ConceptForm::ControlledCategory + | ConceptForm::ControlledCodeList + ) { + paths.insert(concept_codelist_path(&concept.constraints)?.to_owned()); + } + } + for name in requirement_selector_profiles(config, requirement) { + let profile = config.selector_profiles.get(&name).ok_or_else(|| { + invalid_artifact( + "the requirement names a selector profile the configuration does not define", + ) + })?; + for (_, field) in profile.fields.iter() { + if let SelectorField::ControlledCode { codelist, .. } = field { + paths.insert(codelist.as_str().to_owned()); + } + } + } + paths.insert(config.signing.active_public_jwk_file.as_str().to_owned()); + for path in &config.signing.published_public_jwk_files { + paths.insert(path.as_str().to_owned()); + } + paths.extend(reviewed_schema_paths(requirement.concepts.iter(), files)?); + paths.extend(reviewed_bucket_codelist_paths( + requirement.concepts.iter(), + files, + )?); + Ok(paths) +} + +/// The selector profiles one requirement can be served through: those its +/// subject roles declare, and those a grant for it names. +fn requirement_selector_profiles( + config: &EvidenceConfig, + requirement: &RequirementConfig, +) -> BTreeSet { + let mut names: BTreeSet = requirement + .subject_roles + .iter() + .flat_map(|role| role.selector_profiles.iter().cloned()) + .collect(); + for (_, profile) in config.authority_profiles.iter() { + for grant in &profile.grants { + if grant.requirement == requirement.id { + names.extend( + grant + .subjects + .iter() + .map(|subject| subject.selector_profile.clone()), + ); + } + } + } + names +} + +/// The configuration one requirement depends on, in the canonical form its +/// revision digest covers. +/// +/// The projection starts from the complete parsed configuration and replaces +/// only the four members that hold per-requirement configuration, keeping this +/// requirement's own entries: the requirement itself, the single source it +/// names, the selector profiles it can be served through, and the authority +/// grants that offer it. Every other member is kept exactly as configured, so a +/// configuration member added later is covered without revisiting this +/// projection. +/// +/// Starting from the parsed configuration rather than the file bytes is what +/// makes the projection possible at all, and it is faithful because the +/// configuration types reject an unknown member: nothing in the reviewed file +/// can be dropped by parsing it. Comments and formatting are not covered, +/// because neither can change an assertion. +fn canonical_projection( + config: &EvidenceConfig, + requirement: &RequirementConfig, +) -> Result, BundleError> { + let mut document = serde_json::to_value(config) + .map_err(|_| invalid_artifact("the configuration does not project"))?; + let members = document + .as_object_mut() + .ok_or_else(|| invalid_artifact("the configuration does not project as a mapping"))?; + let requirement_value = serde_json::to_value(requirement) + .map_err(|_| invalid_artifact("the requirement does not project"))?; + members.insert( + "requirements".to_owned(), + JsonValue::Array(vec![requirement_value]), + ); + let profiles = requirement_selector_profiles(config, requirement); + retain_members(members, "sources", |name| name == requirement.source)?; + retain_members(members, "selectorProfiles", |name| profiles.contains(name))?; + preserve_selector_field_order(members, config, &profiles)?; + project_authority_profiles(members, &requirement.id)?; + // RFC 8785 canonicalization, shared with the rest of the stack, makes + // order-insensitive mappings and number formatting deterministic. The one + // mapping whose declaration order changes assertion bytes is projected as + // a sequence first, so canonicalization cannot erase that distinction. + canonicalize_json(&document) + .map_err(|_| invalid_artifact("the projection does not canonicalize")) +} + +/// Keep only the named members of one projected configuration mapping. +fn retain_members( + members: &mut JsonMap, + member: &str, + keep: impl Fn(&str) -> bool, +) -> Result<(), BundleError> { + let mapping = members + .get_mut(member) + .and_then(JsonValue::as_object_mut) + .ok_or_else(|| invalid_artifact("the configuration does not project as a mapping"))?; + mapping.retain(|name, _| keep(name)); + Ok(()) +} + +/// Preserve the declaration order that defines canonical selector encoding. +/// +/// `OrderedMap` serializes as a JSON object, whose member order RFC 8785 +/// deliberately erases. Selector field order is not presentation: it orders +/// the normalized values used by subject binding. Project each retained field +/// mapping as `[name, value]` pairs before canonicalization so a reorder moves +/// the revision with the assertion behavior it protects. +fn preserve_selector_field_order( + members: &mut JsonMap, + config: &EvidenceConfig, + retained_profiles: &BTreeSet, +) -> Result<(), BundleError> { + let projected_profiles = members + .get_mut("selectorProfiles") + .and_then(JsonValue::as_object_mut) + .ok_or_else(|| invalid_artifact("the selector profiles do not project as a mapping"))?; + for name in retained_profiles { + let configured = config + .selector_profiles + .get(name) + .ok_or_else(|| invalid_artifact("a retained selector profile is not configured"))?; + let projected = projected_profiles + .get_mut(name) + .and_then(JsonValue::as_object_mut) + .ok_or_else(|| invalid_artifact("a selector profile does not project as a mapping"))?; + let fields = configured + .fields + .iter() + .map(|(field_name, field)| { + serde_json::to_value(field) + .map(|value| { + JsonValue::Array(vec![JsonValue::String(field_name.to_owned()), value]) + }) + .map_err(|_| invalid_artifact("a selector field does not project")) + }) + .collect::, _>>()?; + projected.insert("fields".to_owned(), JsonValue::Array(fields)); + } + Ok(()) +} + +/// Keep only the grants that offer one requirement, and only the authority +/// profiles left holding at least one of them. +fn project_authority_profiles( + members: &mut JsonMap, + requirement_id: &str, +) -> Result<(), BundleError> { + let profiles = members + .get_mut("authorityProfiles") + .and_then(JsonValue::as_object_mut) + .ok_or_else(|| invalid_artifact("the configuration does not project as a mapping"))?; + for (_, profile) in profiles.iter_mut() { + let grants = profile + .get_mut("grants") + .and_then(JsonValue::as_array_mut) + .ok_or_else(|| invalid_artifact("an authority profile does not project"))?; + grants.retain(|grant| { + grant.get("requirement").and_then(JsonValue::as_str) == Some(requirement_id) + }); + } + profiles.retain(|_, profile| { + profile + .get("grants") + .and_then(JsonValue::as_array) + .is_some_and(|grants| !grants.is_empty()) + }); + Ok(()) +} + fn compute_named_revision( domain: &[u8], files: &BTreeMap>, @@ -2109,6 +2386,227 @@ mod tests { assert_ne!(compute_revision(&first), compute_revision(&renamed)); } + /// The revision every configured requirement carries, keyed by requirement. + #[cfg(unix)] + fn requirement_revisions(root: &Path) -> BTreeMap { + let bundle = Bundle::load(root).expect("the acceptance bundle loads"); + bundle + .config + .requirements + .iter() + .map(|requirement| { + ( + requirement.id.clone(), + bundle + .configuration_revision(&requirement.id) + .expect("a configured requirement has a revision") + .to_owned(), + ) + }) + .collect() + } + + /// Load the multi-requirement acceptance bundle, apply one edit to its + /// configuration or artifacts, and answer the revisions before and after. + #[cfg(unix)] + fn revisions_across_edit( + edit: impl FnOnce(&Path), + ) -> (BTreeMap, BTreeMap) { + let directory = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("all-definitions", directory.path()); + set_tree_mode(directory.path(), 0o555, 0o444); + let before = requirement_revisions(directory.path()); + + set_tree_mode(directory.path(), 0o755, 0o644); + edit(directory.path()); + set_tree_mode(directory.path(), 0o555, 0o444); + let after = requirement_revisions(directory.path()); + (before, after) + } + + /// The point of scoping a revision per requirement: an edit that serves one + /// requirement leaves the revision every other relying party pinned alone. + /// Before this, one shared bundle digest meant any byte change anywhere + /// broke every relying party at once, with nothing but an opaque policy + /// failure to explain it. + #[cfg(unix)] + #[test] + fn an_edit_for_one_requirement_leaves_the_other_revisions_alone() { + const EDITED: &str = "urn:example:fixture:requirement:residence-region:v1"; + let (before, after) = revisions_across_edit(|root| { + let script = root.join("derivations/residence-region.rhai"); + let text = fs::read_to_string(&script).expect("the derivation reads"); + fs::write(&script, format!("{text}\n// reviewed again\n")) + .expect("the derivation writes"); + }); + + assert_ne!(before[EDITED], after[EDITED]); + for (requirement, revision) in &before { + if requirement != EDITED { + assert_eq!( + revision, &after[requirement], + "`{requirement}` was not edited and keeps its revision" + ); + } + } + } + + /// The same isolation for the configuration file itself, which is the churn + /// a whole-file digest cannot avoid: every requirement is configured in one + /// `evidence.yaml`, so onboarding or retuning one of them used to invalidate + /// all of them. + #[cfg(unix)] + #[test] + fn a_configuration_edit_for_one_requirement_leaves_the_other_revisions_alone() { + const EDITED: &str = "urn:example:fixture:requirement:professional-licence-status:v1"; + let (before, after) = revisions_across_edit(|root| { + let path = root.join(CONFIG_FILE); + let text = fs::read_to_string(&path).expect("the configuration reads"); + // The only requirement configured with this observation timezone + // is the edited one, so the replacement cannot reach a sibling. + assert_eq!( + text.matches("observationTimezone: Africa/Nairobi").count(), + 1 + ); + fs::write( + &path, + text.replace( + "observationTimezone: Africa/Nairobi", + "observationTimezone: Africa/Accra", + ), + ) + .expect("the configuration writes"); + }); + + assert_ne!(before[EDITED], after[EDITED]); + for (requirement, revision) in &before { + if requirement != EDITED { + assert_eq!( + revision, &after[requirement], + "`{requirement}` was not edited and keeps its revision" + ); + } + } + } + + /// Selector field declaration order controls normalized subject values and + /// therefore the audience-scoped subject binding. RFC 8785 sorts object + /// members, so the projection must preserve this order explicitly. + #[cfg(unix)] + #[test] + fn selector_field_reordering_changes_the_affected_requirement_revision() { + const EDITED: &str = "urn:example:fixture:requirement:adult-status:v1"; + let (before, after) = revisions_across_edit(|root| { + let path = root.join(CONFIG_FILE); + let text = fs::read_to_string(&path).expect("the configuration reads"); + let original = concat!( + " given_name: {type: string, minimumBytes: 1, maximumBytes: 200}\n", + " family_name: {type: string, minimumBytes: 1, maximumBytes: 200}\n", + " birth_date: {type: date}\n", + ); + let reordered = concat!( + " birth_date: {type: date}\n", + " family_name: {type: string, minimumBytes: 1, maximumBytes: 200}\n", + " given_name: {type: string, minimumBytes: 1, maximumBytes: 200}\n", + ); + assert_eq!(text.matches(original).count(), 1); + fs::write(&path, text.replace(original, reordered)).expect("the configuration writes"); + }); + + assert_ne!(before[EDITED], after[EDITED]); + for (requirement, revision) in &before { + if requirement != EDITED { + assert_eq!( + revision, &after[requirement], + "`{requirement}` does not use the reordered selector profile" + ); + } + } + } + + /// Isolation between requirements is the only narrowing. A deployment-wide + /// edit still changes every requirement's revision, so nothing that can + /// change an assertion has stopped being covered. + #[cfg(unix)] + #[test] + fn a_deployment_wide_edit_changes_every_requirement_revision() { + let (before, after) = revisions_across_edit(|root| { + let path = root.join(CONFIG_FILE); + let text = fs::read_to_string(&path).expect("the configuration reads"); + assert_eq!(text.matches("keyVersion: 1").count(), 1); + fs::write(&path, text.replace("keyVersion: 1", "keyVersion: 2")) + .expect("the configuration writes"); + }); + + assert_eq!(before.len(), 4); + for (requirement, revision) in &before { + assert_ne!( + revision, &after[requirement], + "`{requirement}` depends on the edited deployment configuration" + ); + } + } + + /// The projection keeps every configuration member. A member it dropped + /// would stop being covered by any revision, which is a silently narrower + /// tripwire rather than a visible failure, so the member list is asserted + /// against the configuration itself instead of a copy of it. + #[cfg(unix)] + #[test] + fn the_projection_covers_every_configuration_member() { + let directory = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("all-definitions", directory.path()); + set_tree_mode(directory.path(), 0o555, 0o444); + let bundle = Bundle::load(directory.path()).expect("the acceptance bundle loads"); + + let configured = serde_json::to_value(&bundle.config).expect("the configuration projects"); + let projected: JsonValue = serde_json::from_slice( + &canonical_projection(&bundle.config, &bundle.config.requirements[0]) + .expect("the projection is canonical JSON"), + ) + .expect("the projection parses"); + + assert_eq!( + projected + .as_object() + .expect("the projection is a mapping") + .keys() + .collect::>(), + configured + .as_object() + .expect("the configuration is a mapping") + .keys() + .collect::>() + ); + // Only the four per-requirement members are narrowed, and each keeps + // exactly what this requirement reaches. + assert_eq!(projected["requirements"].as_array().map(Vec::len), Some(1)); + assert_eq!(projected["sources"].as_object().map(JsonMap::len), Some(1)); + assert!(projected["sources"].get("source-a").is_some()); + } + + /// A revision must depend on the configuration alone. Canonical JSON is + /// what keeps it independent of the member ordering a dependency happens to + /// use, so a feature selection somewhere in the tree cannot invalidate every + /// pinned revision without a configuration change. + #[cfg(unix)] + #[test] + fn the_projection_is_already_canonical() { + let directory = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("all-definitions", directory.path()); + set_tree_mode(directory.path(), 0o555, 0o444); + let bundle = Bundle::load(directory.path()).expect("the acceptance bundle loads"); + + let projection = canonical_projection(&bundle.config, &bundle.config.requirements[0]) + .expect("the projection is canonical JSON"); + let reparsed: JsonValue = + serde_json::from_slice(&projection).expect("the projection parses"); + assert_eq!( + canonicalize_json(&reparsed).expect("the parsed projection canonicalizes"), + projection + ); + } + #[test] fn fixture_coverage_is_case_neutral_but_complete() { let fixture: YamlValue = serde_norway::from_str( @@ -2232,8 +2730,11 @@ mod tests { set_tree_mode(directory.path(), 0o555, 0o444); let bundle = Bundle::load(directory.path()).expect("bundle loads"); - assert!(bundle.configuration_revision().starts_with("sha256:")); - assert_eq!(bundle.configuration_revision().len(), 71); + let revision = bundle + .configuration_revision(&bundle.config.requirements[0].id) + .expect("the configured requirement has a revision"); + assert!(revision.starts_with("sha256:")); + assert_eq!(revision.len(), 71); assert_eq!(bundle.scripts.len(), 3); assert_eq!(bundle.fact_schemas.len(), 3); assert_eq!(bundle.fixtures.len(), 1); diff --git a/crates/registry-evidence/src/contracts.rs b/crates/registry-evidence/src/contracts.rs index 68e727b15..2b0dce816 100644 --- a/crates/registry-evidence/src/contracts.rs +++ b/crates/registry-evidence/src/contracts.rs @@ -335,12 +335,11 @@ fn definitions_schema() -> Value { "type": "object", "additionalProperties": false, "required": [ - "schema", "assuranceProfile", "configurationRevision", "issuedBy", "providedBy", "definitions" + "schema", "assuranceProfile", "issuedBy", "providedBy", "definitions" ], "properties": { "schema": {"const": "registry.evidence-definitions/v1"}, "assuranceProfile": {"enum": ["local", "production", "evidence-grade"]}, - "configurationRevision": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, "issuedBy": {"type": "string", "format": "uri", "maxLength": 512}, "providedBy": {"type": "string", "format": "uri", "maxLength": 512}, "definitions": { @@ -352,11 +351,12 @@ fn definitions_schema() -> Value { "definition": { "type": "object", "additionalProperties": false, "required": [ - "requirement", "kind", "evidenceType", "purpose", + "requirement", "configurationRevision", "kind", "evidenceType", "purpose", "referenceFrameworks", "subjects", "concepts" ], "properties": { "requirement": {"type": "string", "format": "uri", "maxLength": 512}, + "configurationRevision": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, "kind": {"enum": ["criterion", "information-requirement", "constraint"]}, "evidenceType": {"type": "string", "format": "uri", "maxLength": 512}, "purpose": {"type": "string", "pattern": "^[a-z][a-z0-9._:-]{0,127}$"}, @@ -1329,11 +1329,11 @@ mod tests { json!({ "schema": "registry.evidence-definitions/v1", "assuranceProfile": "evidence-grade", - "configurationRevision": format!("sha256:{}", "0".repeat(64)), "issuedBy": "urn:example:issuer", "providedBy": "urn:example:provider", "definitions": [{ "requirement": "urn:example:requirement:v1", + "configurationRevision": format!("sha256:{}", "0".repeat(64)), "kind": "criterion", "evidenceType": "urn:example:evidence-type:v1", "purpose": "casework", diff --git a/crates/registry-evidence/src/kernel.rs b/crates/registry-evidence/src/kernel.rs index 544526d63..4541e5171 100644 --- a/crates/registry-evidence/src/kernel.rs +++ b/crates/registry-evidence/src/kernel.rs @@ -197,7 +197,7 @@ impl std::fmt::Debug for OfflineKernel { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("OfflineKernel") - .field("configuration_revision", &self.bundle.revision()) + .field("bundle_revision", &self.bundle.revision()) .field("source_count", &self.extractions.len()) .field("requirement_count", &self.derivations.len()) .finish() @@ -535,7 +535,11 @@ impl OfflineKernel { valid_until, purpose: input.purpose.to_owned(), audience: input.audience.to_owned(), - configuration_revision: self.bundle.revision().to_owned(), + configuration_revision: self + .bundle + .configuration_revision(&requirement.id) + .ok_or(KernelError::Requirement)? + .to_owned(), subjects: input.subjects, supported_values: values.0, }) diff --git a/crates/registry-evidence/src/local_verification.rs b/crates/registry-evidence/src/local_verification.rs index 3d9dec52c..ea4c835c3 100644 --- a/crates/registry-evidence/src/local_verification.rs +++ b/crates/registry-evidence/src/local_verification.rs @@ -108,6 +108,12 @@ pub async fn prepare_local_verification_context_for_format( { return Err(LocalVerificationError); } + // The pinned revision covers this requirement's own closure, so it matches + // what the assertion will carry rather than the whole deployment. + let configuration_revision = bundle + .configuration_revision(&requirement.id) + .ok_or(LocalVerificationError)? + .to_owned(); let authenticator = Authenticator::from_config( &bundle.config.authentication, @@ -169,7 +175,7 @@ pub async fn prepare_local_verification_context_for_format( evidence_type: requirement.evidence_type.clone(), purpose: resolved.purpose, audience: resolved.audience, - configuration_revision: bundle.revision().to_owned(), + configuration_revision, request_nonce: request.request_nonce.clone(), expected_subjects, expected_outputs: requirement diff --git a/crates/registry-evidence/src/main.rs b/crates/registry-evidence/src/main.rs index 5a2d0a5e2..6c56b6aa7 100644 --- a/crates/registry-evidence/src/main.rs +++ b/crates/registry-evidence/src/main.rs @@ -1653,7 +1653,12 @@ async fn sign_and_verify_fixture_evidence( policy.evidence_type = requirement.evidence_type.clone(); policy.purpose = resolved.purpose.clone(); policy.audience = OFFLINE_AUDIENCE.to_owned(); - policy.configuration_revision = bundle.revision().to_owned(); + policy.configuration_revision = bundle + .configuration_revision(&requirement.id) + .ok_or(CliError( + "fixture requirement has no configuration revision", + ))? + .to_owned(); let verified = verify_flattened_jws( &serde_json::to_vec(&signed) .map_err(|_| CliError("fixture signed evidence is not representable"))?, diff --git a/crates/registry-evidence/src/model.rs b/crates/registry-evidence/src/model.rs index ad2d9cd06..02f09c098 100644 --- a/crates/registry-evidence/src/model.rs +++ b/crates/registry-evidence/src/model.rs @@ -74,7 +74,6 @@ pub struct EvidenceRequest { pub struct EvidenceDefinitions { pub schema: String, pub assurance_profile: AssuranceProfile, - pub configuration_revision: String, pub issued_by: String, pub provided_by: String, pub definitions: Vec, @@ -84,6 +83,10 @@ pub struct EvidenceDefinitions { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct EvidenceDefinition { pub requirement: String, + /// The revision an assertion for this requirement carries. It covers this + /// requirement's own configuration and artifact closure, so a relying party + /// pins one requirement without depending on the rest of the deployment. + pub configuration_revision: String, pub kind: String, pub evidence_type: String, pub purpose: String, @@ -407,10 +410,18 @@ mod tests { let definitions = EvidenceDefinitions { schema: "protected-discovery-schema-canary".to_owned(), assurance_profile: AssuranceProfile::EvidenceGrade, - configuration_revision: "protected-discovery-revision-canary".to_owned(), issued_by: "protected-discovery-issuer-canary".to_owned(), provided_by: "protected-discovery-provider-canary".to_owned(), - definitions: Vec::new(), + definitions: vec![EvidenceDefinition { + requirement: "protected-discovery-requirement-canary".to_owned(), + configuration_revision: "protected-discovery-revision-canary".to_owned(), + kind: "protected-discovery-kind-canary".to_owned(), + evidence_type: "protected-discovery-type-canary".to_owned(), + purpose: "protected-discovery-purpose-canary".to_owned(), + reference_frameworks: vec!["protected-discovery-framework-canary".to_owned()], + subjects: Vec::new(), + concepts: Vec::new(), + }], }; let unsigned_envelope = UnsignedEvidenceEnvelope { diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs index 6693f11f3..63fbf6486 100644 --- a/crates/registry-evidence/src/runtime.rs +++ b/crates/registry-evidence/src/runtime.rs @@ -359,7 +359,7 @@ impl std::fmt::Debug for EvidenceRuntime { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("EvidenceRuntime") - .field("configuration_revision", &self.kernel.bundle().revision()) + .field("bundle_revision", &self.kernel.bundle().revision()) .field("source_count", &self.sources.len()) .field("signing_key_id", &self.signer.key_id()) .finish_non_exhaustive() @@ -604,7 +604,6 @@ impl EvidenceRuntime { let response = EvidenceDefinitions { schema: EVIDENCE_DEFINITIONS_SCHEMA_V1.to_owned(), assurance_profile: self.bundle().config.assurance_profile, - configuration_revision: self.bundle().revision().to_owned(), issued_by: self.bundle().config.issuer.id.clone(), provided_by: self.bundle().config.service.provider_id.clone(), definitions, @@ -673,8 +672,15 @@ impl EvidenceRuntime { }) .collect(); + let configuration_revision = self + .bundle() + .configuration_revision(&requirement.id) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "discovery-requirement"))? + .to_owned(); + Ok(EvidenceDefinition { requirement: requirement.id.clone(), + configuration_revision, kind: requirement_kind_name(requirement.kind).to_owned(), evidence_type: requirement.evidence_type.clone(), purpose: request.purpose.clone(), diff --git a/crates/registry-evidence/src/runtime_tests.rs b/crates/registry-evidence/src/runtime_tests.rs index f30f06afc..ec570564b 100644 --- a/crates/registry-evidence/src/runtime_tests.rs +++ b/crates/registry-evidence/src/runtime_tests.rs @@ -217,10 +217,24 @@ async fn first_curl_exercises_and_verifies_the_evidence_server() { .await .expect("curl discovery response arrives within three minutes"); assert_eq!(definitions.definitions.len(), 4); - assert_eq!( - definitions.configuration_revision, - fixture.runtime.bundle().revision() - ); + // Discovery publishes the revision an assertion for that one requirement + // will carry, so a relying party pins per requirement and the four + // coequal requirements do not share one deployment-wide value. + for definition in &definitions.definitions { + assert_eq!( + Some(definition.configuration_revision.as_str()), + fixture + .runtime + .bundle() + .configuration_revision(&definition.requirement) + ); + } + let published_revisions: BTreeSet<&str> = definitions + .definitions + .iter() + .map(|definition| definition.configuration_revision.as_str()) + .collect(); + assert_eq!(published_revisions.len(), 4); let serialized_definitions = serde_json::to_string(&definitions).expect("discovery response serializes"); for prohibited in [ @@ -401,10 +415,15 @@ async fn real_router_serves_all_definitions_concurrently_without_crossing_bounda ); let standard_definitions = standard_discovery.json::(); assert_eq!(standard_definitions.definitions.len(), 3); - assert_eq!( - standard_definitions.configuration_revision, - fixture.runtime.bundle().revision() - ); + for definition in &standard_definitions.definitions { + assert_eq!( + Some(definition.configuration_revision.as_str()), + fixture + .runtime + .bundle() + .configuration_revision(&definition.requirement) + ); + } assert!(standard_definitions .definitions .iter() @@ -1146,6 +1165,13 @@ async fn serving_runtime_never_reloads_merges_or_falls_back_after_bundle_capture let fixture = acceptance_runtime().await; let captured_revision = fixture.runtime.bundle().revision().to_owned(); let captured_runtime_revision = fixture.runtime.runtime_revision().to_owned(); + let adult_requirement = adult_request().requirement; + let captured_requirement_revision = fixture + .runtime + .bundle() + .configuration_revision(&adult_requirement) + .expect("the captured bundle configures the requirement") + .to_owned(); let captured_config = fixture .runtime .bundle() @@ -1180,6 +1206,13 @@ async fn serving_runtime_never_reloads_merges_or_falls_back_after_bundle_capture .expect("add an unreferenced fallback-like artifact"); assert_eq!(fixture.runtime.bundle().revision(), captured_revision); + assert_eq!( + fixture + .runtime + .bundle() + .configuration_revision(&adult_requirement), + Some(captured_requirement_revision.as_str()) + ); assert_eq!( fixture.runtime.runtime_revision(), captured_runtime_revision @@ -1216,7 +1249,10 @@ async fn serving_runtime_never_reloads_merges_or_falls_back_after_bundle_capture &verification_policy(&fixture.runtime, &request, &serialized), ) .expect("captured-revision assertion verifies"); - assert_eq!(evidence.configuration_revision, captured_revision); + assert_eq!( + evidence.configuration_revision, + captured_requirement_revision + ); assert_eq!( evidence.supported_values[0].value, PublicValue::Boolean(true) @@ -3124,7 +3160,11 @@ fn verification_policy_stub( evidence_type: requirement.evidence_type.clone(), purpose: request.purpose.clone(), audience: EVIDENCE_AUDIENCE.to_owned(), - configuration_revision: runtime.bundle().revision().to_owned(), + configuration_revision: runtime + .bundle() + .configuration_revision(&request.requirement) + .expect("the loaded requirement has a revision") + .to_owned(), request_nonce: request.request_nonce.clone(), expected_subjects: Vec::new(), expected_outputs: Vec::new(), @@ -4662,7 +4702,11 @@ fn verification_policy( policy.evidence_type = requirement.evidence_type.clone(); policy.purpose = request.purpose.clone(); policy.audience = EVIDENCE_AUDIENCE.to_owned(); - policy.configuration_revision = runtime.bundle().revision().to_owned(); + policy.configuration_revision = runtime + .bundle() + .configuration_revision(&request.requirement) + .expect("the loaded requirement has a revision") + .to_owned(); policy } diff --git a/crates/registry-evidence/tests/deployment_projects.rs b/crates/registry-evidence/tests/deployment_projects.rs index ff7397313..4a19b98b0 100644 --- a/crates/registry-evidence/tests/deployment_projects.rs +++ b/crates/registry-evidence/tests/deployment_projects.rs @@ -596,7 +596,10 @@ async fn execute_response( policy.evidence_type = requirement.evidence_type.clone(); policy.purpose = resolved.purpose.clone(); policy.audience = AUDIENCE.to_owned(); - policy.configuration_revision = bundle.revision().to_owned(); + policy.configuration_revision = bundle + .configuration_revision(&requirement.id) + .unwrap_or_else(|| panic!("{label}: the requirement has no configuration revision")) + .to_owned(); let verified = verify_flattened_jws( &serde_json::to_vec(&signed) .unwrap_or_else(|_| panic!("{label}: signed evidence encoding failed")), diff --git a/crates/registry-evidence/tests/relay_shaped_source.rs b/crates/registry-evidence/tests/relay_shaped_source.rs index 119a1c471..6e8f1289f 100644 --- a/crates/registry-evidence/tests/relay_shaped_source.rs +++ b/crates/registry-evidence/tests/relay_shaped_source.rs @@ -501,7 +501,11 @@ async fn a_relay_shaped_protected_read_backs_a_full_signed_minimum_disclosure_as policy.evidence_type = "urn:example:fixture:evidence-type:residence-region:v1".to_owned(); policy.purpose = "fixture-routing".to_owned(); policy.audience = AUDIENCE.to_owned(); - policy.configuration_revision = kernel.bundle().revision().to_owned(); + policy.configuration_revision = kernel + .bundle() + .configuration_revision(REQUIREMENT) + .expect("the requirement has a configuration revision") + .to_owned(); let verified = verify_flattened_jws(&serialized, &jwks, &policy) .expect("signed Evidence verifies against the deployment JWKS"); assert_eq!(verified.supported_values.len(), 1); diff --git a/crates/registry-evidence/tests/selector_conformance.rs b/crates/registry-evidence/tests/selector_conformance.rs index bc7ce7f99..258b0a695 100644 --- a/crates/registry-evidence/tests/selector_conformance.rs +++ b/crates/registry-evidence/tests/selector_conformance.rs @@ -393,7 +393,11 @@ async fn every_selector_profile_runs_the_complete_signed_service_path() { policy.evidence_type = requirement.evidence_type.clone(); policy.purpose = request.purpose.clone(); policy.audience = EVIDENCE_AUDIENCE.to_owned(); - policy.configuration_revision = service.bundle.revision().to_owned(); + policy.configuration_revision = service + .bundle + .configuration_revision(&request.requirement) + .expect("the requirement has a configuration revision") + .to_owned(); let evidence = verify_flattened_jws( &serialized, &jwks_document(service.signer.public_jwk(), []).expect("JWKS builds"), diff --git a/crates/registry-evidence/tests/source_contracts.rs b/crates/registry-evidence/tests/source_contracts.rs index 327647935..473da454b 100644 --- a/crates/registry-evidence/tests/source_contracts.rs +++ b/crates/registry-evidence/tests/source_contracts.rs @@ -1293,7 +1293,11 @@ async fn every_frozen_source_shape_executes_through_production_materialization_a policy.evidence_type = "urn:example:fixture:evidence-type:residence-region:v1".to_owned(); policy.purpose = "fixture-routing".to_owned(); policy.audience = "https://relying.invalid/residence-procedure".to_owned(); - policy.configuration_revision = kernel.bundle().revision().to_owned(); + policy.configuration_revision = kernel + .bundle() + .configuration_revision(requirement) + .expect("the requirement has a configuration revision") + .to_owned(); let verified = verify_flattened_jws(&serialized, &jwks, &policy) .expect("signed residence Evidence verifies under the exact relying policy"); assert_eq!( diff --git a/crates/registry-evidencectl/tests/production_handoff.rs b/crates/registry-evidencectl/tests/production_handoff.rs index 805ce1a38..293389e49 100644 --- a/crates/registry-evidencectl/tests/production_handoff.rs +++ b/crates/registry-evidencectl/tests/production_handoff.rs @@ -96,6 +96,11 @@ fn production_candidate_handoff_reaches_verified_assertion_and_audit() { fixture.wait_for_evidence(&mut service); let token = fixture.access_token(); + let published_revision = published_configuration_revision(fixture.evidence_port, &token); + assert_ne!( + published_revision, revision, + "an assertion carries its requirement's own revision, not the bundle's" + ); let nonce = URL_SAFE_NO_PAD.encode([0x42_u8; 32]); let (status, response) = post_evidence(fixture.evidence_port, &token, &nonce); if status != 200 { @@ -126,7 +131,7 @@ fn production_candidate_handoff_reaches_verified_assertion_and_audit() { let payload = signed_payload(&response); assert_eq!(payload["assuranceProfile"], "production"); - assert_eq!(payload["configurationRevision"], revision); + assert_eq!(payload["configurationRevision"], published_revision); assert_eq!(payload["supportedValues"][0]["providesValueFor"], CONCEPT); assert_eq!(payload["supportedValues"][0]["value"], true); let payload_bytes = serde_json::to_vec(&payload).expect("payload serializes"); @@ -150,7 +155,7 @@ fn production_candidate_handoff_reaches_verified_assertion_and_audit() { "signed payload retained a source credential" ); - fixture.write_verification_policy(&payload, &nonce, &revision); + fixture.write_verification_policy(&payload, &nonce, &published_revision); assert_success( Command::new(evidence) .arg("verify") @@ -251,6 +256,7 @@ fn production_candidate_accepts_a_token_from_an_independent_real_mint() { ); let token = token.trim(); + let published_revision = published_configuration_revision(fixture.evidence_port, token); let nonce = URL_SAFE_NO_PAD.encode([0x24_u8; 32]); let (status, response) = post_evidence(fixture.evidence_port, token, &nonce); assert_eq!(status, 200, "a real Mint token must authorize Evidence"); @@ -259,7 +265,7 @@ fn production_candidate_accepts_a_token_from_an_independent_real_mint() { .expect("protect Mint-backed response"); let payload = signed_payload(&response); assert_eq!(payload["assuranceProfile"], "production"); - assert_eq!(payload["configurationRevision"], revision); + assert_eq!(payload["configurationRevision"], published_revision); assert_eq!(payload["supportedValues"][0]["providesValueFor"], CONCEPT); assert_eq!(payload["supportedValues"][0]["value"], true); assert!( @@ -270,7 +276,7 @@ fn production_candidate_accepts_a_token_from_an_independent_real_mint() { "signed payload retained the Mint access token" ); - fixture.write_verification_policy(&payload, &nonce, &revision); + fixture.write_verification_policy(&payload, &nonce, &published_revision); assert_success( Command::new(evidence) .arg("verify") @@ -2039,6 +2045,42 @@ fn post_evidence(port: u16, token: &str, nonce: &str) -> (u16, Vec) { (status, response[separator + 4..].to_vec()) } +/// The configuration revision discovery publishes for the fixture requirement. +/// +/// It is requirement scoped, so it is not the deployment's bundle revision. +/// Reading it from discovery keeps the assertion check independent of the +/// signed payload it is compared against. +fn published_configuration_revision(port: u16, token: &str) -> String { + let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("Evidence connection"); + stream + .set_read_timeout(Some(Duration::from_secs(15))) + .expect("request timeout"); + write!( + stream, + "GET /v1/evidence-definitions HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {token}\r\nAccept: application/json\r\nConnection: close\r\n\r\n" + ) + .expect("discovery request headers"); + let mut response = Vec::new(); + stream.read_to_end(&mut response).expect("response bytes"); + let separator = response + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("HTTP response separator"); + let document: Value = + serde_json::from_slice(&response[separator + 4..]).expect("discovery document JSON"); + let definitions = document["definitions"] + .as_array() + .expect("discovery publishes definitions"); + let revision = definitions + .iter() + .find(|definition| definition["requirement"] == REQUIREMENT) + .and_then(|definition| definition["configurationRevision"].as_str()) + .expect("the fixture requirement publishes its own configuration revision") + .to_owned(); + assert!(revision.starts_with("sha256:") && revision.len() == 71); + revision +} + fn signed_payload(response: &[u8]) -> Value { let jws: Value = serde_json::from_slice(response).expect("flattened JWS response"); let encoded = jws["payload"].as_str().expect("flattened JWS payload"); diff --git a/docs/site/src/content/docs/spec/rs-pr-evidence.mdx b/docs/site/src/content/docs/spec/rs-pr-evidence.mdx index 46280ea0a..e3c2d2f2d 100644 --- a/docs/site/src/content/docs/spec/rs-pr-evidence.mdx +++ b/docs/site/src/content/docs/spec/rs-pr-evidence.mdx @@ -497,8 +497,12 @@ trust domain, issuer governance boundary, bundle lifecycle, signer, and audit bo (invariant `V1-I19`). REQ-PR-EVIDENCE-056: The bundle revision MUST be a digest over the complete atomic bundle bytes and -layout manifest, and MUST be carried in every assertion and in every native audit event in the form -fixed by `products/evidence/contracts/evidence.schema.yaml` and +layout manifest and MUST be carried in every native audit event. +Every assertion MUST instead carry the configuration revision for its requirement, computed over +that requirement's configuration and artifact closure and published with the requirement through +authenticated definition discovery, in the forms fixed by +`products/evidence/contracts/definitions.schema.yaml`, +`products/evidence/contracts/evidence.schema.yaml`, and `products/evidence/contracts/audit-event.schema.yaml` (`products/evidence/contracts/cccev-field-mapping.yaml`). @@ -626,9 +630,10 @@ An Evidence Gateway deployment conforms to this specification when it: REQ-PR-EVIDENCE-047, REQ-PR-EVIDENCE-048, REQ-PR-EVIDENCE-049, REQ-PR-EVIDENCE-050, REQ-PR-EVIDENCE-051); - treats deployment input as immutable, restricts the runtime file to process-local bindings, fails - readiness on an untrusted bundle, serves one trust domain, and carries the bundle revision in every - assertion and audit event (REQ-PR-EVIDENCE-052, REQ-PR-EVIDENCE-053, REQ-PR-EVIDENCE-054, - REQ-PR-EVIDENCE-055, REQ-PR-EVIDENCE-056); + readiness on an untrusted bundle, serves one trust domain, carries the bundle revision in every + native audit event, and carries the requirement-scoped configuration revision in every assertion + (REQ-PR-EVIDENCE-052, REQ-PR-EVIDENCE-053, REQ-PR-EVIDENCE-054, REQ-PR-EVIDENCE-055, + REQ-PR-EVIDENCE-056); - carries the governed assurance profile through discovery, assertions, audit, and verification, confines fixture omission and credential-free loopback sources to `local`, and refuses those exceptions under `production` and `evidence-grade` (REQ-PR-EVIDENCE-057, diff --git a/docs/site/src/content/docs/start/evaluate-evidence.mdx b/docs/site/src/content/docs/start/evaluate-evidence.mdx index cdd51c55b..9b187068f 100644 --- a/docs/site/src/content/docs/start/evaluate-evidence.mdx +++ b/docs/site/src/content/docs/start/evaluate-evidence.mdx @@ -125,7 +125,7 @@ reviewed bundle unchanged, then mounts a separate container runtime, secret root, and persistent audit volume. It binds Evidence Gateway to a private Compose-network address and puts operator-controlled TLS in front. Only the runtime revision changes when container paths or listener bindings change; -the bundle revision remains the assertion `configurationRevision`. +each requirement's `configurationRevision` stays what its assertions carry. The [Evidence Gateway candidate Compose guide](../../tutorials/integrate-evidence-candidate-with-docker-compose/) covers that shape. Registry Mint remains optional when the deployment has no suitable OIDC issuer. diff --git a/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx b/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx index 95f10b79b..f0991e829 100644 --- a/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx +++ b/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx @@ -175,7 +175,8 @@ evidence --runtime "/runtime.yaml" serve ``` Route traffic through operator-controlled TLS only after `GET /ready` succeeds. The listener stays -private. The bundle revision remains the deployed assertion `configurationRevision`. +private. Each requirement's own configuration revision remains the deployed assertion +`configurationRevision`. ## Exercise and verify the HTTP boundary diff --git a/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx b/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx index 455de246f..8bc0ad0d7 100644 --- a/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx +++ b/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx @@ -116,9 +116,10 @@ an audit event. The bundle revision covers exact bundle bytes and remains the same in host and Compose deployments. The runtime revision covers exact runtime bytes and bound private CA files, so it changes with -container paths, listener bindings, or trust files. Signed assertions carry the bundle revision as -`configurationRevision`, never the runtime revision. Neither revision contains secret values or -audit contents. +container paths, listener bindings, or trust files. A configuration revision is narrower than either: +it covers one requirement's own configuration and artifacts. Signed assertions carry the revision of +the requirement they answer as `configurationRevision`, never the runtime or bundle revision. No +revision contains secret values or audit contents. ## Add optional Mint diff --git a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx index 922894e7f..5fe914fc0 100644 --- a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx +++ b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx @@ -241,7 +241,6 @@ PY ```json { "assuranceProfile": "local", - "configurationRevision": "sha256:", "definitions": [ { "concepts": [ @@ -250,6 +249,7 @@ PY "id": "urn:registrystack:evidence:local:concept:adult-status:is_adult" } ], + "configurationRevision": "sha256:", "evidenceType": "urn:registrystack:evidence:local:evidence-type:adult-status", "kind": "criterion", "purpose": "age-check", @@ -352,7 +352,10 @@ document = json.dumps( "evidence_type": definition["evidenceType"], "issued_by": published["issuedBy"], "provided_by": published["providedBy"], - "configuration_revision": published["configurationRevision"], + # Published per requirement, so it is read from this definition. It + # covers only what this requirement depends on: an unrelated bundle edit + # leaves it unchanged, and this procedure keeps verifying. + "configuration_revision": definition["configurationRevision"], "expected_assurance_profile": published["assuranceProfile"], "audience": AUDIENCE, "expected_outputs": EXPECTED_OUTPUTS, @@ -439,9 +442,10 @@ restating them, so a changed request shape cannot pass review and then reach a r `subject_expectations` is absent from the file because it is per-request: it is what the application already knows about the subject in front of it. -Regenerating this file is a review step, not a retry. The revision covers the deployment's entire -governed configuration, so it moves whenever an operator changes any of it, and verification then -fails until someone has looked at what changed. When that happens, keep the reviewed copy, write a +Regenerating this file is a review step, not a retry. The revision covers this requirement's own +governed configuration, so it moves when an operator changes something this requirement depends on, +and verification then fails until someone has looked at what changed. A change elsewhere in the +deployment leaves it alone. When it does move, keep the reviewed copy, write a new one, and `diff` them before accepting: a changed revision alone is routine, while a changed `evidence_type`, `issued_by`, concept set, or `published_shape` means the question, or the way it must be asked, moved. diff --git a/products/evidence/CONCEPT.md b/products/evidence/CONCEPT.md index f361c0272..7d17fde3f 100644 --- a/products/evidence/CONCEPT.md +++ b/products/evidence/CONCEPT.md @@ -839,7 +839,7 @@ the unsigned envelope: "validUntil": "2026-08-03T12:00:00Z", "purpose": "benefit-eligibility", "audience": "urn:example:agency:benefits", - "configurationRevision": "sha256:bundle-digest", + "configurationRevision": "sha256:requirement-digest", "subjects": [ { "role": "subject", @@ -913,9 +913,9 @@ audit, or public errors. Configuration and key state do not hot reload. The published JWKS is key discovery, not a trust anchor. A verifier obtains the provider identity and JWKS location through trusted deployment configuration or governed metadata, then allowlists the algorithm and resolves `kid` only within that trusted key set. It never follows a message-provided `jku`, `x5u`, or equivalent remote key URL. The signature covers the request nonce, issuer, technical provider, Evidence -Type, requirement revision, purpose, audience, role-bound subjects, Supported -Values, bundle revision, evidence identifier, and all observation and validity -times because those fields are inside the payload. +Type, requirement, purpose, audience, role-bound subjects, Supported Values, the +requirement's configuration revision, evidence identifier, and all observation +and validity times because those fields are inside the payload. Verification proves that the technical provider controlling the referenced key signed the exact payload. It does not by itself prove the source fact is true, confer legal notarization, create a qualified electronic signature, or turn the assertion into a holder credential. Governance must establish that the technical provider is authorized to produce evidence for the named legal issuer. @@ -1036,7 +1036,8 @@ another requester is not entitled to know. `GET /v1/evidence-definitions` authenticates the caller and returns only complete request shapes that match exactly one authority path. Each item -contains bundle revision, issuer, provider, requirement, Evidence Type, +contains the requirement's configuration revision, issuer, provider, +requirement, Evidence Type, purpose, reference frameworks, output concepts and forms, complete subject roles, selector profiles, value origins, and safe selector field types and bounds. Controlled-code fields expose the governed scheme identity and diff --git a/products/evidence/IMPLEMENTATION.md b/products/evidence/IMPLEMENTATION.md index b1864d04c..e6e0e641c 100644 --- a/products/evidence/IMPLEMENTATION.md +++ b/products/evidence/IMPLEMENTATION.md @@ -447,8 +447,8 @@ Rust accepts `array` only when: - codelist, cardinality, string, collection, and total-result limits pass. Rhai never creates the Evidence identifier, issuer, provider, requirement, -Evidence Type, purpose, audience, subject bindings, timestamps, bundle revision, -JWS headers, signature, or audit record. +Evidence Type, purpose, audience, subject bindings, timestamps, configuration +revision, JWS headers, signature, or audit record. ## Version-one acceptance definition set diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md index ecf1731fe..48331e0cb 100644 --- a/products/evidence/OPERATOR-CONTRACT.md +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -114,8 +114,10 @@ root. Secret ownership and mode requirements remain unchanged: each referenced secret is a regular owner-only file accepted by the eventual service identity. The bundle and runtime must be non-writable to that identity. The copied runtime is target-specific; its revision and bound private-CA bytes are not the -bundle revision, and signed assertions continue to carry only the bundle -revision as `configurationRevision`. +bundle revision, and signed assertions continue to carry only a configuration +revision as `configurationRevision`. That value is scoped to the one requirement +the assertion answers, not to the whole deployment, so it is neither the runtime +revision nor the bundle revision. Run the following grouped handoff after provisioning and whenever candidate bytes, runtime bindings, trust files, or secrets change: @@ -203,14 +205,15 @@ Discovery uses four separately trusted surfaces: | Artifact | Purpose | What it does not do | |---|---|---| | Generated Evidence OpenAPI | Describes `GET /v1/evidence-definitions`, `POST /v1/evidence`, operational routes, envelopes, media types, and safe problems. | It contains no deployment definitions or entitlements. | -| Authenticated definition response | Lists the exact complete request shapes available to this verified token at this bundle revision. | It performs no provider access, does not grant authority, and is not a global catalog. | +| Authenticated definition response | Lists the exact complete request shapes available to this verified token at this bundle revision, each with the configuration revision an assertion for that one requirement carries. | It performs no provider access, does not grant authority, and is not a global catalog. | | Static onboarding material | Gives an approved consumer token-acquisition instructions, human descriptions, legal context, endpoint trust, and verifier policy through the existing API catalog, developer portal, configuration repository, or bilateral process. | It is not accepted by the runtime and grants no authority. | | Evidence JWKS | Publishes the active and retained public verification keys. | It is not a trust anchor and contains no definition or entitlement metadata. | Each item in `definitions` is one complete invocable combination, not a cartesian product for the client to assemble. It contains: -- exact governed bundle revision plus legal issuer and technical provider; +- the requirement's own configuration revision plus legal issuer and technical + provider; - requirement and Evidence Type identifiers; - one allowed purpose; - output concept identifiers and value forms; @@ -244,13 +247,15 @@ The publication workflow is: governed bundle revision. 3. Publish the generic OpenAPI and static onboarding material; configure token issuance and verifier trust through the same governed process. -4. Obtain a token, call `GET /v1/evidence-definitions`, and bind the returned - `configurationRevision` to the deployment revision expected during rollout. +4. Obtain a token, call `GET /v1/evidence-definitions`, and bind each returned + `configurationRevision` to the requirement it is published under. A relying + party pins the requirements it consumes, not the deployment. 5. Construct requests only from one returned complete shape. Do not combine subjects, profiles, purposes, or fields across items. 6. On a relevant bundle or trust change, update onboarding material and - coordinate rollout. Clients observe the new revision through authenticated - discovery, not by probing problem responses. + coordinate rollout with the relying parties whose requirements changed + revision. Clients observe a new revision through authenticated discovery, + not by probing problem responses. Version one does not implement a public, cross-requester, searchable, mutable, or federated catalog, a registration editor, or a `describe` CLI command. diff --git a/products/evidence/contracts/cccev-field-mapping.yaml b/products/evidence/contracts/cccev-field-mapping.yaml index 3d5e67a86..8c4e27651 100644 --- a/products/evidence/contracts/cccev-field-mapping.yaml +++ b/products/evidence/contracts/cccev-field-mapping.yaml @@ -53,7 +53,7 @@ mapping: rule: Audience derived from authenticated authority context. Evidence.configurationRevision: source: evidence_extension - rule: sha256 digest of the complete atomic bundle bytes and layout manifest. + rule: sha256 digest of the configuration and artifact closure of the one requirement the assertion answers, not of the whole bundle. Evidence.subjects: source: evidence_extension rule: Closed role-bound, audience-scoped subject bindings; selectors are never included. diff --git a/products/evidence/contracts/definitions.schema.yaml b/products/evidence/contracts/definitions.schema.yaml index 52def87fa..9b096d4ab 100644 --- a/products/evidence/contracts/definitions.schema.yaml +++ b/products/evidence/contracts/definitions.schema.yaml @@ -3,13 +3,10 @@ $id: https://registrystack.org/schemas/evidence/definitions-v1.json title: Requester-scoped Evidence definitions Version 1 type: object additionalProperties: false -required: [schema, assuranceProfile, configurationRevision, issuedBy, providedBy, definitions] +required: [schema, assuranceProfile, issuedBy, providedBy, definitions] properties: schema: {const: registry.evidence-definitions/v1} assuranceProfile: {enum: [local, production, evidence-grade]} - configurationRevision: - type: string - pattern: '^sha256:[a-f0-9]{64}$' issuedBy: {type: string, format: uri, maxLength: 512} providedBy: {type: string, format: uri, maxLength: 512} definitions: @@ -23,6 +20,7 @@ $defs: additionalProperties: false required: - requirement + - configurationRevision - kind - evidenceType - purpose @@ -31,6 +29,14 @@ $defs: - concepts properties: requirement: {type: string, format: uri, maxLength: 512} + configurationRevision: + description: >- + The revision an assertion for this requirement carries. It covers this + requirement's own configuration and artifact closure, so a relying + party pins one requirement without depending on the rest of the + deployment. + type: string + pattern: '^sha256:[a-f0-9]{64}$' kind: {enum: [criterion, information-requirement, constraint]} evidenceType: {type: string, format: uri, maxLength: 512} purpose: {type: string, pattern: '^[a-z][a-z0-9._:-]{0,127}$'} diff --git a/products/evidence/contracts/evidence.schema.yaml b/products/evidence/contracts/evidence.schema.yaml index 454f2316a..97b121e8a 100644 --- a/products/evidence/contracts/evidence.schema.yaml +++ b/products/evidence/contracts/evidence.schema.yaml @@ -36,7 +36,13 @@ properties: validUntil: {type: string, format: date-time} purpose: {type: string, pattern: '^[a-z][a-z0-9._:-]{0,127}$'} audience: {type: string, format: uri, maxLength: 512} - configurationRevision: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + configurationRevision: + type: string + pattern: '^sha256:[a-f0-9]{64}$' + description: >- + The revision of the configuration and artifact closure this requirement's + evaluation reached. It is scoped to the requirement, not to the whole + deployment, so an edit that cannot change this assertion leaves it alone. subjects: type: array minItems: 1 diff --git a/products/evidence/contracts/verification-policy.schema.yaml b/products/evidence/contracts/verification-policy.schema.yaml index 1dfd42bca..7b80eb644 100644 --- a/products/evidence/contracts/verification-policy.schema.yaml +++ b/products/evidence/contracts/verification-policy.schema.yaml @@ -25,7 +25,13 @@ properties: evidenceType: {type: string, format: uri, maxLength: 512} purpose: {type: string, pattern: '^[a-z][a-z0-9._:-]{0,127}$'} audience: {type: string, format: uri, maxLength: 512} - configurationRevision: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + configurationRevision: + type: string + pattern: '^sha256:[a-f0-9]{64}$' + description: >- + The requirement-scoped revision the relying party pinned, as the + requirement's own definition published it. A deployment's unrelated edits + do not change it. requestNonce: type: string pattern: '^[A-Za-z0-9_-]{43}$' diff --git a/products/evidence/generated/evidence-definitions-v1.schema.json b/products/evidence/generated/evidence-definitions-v1.schema.json index 8ab71297a..0814ed3e4 100644 --- a/products/evidence/generated/evidence-definitions-v1.schema.json +++ b/products/evidence/generated/evidence-definitions-v1.schema.json @@ -43,6 +43,10 @@ "type": "array", "uniqueItems": true }, + "configurationRevision": { + "pattern": "^sha256:[a-f0-9]{64}$", + "type": "string" + }, "evidenceType": { "format": "uri", "maxLength": 512, @@ -87,6 +91,7 @@ }, "required": [ "requirement", + "configurationRevision", "kind", "evidenceType", "purpose", @@ -291,10 +296,6 @@ "evidence-grade" ] }, - "configurationRevision": { - "pattern": "^sha256:[a-f0-9]{64}$", - "type": "string" - }, "definitions": { "items": { "$ref": "#/$defs/definition" @@ -320,7 +321,6 @@ "required": [ "schema", "assuranceProfile", - "configurationRevision", "issuedBy", "providedBy", "definitions" diff --git a/products/evidence/generated/registry-evidence.openapi.json b/products/evidence/generated/registry-evidence.openapi.json index 2476a9419..7d4085e57 100644 --- a/products/evidence/generated/registry-evidence.openapi.json +++ b/products/evidence/generated/registry-evidence.openapi.json @@ -176,6 +176,10 @@ "type": "array", "uniqueItems": true }, + "configurationRevision": { + "pattern": "^sha256:[a-f0-9]{64}$", + "type": "string" + }, "evidenceType": { "format": "uri", "maxLength": 512, @@ -221,6 +225,7 @@ }, "required": [ "requirement", + "configurationRevision", "kind", "evidenceType", "purpose", @@ -328,10 +333,6 @@ ], "type": "string" }, - "configurationRevision": { - "pattern": "^sha256:[a-f0-9]{64}$", - "type": "string" - }, "definitions": { "items": { "$ref": "#/components/schemas/EvidenceDefinition" @@ -360,7 +361,6 @@ "required": [ "schema", "assuranceProfile", - "configurationRevision", "issuedBy", "providedBy", "definitions" diff --git a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md index 568a1a79c..5a2e76cb5 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md +++ b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md @@ -663,6 +663,16 @@ release, bundle bytes and revision are identical. The copied runtime is environment-specific and has its own revision; it is not part of the bundle revision or signed `configurationRevision`. +The bundle revision identifies the whole reviewed bundle and is what an operator +records at approval. A signed `configurationRevision` is narrower: it covers +only the configuration and artifacts one requirement depends on. Editing a +requirement, its source, one of its selector profiles, an authority grant naming +it, or any script, schema, codelist, or fixture file those reach changes that +requirement's revision. Retired public verification keys stay in every +requirement's closure, so key rollover still changes every revision. An edit +outside a requirement's closure leaves its revision unchanged, so it does not +force every relying party to re-review. + After approval, transfer the exact candidate, provision independent owner-only secrets under the configured secret root, make bundle and runtime non-writable to the service identity, and run the grouped handoff once whenever candidate @@ -694,7 +704,8 @@ Docker Compose is a documented adapter rather than build output. It mounts the candidate bundle unchanged and read-only; mounts a distinct container runtime, secrets, and persistent audit storage separately; binds Evidence privately; and keeps TLS and public routing operator-controlled. The Compose runtime has its -own revision while assertions continue to carry the unchanged bundle revision. +own revision while assertions continue to carry their unchanged per-requirement +configuration revisions. When Mint shares that network, retain its public HTTPS issuer and JWKS URI; internal plain-HTTP service names do not replace them. @@ -706,5 +717,7 @@ The consumer then calls authenticated `GET /v1/evidence-definitions` and uses one returned complete requirement, purpose, concept, role, selector, and value origin shape. The endpoint does not publish the whole bundle, source internals, authority tags, secrets, selector values, or unrelated definitions. A change -to an offered contract changes the returned `configurationRevision` and needs -a coordinated rollout; clients do not infer alternatives from runtime errors. +to an offered contract changes that definition's returned +`configurationRevision` and needs a coordinated rollout with the relying parties +consuming that requirement; clients do not infer alternatives from runtime +errors.